feat(coordinator): logout button in the operator UI

The dashboard and job pages show a 'Log out' control (POST /ui/logout) and a
'Signed in · <role>' label when a userservice session is active. Under basic
auth (no session) neither appears, so the fallback UI is unchanged.

Threads a template-only Session view (json:"-") from authctx into the
dashboard and job views. Tests assert the control renders only in session mode.
This commit is contained in:
Efremenko Arhip
2026-07-26 21:03:50 +03:00
parent 5a9a10c681
commit 49eb662798
4 changed files with 69 additions and 2 deletions
@@ -13,7 +13,7 @@
<main class="page">
<header class="top">
<div><p class="eyebrow">Local scientific compute</p><h1 class="title">SciMesh control room</h1><p class="lead">Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.</p><div class="live"><i class="pulse"></i><span id="refresh-state">Live overview · refreshes every 2 seconds</span></div></div>
<a class="button" href="/ui/jobs/new"> New similarity search</a>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">{{if .Session}}<span class="live" style="margin-top:0">Signed in · {{.Session.Role}}</span>{{end}}<a class="button" href="/ui/jobs/new"> New similarity search</a>{{if .Session}}<form method="post" action="/ui/logout" style="margin:0"><button class="button" type="submit" style="background:#23344d;color:#dce8ff;box-shadow:none">Log out</button></form>{{end}}</div>
</header>
<section class="summary" aria-label="Pipeline summary">
<div class="panel"><strong>How a search becomes a result</strong><div class="pipeline"><div class="flow-step"><span><i class="dot"></i>01</span><b>Upload TSV</b><small>The coordinator validates and slices the dataset.</small></div><div class="flow-step"><span><i class="dot"></i>02</span><b>Run shards</b><small>Workers fingerprint molecules and return shard top-k CSVs.</small></div><div class="flow-step"><span><i class="dot"></i>03</span><b>Merge exactly</b><small>The coordinator ranks retained candidates deterministically.</small></div><div class="flow-step"><span><i class="dot"></i>04</span><b>Download CSV</b><small>A checksum-protected global result is ready.</small></div></div></div>
@@ -12,7 +12,7 @@
</head>
<body>
<main class="page">
<a class="back" href="/ui">← Back to control room</a>
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px"><a class="back" href="/ui">← Back to control room</a>{{if .Session}}<form method="post" action="/ui/logout" style="margin:0"><button type="submit" style="border:0;border-radius:10px;padding:9px 14px;background:#23344d;color:#dce8ff;font:inherit;font-weight:800;cursor:pointer">Log out</button></form>{{end}}</div>
<div class="top"><div><p class="eyebrow">{{workloadLabel .Workload}}</p><h1 class="title">Live pipeline</h1><p class="subtitle">One job, shown from accepted input through its final coordinator-owned scientific result.</p></div><div class="live" id="refresh-state">Live · refreshes every 2 seconds</div></div>
<section class="panel summary"><div class="summary-top"><div><span id="status" class="badge badge-{{statusClass .Status}}">{{statusLabel .Status}}</span><p id="hint" class="hint">{{statusHint .Status}}</p></div><div id="stop-wrap" {{if not (cancellable .Status)}}class="hidden"{{end}}><button id="stop-job" class="stop" type="button">Stop unfinished shards</button><div class="live">Completed shards are preserved.</div></div></div><div class="bar"><span id="progress-bar" style="width:{{progressPercent .Completed .Failed .Cancelled .Total}}%"></span></div><p class="progress-line" id="progress">{{.Completed}} of {{.Total}} shards complete</p><div class="metrics"><div class="metric"><b id="total">{{.Total}}</b><small>total shards</small></div><div class="metric"><b id="completed">{{.Completed}}</b><small>completed</small></div><div class="metric"><b id="pending">{{.Pending}}</b><small>waiting</small></div><div class="metric"><b id="active">{{add .Leased .Running}}</b><small>with workers</small></div><div class="metric"><b id="failed">{{.Failed}}</b><small>failed</small></div><div class="metric"><b id="cancelled">{{.Cancelled}}</b><small>stopped</small></div></div></section>
@@ -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)")
}
}
+25
View File
@@ -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}