{{range .Capabilities}}{{.}}{{end}}
Last signal · {{time .LastHeartbeatAt}}
diff --git a/STATUS.md b/STATUS.md index 1555d37..cd13285 100644 --- a/STATUS.md +++ b/STATUS.md @@ -38,7 +38,7 @@ real PostgreSQL smoke test) passed on 2026-07-24. | CTX-08 Distributed similarity-search | Implemented | Python planner resolves `query_id` once, creates deterministic shard plans, worker adapter emits exact partial top-k CSVs/metrics, and reducer matches the local reference. | | CTX-09 Reducer and final-result API | Implemented | Atomic `reducing` claim, deterministic coordinator-side top-k reducer, sanitized reducer failure, final artifact persistence, `result_uri`, and final CSV download. | | CTX-10 Distributed similarity-graph | Not started | Local reference exists. | -| CTX-11 Dashboard/operator view | Implemented | Protected local view: job/task/worker status, validated similarity-search upload, partial-artifact diagnostics, final-result download, and bounded polling. | +| CTX-11 Dashboard/operator view | Implemented | Protected live control room: recent-run/worker overview, real pipeline-stage visualization, shard attempts and safe failures, validated similarity-search upload, coordinator artifacts, final-result download, and bounded polling. | | CTX-12 Reliability, security, CI | In progress | Unit, race, PostgreSQL integration, and smoke checks exist; CI hardening remains. | ## Next recommended assignment diff --git a/coordinator/README.md b/coordinator/README.md index eeebca2..02ef5d7 100644 --- a/coordinator/README.md +++ b/coordinator/README.md @@ -76,10 +76,19 @@ UI_AUTH_TOKEN='local-ui-secret' make up ``` The UI is disabled by default and never accepts the worker bearer token. -It shows recent jobs, task/worker state, diagnostic shard artifacts, and the -final CSV for completed similarity-search jobs. The coordinator enters -`reducing` after the last shard completes, then exposes the final deterministic -global top-k result when merging succeeds. +The **control room** shows live workers, recent runs, shard state/attempts, +safe failures, coordinator artifacts, and the final CSV for completed +similarity-search jobs. The job page follows the real stages: TSV accepted → +shards execute → workers return CSVs → `reducing` → final deterministic global +top-k result. It polls only its own coordinator read-model and never controls +or exposes worker processes. + +For a hands-on run, open `/ui`, choose **New similarity search**, select a +small ChEMBL-style TSV, then leave one or more `scimesh-worker` processes +running in separate terminals. The detail page updates every two seconds and +stops polling after a completed, failed, or cancelled job. Download the +`final_result` artifact only after the job reaches **Completed**; shard partial +CSVs remain available as diagnostics. `up` starts three services in order: Postgres waits until `pg_isready` passes, a one-shot `migrate` container applies the schema and exits, and only then does the diff --git a/coordinator/internal/storage/postgres/integration_test.go b/coordinator/internal/storage/postgres/integration_test.go index 7628a50..3252b00 100644 --- a/coordinator/internal/storage/postgres/integration_test.go +++ b/coordinator/internal/storage/postgres/integration_test.go @@ -133,6 +133,33 @@ func TestClaimReductionIsAtomic(t *testing.T) { } } +func TestUIReadRepoListsReducerFields(t *testing.T) { + pool := testPool(t) + job, _ := seedJob(t, pool, 1) + jobs := NewJobRepo(pool) + ctx := context.Background() + if err := jobs.UpdateStatus(ctx, job.ID, domain.JobReducing, nil); err != nil { + t.Fatal(err) + } + if claimed, err := jobs.ClaimReduction(ctx, job.ID, time.Now().UTC()); err != nil || !claimed { + t.Fatalf("claim reduction = (%v, %v)", claimed, err) + } + listed, err := NewUIReadRepo(pool).ListJobs(ctx, 20) + if err != nil { + t.Fatalf("list UI jobs: %v", err) + } + for _, item := range listed { + if item.ID != job.ID { + continue + } + if item.Status != domain.JobReducing || item.ReducerStartedAt == nil { + t.Fatalf("UI reducer projection = %+v", item) + } + return + } + t.Fatalf("seeded job %s is missing from UI list", job.ID) +} + // A job must land whole or not at all: a half-created job leaves chunks no // worker could ever complete. func TestCreateJobRollsBackOnFailure(t *testing.T) { diff --git a/coordinator/internal/storage/postgres/ui_read_repo.go b/coordinator/internal/storage/postgres/ui_read_repo.go index 81270b1..c759b2a 100644 --- a/coordinator/internal/storage/postgres/ui_read_repo.go +++ b/coordinator/internal/storage/postgres/ui_read_repo.go @@ -41,13 +41,12 @@ func (r *UIReadRepo) ListJobs(ctx context.Context, limit int) ([]domain.Job, err for rows.Next() { var j domain.Job var status string - var inputURI *string - if err := rows.Scan(&j.ID, &j.Workload, &inputURI, &j.Parameters, &status, &j.CreatedAt, &j.CompletedAt); err != nil { + if err := rows.Scan( + &j.ID, &j.Workload, &j.InputURI, &j.Parameters, &status, &j.CreatedAt, &j.CompletedAt, + &j.InputArtifactID, &j.ResultArtifactID, &j.ErrorCode, &j.ErrorMessage, &j.ReducerStartedAt, + ); err != nil { return nil, err } - if inputURI != nil { - j.InputURI = *inputURI - } j.Status = domain.JobStatus(status) jobs = append(jobs, j) } diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go index 560c85b..a159ab2 100644 --- a/coordinator/internal/transport/http/server.go +++ b/coordinator/internal/transport/http/server.go @@ -81,6 +81,7 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler { ui.HandleFunc("GET /ui", s.handleUIHome) ui.HandleFunc("GET /ui/jobs/new", s.handleUINewJob) ui.HandleFunc("GET /ui/jobs/{job_id}", s.handleUIJob) + ui.HandleFunc("GET /ui/api/overview", s.handleUIOverviewJSON) ui.HandleFunc("GET /ui/api/jobs/{job_id}", s.handleUIJobJSON) ui.HandleFunc("POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob) ui.HandleFunc("POST /ui/api/jobs/upload", s.handleUploadDataset) diff --git a/coordinator/internal/transport/http/server_test.go b/coordinator/internal/transport/http/server_test.go index 97c7681..1146344 100644 --- a/coordinator/internal/transport/http/server_test.go +++ b/coordinator/internal/transport/http/server_test.go @@ -152,11 +152,36 @@ func TestUIRequiresDistinctCredentialAndRendersDashboard(t *testing.T) { t.Fatalf("UI status: %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) - if !strings.Contains(string(body), "SciMesh operator dashboard") { + if !strings.Contains(string(body), "SciMesh control room") { t.Errorf("dashboard body missing title") } } +func TestUIOverviewReturnsLiveSafeProjection(t *testing.T) { + e := newEnv(t, healthy) + code, _ := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c","input_sha256":"sha"}]}`) + if code != http.StatusCreated { + t.Fatalf("create job: %d", code) + } + req, _ := http.NewRequestWithContext(context.Background(), "GET", e.ts.URL+"/ui/api/overview", nil) + req.SetBasicAuth("operator", uiToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var overview map[string]any + if err := json.NewDecoder(resp.Body).Decode(&overview); err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK || overview["active_jobs"].(float64) != 1 || overview["online_workers"].(float64) != 1 { + t.Fatalf("overview = (%d, %v)", resp.StatusCode, overview) + } + if _, leaked := overview["worker_auth_token"]; leaked { + t.Fatal("overview must not expose authentication configuration") + } +} + func TestUIDisabledReturnsNotFound(t *testing.T) { e := newEnvWithUIToken(t, healthy, "") resp := e.get(t, "/ui") @@ -437,6 +462,33 @@ func TestSimilaritySearchLifecyclePublishesFinalResult(t *testing.T) { if resp.StatusCode != http.StatusOK || string(body) != "rank,chembl_id,canonical_smiles,similarity\n1,A,CC,0.900000\n" { t.Fatalf("final result = (%d, %q)", resp.StatusCode, body) } + + uiRequest, _ := http.NewRequestWithContext(context.Background(), "GET", e.ts.URL+"/ui/jobs/"+jobID, nil) + uiRequest.SetBasicAuth("operator", uiToken) + uiResponse, err := http.DefaultClient.Do(uiRequest) + if err != nil { + t.Fatal(err) + } + defer uiResponse.Body.Close() + uiBody, _ := io.ReadAll(uiResponse.Body) + if uiResponse.StatusCode != http.StatusOK || !strings.Contains(string(uiBody), "Final result ready") { + t.Fatalf("final UI = (%d, %q)", uiResponse.StatusCode, uiBody) + } + + jsonRequest, _ := http.NewRequestWithContext(context.Background(), "GET", e.ts.URL+"/ui/api/jobs/"+jobID, nil) + jsonRequest.SetBasicAuth("operator", uiToken) + jsonResponse, err := http.DefaultClient.Do(jsonRequest) + if err != nil { + t.Fatal(err) + } + defer jsonResponse.Body.Close() + var detail map[string]any + if err := json.NewDecoder(jsonResponse.Body).Decode(&detail); err != nil { + t.Fatal(err) + } + if jsonResponse.StatusCode != http.StatusOK || detail["final_result_available"] != true { + t.Fatalf("final UI JSON = (%d, %v)", jsonResponse.StatusCode, detail) + } } func TestForeignArtifactResultConflict(t *testing.T) { diff --git a/coordinator/internal/transport/http/templates/dashboard.html b/coordinator/internal/transport/http/templates/dashboard.html index e643cc7..af31dd1 100644 --- a/coordinator/internal/transport/http/templates/dashboard.html +++ b/coordinator/internal/transport/http/templates/dashboard.html @@ -4,20 +4,36 @@
-Local coordinator
See where a computation is and what should happen next.
| Computation | State | Progress | |
|---|---|---|---|
| {{workloadLabel .Workload}} Open job details | {{statusLabel .Status}} {{statusHint .Status}} | {{.Completed}} / {{.Total}} complete{{if gt .Failed 0}} · failed: {{.Failed}}{{end}}{{if gt .Cancelled 0}} · stopped: {{.Cancelled}}{{end}} | |
| No jobs yet. Click “Start a check”, upload a small TSV, and leave a worker running. | |||
{{.}} {{end}}scimesh-worker with the coordinator URL and worker token.Local scientific compute
Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.
{{len .Jobs}} shown · newest first
Workers register themselves; this page never controls their processes.
{{range .Capabilities}}{{.}}{{end}}
Last signal · {{time .LastHeartbeatAt}}
scimesh-worker in another terminal, then return here.