diff --git a/coordinator/internal/transport/http/templates/dashboard.html b/coordinator/internal/transport/http/templates/dashboard.html index af31dd1..de530f3 100644 --- a/coordinator/internal/transport/http/templates/dashboard.html +++ b/coordinator/internal/transport/http/templates/dashboard.html @@ -13,7 +13,7 @@

Local scientific compute

SciMesh control room

Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.

Live overview · refreshes every 2 seconds
- + New similarity search +
{{if .Session}}Signed in · {{.Session.Role}}{{end}}+ New similarity search{{if .Session}}
{{end}}
How a search becomes a result
01Upload TSVThe coordinator validates and slices the dataset.
02Run shardsWorkers fingerprint molecules and return shard top-k CSVs.
03Merge exactlyThe coordinator ranks retained candidates deterministically.
04Download CSVA checksum-protected global result is ready.
diff --git a/coordinator/internal/transport/http/templates/job.html b/coordinator/internal/transport/http/templates/job.html index f0091e6..49f8289 100644 --- a/coordinator/internal/transport/http/templates/job.html +++ b/coordinator/internal/transport/http/templates/job.html @@ -12,7 +12,7 @@
- ← Back to control room +
← Back to control room{{if .Session}}
{{end}}

{{workloadLabel .Workload}}

Live pipeline

One job, shown from accepted input through its final coordinator-owned scientific result.

Live · refreshes every 2 seconds
{{statusLabel .Status}}

{{statusHint .Status}}

Completed shards are preserved.

{{.Completed}} of {{.Total}} shards complete

{{.Total}}total shards
{{.Completed}}completed
{{.Pending}}waiting
{{add .Leased .Running}}with workers
{{.Failed}}failed
{{.Cancelled}}stopped
diff --git a/coordinator/internal/transport/http/ui_logout_internal_test.go b/coordinator/internal/transport/http/ui_logout_internal_test.go new file mode 100644 index 0000000..37f15c2 --- /dev/null +++ b/coordinator/internal/transport/http/ui_logout_internal_test.go @@ -0,0 +1,42 @@ +package http + +import ( + "bytes" + "strings" + "testing" + + "github.com/emil28092005/SciMesh/coordinator/internal/usecase" +) + +func render(t *testing.T, name string, data any) string { + t.Helper() + var buf bytes.Buffer + if err := uiTemplates.ExecuteTemplate(&buf, name, data); err != nil { + t.Fatalf("render %s: %v", name, err) + } + return buf.String() +} + +func TestDashboardLogoutOnlyInSession(t *testing.T) { + withSession := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "admin"}}) + if !strings.Contains(withSession, "/ui/logout") || !strings.Contains(withSession, "Log out") { + t.Error("dashboard must show a logout control in session mode") + } + + noSession := render(t, "dashboard.html", usecase.DashboardView{}) + if strings.Contains(noSession, "/ui/logout") { + t.Error("dashboard must not show logout under basic auth (no session)") + } +} + +func TestJobLogoutOnlyInSession(t *testing.T) { + withSession := render(t, "job.html", usecase.JobDetailView{Session: &usecase.SessionView{Role: "user"}}) + if !strings.Contains(withSession, "/ui/logout") { + t.Error("job page must show a logout control in session mode") + } + + noSession := render(t, "job.html", usecase.JobDetailView{}) + if strings.Contains(noSession, "/ui/logout") { + t.Error("job page must not show logout under basic auth (no session)") + } +} diff --git a/coordinator/internal/usecase/ui.go b/coordinator/internal/usecase/ui.go index b1f1dad..a8624ab 100644 --- a/coordinator/internal/usecase/ui.go +++ b/coordinator/internal/usecase/ui.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" + "github.com/emil28092005/SciMesh/coordinator/internal/authctx" "github.com/emil28092005/SciMesh/coordinator/internal/domain" ) @@ -87,13 +88,35 @@ type DashboardView struct { ActiveJobs int `json:"active_jobs"` FinishedJobs int `json:"finished_jobs"` OnlineWorkers int `json:"online_workers"` + // Session is the signed-in user, when the UI runs in session mode. nil under + // basic auth. Template-only, never serialised to the polling JSON. + Session *SessionView `json:"-"` } + +// SessionView is the minimal identity the UI header needs to show who is signed +// in and to offer a logout control. +type SessionView struct { + Role string + Verified bool +} + +// sessionViewFrom builds the header session info from the request context, or +// nil when the caller is not an authenticated user (basic-auth operator). +func sessionViewFrom(ctx context.Context) *SessionView { + r, ok := authctx.From(ctx) + if !ok { + return nil + } + return &SessionView{Role: r.Role, Verified: r.Verified} +} + type JobDetailView struct { JobCard Tasks []TaskCard `json:"tasks"` Artifacts []ArtifactCard `json:"artifacts"` Parameters []ParameterCard `json:"parameters"` FinalResultAvailable bool `json:"final_result_available"` + Session *SessionView `json:"-"` } type Dashboard struct{ read UIReadRepository } @@ -134,6 +157,7 @@ func (d *Dashboard) Overview(ctx context.Context, limit int) (DashboardView, err out.OnlineWorkers++ } } + out.Session = sessionViewFrom(ctx) return out, nil } @@ -168,6 +192,7 @@ func (d *Dashboard) JobDetail(ctx context.Context, jobID uuid.UUID) (JobDetailVi Tasks: make([]TaskCard, 0, len(tasks)), Artifacts: make([]ArtifactCard, 0, len(artifacts)), Parameters: uiParameters(job.Parameters), + Session: sessionViewFrom(ctx), } for _, task := range tasks { card := TaskCard{ID: task.ID.String(), ChunkIndex: task.ChunkIndex, Status: string(task.Status), Attempt: task.Attempt, MaxAttempts: task.MaxAttempts, LeaseExpiresAt: task.LeaseExpiresAt, StartedAt: task.StartedAt, CompletedAt: task.CompletedAt}