diff --git a/backend/app/api/v1/studies.py b/backend/app/api/v1/studies.py index 95846587..e7cfeba6 100644 --- a/backend/app/api/v1/studies.py +++ b/backend/app/api/v1/studies.py @@ -288,6 +288,7 @@ async def list_studies( limit: Annotated[int, Query(ge=1, le=MAX_PAGE_LIMIT)] = DEFAULT_PAGE_LIMIT, since: Annotated[datetime | None, Query()] = None, study_status: Annotated[StudyStatusWire | None, Query(alias="status")] = None, + cluster_id: Annotated[str | None, Query(min_length=1, max_length=36)] = None, q: Annotated[str | None, Query(min_length=2, max_length=200)] = None, sort: Annotated[StudySortKey | None, Query()] = None, ) -> StudyListResponse: @@ -327,10 +328,13 @@ async def list_studies( limit=limit, since=since, status=status_filter, + cluster_id=cluster_id, q=q, sort=sort, ) - total = await repo.count_studies(db, since=since, status=status_filter, q=q) + total = await repo.count_studies( + db, since=since, status=status_filter, cluster_id=cluster_id, q=q + ) response.headers["X-Total-Count"] = str(total) next_cursor: str | None = None diff --git a/backend/app/db/repo/study.py b/backend/app/db/repo/study.py index 5526fdcd..dbe36352 100644 --- a/backend/app/db/repo/study.py +++ b/backend/app/db/repo/study.py @@ -71,6 +71,7 @@ async def list_studies( limit: int = 50, since: datetime | None = None, status: StudyStatusFilter | None = None, + cluster_id: str | None = None, q: str | None = None, sort: str | None = None, ) -> Sequence[Study]: @@ -82,7 +83,9 @@ async def list_studies( ``id DESC`` tie-breaker. ``since`` filters to ``created_at >= since``. ``status`` filters to a - single state. ``q`` is an optional Postgres FTS match against + single state. ``cluster_id`` scopes to studies belonging to a single + cluster (used by the cluster detail page's "Studies using this cluster" + section). ``q`` is an optional Postgres FTS match against ``search_vector`` (studies.name + target). Limit clamped at 200. """ parsed_sort: ParsedSort | None = parse_sort(sort, _STUDY_SORT_COLUMNS) @@ -91,6 +94,8 @@ async def list_studies( stmt = stmt.where(Study.status == status) if since is not None: stmt = stmt.where(Study.created_at >= since) + if cluster_id is not None: + stmt = stmt.where(Study.cluster_id == cluster_id) fts = fts_predicate(q) if fts is not None: stmt = stmt.where(fts) @@ -116,6 +121,7 @@ async def count_studies( *, since: datetime | None = None, status: StudyStatusFilter | None = None, + cluster_id: str | None = None, q: str | None = None, ) -> int: """COUNT(*) studies matching the filter (for the X-Total-Count header).""" @@ -124,6 +130,8 @@ async def count_studies( stmt = stmt.where(Study.status == status) if since is not None: stmt = stmt.where(Study.created_at >= since) + if cluster_id is not None: + stmt = stmt.where(Study.cluster_id == cluster_id) fts = fts_predicate(q) if fts is not None: stmt = stmt.where(fts) diff --git a/backend/tests/integration/test_studies_api.py b/backend/tests/integration/test_studies_api.py index 98286ac8..d647745a 100644 --- a/backend/tests/integration/test_studies_api.py +++ b/backend/tests/integration/test_studies_api.py @@ -359,3 +359,75 @@ async def test_get_study_unknown_id_returns_404( resp = await async_client.get("/api/v1/studies/00000000-0000-0000-0000-000000000000") assert resp.status_code == 404 assert resp.json()["detail"]["error_code"] == "STUDY_NOT_FOUND" + + +# --------------------------------------------------------------------------- +# cluster_id filter (bug_cluster_detail_studies_unfiltered fix) +# --------------------------------------------------------------------------- + + +async def test_list_studies_filters_by_cluster_id( + async_client: httpx.AsyncClient, +) -> None: + """GET /studies?cluster_id={id} scopes to that cluster only. + + Regression for the bug surfaced during guide 01 audit: the frontend's + "Studies using this cluster" section sent ?cluster_id= but the backend + silently ignored it (no Query param declared) → unfiltered global list. + + Seeds two independent clusters (each with its own template/query-set/ + judgment-list/study), then asserts GET /studies?cluster_id=A returns + only A's study and excludes B's. + """ + ids_a = await _seed_minimum_for_post_studies() + ids_b = await _seed_minimum_for_post_studies() + + body_a = { + "name": f"study-a-{uuid.uuid4().hex[:8]}", + "cluster_id": ids_a["cluster_id"], + "target": "stub-index", + "template_id": ids_a["template_id"], + "query_set_id": ids_a["query_set_id"], + "judgment_list_id": ids_a["judgment_list_id"], + "search_space": _VALID_SEARCH_SPACE, + "objective": {"metric": "ndcg", "k": 10}, + "config": {"max_trials": 5}, + } + body_b = { + **body_a, + "name": f"study-b-{uuid.uuid4().hex[:8]}", + "cluster_id": ids_b["cluster_id"], + "template_id": ids_b["template_id"], + "query_set_id": ids_b["query_set_id"], + "judgment_list_id": ids_b["judgment_list_id"], + } + post_a = await async_client.post("/api/v1/studies", json=body_a) + post_b = await async_client.post("/api/v1/studies", json=body_b) + assert post_a.status_code == 201 + assert post_b.status_code == 201 + study_a_id = post_a.json()["id"] + study_b_id = post_b.json()["id"] + + # Scoped to A: returns A's study, excludes B's. + resp_a = await async_client.get(f"/api/v1/studies?cluster_id={ids_a['cluster_id']}") + assert resp_a.status_code == 200 + ids_returned_a = {row["id"] for row in resp_a.json()["data"]} + assert study_a_id in ids_returned_a + assert study_b_id not in ids_returned_a + # X-Total-Count parity — also scoped. + total_a = int(resp_a.headers["X-Total-Count"]) + assert total_a == len(ids_returned_a) + + # Scoped to B: mirrors. + resp_b = await async_client.get(f"/api/v1/studies?cluster_id={ids_b['cluster_id']}") + assert resp_b.status_code == 200 + ids_returned_b = {row["id"] for row in resp_b.json()["data"]} + assert study_b_id in ids_returned_b + assert study_a_id not in ids_returned_b + + # No cluster_id filter → both studies visible (global list still works). + resp_all = await async_client.get("/api/v1/studies") + assert resp_all.status_code == 200 + ids_returned_all = {row["id"] for row in resp_all.json()["data"]} + assert study_a_id in ids_returned_all + assert study_b_id in ids_returned_all diff --git a/docs/00_overview/DASHBOARD.md b/docs/00_overview/DASHBOARD.md index 4746b8d3..fac3c5ec 100644 --- a/docs/00_overview/DASHBOARD.md +++ b/docs/00_overview/DASHBOARD.md @@ -6,7 +6,7 @@ _Top-level index across MVP1 → GA v1+ as of **2026-05-21**. Click a release na | Release | Theme | Progress | Status | |---|---|---|---| -| [MVP1 / v0.1](MVP1_DASHBOARD.md) | The Loop | 54 / 54 scoped done · 4 remaining | **In progress** | +| [MVP1 / v0.1](MVP1_DASHBOARD.md) | The Loop | 54 / 54 scoped done · 6 remaining | **In progress** | | [MVP2 / v0.2](MVP2_DASHBOARD.md) | Observable | 1 / 1 scoped done · 1 remaining | **In progress** | | MVP3 / v0.3 | Production Stacks | — | **Not yet scoped** | | MVP4 / v0.4 | Multi-tenant, Multi-LLM | — | **Not yet scoped** | diff --git a/docs/00_overview/MVP1_DASHBOARD.md b/docs/00_overview/MVP1_DASHBOARD.md index 4fb0cefb..dd2f9c0e 100644 --- a/docs/00_overview/MVP1_DASHBOARD.md +++ b/docs/00_overview/MVP1_DASHBOARD.md @@ -15,9 +15,9 @@ Pull from the Idea backlog or capture a new feature spec. | Metric | Value | |---|---| | Scoped items done | **54 / 54** (100%) — feat_/infra_/chore_/epic_ past idea stage | -| Path to MVP1 | **4** items remaining (features + bugs + chores) | -| Open bugs | 1 | -| Open chores | 3 (idea-stage debt) | +| Path to MVP1 | **6** items remaining (features + bugs + chores) | +| Open bugs | 2 | +| Open chores | 4 (idea-stage debt) | | Backlog ideas | 2 idea-only feat/infra (not yet scoped into MVP1) | | In flight | 0 feature(s) actively shipping | @@ -105,15 +105,17 @@ _None._ _None._ -### Idea (6) +### Idea (8) | Feature | Type | One-liner | Depends on | Status | |---|---|---|---|---| | [feat_pr_metric_confidence](../02_product/planned_features/feat_pr_metric_confidence/idea.md) | Feature | When the operator's approver opens a study-backed PR in the central search-config repo, the only confidence signal in the PR body is two scalar point estimates. From [`_render_pr_body_study_backed`](. | — | Idea — surfaced during a 2026-05-20 conversation reviewing two outside articles for relevance to RelyLoop ([Doug Turnbull, "Autoresearching a better MSMarco BM25", 2026-05-17](https://softwaredoug.com/blog/2026/05/17/autoresearching-a-better-msmarco-bm25) and [Li/Wang/Wang, "Choosing the Better Bandit Algorithm under Data Sharing", arXiv:2507.11891v2](https://arxiv.org/pdf/2507.11891)). The articles themselves are not directly material to RelyLoop's roadmap; what surfaced as material — after several rounds of honest filtering — is the underlying question they prompted: **how confident should the approver be in the metric reported on the PR?** | | [feat_study_clone_from_previous](../02_product/planned_features/feat_study_clone_from_previous/idea.md) | Feature | A relevance engineer's normal workflow after the first study completes: | — | Idea — surfaced during a UX review of parameter-tuning ergonomics on 2026-05-19. | +| [chore_cluster_detail_show_target_filter](../02_product/planned_features/chore_cluster_detail_show_target_filter/idea.md) | Chore | `feat_cluster_target_filter` (PR #168) shipped the column in the DB and the input on the register modal, but the cluster detail page wasn't updated to display the value. Operators can: - See it in the | — | Idea — identified by guide-gen visual audit (guide 01 regen) | | [chore_guide_01_screenshot_refresh_target_filter](../02_product/planned_features/chore_guide_01_screenshot_refresh_target_filter/idea.md) | Chore | The guide is still operationally correct — the new field is optional, defaults to null, and doesn't change the happy-path flow described in the guide. But: | — | Idea — captured during `feat_cluster_target_filter` impl | | [chore_guide_06_screenshot_refresh_target_picker](../02_product/planned_features/chore_guide_06_screenshot_refresh_target_picker/idea.md) | Chore | The walkthrough guide 06 ("Create and monitor a study") shows the operator opening the create-study modal as part of the wizard tour. The single Step-1 screenshot now disagrees with shipped UI — opera | — | Idea — surfaced during `feat_create_study_target_autocomplete` post-impl guide-impact assessment. | | [chore_template_library_expansion](../02_product/planned_features/chore_template_library_expansion/idea.md) | Chore | Three connected gaps: | — | Idea — surfaced during a UX review of parameter-tuning ergonomics on 2026-05-19. | +| [bug_cluster_detail_studies_unfiltered](../02_product/planned_features/bug_cluster_detail_studies_unfiltered/idea.md) | Bug | - **User confusion:** the heading says "Studies using this cluster" but shows studies from every cluster. - **Information leak (mild):** a low-privilege user on the cluster detail page sees studies th | — | Bug — identified by guide-gen visual audit (guide 01 regen) | | [bug_e2e_target_dropdown_flake](../02_product/planned_features/bug_e2e_target_dropdown_flake/idea.md) | Bug | The skipped test seeds two ES indices via Playwright's `request.put` (Node), opens the create-study modal, picks the seeded cluster via the cluster ``… | — | Idea — surfaced during `feat_create_study_target_autocomplete` Story F2 implementation; the new E2E happy-path spec is currently `test.skip`'d. | ## Dependency graph diff --git a/docs/00_overview/dashboard.html b/docs/00_overview/dashboard.html index 8774a07f..797f9dbe 100644 --- a/docs/00_overview/dashboard.html +++ b/docs/00_overview/dashboard.html @@ -371,7 +371,7 @@

Releases

The Loop
-
54 / 54 scoped done · 4 remaining
+
54 / 54 scoped done · 6 remaining
In progress
diff --git a/docs/00_overview/mvp1_dashboard.html b/docs/00_overview/mvp1_dashboard.html index ecbcd921..b6512b17 100644 --- a/docs/00_overview/mvp1_dashboard.html +++ b/docs/00_overview/mvp1_dashboard.html @@ -390,17 +390,17 @@

MVP1 Progress

Path to MVP1
-
4
+
6
items left = features + bugs + chores
Open bugs
-
1
+
2
tracked bug_* idea files
Open chores
-
3
+
4
idea-stage chore_* (debt)
@@ -428,7 +428,7 @@

Pipeline

-

Idea 6

+

Idea 8

@@ -454,6 +454,18 @@

Idea 6

+
+ +
+ Chore + +
+
`feat_cluster_target_filter` (PR #168) shipped the column in the DB and the input on the register modal, but the cluster detail page wasn't updated to display the value. Operators can: - See it in the
+ + +
+ +
@@ -490,6 +502,18 @@

Idea 6

+
+ +
+ Bug + +
+
- **User confusion:** the heading says "Studies using this cluster" but shows studies from every cluster. - **Information leak (mild):** a low-privilege user on the cluster detail page sees studies th
+ + +
+ +
diff --git a/docs/02_product/planned_features/chore_guide_01_screenshot_refresh_target_filter/idea.md b/docs/02_product/planned_features/chore_guide_01_screenshot_refresh_target_filter/idea.md index 9072c260..df54a10c 100644 --- a/docs/02_product/planned_features/chore_guide_01_screenshot_refresh_target_filter/idea.md +++ b/docs/02_product/planned_features/chore_guide_01_screenshot_refresh_target_filter/idea.md @@ -42,6 +42,56 @@ so the create-study modal only shows matching indices for this cluster"). Scope estimate: ~30 minutes (Playwright run is ~2min, caption edits ~10min, review ~10min). +### Use realistic seed-scenario data (NOT `walkthrough-{6chars}` placeholders) + +The current spec at +[`ui/tests/e2e/guides/01_register_first_cluster.spec.ts:25`](../../../../ui/tests/e2e/guides/01_register_first_cluster.spec.ts#L25) +generates a throwaway cluster name like `walkthrough-a3b9c1` and uses +`local-es` for the credentials ref. That works mechanically but the +resulting screenshots look like dev-test artifacts, not a real operator's +first cluster. The screenshot reader's first impression should be **a +relatable production-style scenario**, mirroring what `make seed-demo` +already plants into a fresh dev DB ([`scripts/seed_meaningful_demos.py`](../../../../scripts/seed_meaningful_demos.py)). + +When `/guide-gen 01 --regen` runs, update the spec to use the +**acme-products-prod** scenario verbatim from the seed file (the first +e-commerce scenario — most relatable for the first-touch screenshot). +Keep a `randomUUID().slice(0, 6)` suffix on the cluster name so the +test doesn't collide with an already-seeded `acme-products-prod` row; +everything else should match the seed: + +| Field | Value | Source | +|---|---|---| +| Name | `acme-products-prod-${uuid6}` | mirrors seed slug + collision suffix | +| Engine type | `elasticsearch` | seed | +| Base URL | `http://elasticsearch:9200` | seed (host-network alias inside the Compose network) | +| Auth kind | `es_basic` | seed | +| Credentials ref | `local-es` | seed | +| Environment | `prod` | seed | +| Notes | `"Production Elasticsearch cluster — e-commerce product search."` | new, infers from seed's "e-commerce" framing | +| **Target filter** | `products*` | seed — the whole point of this guide refresh | + +The Step 6 detail-page screenshot then shows a realistic operator landing +page with the target filter glob visible — which is exactly what the +caption update should call out. + +**Caption update for the new Target filter step** (between Step 03 and 04 in the existing guide): + +> "**Optional: restrict this cluster's index picker.** Many production +> Elasticsearch clusters host indices for multiple products or teams. +> Set Target filter to a glob like `products*` so when you later +> create a study against this cluster, the index picker only shows +> matching indices instead of every index on the box. Brace expansion +> isn't supported (`docs-{en,fr}*` won't work); use multiple registrations +> or a wider glob like `docs-*`." + +**Important — keep the test self-contained.** The Playwright spec +shouldn't depend on `make seed-demo` having run; it just borrows the +seed scenario's *naming + field values* to produce believable screenshots. +The spec still creates its own cluster via the modal (with the +collision-suffixed name) so a clean dev stack will render the guide +correctly without any prerequisite seed step. + ## Sibling coordination Pairs with the (now-merged) diff --git a/ui/public/guides/01_register_first_cluster/01-clusters-list.png b/ui/public/guides/01_register_first_cluster/01-clusters-list.png index 5c94d9e8..b399eae3 100644 Binary files a/ui/public/guides/01_register_first_cluster/01-clusters-list.png and b/ui/public/guides/01_register_first_cluster/01-clusters-list.png differ diff --git a/ui/public/guides/01_register_first_cluster/02-register-modal-empty.png b/ui/public/guides/01_register_first_cluster/02-register-modal-empty.png index ecd11556..d1e35e0a 100644 Binary files a/ui/public/guides/01_register_first_cluster/02-register-modal-empty.png and b/ui/public/guides/01_register_first_cluster/02-register-modal-empty.png differ diff --git a/ui/public/guides/01_register_first_cluster/03-register-modal-filled.png b/ui/public/guides/01_register_first_cluster/03-register-modal-filled.png index 678ea8b6..c5761414 100644 Binary files a/ui/public/guides/01_register_first_cluster/03-register-modal-filled.png and b/ui/public/guides/01_register_first_cluster/03-register-modal-filled.png differ diff --git a/ui/public/guides/01_register_first_cluster/04-cluster-registered.png b/ui/public/guides/01_register_first_cluster/04-cluster-registered.png index 1567b4fc..fc703733 100644 Binary files a/ui/public/guides/01_register_first_cluster/04-cluster-registered.png and b/ui/public/guides/01_register_first_cluster/04-cluster-registered.png differ diff --git a/ui/public/guides/01_register_first_cluster/05-cluster-detail.png b/ui/public/guides/01_register_first_cluster/05-cluster-detail.png index dcc83b73..da72f0cd 100644 Binary files a/ui/public/guides/01_register_first_cluster/05-cluster-detail.png and b/ui/public/guides/01_register_first_cluster/05-cluster-detail.png differ diff --git a/ui/public/guides/01_register_first_cluster/metadata.json b/ui/public/guides/01_register_first_cluster/metadata.json index 3663ac7f..8850bbbd 100644 --- a/ui/public/guides/01_register_first_cluster/metadata.json +++ b/ui/public/guides/01_register_first_cluster/metadata.json @@ -1,33 +1,33 @@ { "title": "Register your first cluster", - "description": "Add an Elasticsearch or OpenSearch cluster to RelyLoop and verify the connection probe succeeds.", + "description": "Add an Elasticsearch or OpenSearch cluster to RelyLoop, scope its index picker with the optional Target filter, and verify the connection probe succeeds.", "order": 1, "tags": [ "getting-started", "clusters", "setup" ], - "estimated_time": "2 minutes", + "estimated_time": "3 minutes", "screenshots": [ { "file": "01-clusters-list.png", - "caption": "Open the Clusters page. The list shows every registered Elasticsearch and OpenSearch cluster \u2014 empty on a fresh install. Click 'Register cluster' in the top right to begin." + "caption": "Open the Clusters page. The list shows every registered Elasticsearch and OpenSearch cluster — here, four meaningful demo scenarios already seeded by `make seed-demo`. Click 'Register cluster' in the top right to add your own." }, { "file": "02-register-modal-empty.png", - "caption": "The registration modal opens with sensible defaults: Engine = elasticsearch, Environment = dev, Auth kind = es_apikey. Fill the Name, Base URL, and Credentials ref fields." + "caption": "The registration modal opens with sensible defaults: Engine = elasticsearch, Environment = dev, Auth kind = es_apikey. Fill the Name, Base URL, and Credentials ref fields — and scroll down within the modal to reveal the Notes and Target filter inputs further below." }, { "file": "03-register-modal-filled.png", - "caption": "For the local Compose stack: Base URL is http://elasticsearch:9200 and Credentials ref is local-es. Switch Auth kind to es_basic \u2014 the local-es secret stores username + password, not an API key. (The placeholder credentials are pre-mounted by `make up`.)" + "caption": "Fill in realistic values for a production e-commerce cluster: Name = acme-products-prod-*, Base URL = http://elasticsearch:9200, Credentials ref = local-es, Environment = prod, Auth kind = es_basic. Set the optional Target filter to a glob like `products*` so this cluster's index picker (used later by the create-study modal) only shows matching indices instead of every index on the box. Brace expansion (`docs-{en,fr}*`) isn't supported — use a wider glob like `docs-*` or register multiple clusters. Scroll to the Register button and click it." }, { "file": "04-cluster-registered.png", - "caption": "Click Register. RelyLoop fires a connection probe through the engine adapter \u2014 if the cluster is reachable, it's added to the list with a green health badge. CLUSTER_UNREACHABLE means the URL or credentials are wrong." + "caption": "RelyLoop fires a connection probe through the engine adapter. If the cluster is reachable, it's added to the list with a health badge (green when healthy, yellow when only partially reachable, red on `CLUSTER_UNREACHABLE`). The toast in the bottom right confirms the registration and the probe result." }, { "file": "05-cluster-detail.png", - "caption": "Click the cluster row to open its detail page. From here you can see the live health probe result, the credentials reference, and any studies that have run against this cluster." + "caption": "Click the new cluster row to open its detail page. You see the live health probe result, version, base URL, auth kind, notes, and the Target filter you set (here `products*` scopes this cluster's index picker). The 'Studies using this cluster' section underneath is empty for a fresh cluster — it fills in once you start running studies against it. Guide 06 walks you through creating one." } ], "video": "walkthrough.webm" diff --git a/ui/public/guides/01_register_first_cluster/script.md b/ui/public/guides/01_register_first_cluster/script.md index 8b90024f..6fc3b0f0 100644 --- a/ui/public/guides/01_register_first_cluster/script.md +++ b/ui/public/guides/01_register_first_cluster/script.md @@ -1,6 +1,6 @@ # Register your first cluster -> 2-minute walkthrough — first step of the Karpathy loop. +> 3-minute walkthrough — first step of the Karpathy loop. RelyLoop optimizes search relevance off-line, but it needs to know *which* cluster to tune against. A "cluster" record carries the URL, engine type, @@ -11,37 +11,56 @@ a cluster by ID. ## Steps 1. **Open the Clusters page.** Click "Clusters" in the top nav. On a fresh - install the list is empty; subsequent registrations append rows. + install the list is empty; if you ran `make seed-demo` you'll see four + meaningful demo scenarios (acme-products-prod, corp-docs-search, + news-search-staging, jobs-marketplace-prod) — register your own to add + another row. 2. **Click "Register cluster" in the top right.** A modal opens with the - defaults pre-filled. + defaults pre-filled. The form extends below the visible viewport — + scroll within the modal to see every field including the optional + Notes and Target filter inputs. -3. **Fill the form:** - - **Name** — lowercase + dashes only (e.g., `local-es`, `prod-search-1`). +3. **Fill the form** with realistic values for a production e-commerce + cluster: + - **Name** — lowercase + dashes only (e.g., `acme-products-prod`). - **Engine** — elasticsearch or opensearch. - - **Environment** — dev / staging / prod. + - **Environment** — `prod` for production clusters, `staging`/`dev` + otherwise. - **Base URL** — `http://elasticsearch:9200` for the local Compose stack (use the internal Docker hostname, not `localhost`, because the API container probes the cluster from inside the network). - - **Auth kind** — es_apikey + an API key, OR es_basic + username/password. - The local-es fixture uses es_basic. + - **Auth kind** — `es_apikey` + an API key, OR `es_basic` + username/ + password. The local-es fixture uses `es_basic`. - **Credentials ref** — the filename under `./secrets/` holding the credential. `local-es` is pre-mounted by `make up`. + - **Notes** — a free-form description of the cluster's purpose + (e.g., "Production Elasticsearch cluster — e-commerce product search"). + - **Target filter (optional)** — a glob that scopes this cluster's + index picker. Set it to `products*` and the create-study modal's + index dropdown will only show matching indices for this cluster + instead of every index on the box. Brace expansion + (`docs-{en,fr}*`) isn't supported — use a wider glob like `docs-*` + or register multiple clusters. 4. **Submit.** RelyLoop calls the adapter's `verify_credentials()` probe - against the cluster. Reachable + authenticated clusters land in the list - with a green health badge; unreachable ones return - `CLUSTER_UNREACHABLE` with the underlying error in the response body. + against the cluster. Reachable + authenticated clusters land in the + list with a health badge (green when healthy, yellow when partially + reachable, red on `CLUSTER_UNREACHABLE`). The toast in the bottom + right confirms the registration and the probe result. 5. **Click the row** to see the cluster's detail page — health probe, - credentials reference, and the studies that have run against it. + version, base URL, auth kind, your notes, the Target filter you set, + and the studies that have run against this cluster (empty for a + freshly-registered cluster). ## Next Now that you have a cluster registered, create a query set to tune for: -see [Guide 02: Create a query set + judgments](#). +see [Guide 04: Create a query set](#). ## Reference -- API: `POST /api/v1/clusters` with `{name, engine_type, environment, base_url, auth_kind, credentials_ref}` +- API: `POST /api/v1/clusters` with `{name, engine_type, environment, base_url, auth_kind, credentials_ref, notes?, target_filter?}` - Bulk-register the tutorial clusters: `make seed-clusters` registers `local-es` + `local-opensearch` +- Seed 4 realistic demo scenarios with target filters baked in: `make seed-demo` diff --git a/ui/src/__tests__/components/clusters/cluster-detail-summary.test.tsx b/ui/src/__tests__/components/clusters/cluster-detail-summary.test.tsx new file mode 100644 index 00000000..9139b37c --- /dev/null +++ b/ui/src/__tests__/components/clusters/cluster-detail-summary.test.tsx @@ -0,0 +1,46 @@ +/** + * Unit tests for ClusterDetailSummary's target_filter rendering + * (chore_cluster_detail_show_target_filter — bundled into the guide-01 regen + * PR after the visual audit surfaced the missing field). + */ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +import { ClusterDetailSummary } from '@/components/clusters/cluster-detail-summary'; +import type { ClusterDetail } from '@/lib/api/clusters'; + +const BASE_CLUSTER: ClusterDetail = { + id: 'c-1', + name: 'acme-products-prod', + engine_type: 'elasticsearch', + environment: 'prod', + base_url: 'http://elasticsearch:9200', + auth_kind: 'es_basic', + engine_config: null, + notes: null, + target_filter: null, + created_at: '2026-05-21T00:00:00Z', + health_check: { + status: 'green', + version: '9.4.0', + checked_at: '2026-05-21T00:00:00Z', + error: null, + }, +}; + +describe('ClusterDetailSummary — target_filter', () => { + it('renders the glob value when set', () => { + render(); + expect(screen.getByText('Target filter')).toBeInTheDocument(); + expect(screen.getByText('products*')).toBeInTheDocument(); + }); + + it('renders an em-dash placeholder when null', () => { + render(); + expect(screen.getByText('Target filter')).toBeInTheDocument(); + // The dd contains a muted "—" span when target_filter is null. + const targetFilterLabel = screen.getByText('Target filter'); + const dd = targetFilterLabel.parentElement?.querySelector('dd'); + expect(dd?.textContent).toBe('—'); + }); +}); diff --git a/ui/src/components/clusters/cluster-detail-summary.tsx b/ui/src/components/clusters/cluster-detail-summary.tsx index ac5e0462..bbb3f98b 100644 --- a/ui/src/components/clusters/cluster-detail-summary.tsx +++ b/ui/src/components/clusters/cluster-detail-summary.tsx @@ -52,6 +52,12 @@ export function ClusterDetailSummary({ cluster }: ClusterDetailSummaryProps) {
{cluster.notes}
)} +
+
Target filter
+
+ {cluster.target_filter ?? } +
+
diff --git a/ui/src/components/guides/guide-types.ts b/ui/src/components/guides/guide-types.ts index 67a1f08a..a940ac5c 100644 --- a/ui/src/components/guides/guide-types.ts +++ b/ui/src/components/guides/guide-types.ts @@ -93,8 +93,8 @@ export const GUIDE_REGISTRY: GuideRegistryEntry[] = [ id: '01_register_first_cluster', title: 'Register your first cluster', description: - 'Add an Elasticsearch or OpenSearch cluster to RelyLoop and verify the connection probe succeeds.', - estimatedTime: '2 minutes', + 'Add an Elasticsearch or OpenSearch cluster to RelyLoop, scope its index picker with the optional Target filter, and verify the connection probe succeeds.', + estimatedTime: '3 minutes', }, { id: '02_review_a_proposal', diff --git a/ui/tests/e2e/guides/01_register_first_cluster.spec.ts b/ui/tests/e2e/guides/01_register_first_cluster.spec.ts index 1c14f013..b31bc1ad 100644 --- a/ui/tests/e2e/guides/01_register_first_cluster.spec.ts +++ b/ui/tests/e2e/guides/01_register_first_cluster.spec.ts @@ -1,9 +1,17 @@ /** * Walkthrough: Register your first cluster (guide 01). * - * Captures the operator's first-time cluster-registration journey: - * land on /clusters → open the modal → fill the form → submit → see the - * new row in the list with health status. + * Captures the operator's first-time cluster-registration journey using the + * **acme-products-prod** scenario from `scripts/seed_meaningful_demos.py` so + * the screenshots look like a real production e-commerce cluster rather than + * a `walkthrough-{6hex}` dev-test artifact. The Target filter input + * (`feat_cluster_target_filter`, PR #168) is filled with `products*` to teach + * the per-cluster index-scoping feature. + * + * The spec is self-contained — it does NOT depend on `make seed-demo` having + * run; it just borrows the scenario's naming + field values. The cluster name + * is suffixed with `randomUUID().slice(0, 6)` so reruns and seeded state don't + * collide. * * Usage: * cd ui @@ -21,7 +29,13 @@ const SCREENSHOTS = path.resolve(__dirname, '../../../public/guides/01_register_ test.describe('Walkthrough: Register your first cluster', () => { test('captures the full cluster-registration journey', async ({ page }) => { - const name = `walkthrough-${randomUUID().slice(0, 6)}`; + // Mirror scripts/seed_meaningful_demos.py SCENARIOS[0] (acme-products-prod) + // with a UUID suffix so reruns and the already-seeded canonical cluster + // don't collide. Everything else (engine, URL, auth, creds, env, target + // filter) comes verbatim from the seed scenario. + const name = `acme-products-prod-${randomUUID().slice(0, 6)}`; + const notes = 'Production Elasticsearch cluster — e-commerce product search.'; + const targetFilter = 'products*'; // ── 01: Land on /clusters list ───────────────────────────────────── await page.goto('/clusters'); @@ -42,19 +56,31 @@ test.describe('Walkthrough: Register your first cluster', () => { fullPage: false, }); - // ── 03: Fill the form ────────────────────────────────────────────── + // ── 03: Fill the form (acme-products-prod realistic values) ─────── await page.getByLabel('Name', { exact: true }).fill(name); await page.getByLabel('Base URL', { exact: true }).fill('http://elasticsearch:9200'); await page.getByLabel(/^Credentials ref/).fill('local-es'); + // The acme scenario uses Production environment. + await page.locator('#cl-env').click(); + await page.getByRole('option', { name: 'prod' }).click(); + // local-es credentials are username+password; switch auth_kind to match. await page.locator('#cl-auth').click(); await page.getByRole('option', { name: 'es_basic' }).click(); + // Notes: describe the scenario in operator-relatable language. + await page.getByLabel('Notes', { exact: true }).fill(notes); + + // Target filter: scope this cluster's index picker to the e-commerce + // products family. The caption for this step teaches the new feature. + await page.getByLabel(/^Target filter/).fill(targetFilter); + + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await page.waitForTimeout(400); await page.screenshot({ path: path.join(SCREENSHOTS, '03-register-modal-filled.png'), - fullPage: false, + fullPage: true, }); // ── 04: Submit + wait for the 201 ─────────────────────────────────