From a3ae8ad04285161bd1d141f0dd9101b8b6bf5107 Mon Sep 17 00:00:00 2001 From: Aldon Smith Date: Wed, 27 May 2026 12:38:59 -0400 Subject: [PATCH 1/6] feat: add overview context questions --- apps/web/README.md | 7 + apps/web/app/context-question-panel.tsx | 114 +++++ apps/web/app/globals.css | 171 +++++++ apps/web/app/layout.tsx | 6 +- apps/web/app/page.tsx | 2 + apps/web/app/process-sentinel/page.tsx | 49 ++ .../web/e2e/operations-workbench-demo.spec.ts | 15 +- apps/web/lib/api-client.ts | 24 + apps/web/tests/api-client.test.mjs | 5 + apps/web/tests/app-shell.test.mjs | 35 ++ docs/LEARNING_LOG.md | 57 +++ ...IONS_WORKBENCH_INFORMATION_ARCHITECTURE.md | 20 +- services/api/README.md | 17 + services/api/factory_api/context_questions.py | 443 ++++++++++++++++++ services/api/factory_api/main.py | 29 +- services/api/tests/test_api.py | 153 ++++++ 16 files changed, 1142 insertions(+), 5 deletions(-) create mode 100644 apps/web/app/context-question-panel.tsx create mode 100644 apps/web/app/process-sentinel/page.tsx create mode 100644 services/api/factory_api/context_questions.py diff --git a/apps/web/README.md b/apps/web/README.md index 199daea..b881834 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -86,6 +86,12 @@ RCA/CAPA draft panels should be treated as missing runtime data until Demo-Factory, FIP Compose, connector ingestion, and Process Sentinel have all run. +The overview dashboard includes a deterministic context question form that +posts to `POST /context/questions`. It answers only from current browser-safe +API data such as domain context, Sentinel detections, evidence, +recommendations, runtime health, and connection profile summaries. It does not +call an LLM, AI SDK, model gateway, RAG index, or external provider. + For focused Workbench development outside the full Compose stack, run the API directly from the repository root: @@ -96,6 +102,7 @@ make api ## Routes - `/` - overview dashboard +- `/process-sentinel` - Process Sentinel workflow entry point - `/connections` - OPC-UA, MQTT, and BACnet profile management with redacted credential references - `/protocol-diagnostics` - read-only connection health and mapping diagnostics diff --git a/apps/web/app/context-question-panel.tsx b/apps/web/app/context-question-panel.tsx new file mode 100644 index 0000000..0aba1b4 --- /dev/null +++ b/apps/web/app/context-question-panel.tsx @@ -0,0 +1,114 @@ +"use client"; + +import Link from "next/link"; +import { FormEvent, useId, useState } from "react"; + +import { + type ContextQuestionResponse, + formatApiError, + workbenchApi, +} from "../lib/api-client"; + +const exampleQuestions = [ + "What is the most important finding right now?", + "Why was the primary detection flagged?", + "How many recommendations are pending review?", +]; +const confidenceLabels: Record = { + partial: "partial", + supported: "supported", + unsupported: "unsupported", +}; + +export function ContextQuestionPanel() { + const questionId = useId(); + const [question, setQuestion] = useState(""); + const [answer, setAnswer] = useState(null); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + const trimmedQuestion = question.trim(); + if (!trimmedQuestion || isSubmitting) { + return; + } + setIsSubmitting(true); + setError(null); + try { + setAnswer(await workbenchApi.askContextQuestion({ question: trimmedQuestion })); + } catch (submitError) { + setAnswer(null); + setError(formatApiError(submitError)); + } finally { + setIsSubmitting(false); + } + } + + const suggestions = answer?.suggested_questions.length + ? answer.suggested_questions + : exampleQuestions; + + return ( +
+

Ask about current factory context

+
+ +
+ setQuestion(event.target.value)} + placeholder="Ask about detections, evidence, recommendations, source health, or current batch..." + type="text" + value={question} + /> + +
+
+ +
+ {error ? ( +

Question failed: {error}

+ ) : null} + {answer ? ( +
+
+ + {confidenceLabels[answer.confidence]} + + {answer.question} +
+

{answer.answer}

+ {answer.sources.length > 0 ? ( +
    + {answer.sources.map((source) => ( +
  • + + {source.label} + {source.type} + +
  • + ))} +
+ ) : null} + {answer.confidence === "unsupported" ? ( +
+ Try asking +
    + {suggestions.map((suggestion) => ( +
  • {suggestion}
  • + ))} +
+
+ ) : null} +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 0ef5116..b919bf0 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -270,6 +270,173 @@ a { margin-top: 24px; } +.context-question-panel { + display: grid; + gap: 10px; + margin: 0 0 18px; +} + +.context-question-panel h2 { + margin-bottom: 0; + color: var(--muted); + font-size: 0.78rem; + font-weight: 760; + text-transform: uppercase; +} + +.context-question-form { + display: grid; + gap: 7px; +} + +.context-question-form label { + color: var(--muted); + font-size: 0.78rem; + font-weight: 760; + text-transform: uppercase; +} + +.context-question-form div { + display: flex; + gap: 8px; +} + +.context-question-form input { + width: 100%; + min-width: 0; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface); + color: var(--text); + font: inherit; + line-height: 1.5; + padding: 12px 13px; +} + +.context-question-form input:focus { + border-color: var(--accent); + outline: 3px solid var(--focus-ring); + outline-offset: 1px; +} + +.context-question-form button { + min-width: 92px; + border: 1px solid var(--accent); + border-radius: 7px; + background: var(--accent); + color: #ffffff; + cursor: pointer; + font: inherit; + font-weight: 760; + line-height: 1; + padding: 12px 14px; +} + +.context-question-form button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.context-question-form button:hover:not(:disabled), +.context-question-form button:focus-visible { + background: var(--accent-strong); + outline: 3px solid var(--focus-ring); + outline-offset: 2px; +} + +.context-question-result { + min-height: 1.25em; +} + +.context-question-result article { + display: grid; + gap: 10px; + border-left: 4px solid var(--accent); + background: #e7f3f1; + color: #21423e; + padding: 14px; +} + +.context-question-result p { + margin-bottom: 0; + line-height: 1.5; +} + +.context-question-result-heading { + display: grid; + gap: 6px; +} + +.context-confidence { + width: fit-content; + border: 1px solid #9bd3ca; + border-radius: 999px; + background: var(--accent-bg); + color: var(--accent-strong); + font-size: 0.72rem; + font-weight: 800; + line-height: 1; + padding: 6px 8px; + text-transform: uppercase; +} + +.context-confidence-partial { + border-color: #f5d38d; + background: var(--warning-bg); + color: var(--warning); +} + +.context-confidence-unsupported { + border-color: #c3c9d1; + background: #f2f4f7; + color: var(--draft); +} + +.context-source-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.context-source-list a { + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid #9bd3ca; + border-radius: 999px; + background: var(--surface); + color: var(--accent-strong); + font-size: 0.84rem; + font-weight: 760; + line-height: 1.2; + padding: 7px 10px; +} + +.context-source-list a:hover, +.context-source-list a:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 1px; +} + +.context-source-list span { + color: var(--muted); + font-size: 0.72rem; + text-transform: uppercase; +} + +.context-question-suggestions { + display: grid; + gap: 6px; +} + +.context-question-suggestions ul { + margin: 0; + padding-left: 18px; +} + .demo-label { display: inline-flex; width: fit-content; @@ -2014,6 +2181,10 @@ code { width: 100%; } + .context-question-form div { + display: grid; + } + .api-connection-details { grid-template-columns: 1fr; } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index cd1a70f..aa3f7db 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -20,9 +20,13 @@ type NavGroup = { }; const navGroups: NavGroup[] = [ + { + items: [{ href: "/", label: "Overview" }], + label: "Platform", + }, { items: [ - { href: "/", label: "Overview" }, + { href: "/process-sentinel", label: "Process Sentinel" }, { href: "/detections", label: "Detections" }, { href: "/recommendations", label: "Recommendations" }, { href: "/rca-capa-draft", label: "RCA/CAPA Draft" }, diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 34d2886..87a3673 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -5,6 +5,7 @@ import { ApiErrorPanel, StatusBadge, } from "./components/demo-state"; +import { ContextQuestionPanel } from "./context-question-panel"; import { type Area, type Batch, @@ -40,6 +41,7 @@ export default async function OverviewPage() {

{overview.ok ? overview.context.siteName : "Operations Workbench"}

+

{overview.ok ? overview.context.siteDescription diff --git a/apps/web/app/process-sentinel/page.tsx b/apps/web/app/process-sentinel/page.tsx new file mode 100644 index 0000000..4ce4aec --- /dev/null +++ b/apps/web/app/process-sentinel/page.tsx @@ -0,0 +1,49 @@ +import Link from "next/link"; + +export const dynamic = "force-dynamic"; + +export default function ProcessSentinelPage() { + return ( +

+
+

Process Sentinel

+

+ Follow the read-only Process Sentinel workflow from detection, to + evidence explanation, to governed recommendation review, to RCA/CAPA + draft preview. +

+
+
+ Process Sentinel is advisory decision support over the current + external-source event store. It does not perform industrial writeback, + product disposition, QMS/MES updates, or production CAPA creation. +
+
+
+ Detect +

Detections

+

Review active Process Sentinel findings and open the primary evidence path.

+ + Open detections + +
+
+ Review +

Recommendations

+

Record a human approval, rejection, or deferral for advisory recommendations.

+ + Open recommendations + +
+
+ Draft +

RCA/CAPA Draft

+

Preview investigation draft language generated from current Sentinel state.

+ + Open RCA/CAPA draft + +
+
+
+ ); +} diff --git a/apps/web/e2e/operations-workbench-demo.spec.ts b/apps/web/e2e/operations-workbench-demo.spec.ts index 5fc2759..4928bf9 100644 --- a/apps/web/e2e/operations-workbench-demo.spec.ts +++ b/apps/web/e2e/operations-workbench-demo.spec.ts @@ -23,6 +23,19 @@ test("walks the external-source Operations Workbench demo path", async ({ page } await expect(page.getByText("Read-only diagnostics")).toBeVisible(); await expect(page.getByText("Writeback")).toBeVisible(); await expect(page.getByText("Disabled", { exact: true })).toBeVisible(); + const contextQuestion = page.getByRole("textbox", { + name: "Ask about current factory context", + }); + await expect(contextQuestion).toBeVisible(); + await page + .getByRole("textbox", { name: "Ask about current factory context" }) + .fill("What is the most important finding right now?"); + await page.getByRole("button", { name: "Ask" }).click(); + await expect(page.getByText("supported", { exact: true })).toBeVisible(); + await expect(page.getByText("highest-priority finding")).toBeVisible(); + await page.locator(`a[href="/detections/${detectionId}"]`).first().click(); + await expect(page.getByRole("heading", { name: "Detection detail" })).toBeVisible(); + await page.locator('a[href="/"]').first().click(); await page.locator('a[href="/connections"]').first().click(); const duplicateSourceProfiles = await createDuplicateSourceConnectionProfiles(page); await page.locator('a[href="/protocol-diagnostics"]').first().click(); @@ -87,7 +100,7 @@ test("walks the external-source Operations Workbench demo path", async ({ page } await expect(page.getByRole("region", { name: "Local API connection state" })).toBeVisible(); await expect(page.getByText("API target").first()).toBeVisible(); await expect(page.getByText("Health", { exact: true }).first()).toBeVisible(); - await expect(page.getByText("external_demo_factory")).toBeVisible(); + await expect(page.getByText("external_demo_factory").first()).toBeVisible(); await expect(page.getByRole("heading", { name: "Greenville Demo Site" })).toBeVisible(); await expect(page.getByText("Active detections")).toBeVisible(); await expect(page.getByText("Pending recommendations")).toBeVisible(); diff --git a/apps/web/lib/api-client.ts b/apps/web/lib/api-client.ts index 760e482..0be95f2 100644 --- a/apps/web/lib/api-client.ts +++ b/apps/web/lib/api-client.ts @@ -222,6 +222,25 @@ export type RecommendationDecisionRequest = { reason: string; }; +export type ContextQuestionRequest = { + question: string; +}; + +export type ContextQuestionSource = { + type: string; + id: string; + label: string; + href: string; +}; + +export type ContextQuestionResponse = { + question: string; + answer: string; + confidence: "supported" | "partial" | "unsupported"; + sources: ContextQuestionSource[]; + suggested_questions: string[]; +}; + export type ApprovalDecision = { approval_id: string; recommendation_id: string; @@ -300,6 +319,11 @@ export const workbenchApi = { listEquipment: () => requestJson("/equipment"), listProcessSignals: () => requestJson("/process-signals"), listBatches: () => requestJson("/batches"), + askContextQuestion: (request: ContextQuestionRequest) => + requestJson("/context/questions", { + body: JSON.stringify(request), + method: "POST", + }), listDetections: () => requestJson("/sentinel/detections"), getDetection: (detectionId: string) => requestJson(`/sentinel/detections/${encodeURIComponent(detectionId)}`), diff --git a/apps/web/tests/api-client.test.mjs b/apps/web/tests/api-client.test.mjs index 16e2826..b5ecfff 100644 --- a/apps/web/tests/api-client.test.mjs +++ b/apps/web/tests/api-client.test.mjs @@ -19,6 +19,7 @@ const requiredEndpoints = [ "/equipment", "/process-signals", "/batches", + "/context/questions", "/sentinel/detections", "/sentinel/detections/${encodeURIComponent(detectionId)}", "/sentinel/detections/${encodeURIComponent(detectionId)}/evidence", @@ -50,6 +51,10 @@ test("typed workbench API client covers demo endpoints", () => { assert.match(client, /listEquipment/); assert.match(client, /listProcessSignals/); assert.match(client, /listBatches/); + assert.match(client, /askContextQuestion/); + assert.match(client, /export type ContextQuestionResponse/); + assert.match(client, /confidence: "supported" \| "partial" \| "unsupported"/); + assert.match(client, /sources: ContextQuestionSource\[\]/); assert.match(client, /listDetections/); assert.match(client, /getDetection/); assert.match(client, /listDetectionEvidence/); diff --git a/apps/web/tests/app-shell.test.mjs b/apps/web/tests/app-shell.test.mjs index 90465dc..fdaf074 100644 --- a/apps/web/tests/app-shell.test.mjs +++ b/apps/web/tests/app-shell.test.mjs @@ -9,6 +9,8 @@ const root = fileURLToPath(new URL("..", import.meta.url)); const requiredRoutes = [ "app/page.tsx", + "app/context-question-panel.tsx", + "app/process-sentinel/page.tsx", "app/connections/page.tsx", "app/connections/connections-workspace.tsx", "app/protocol-diagnostics/page.tsx", @@ -36,8 +38,13 @@ test("navigation includes the required demo routes", () => { assert.match(layout, /operator-sidebar/); assert.match(layout, /Workbench navigation/); assert.match(layout, /Operator Console/); + assert.match(layout, /Platform/); + assert.match(layout, /label: "Platform"/); assert.match(layout, /Sentinel workflows/); assert.match(layout, /Overview/); + assert.match(layout, /href: "\/"/); + assert.match(layout, /Process Sentinel/); + assert.match(layout, /href: "\/process-sentinel"/); assert.match(layout, /Detections/); assert.match(layout, /Recommendations/); assert.match(layout, /RCA\/CAPA Draft/); @@ -247,8 +254,17 @@ test("operator shell uses the Demo Factory console palette", () => { test("overview page contains manufacturer demo dashboard content", () => { const overview = readFileSync(join(root, "app/page.tsx"), "utf8"); + const contextQuestion = readFileSync(join(root, "app/context-question-panel.tsx"), "utf8"); const demoState = readFileSync(join(root, "app/components/demo-state.tsx"), "utf8"); + assert.match(overview, /ContextQuestionPanel/); + assert.match(contextQuestion, /Ask about current factory context/); + assert.match(contextQuestion, /workbenchApi\.askContextQuestion/); + assert.match(contextQuestion, /Context answer sources/); + assert.match(contextQuestion, /suggested_questions/); + assert.match(contextQuestion, /supported/); + assert.match(contextQuestion, /partial/); + assert.match(contextQuestion, /unsupported/); assert.match(overview, /Current demo context/); assert.match(overview, /Active detections/); assert.match(overview, /Pending recommendations/); @@ -264,6 +280,25 @@ test("overview page contains manufacturer demo dashboard content", () => { assert.match(demoState, /Read-only runtime expected/); }); +test("process sentinel has a workflow entry route separate from overview", () => { + const route = readFileSync(join(root, "app/process-sentinel/page.tsx"), "utf8"); + const readme = readFileSync(join(root, "README.md"), "utf8"); + const ia = readFileSync( + join(root, "../../docs/demo/OPERATIONS_WORKBENCH_INFORMATION_ARCHITECTURE.md"), + "utf8", + ); + + assert.match(route, /Process Sentinel/); + assert.match(route, /Process Sentinel workflow/); + assert.match(route, /Open detections/); + assert.match(route, /Open recommendations/); + assert.match(route, /Open RCA\/CAPA draft/); + assert.match(route, /does not perform industrial writeback/); + assert.match(readme, /\/process-sentinel/); + assert.match(ia, /\/process-sentinel/); + assert.match(ia, /Overview is global factory context/); +}); + test("detections pages contain list and detail content", () => { const list = readFileSync(join(root, "app/detections/page.tsx"), "utf8"); const detail = readFileSync(join(root, "app/detections/[detectionId]/page.tsx"), "utf8"); diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index bb30eb6..01e220d 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -22,6 +22,63 @@ This file should be updated by Codex after each meaningful change. ### What to learn next ``` +## 2026-05-27 - Overview context questions + +### What changed + +Added a global overview context question flow and moved Process Sentinel into +its own workflow entry route at `/process-sentinel`. + +### Why it matters + +The Workbench landing page now reads as factory-level runtime context instead +of the first step in the Process Sentinel workflow. Operators can ask supported +questions about current context, detections, evidence, recommendations, and +source health without introducing model calls or writeback behavior. + +### How it works + +The API exposes `POST /context/questions`. The endpoint uses deterministic +keyword intent matching over existing browser-safe data: domain context, +Sentinel detections, evidence, recommendations, runtime health, and connection +profile summaries. Unsupported questions return `confidence: "unsupported"` +with example questions instead of guessing. + +### How to run it + +```bash +make api +cd apps/web +npm run dev +``` + +Open `/` for the global overview or `/process-sentinel` for the Sentinel +workflow entry point. + +### How to test it + +```bash +.venv/bin/python -m pytest services/api/tests/test_api.py +cd apps/web && npm test +cd apps/web && npm run test:e2e +``` + +### Key files + +- `services/api/factory_api/context_questions.py` +- `services/api/factory_api/main.py` +- `apps/web/app/context-question-panel.tsx` +- `apps/web/app/page.tsx` +- `apps/web/app/process-sentinel/page.tsx` +- `apps/web/lib/api-client.ts` +- `docs/demo/OPERATIONS_WORKBENCH_INFORMATION_ARCHITECTURE.md` + +### What to learn next + +Plan any AI-backed context assistant separately around the documented local +model gateway and Factory Memory direction, after deterministic browser-safe +answers remain reliable. + ## 2026-05-27 - Dockerized runtime epic status cleanup ### What changed diff --git a/docs/demo/OPERATIONS_WORKBENCH_INFORMATION_ARCHITECTURE.md b/docs/demo/OPERATIONS_WORKBENCH_INFORMATION_ARCHITECTURE.md index dfd2b6d..24e8d78 100644 --- a/docs/demo/OPERATIONS_WORKBENCH_INFORMATION_ARCHITECTURE.md +++ b/docs/demo/OPERATIONS_WORKBENCH_INFORMATION_ARCHITECTURE.md @@ -12,6 +12,7 @@ Sentinel workflows usable. - Connections - Protocol Diagnostics - Tag/Source Browser +- Process Sentinel - Detections - Recommendations - RCA/CAPA Draft @@ -29,12 +30,29 @@ External Demo-Factory protocol sources ## Page Boundaries +- Overview: global factory context, runtime health, summary metrics, and + deterministic context Q&A over current browser-safe API data. - Connections: define redacted OPC-UA, MQTT, and BACnet profiles. - Protocol Diagnostics: test configured profiles with read-only checks. - Tag/Source Browser: inspect source inventory, mapping state, quality, asset, area, and FactoryEvent context. +- `/process-sentinel`: workflow entry point for Process Sentinel. - Sentinel pages: show detections, evidence, governed recommendations, and - RCA/CAPA draft previews. + RCA/CAPA draft previews. Overview is global factory context and is not a + Process Sentinel workflow step. + +## Context Questions + +The overview dashboard can post deterministic questions to +`POST /context/questions`. V1 answers only from existing FIP API data: current +site, area, asset, batch, work order, product, detection count, primary +detection evidence, recommendation status, API health, and connection profile +summaries. + +Unsupported questions must fail safely with `confidence: "unsupported"` and +suggested example questions. Do not add an LLM, AI SDK, model gateway, RAG +index, external provider, secret exposure, production-readiness claim, or +writeback behavior to this IA. ## Scope Rejections diff --git a/services/api/README.md b/services/api/README.md index 59cc3fd..e426cf0 100644 --- a/services/api/README.md +++ b/services/api/README.md @@ -35,6 +35,7 @@ The API currently exposes: - Health and event query endpoints. - Read-only domain context endpoints for sites, areas, equipment, process signals, batches, quality results, deviations, alerts, and investigations. +- `POST /context/questions` for deterministic browser-safe context answers. - Local connection profile CRUD endpoints for OPC-UA, MQTT, and BACnet definitions. - Process Sentinel detections, evidence, recommendations, and RCA/CAPA draft @@ -88,6 +89,22 @@ or reading configured BACnet objects. Adapter polling, live protocol sessions, tag browsing, and ingestion workers are tracked as separate follow-up work. +## Context Question API + +`POST /context/questions` accepts a single `question` string and returns a +deterministic answer with `confidence`, source links, and optional suggested +questions. V1 uses keyword intent matching over existing API data only: + +- Current site, area, line, asset, batch, work order, and product context. +- Active Process Sentinel detection count and highest-priority detection. +- Evidence summaries explaining why the primary detection was flagged. +- Pending recommendation count and recommendation status. +- Browser-safe API, source, connector, storage, and connection profile health. + +Unsupported questions return `confidence: "unsupported"` and example questions +instead of guessing. The endpoint does not call an LLM, AI SDK, model gateway, +RAG index, external provider, or hidden data source. + ## Governed Recommendation Audit Reads The local MVP API records governed recommendation decisions and local audit diff --git a/services/api/factory_api/context_questions.py b/services/api/factory_api/context_questions.py new file mode 100644 index 0000000..07e6e9c --- /dev/null +++ b/services/api/factory_api/context_questions.py @@ -0,0 +1,443 @@ +from __future__ import annotations + +from typing import Literal + +from factory_events import ProtocolConnectionProfile +from process_sentinel.models import Detection, EvidenceItem, Recommendation +from pydantic import BaseModel, Field, model_validator + +from factory_api.domain import DomainData + +ContextQuestionConfidence = Literal["supported", "partial", "unsupported"] + +ACTIVE_DETECTION_STATUSES = { + "new", + "investigating", + "recommendation_created", + "acknowledged", +} +PENDING_RECOMMENDATION_STATUSES = {"draft", "proposed", "needs_review"} +SEVERITY_RANK = {"high": 3, "medium": 2, "low": 1} +EXAMPLE_QUESTIONS = [ + "What is the current site and batch context?", + "What is the most important finding right now?", + "Why was the primary detection flagged?", + "How many recommendations are pending review?", + "What is the API and source health?", +] + + +class ContextQuestionRequest(BaseModel): + question: str = Field(min_length=1, max_length=500) + + @model_validator(mode="after") + def strip_and_validate_question(self) -> ContextQuestionRequest: + stripped = self.question.strip() + if not stripped: + msg = "question must not be empty" + raise ValueError(msg) + self.question = stripped + return self + + +class ContextQuestionSource(BaseModel): + type: str = Field(min_length=1) + id: str = Field(min_length=1) + label: str = Field(min_length=1) + href: str = Field(min_length=1) + + +class ContextQuestionResponse(BaseModel): + question: str + answer: str + confidence: ContextQuestionConfidence + sources: list[ContextQuestionSource] + suggested_questions: list[str] = Field(default_factory=list) + + +class RuntimeContext(BaseModel): + health: dict[str, str] + connection_profiles: list[ProtocolConnectionProfile] + domain: DomainData + detections: list[Detection] + evidence: list[EvidenceItem] + recommendations: list[Recommendation] + + +def answer_context_question( + request: ContextQuestionRequest, + context: RuntimeContext, +) -> ContextQuestionResponse: + question_key = request.question.lower() + + if _asks_about_health(question_key): + return _answer_health(request.question, context) + if _asks_why_detection_was_flagged(question_key): + return _answer_detection_evidence(request.question, context) + if _asks_about_recommendations(question_key): + return _answer_recommendations(request.question, context) + if _asks_about_detections(question_key): + return _answer_detections(request.question, context) + if _asks_about_current_context(question_key): + return _answer_current_context(request.question, context, question_key) + return _unsupported(request.question) + + +def _answer_current_context( + question: str, + context: RuntimeContext, + question_key: str, +) -> ContextQuestionResponse: + detection = _select_primary_detection(context.detections) + batch = _select_current_batch(context.domain) + asset = _select_current_asset(context.domain, detection) + area = _select_current_area(context.domain, batch, asset) + site = _select_current_site(context.domain, area, batch) + work_order_id = detection.related_work_order_id if detection is not None else None + + answer = ( + f"Current context is {site.name if site else 'an unknown site'}, " + f"{area.name if area else 'an unknown area'}, " + f"{asset.name if asset else 'an unlinked asset'}, " + f"batch {batch.batch_id if batch else 'unavailable'}, " + f"work order {work_order_id or 'unavailable'}, and product " + f"{batch.product_name if batch else 'unavailable'}." + ) + confidence: ContextQuestionConfidence = "supported" + if any(term in question_key for term in ("operator", "supervisor", "shift")): + answer += ( + " Operator, supervisor, and shift assignment are not present in the " + "current browser-safe context." + ) + confidence = "partial" + + sources: list[ContextQuestionSource] = [] + if site is not None: + sources.append( + ContextQuestionSource( + type="site", + id=site.site_id, + label=site.name, + href="/", + ) + ) + if area is not None: + sources.append( + ContextQuestionSource( + type="area", + id=area.area_id, + label=area.name, + href="/", + ) + ) + if asset is not None: + sources.append( + ContextQuestionSource( + type="asset", + id=asset.equipment_id, + label=asset.name, + href="/tag-source-browser", + ) + ) + if batch is not None: + sources.append( + ContextQuestionSource( + type="batch", + id=batch.batch_id, + label=batch.batch_id, + href="/", + ) + ) + if work_order_id is not None and detection is not None: + sources.append( + ContextQuestionSource( + type="work_order", + id=work_order_id, + label=work_order_id, + href=f"/detections/{detection.detection_id}", + ) + ) + return ContextQuestionResponse( + question=question, + answer=answer, + confidence=confidence, + sources=sources, + ) + + +def _answer_detections(question: str, context: RuntimeContext) -> ContextQuestionResponse: + active_detections = _active_detections(context.detections) + primary = _select_primary_detection(context.detections) + if primary is None: + return ContextQuestionResponse( + question=question, + answer="There are no active Process Sentinel detections in the current runtime state.", + confidence="supported", + sources=[ + ContextQuestionSource( + type="detection_list", + id="sentinel-detections", + label="Process Sentinel detections", + href="/detections", + ) + ], + ) + + return ContextQuestionResponse( + question=question, + answer=( + f"There are {len(active_detections)} active Process Sentinel detections. " + f"The highest-priority finding is {primary.summary} " + f"({primary.detection_id}) with {primary.severity} severity, " + f"{primary.status} status, and {round(primary.confidence * 100)}% confidence." + ), + confidence="supported", + sources=[_detection_source(primary)], + ) + + +def _answer_detection_evidence(question: str, context: RuntimeContext) -> ContextQuestionResponse: + primary = _select_primary_detection(context.detections) + if primary is None: + return ContextQuestionResponse( + question=question, + answer=( + "No primary detection is available, so there is no evidence " + "summary to explain yet." + ), + confidence="partial", + sources=[], + ) + evidence_items = [ + item for item in context.evidence if item.detection_id == primary.detection_id + ] + if not evidence_items: + return ContextQuestionResponse( + question=question, + answer=( + f"{primary.summary} is the primary detection, but no evidence items are " + "available in the current Sentinel state." + ), + confidence="partial", + sources=[_detection_source(primary)], + ) + + evidence_summary = "; ".join(item.description for item in evidence_items[:3]) + return ContextQuestionResponse( + question=question, + answer=f"{primary.summary} was flagged because {evidence_summary}", + confidence="supported", + sources=[ + _detection_source(primary), + *[ + ContextQuestionSource( + type="evidence", + id=item.evidence_id, + label=item.title, + href=f"/detections/{primary.detection_id}", + ) + for item in evidence_items[:3] + ], + ], + ) + + +def _answer_recommendations(question: str, context: RuntimeContext) -> ContextQuestionResponse: + pending = [ + recommendation + for recommendation in context.recommendations + if recommendation.status in PENDING_RECOMMENDATION_STATUSES + ] + if not pending: + return ContextQuestionResponse( + question=question, + answer="There are no pending governed recommendations in the current runtime state.", + confidence="supported", + sources=[ + ContextQuestionSource( + type="recommendation_list", + id="recommendations", + label="Recommendation review", + href="/recommendations", + ) + ], + ) + + statuses = ", ".join( + f"{recommendation.recommendation_id}: {recommendation.status}" + for recommendation in pending[:3] + ) + return ContextQuestionResponse( + question=question, + answer=( + f"There are {len(pending)} pending governed recommendations. Current " + f"recommendation status: {statuses}." + ), + confidence="supported", + sources=[ + ContextQuestionSource( + type="recommendation", + id=recommendation.recommendation_id, + label=recommendation.recommended_action, + href=f"/recommendations?detection_id={recommendation.detection_id}", + ) + for recommendation in pending[:3] + ], + ) + + +def _answer_health(question: str, context: RuntimeContext) -> ContextQuestionResponse: + enabled_profiles = [ + profile for profile in context.connection_profiles if profile.enabled + ] + disabled_profiles = [ + profile for profile in context.connection_profiles if not profile.enabled + ] + health = context.health + return ContextQuestionResponse( + question=question, + answer=( + f"The API reports {health.get('status', 'unknown')} health, " + f"{health.get('source_mode', 'unknown')} source mode, " + f"{health.get('storage_backend', 'unknown')} storage, and " + f"{health.get('connector_mode', 'unknown')} connector mode. " + f"There are {len(enabled_profiles)} enabled and {len(disabled_profiles)} " + "disabled connection profiles visible to the browser." + ), + confidence="supported", + sources=[ + ContextQuestionSource( + type="health", + id="api-health", + label="API health", + href="/protocol-diagnostics", + ), + ContextQuestionSource( + type="connection_profiles", + id="connection-profiles", + label="Connection profiles", + href="/connections", + ), + ], + ) + + +def _unsupported(question: str) -> ContextQuestionResponse: + return ContextQuestionResponse( + question=question, + answer=( + "I can only answer deterministic questions about current factory context, " + "Process Sentinel detections, evidence, recommendations, and browser-safe " + "runtime health. Try one of the example questions below." + ), + confidence="unsupported", + sources=[], + suggested_questions=EXAMPLE_QUESTIONS, + ) + + +def _asks_about_current_context(question_key: str) -> bool: + return any( + term in question_key + for term in ( + "site", + "area", + "line", + "asset", + "batch", + "work order", + "product", + "context", + "operator", + "supervisor", + "shift", + ) + ) + + +def _asks_about_detections(question_key: str) -> bool: + return any( + term in question_key + for term in ("detection", "finding", "findings", "important", "priority", "count") + ) + + +def _asks_why_detection_was_flagged(question_key: str) -> bool: + return any(term in question_key for term in ("why", "flagged", "evidence")) + + +def _asks_about_recommendations(question_key: str) -> bool: + return any( + term in question_key + for term in ("recommendation", "recommendations", "pending", "status") + ) + + +def _asks_about_health(question_key: str) -> bool: + return any( + term in question_key + for term in ("health", "api", "source", "connector", "connection", "profile", "storage") + ) + + +def _active_detections(detections: list[Detection]) -> list[Detection]: + return [item for item in detections if item.status in ACTIVE_DETECTION_STATUSES] + + +def _select_primary_detection(detections: list[Detection]) -> Detection | None: + active_detections = _active_detections(detections) + if not active_detections: + return None + return sorted( + active_detections, + key=lambda item: (SEVERITY_RANK[item.severity], item.confidence), + reverse=True, + )[0] + + +def _select_current_batch(domain: DomainData): + return next( + (batch for batch in domain.batches if batch.status in {"running", "held"}), + domain.batches[0] if domain.batches else None, + ) + + +def _select_current_asset(domain: DomainData, detection: Detection | None): + related_asset_id = ( + detection.related_asset_ids[0] if detection and detection.related_asset_ids else None + ) + return next( + (asset for asset in domain.equipment if asset.equipment_id == related_asset_id), + domain.equipment[0] if domain.equipment else None, + ) + + +def _select_current_area(domain: DomainData, batch, asset): + return next( + ( + area + for area in domain.areas + if area.area_id in {getattr(asset, "area_id", None), getattr(batch, "area_id", None)} + ), + domain.areas[0] if domain.areas else None, + ) + + +def _select_current_site(domain: DomainData, area, batch): + return next( + ( + site + for site in domain.sites + if site.site_id in {getattr(area, "site_id", None), getattr(batch, "site_id", None)} + ), + domain.sites[0] if domain.sites else None, + ) + + +def _detection_source(detection: Detection) -> ContextQuestionSource: + return ContextQuestionSource( + type="detection", + id=detection.detection_id, + label=detection.summary, + href=f"/detections/{detection.detection_id}", + ) diff --git a/services/api/factory_api/main.py b/services/api/factory_api/main.py index 00e80b7..af0a6d0 100644 --- a/services/api/factory_api/main.py +++ b/services/api/factory_api/main.py @@ -24,6 +24,11 @@ serialize_connection_profile_for_browser, ) from factory_api.connection_tests import test_connection_profile_data +from factory_api.context_questions import ( + ContextQuestionRequest, + RuntimeContext, + answer_context_question, +) from factory_api.domain import DomainData, build_demo_domain_data DEFAULT_CORS_ORIGINS = ( @@ -160,13 +165,18 @@ def connection_profile_store() -> ConnectionProfileStore: def domain() -> DomainData: return resolved_domain_data - @app.get("/health") - def health() -> dict: + def runtime_health() -> dict[str, str]: return { "status": "ok", "source_mode": resolved_source_mode, "storage_backend": resolved_storage_backend, "connector_mode": resolved_connector_mode, + } + + @app.get("/health") + def health() -> dict: + return { + **runtime_health(), "simulator_backed": False, "simulator_backed_migration_note": ( "Deprecated compatibility field. Use source_mode and connector_mode " @@ -177,6 +187,21 @@ def health() -> dict: "connection_profiles_store": str(resolved_connection_profiles_store), } + @app.post("/context/questions") + def answer_context_question_endpoint(request: ContextQuestionRequest) -> dict: + response = answer_context_question( + request, + RuntimeContext( + health=runtime_health(), + connection_profiles=connection_profile_store().list_profiles(), + domain=domain(), + detections=sentinel_store().list_detections(), + evidence=sentinel_store().list_evidence(), + recommendations=sentinel_store().list_recommendations(), + ), + ) + return response.model_dump(mode="json") + @app.get("/events") def list_events(event_type: str | None = None) -> list[dict]: events = event_store().list_events() diff --git a/services/api/tests/test_api.py b/services/api/tests/test_api.py index 60c1154..94fc7f5 100644 --- a/services/api/tests/test_api.py +++ b/services/api/tests/test_api.py @@ -38,6 +38,159 @@ def test_health_endpoint(tmp_path: Path) -> None: assert "Deprecated compatibility field" in health["simulator_backed_migration_note"] +def test_context_question_endpoint_answers_supported_detection_question( + tmp_path: Path, +) -> None: + events_store_path, state_dir = seed_state(tmp_path) + client = TestClient( + create_app(events_store_path=events_store_path, sentinel_state_dir=state_dir) + ) + + response = client.post( + "/context/questions", + json={"question": "What is the most important finding right now?"}, + ) + + body = response.json() + assert response.status_code == 200 + assert body["question"] == "What is the most important finding right now?" + assert body["confidence"] == "supported" + assert "highest-priority finding" in body["answer"] + assert "det_fill_weight_gradual_drift" in body["answer"] + assert body["sources"] == [ + { + "type": "detection", + "id": "det_fill_weight_gradual_drift", + "label": ( + "Advisory: fill weight is trending upward, which may move the affected " + "work order toward the upper quality limit." + ), + "href": "/detections/det_fill_weight_gradual_drift", + } + ] + + +def test_context_question_endpoint_uses_evidence_for_why_question( + tmp_path: Path, +) -> None: + events_store_path, state_dir = seed_state(tmp_path) + client = TestClient( + create_app(events_store_path=events_store_path, sentinel_state_dir=state_dir) + ) + + response = client.post( + "/context/questions", + json={"question": "Why was the primary detection flagged?"}, + ) + + body = response.json() + assert response.status_code == 200 + assert body["confidence"] == "supported" + assert "was flagged because" in body["answer"] + assert "Baseline average" in body["answer"] + assert [source["type"] for source in body["sources"]] == [ + "detection", + "evidence", + "evidence", + ] + assert body["sources"][1]["href"] == "/detections/det_fill_weight_gradual_drift" + + +def test_context_question_endpoint_returns_partial_context_when_field_missing( + tmp_path: Path, +) -> None: + events_store_path, state_dir = seed_state(tmp_path) + client = TestClient( + create_app(events_store_path=events_store_path, sentinel_state_dir=state_dir) + ) + + response = client.post( + "/context/questions", + json={"question": "What is the current batch and shift supervisor?"}, + ) + + body = response.json() + assert response.status_code == 200 + assert body["confidence"] == "partial" + assert "BATCH-DEMO-1007" in body["answer"] + assert "shift assignment" in body["answer"] + assert {source["type"] for source in body["sources"]} >= { + "site", + "area", + "asset", + "batch", + "work_order", + } + + +def test_context_question_endpoint_answers_recommendation_and_health_questions( + tmp_path: Path, +) -> None: + events_store_path, state_dir = seed_state(tmp_path) + client = TestClient( + create_app(events_store_path=events_store_path, sentinel_state_dir=state_dir) + ) + + recommendation_response = client.post( + "/context/questions", + json={"question": "How many recommendations are pending review?"}, + ) + health_response = client.post( + "/context/questions", + json={"question": "What is the API and source health?"}, + ) + + recommendation = recommendation_response.json() + health = health_response.json() + assert recommendation_response.status_code == 200 + assert recommendation["confidence"] == "supported" + assert "1 pending governed recommendations" in recommendation["answer"] + assert recommendation["sources"][0]["type"] == "recommendation" + assert recommendation["sources"][0]["href"] == ( + "/recommendations?detection_id=det_fill_weight_gradual_drift" + ) + assert health_response.status_code == 200 + assert health["confidence"] == "supported" + assert "external_demo_factory source mode" in health["answer"] + assert [source["href"] for source in health["sources"]] == [ + "/protocol-diagnostics", + "/connections", + ] + + +def test_context_question_endpoint_fails_safely_for_unsupported_question( + tmp_path: Path, +) -> None: + events_store_path, state_dir = seed_state(tmp_path) + client = TestClient( + create_app(events_store_path=events_store_path, sentinel_state_dir=state_dir) + ) + + response = client.post( + "/context/questions", + json={"question": "Write a CAPA and close the deviation automatically."}, + ) + + body = response.json() + assert response.status_code == 200 + assert body["confidence"] == "unsupported" + assert "deterministic questions" in body["answer"] + assert body["sources"] == [] + assert "What is the most important finding right now?" in body["suggested_questions"] + + +def test_context_question_endpoint_rejects_empty_question(tmp_path: Path) -> None: + events_store_path, state_dir = seed_state(tmp_path) + client = TestClient( + create_app(events_store_path=events_store_path, sentinel_state_dir=state_dir) + ) + + response = client.post("/context/questions", json={"question": " "}) + + assert response.status_code == 422 + assert response.json()["error"]["code"] == "request_validation_failed" + + def test_event_and_detection_query_endpoints(tmp_path: Path) -> None: events_store_path, state_dir = seed_state(tmp_path) client = TestClient( From ed9fafbe9c705bbca2c9c2e127a97dfb795ae6a6 Mon Sep 17 00:00:00 2001 From: Aldon Smith Date: Wed, 27 May 2026 13:11:01 -0400 Subject: [PATCH 2/6] ui: polish operations console copy --- apps/web/app/components/demo-state.tsx | 37 +++--- .../web/app/detections/[detectionId]/page.tsx | 37 +++--- apps/web/app/detections/page.tsx | 32 +++--- apps/web/app/globals.css | 19 ++++ apps/web/app/layout.tsx | 18 +-- apps/web/app/page.tsx | 106 ++++++++++-------- apps/web/app/process-sentinel/page.tsx | 5 +- .../app/rca-capa-draft/draft-copy-button.tsx | 2 +- apps/web/app/rca-capa-draft/page.tsx | 39 +++---- apps/web/app/recommendations/page.tsx | 25 ++--- .../recommendation-review-panel.tsx | 14 +-- .../web/e2e/operations-workbench-demo.spec.ts | 35 +++--- apps/web/lib/product-copy.ts | 12 ++ apps/web/tests/app-shell.test.mjs | 96 ++++++++-------- docs/LEARNING_LOG.md | 43 +++++++ services/api/factory_api/domain.py | 6 +- services/api/tests/test_api.py | 24 ++-- services/api/tests/test_domain.py | 2 +- .../process_sentinel/rules.py | 7 +- services/process-sentinel/tests/test_rules.py | 3 +- ...st_connection_management_epic_readiness.py | 7 +- 21 files changed, 326 insertions(+), 243 deletions(-) create mode 100644 apps/web/lib/product-copy.ts diff --git a/apps/web/app/components/demo-state.tsx b/apps/web/app/components/demo-state.tsx index e97bfbc..411a148 100644 --- a/apps/web/app/components/demo-state.tsx +++ b/apps/web/app/components/demo-state.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import type { HealthResponse } from "../../lib/api-client"; import { getApiBaseUrl } from "../../lib/api-client"; +import { productCopy } from "../../lib/product-copy"; type ApiErrorPanelProps = { apiBaseUrl?: string; @@ -46,30 +47,31 @@ export function ApiConnectionBanner({ health, }: ApiConnectionBannerProps) { return ( -
+
- Local API connected + Integration details - Workbench data is coming from the configured FIP API target for the - external-source runtime. + Operational views are reading from the configured manufacturing data + service. Technical endpoint details are shown here for integration + support.
-
API target
+
{productCopy.integrationEndpointLabel}
{apiBaseUrl}
-
Health
+
Data health
{health?.status ?? "Not checked"}
-
Source
-
{health?.source_mode ?? "External source expected"}
+
Data source
+
{productCopy.dataSourceLabel}
-
Connector mode
-
{health?.connector_mode ?? "Read-only runtime expected"}
+
Writeback policy
+
{productCopy.writebackPolicyDetail}
@@ -84,13 +86,14 @@ export function ApiErrorPanel({
API connection issue - The Workbench could not reach the local FIP API at {apiBaseUrl}. + The Workbench could not reach the configured manufacturing data service. - Start Demo-Factory, then start the FIP Docker Compose stack. Check{" "} - curl http://localhost:8000/health, then refresh this page. - If the API is using a different port, restart the Workbench with{" "} - NEXT_PUBLIC_API_BASE_URL set to that target. + Verify the validation data source, service health, and integration + endpoint configuration, then refresh this page. + + + Integration endpoint: {apiBaseUrl} Details: {message}
@@ -117,12 +120,12 @@ export function MissingDataPanel({ nextStep, text, title }: MissingDataPanelProp ); } -export function LoadingState({ title = "Loading local demo data" }: LoadingStateProps) { +export function LoadingState({ title = "Loading operational data" }: LoadingStateProps) { return (
{title} - Connecting to the local FIP API runtime. + Connecting to the configured manufacturing data service.
); diff --git a/apps/web/app/detections/[detectionId]/page.tsx b/apps/web/app/detections/[detectionId]/page.tsx index 2336314..3a8d732 100644 --- a/apps/web/app/detections/[detectionId]/page.tsx +++ b/apps/web/app/detections/[detectionId]/page.tsx @@ -10,7 +10,6 @@ import { type Detection, type EvidenceItem, formatApiError, - getApiBaseUrl, workbenchApi, } from "../../../lib/api-client"; @@ -34,15 +33,16 @@ export default async function DetectionDetailPage({ params }: DetectionDetailPag

Detection detail

- Read-only Process Sentinel detection context from the local FIP API. + Review the selected Process Sentinel case, linked evidence, and + recommended review path.

{!result.ok && result.notFound ? ( - The local FIP API did not return a detection for{" "} + The manufacturing data service did not return a detection for{" "} {detectionId}. Open the detection list and choose a current runtime detection. @@ -55,7 +55,7 @@ export default async function DetectionDetailPage({ params }: DetectionDetailPag
- Detection summary + Case summary

{result.detection.summary}

@@ -131,7 +131,7 @@ export default async function DetectionDetailPage({ params }: DetectionDetailPag

RCA/CAPA draft

Preview the human-reviewed RCA/CAPA draft language generated - from the selected external-source detection. + from the selected Process Sentinel case.

) : null} -
-

- Configured API target: {getApiBaseUrl()} -

-
); } @@ -190,11 +185,11 @@ function EvidenceTimeline({ evidenceItems }: { evidenceItems: EvidenceItem[] }) aria-labelledby="evidence-timeline-heading" >
- External-source evidence + Linked evidence

Evidence timeline

- Chronological Process Sentinel evidence from the local FIP API. Use - this to understand why the finding exists before reviewing any + Chronological Process Sentinel evidence for the selected case. Use + this to understand why the finding exists before reviewing the recommendation.

@@ -205,11 +200,11 @@ function EvidenceTimeline({ evidenceItems }: { evidenceItems: EvidenceItem[] }) {evidenceItems.length === 0 ? ( - This detection exists, but the local FIP API did not return - evidence items from the current external-source event store yet. + This detection exists, but no linked evidence items were returned + from the current operational state yet. } title="No evidence available" @@ -278,8 +273,8 @@ function EvidenceTimeline({ evidenceItems }: { evidenceItems: EvidenceItem[] }) function buildFlagExplanation(detection: Detection): string { const typeContext = detection.detection_type === "quality_drift" - ? "Process Sentinel saw a quality trend move away from the expected demo baseline" - : "Process Sentinel saw a process signal move outside the expected demo operating range"; + ? "Process Sentinel identified a quality trend moving away from the expected operating band" + : "Process Sentinel identified a process signal moving outside the expected operating range"; const assetContext = detection.related_asset_ids.length > 0 ? ` for ${formatAssets(detection.related_asset_ids)}` @@ -294,12 +289,12 @@ function buildFlagExplanation(detection: Detection): string { detection.confidence * 100, )}% confidence over ${formatTimeWindow( detection, - )}. This is advisory external-source context for human review, not an autonomous action.`; + )}. This is advisory context for human review, not an autonomous action.`; } function buildTimelineMeaning(evidenceItems: EvidenceItem[]): string { if (evidenceItems.length === 0) { - return "The detection is present, but there is no supporting timeline evidence available yet in the current external-source event store."; + return "The detection is present, but there is no supporting timeline evidence available yet in the current operational state."; } const evidenceTypes = [ diff --git a/apps/web/app/detections/page.tsx b/apps/web/app/detections/page.tsx index 6306684..37ecede 100644 --- a/apps/web/app/detections/page.tsx +++ b/apps/web/app/detections/page.tsx @@ -4,7 +4,6 @@ import { ApiErrorPanel, EmptyState, StatusBadge } from "../components/demo-state import { type Detection, formatApiError, - getApiBaseUrl, workbenchApi, } from "../../lib/api-client"; @@ -18,16 +17,15 @@ export default async function DetectionsPage() {

Detections

- Process Sentinel detections from the current external-source event - store. Open a detection to inspect the current summary and routing - context. + Process Sentinel cases generated from monitored process signals and + linked evidence.

{!result.ok ? : null} {result.ok && result.detections.length === 0 ? ( ) : null} @@ -75,27 +73,35 @@ export default async function DetectionsPage() {
Related assets
{formatAssets(detection.related_asset_ids)}
+
+
Evidence count
+
{result.evidenceCounts[detection.detection_id] ?? 0}
+
- Open detection + Review case ))} ) : null} -
-

- Configured API target: {getApiBaseUrl()} -

-
); } async function loadDetections() { try { - return { detections: await workbenchApi.listDetections(), ok: true as const }; + const detections = await workbenchApi.listDetections(); + const evidenceCounts = Object.fromEntries( + await Promise.all( + detections.map(async (detection) => [ + detection.detection_id, + (await workbenchApi.listDetectionEvidence(detection.detection_id)).length, + ]), + ), + ); + return { detections, evidenceCounts, ok: true as const }; } catch (error) { return { message: formatApiError(error), ok: false as const }; } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index b919bf0..a842a9f 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -647,6 +647,17 @@ h3 { margin-top: 24px; } +.overview-kpi-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-top: 18px; +} + +.overview-kpi-strip .metric-card { + min-height: 112px; +} + .api-connection-banner { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(320px, 1.1fr); @@ -2081,6 +2092,10 @@ code { grid-template-columns: 1fr; } + .overview-kpi-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .context-panel, .detection-panel { grid-column: auto; @@ -2160,6 +2175,10 @@ code { grid-template-columns: 1fr; } + .overview-kpi-strip { + grid-template-columns: 1fr; + } + .diagnostics-card-grid { grid-template-columns: 1fr; } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index aa3f7db..0a739f6 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import type { ReactNode } from "react"; -import { getApiBaseUrl } from "../lib/api-client"; +import { productCopy } from "../lib/product-copy"; import "./globals.css"; export const metadata: Metadata = { @@ -86,20 +86,20 @@ export default function RootLayout({ children }: { children: ReactNode }) {
- Mode - External-source runtime + Environment + {productCopy.environmentLabel}
- API target - {getApiBaseUrl()} + Site + {productCopy.siteLocationLabel}
- Connection policy - Read-only diagnostics + Data health + {productCopy.dataHealthLabel}
- Writeback - Disabled + Writeback policy + {productCopy.writebackPolicyLabel}
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 87a3673..3927687 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,7 +1,6 @@ import Link from "next/link"; import { - ApiConnectionBanner, ApiErrorPanel, StatusBadge, } from "./components/demo-state"; @@ -14,9 +13,9 @@ import { type Recommendation, type Site, formatApiError, - getApiBaseUrl, workbenchApi, } from "../lib/api-client"; +import { productCopy } from "../lib/product-copy"; export const dynamic = "force-dynamic"; @@ -40,58 +39,71 @@ export default async function OverviewPage() { <>
-

{overview.ok ? overview.context.siteName : "Operations Workbench"}

+

{overview.ok ? overview.context.siteName : productCopy.siteName}

{overview.ok ? overview.context.siteDescription - : "The manufacturer demo overview needs the local FIP API to show current factory context."} + : productCopy.siteSubtitle}

- Open detection + View active case
-
+ {!overview.ok ? : null} + {overview.ok ? ( - +
+
+ Active Cases + {overview.activeDetections.length} +
+
+ Pending Reviews + {overview.pendingRecommendations.length} +
+
+ Data Health + {overview.dataHealthLabel} +
+
+ Last Updated + {overview.context.lastUpdated} +
+
) : null} - {!overview.ok ? : null} - {overview.ok ? ( -
+
- Current demo context + Operational Context

{overview.context.areaName}

@@ -113,22 +125,22 @@ export default async function OverviewPage() {
-
+
- Active detections - {overview.activeDetections.length} -

Open Process Sentinel cases from external-source event data.

+ Data source + {productCopy.dataSourceLabel} +

Status: {productCopy.integrationStatusLabel}

- Pending recommendations - {overview.pendingRecommendations.length} -

Human-reviewed recommendations awaiting a demo decision.

+ Writeback policy + {productCopy.writebackPolicyLabel} +

{productCopy.writebackPolicyDetail}

- Most important detection + Priority case {overview.importantDetection ? ( <>

{overview.importantDetection.summary}

@@ -156,15 +168,14 @@ export default async function OverviewPage() { <>

No active detections

- Start Demo-Factory, then start the FIP Docker Compose stack - and refresh this page after Process Sentinel stores runtime - detections. + No active Process Sentinel cases are available in the + current operational state.

)}
- Open detection + Review case
@@ -196,7 +207,7 @@ async function loadOverview() { return { activeDetections, context: buildOverviewContext({ areas, batches, equipment, importantDetection, sites }), - health, + dataHealthLabel: health.status === "ok" ? productCopy.dataHealthLabel : health.status, importantDetection, ok: true as const, pendingRecommendations, @@ -252,18 +263,23 @@ function buildOverviewContext({ sites[0]; return { - areaName: currentArea?.name ?? "Demo production area", - assetName: currentEquipment?.name ?? "Demo asset unavailable", - lineDescription: currentArea?.description ?? "Demo line context unavailable", - productName: currentBatch?.product_name ?? "Demo product unavailable", - siteDescription: - currentSite?.description ?? - "External-source site context will appear when the local FIP API is available.", - siteName: currentSite?.name ?? "Factory Intelligence Platform demo", - workOrderId: importantDetection?.related_work_order_id ?? "Demo work order unavailable", + areaName: currentArea?.name ?? "Production area unavailable", + assetName: currentEquipment?.name ?? "Asset unavailable", + lastUpdated: importantDetection?.created_at + ? formatTimestamp(importantDetection.created_at) + : "Current session", + lineDescription: currentArea?.description ?? "Line context unavailable", + productName: currentBatch?.product_name ?? "Product unavailable", + siteDescription: currentSite?.description ?? productCopy.siteSubtitle, + siteName: currentSite?.name ?? productCopy.siteName, + workOrderId: importantDetection?.related_work_order_id ?? "Work order unavailable", }; } +function formatTimestamp(value: string): string { + return value.replace("T", " ").replace("Z", " UTC"); +} + function severityTone(severity: Detection["severity"]) { if (severity === "high") { return "danger"; diff --git a/apps/web/app/process-sentinel/page.tsx b/apps/web/app/process-sentinel/page.tsx index 4ce4aec..220fc43 100644 --- a/apps/web/app/process-sentinel/page.tsx +++ b/apps/web/app/process-sentinel/page.tsx @@ -15,8 +15,9 @@ export default function ProcessSentinelPage() {
Process Sentinel is advisory decision support over the current - external-source event store. It does not perform industrial writeback, - product disposition, QMS/MES updates, or production CAPA creation. + operational event store. It is read-only and does not perform external + execution, product disposition, QMS/MES updates, or production CAPA + creation.
diff --git a/apps/web/app/rca-capa-draft/draft-copy-button.tsx b/apps/web/app/rca-capa-draft/draft-copy-button.tsx index 68f3682..28b9143 100644 --- a/apps/web/app/rca-capa-draft/draft-copy-button.tsx +++ b/apps/web/app/rca-capa-draft/draft-copy-button.tsx @@ -21,7 +21,7 @@ export function DraftCopyButton({ draftText }: DraftCopyButtonProps) { return (
{copyState === "copied" ? "Copied" : null} diff --git a/apps/web/app/rca-capa-draft/page.tsx b/apps/web/app/rca-capa-draft/page.tsx index 3b1cce1..c7764e6 100644 --- a/apps/web/app/rca-capa-draft/page.tsx +++ b/apps/web/app/rca-capa-draft/page.tsx @@ -10,7 +10,6 @@ import { ApiClientError, type RcaCapaDraft, formatApiError, - getApiBaseUrl, workbenchApi, } from "../../lib/api-client"; import { DraftCopyButton } from "./draft-copy-button"; @@ -31,22 +30,21 @@ export default async function RcaCapaDraftPage({ return (
- Demo-generated draft + Investigation draft

RCA/CAPA Draft

- Preview investigation-ready RCA/CAPA draft language for the selected - external-source Process Sentinel detection. Draft content is - human-review required and is not automatically submitted to QMS or MES - systems. + Review RCA/CAPA draft language generated from the selected case, + linked evidence, and recommendation history. Draft content requires + human review before QMS or MES submission.

{!result.ok && result.notFound ? ( - The local FIP API did not return an RCA/CAPA draft for{" "} + The manufacturing data service did not return an RCA/CAPA draft for{" "} {result.detectionId}. Open a current runtime detection and use its RCA/CAPA draft link. @@ -57,18 +55,18 @@ export default async function RcaCapaDraftPage({ {!result.ok && !result.notFound ? : null} {result.ok && !result.draft ? ( ) : null} {result.ok && result.draft ? (
- This RCA/CAPA draft is generated from runtime detection, evidence, - and recommendation state. It is advisory decision support for human - review only; it is not a validated production record and it does not - submit anything to QMS or MES. + This draft is generated from case evidence and recommendation + state. It is advisory content for human review and is not a + validated production record until reviewed, approved, and submitted + through the appropriate quality workflow.
@@ -95,13 +93,13 @@ export default async function RcaCapaDraftPage({
System submission
- +
Source
- +
@@ -142,7 +140,7 @@ export default async function RcaCapaDraftPage({ className="secondary-action" href={`/detections/${result.draft.detection_id}`} > - Open detection + Review case
@@ -161,11 +159,6 @@ export default async function RcaCapaDraftPage({
) : null} -
-

- Configured API target: {getApiBaseUrl()} -

-
); } @@ -206,7 +199,7 @@ function formatDraftForCopy(draft: RcaCapaDraft): string { "", `Detection ID: ${draft.detection_id}`, "Human review required: yes", - "Demo-generated draft; not submitted to QMS/MES.", + "Investigation draft; not submitted to QMS/MES.", "", "Problem statement", draft.problem_statement, diff --git a/apps/web/app/recommendations/page.tsx b/apps/web/app/recommendations/page.tsx index 1e731d7..ebe5b82 100644 --- a/apps/web/app/recommendations/page.tsx +++ b/apps/web/app/recommendations/page.tsx @@ -4,7 +4,6 @@ import { ApiErrorPanel, EmptyState } from "../components/demo-state"; import { type Recommendation, formatApiError, - getApiBaseUrl, workbenchApi, } from "../../lib/api-client"; import { RecommendationReviewPanel } from "./recommendation-review-panel"; @@ -25,20 +24,19 @@ export default async function RecommendationsPage({ return (
- External-source governed recommendation + Recommendation pending review

Recommendation review

- Review the advisory Process Sentinel recommendation and record a human - decision. This recommendation is advisory and human-reviewed; no - industrial writeback is performed. + Review the advisory recommendation, supporting evidence, and + operational impact before recording a disposition.

{!result.ok ? : null} {result.ok && result.recommendations.length === 0 ? ( ) : null} @@ -53,9 +51,9 @@ export default async function RecommendationsPage({
Recommendations are advisory decision support. A named human reviewer - must approve, reject, or defer before any high-impact action is - considered. The Workbench does not perform industrial writeback, - product disposition, QMS/MES updates, or production CAPA creation. + must approve, reject, or defer the recommendation before any + external execution, system submission, or quality action is + initiated.
@@ -70,7 +68,7 @@ export default async function RecommendationsPage({ className="secondary-action" href={`/detections/${result.selected.detection_id}`} > - Open detection + Review case
@@ -92,11 +90,6 @@ export default async function RecommendationsPage({
) : null} -
-

- Configured API target: {getApiBaseUrl()} -

-
); } diff --git a/apps/web/app/recommendations/recommendation-review-panel.tsx b/apps/web/app/recommendations/recommendation-review-panel.tsx index 0535020..00a3c5a 100644 --- a/apps/web/app/recommendations/recommendation-review-panel.tsx +++ b/apps/web/app/recommendations/recommendation-review-panel.tsx @@ -104,11 +104,11 @@ export function RecommendationReviewPanel({ onSubmit={handleSubmit} >

- Enter a reviewer and decision reason before approving, rejecting, or - deferring this external-source recommendation. + Enter a reviewer name and decision reason before recording a + disposition.

- + submitDecision("approve")} type="button" > - {submittingAction === "approve" ? "Approving..." : "Approve"} + {submittingAction === "approve" ? "Approving..." : "Approve recommendation"}
) : null} diff --git a/apps/web/e2e/operations-workbench-demo.spec.ts b/apps/web/e2e/operations-workbench-demo.spec.ts index 4928bf9..cdaad63 100644 --- a/apps/web/e2e/operations-workbench-demo.spec.ts +++ b/apps/web/e2e/operations-workbench-demo.spec.ts @@ -19,10 +19,13 @@ test("walks the external-source Operations Workbench demo path", async ({ page } await expect(page.getByText("Connections", { exact: true })).toBeVisible(); await expect(page.getByText("Protocol Diagnostics", { exact: true })).toBeVisible(); await expect(page.getByText("Tag/Source Browser", { exact: true })).toBeVisible(); - await expect(page.getByRole("region", { name: "Workbench status strip" })).toBeVisible(); - await expect(page.getByText("Read-only diagnostics")).toBeVisible(); - await expect(page.getByText("Writeback")).toBeVisible(); - await expect(page.getByText("Disabled", { exact: true })).toBeVisible(); + const statusStrip = page.getByRole("region", { name: "Workbench status strip" }); + await expect(statusStrip).toBeVisible(); + await expect(statusStrip.getByText("Environment")).toBeVisible(); + await expect(statusStrip.getByText("Validation")).toBeVisible(); + await expect(statusStrip.getByText("Data health")).toBeVisible(); + await expect(statusStrip.getByText("Writeback policy")).toBeVisible(); + await expect(statusStrip.getByText("Human approval required")).toBeVisible(); const contextQuestion = page.getByRole("textbox", { name: "Ask about current factory context", }); @@ -97,20 +100,20 @@ test("walks the external-source Operations Workbench demo path", async ({ page } .click(); await page.locator('a[href="/"]').first().click(); await expect(page.getByText("Synthetic local scenario; not real plant data.")).toHaveCount(0); - await expect(page.getByRole("region", { name: "Local API connection state" })).toBeVisible(); - await expect(page.getByText("API target").first()).toBeVisible(); - await expect(page.getByText("Health", { exact: true }).first()).toBeVisible(); - await expect(page.getByText("external_demo_factory").first()).toBeVisible(); - await expect(page.getByRole("heading", { name: "Greenville Demo Site" })).toBeVisible(); - await expect(page.getByText("Active detections")).toBeVisible(); - await expect(page.getByText("Pending recommendations")).toBeVisible(); + await expect(page.getByText("API target").first()).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Greenville Manufacturing Site" })).toBeVisible(); + await expect(page.getByText("Active Cases")).toBeVisible(); + await expect(page.getByText("Pending Reviews")).toBeVisible(); + await expect(page.getByText("Operational Context")).toBeVisible(); + await expect(page.getByText("Priority case")).toBeVisible(); + await expect(page.getByRole("link", { name: "Review case" })).toBeVisible(); await expect(page.getByText("Severity", { exact: true })).toBeVisible(); await expect(page.getByText("medium", { exact: true })).toBeVisible(); await page.locator('a[href="/detections"]').first().click(); await expect(page.getByRole("heading", { name: "Detections" })).toBeVisible(); await expect( - page.getByText("Process Sentinel detections from the current external-source event store"), + page.getByText("Process Sentinel cases generated from monitored process signals"), ).toBeVisible(); await page.locator(`a[href="/detections/${detectionId}"]`).click(); @@ -155,7 +158,7 @@ test("walks the external-source Operations Workbench demo path", async ({ page } await expect(page.getByRole("heading", { name: "Detection detail" })).toBeVisible(); await expect(page.getByRole("status")).toContainText("Detection not found"); await expect(page.getByRole("status")).toContainText("Next step:"); - await expect(page.getByRole("status")).toContainText("FIP Docker Compose stack"); + await expect(page.getByRole("status")).toContainText("service health"); }); async function createDuplicateSourceConnectionProfiles(page: Page) { @@ -219,10 +222,10 @@ async function recordDecision( expectedDecision: "approved" | "rejected" | "deferred", ) { await expect(page.getByRole("button", { name: action })).toBeDisabled(); - await page.getByLabel("Reviewer name").fill(`quality_engineer_${expectedDecision}`); + await page.getByLabel("Reviewer Name").fill(`quality_engineer_${expectedDecision}`); await page .getByLabel("Decision reason") - .fill(`Playwright smoke test recorded a ${expectedDecision} demo decision.`); + .fill(`Playwright smoke test recorded a ${expectedDecision} disposition.`); await expect(page.getByRole("button", { name: action })).toBeEnabled(); await expect(page.locator("#decision-submit-status")).toContainText( "Decision actions are available.", @@ -231,7 +234,7 @@ async function recordDecision( await page.keyboard.press("Enter"); const feedback = page.locator(".decision-result"); - await expect(feedback).toContainText(`Demo audit feedback: ${expectedDecision}`); + await expect(feedback).toContainText(`Disposition recorded: ${expectedDecision}`); await expect(feedback).toContainText(`Reviewer: quality_engineer_${expectedDecision}`); await expect(feedback).toContainText(`Updated status: ${expectedDecision}`); await expect(feedback).toContainText("not a validated production audit record"); diff --git a/apps/web/lib/product-copy.ts b/apps/web/lib/product-copy.ts new file mode 100644 index 0000000..4e4c5fc --- /dev/null +++ b/apps/web/lib/product-copy.ts @@ -0,0 +1,12 @@ +export const productCopy = { + dataHealthLabel: "Connected", + dataSourceLabel: "Manufacturing Data Service", + environmentLabel: "Validation", + integrationEndpointLabel: "Integration endpoint", + integrationStatusLabel: "Connected", + siteLocationLabel: "Greenville", + siteName: "Greenville Manufacturing Site", + siteSubtitle: "Process quality monitoring and recommendation review workspace.", + writebackPolicyLabel: "Human approval required", + writebackPolicyDetail: "Read-only; external submission not configured", +} as const; diff --git a/apps/web/tests/app-shell.test.mjs b/apps/web/tests/app-shell.test.mjs index fdaf074..2b954f0 100644 --- a/apps/web/tests/app-shell.test.mjs +++ b/apps/web/tests/app-shell.test.mjs @@ -29,7 +29,7 @@ test("workbench placeholder routes exist", async () => { await Promise.all(requiredRoutes.map((route) => access(join(root, route)))); }); -test("navigation includes the required demo routes", () => { +test("navigation includes the required workbench routes", () => { const layout = readFileSync(join(root, "app/layout.tsx"), "utf8"); assert.match(layout, /Skip to main content/); @@ -57,10 +57,12 @@ test("navigation includes the required demo routes", () => { assert.match(layout, /href: "\/tag-source-browser"/); assert.match(layout, /Workbench status strip/); assert.doesNotMatch(layout, /
{ assert.match(styles, /background: var\(--nav-2\)/); }); -test("overview page contains manufacturer demo dashboard content", () => { +test("overview page contains production operations dashboard content", () => { const overview = readFileSync(join(root, "app/page.tsx"), "utf8"); const contextQuestion = readFileSync(join(root, "app/context-question-panel.tsx"), "utf8"); const demoState = readFileSync(join(root, "app/components/demo-state.tsx"), "utf8"); + const productCopy = readFileSync(join(root, "lib/product-copy.ts"), "utf8"); assert.match(overview, /ContextQuestionPanel/); assert.match(contextQuestion, /Ask about current factory context/); @@ -265,19 +268,21 @@ test("overview page contains manufacturer demo dashboard content", () => { assert.match(contextQuestion, /supported/); assert.match(contextQuestion, /partial/); assert.match(contextQuestion, /unsupported/); - assert.match(overview, /Current demo context/); - assert.match(overview, /Active detections/); - assert.match(overview, /Pending recommendations/); - assert.match(overview, /Most important detection/); - assert.match(overview, /Open detection/); + assert.match(productCopy, /Greenville Manufacturing Site/); + assert.match(productCopy, /Process quality monitoring and recommendation review workspace/); + assert.match(overview, /Current operational state/); + assert.match(overview, /Active Cases/); + assert.match(overview, /Pending Reviews/); + assert.match(overview, /Operational Context/); + assert.match(overview, /Priority case/); + assert.match(overview, /Review case/); assert.match(overview, /selectImportantDetection/); - assert.match(overview, /ApiConnectionBanner/); - assert.match(overview, /API base URL/); - assert.match(overview, /API health/); - assert.match(demoState, /Local API connected/); - assert.match(demoState, /API target/); - assert.match(demoState, /External source expected/); - assert.match(demoState, /Read-only runtime expected/); + assert.doesNotMatch(overview, /ApiConnectionBanner/); + assert.doesNotMatch(overview, /API base URL/); + assert.match(demoState, /Integration details/); + assert.match(demoState, /Integration endpoint/); + assert.match(demoState, /productCopy\.dataSourceLabel/); + assert.match(demoState, /validation data source/); }); test("process sentinel has a workflow entry route separate from overview", () => { @@ -293,7 +298,7 @@ test("process sentinel has a workflow entry route separate from overview", () => assert.match(route, /Open detections/); assert.match(route, /Open recommendations/); assert.match(route, /Open RCA\/CAPA draft/); - assert.match(route, /does not perform industrial writeback/); + assert.match(route, /does not perform external\s+execution/); assert.match(readme, /\/process-sentinel/); assert.match(ia, /\/process-sentinel/); assert.match(ia, /Overview is global factory context/); @@ -306,10 +311,7 @@ test("detections pages contain list and detail content", () => { const demoState = readFileSync(join(root, "app/components/demo-state.tsx"), "utf8"); const styles = readFileSync(join(root, "app/globals.css"), "utf8"); - assert.match( - list, - /Process Sentinel detections from the current external-source event\s+store/, - ); + assert.match(list, /Process Sentinel cases generated from monitored process signals/); assert.match(list, /detection.summary/); assert.match(list, /detection.severity/); assert.match(list, /detection.confidence/); @@ -320,7 +322,8 @@ test("detections pages contain list and detail content", () => { assert.match(list, /formatTimeWindow/); assert.match(list, /related_work_order_id/); assert.match(list, /related_asset_ids/); - assert.match(list, /Open detection/); + assert.match(list, /Evidence count/); + assert.match(list, /Review case/); assert.match(detail, /Detection detail/); assert.match(detail, /getDetection/); assert.match(detail, /Detection not found/); @@ -353,7 +356,7 @@ test("detections pages contain list and detail content", () => { assert.match(detail, /RCA\/CAPA draft/); assert.match(detail, /rca-capa-draft\?detection_id=/); assert.doesNotMatch(detail, new RegExp("simulator-" + "backed", "i")); - assert.match(detail, /External-source evidence/); + assert.match(detail, /Linked evidence/); assert.match(demoState, /function StatusBadge/); assert.match(styles, /status-badge/); assert.match(styles, /status-badge-danger/); @@ -375,7 +378,7 @@ test("recommendation page contains governed review panel", () => { assert.match(page, /RecommendationReviewPanel/); assert.match(page, /Recommendations are advisory decision support/); assert.match(page, /rca-capa-draft\?detection_id=/); - assert.match(panel, /Reviewer name/); + assert.match(panel, /Reviewer Name/); assert.match(panel, /Decision reason/); assert.match(panel, /Approve/); assert.match(panel, /Reject/); @@ -383,7 +386,7 @@ test("recommendation page contains governed review panel", () => { assert.match(panel, /approveRecommendation/); assert.match(panel, /rejectRecommendation/); assert.match(panel, /deferRecommendation/); - assert.match(panel, /Demo audit feedback/); + assert.match(panel, /Disposition recorded/); assert.match(panel, /Recommendation ID/); assert.match(panel, /decisionResult\.recommendation_id/); assert.match(panel, /Reviewer:/); @@ -415,19 +418,20 @@ test("RCA CAPA page contains selected detection draft preview", () => { assert.match(page, /Evidence summary/); assert.match(page, /Recommended containment/); assert.match(page, /CAPA placeholder/); - assert.match(page, /human-review/); - assert.match(page, /not automatically submitted to QMS/); + assert.match(page, /Human review/); + assert.match(page, /System submission/); + assert.match(page, /Not submitted/); assert.match(page, /DraftCopyButton/); assert.match(page, /formatDraftForCopy/); assert.match(page, /StatusBadge/); assert.match(copyButton, /navigator\.clipboard\.writeText/); - assert.match(copyButton, /Copy draft text/); + assert.match(copyButton, /Copy draft/); assert.match(styles, /rca-draft-card/); assert.match(styles, /draft-section/); assert.match(styles, /draft-copy/); }); -test("app shell documents configurable API base URL", () => { +test("app shell keeps integration endpoint details in diagnostics copy", () => { const config = readFileSync(join(root, "lib/api-config.ts"), "utf8"); const client = readFileSync(join(root, "lib/api-client.ts"), "utf8"); const demoState = readFileSync(join(root, "app/components/demo-state.tsx"), "utf8"); @@ -437,14 +441,13 @@ test("app shell documents configurable API base URL", () => { assert.match(client, /apiBaseUrl/); assert.match(client, /local FIP API/); assert.match(config, /http:\/\/127\.0\.0\.1:8000/); - assert.match(demoState, /Start Demo-Factory/); - assert.match(demoState, /FIP Docker Compose stack/); - assert.match(demoState, /NEXT_PUBLIC_API_BASE_URL/); + assert.match(demoState, /Integration details/); + assert.match(demoState, /Integration endpoint/); assert.doesNotMatch(demoState, /Synthetic local scenario; not real plant data/); assert.match(readme, /NEXT_PUBLIC_API_BASE_URL/); }); -test("workbench state panels distinguish local demo data gaps from API failures", () => { +test("workbench state panels distinguish operational data gaps from API failures", () => { const demoState = readFileSync(join(root, "app/components/demo-state.tsx"), "utf8"); const overview = readFileSync(join(root, "app/page.tsx"), "utf8"); const detections = readFileSync(join(root, "app/detections/page.tsx"), "utf8"); @@ -458,23 +461,23 @@ test("workbench state panels distinguish local demo data gaps from API failures" assert.match(demoState, /aria-busy="true"/); assert.match(demoState, /MissingDataPanel/); assert.match(demoState, /loading-panel/); - assert.match(overview, /Start Demo-Factory/); - assert.match(detections, /No Process Sentinel detections are available for the current external-source event store yet/); - assert.match(detections, /curl http:\/\/localhost:8000\/health/); + assert.match(overview, /current operational state/); + assert.match(detections, /No Process Sentinel cases are available for the current operational state yet/); + assert.match(detections, /Verify the validation data source and service health/); assert.match(detail, /Detection not found/); assert.match(detail, /No evidence available/); - assert.match(detail, /FIP Docker Compose stack/); + assert.match(detail, /manufacturing data service/); assert.match(recommendations, /No recommendations returned/); assert.match(recommendations, /No linked recommendation found/); - assert.match(recommendations, /current external-source event store yet/); + assert.match(recommendations, /current operational state yet/); assert.match(draft, /Draft not found/); assert.match(draft, /No detection available for draft preview/); - assert.match(draft, /Start Demo-Factory/); + assert.match(draft, /manufacturing data service/); assert.match(styles, /missing-data-panel/); assert.match(styles, /min-height: 260px/); }); -test("workbench runtime copy points to external sources and Docker recovery", () => { +test("workbench runtime copy points to operational sources and integration recovery", () => { const files = [ "app/layout.tsx", "app/page.tsx", @@ -488,10 +491,11 @@ test("workbench runtime copy points to external sources and Docker recovery", () ]; const combined = files.map((file) => readFileSync(join(root, file), "utf8")).join("\n"); - assert.match(combined, /Start Demo-Factory/); - assert.match(combined, /FIP Docker Compose stack/); - assert.match(combined, /The Workbench could not reach the local FIP API/); - assert.match(combined, /advisory and human-reviewed/); + assert.match(combined, /manufacturing data service/); + assert.match(combined, /validation data source/); + assert.match(combined, /The Workbench could not reach the configured manufacturing data service/); + assert.match(combined, /advisory decision support/); + assert.match(combined, /human-reviewed RCA\/CAPA draft/); assert.doesNotMatch(combined, new RegExp("simulator-" + "backed", "i")); assert.doesNotMatch(combined, new RegExp("rerun make" + " demo", "i")); assert.doesNotMatch(combined, new RegExp("make " + "demo-data", "i")); diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 01e220d..6dc9505 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -5227,3 +5227,46 @@ make typecheck Run the smoke path with Demo-Factory intentionally stopped once and compare the failure output against the runtime troubleshooting guide. Tighten any recovery copy that does not lead directly to the missing source, service, or log output. + +## 2026-05-27 - Production operations console copy + +### What changed + +Updated the operator console copy and information hierarchy so Overview, +Detections, Recommendations, RCA/CAPA Draft, and shared status panels read as a +validation operations workspace instead of a simulator/demo screen. Added shared +product copy constants for site, environment, data source, and writeback policy +labels. + +### Why it was built that way + +The UI still runs against the same local/API-backed data and remains read-only, +but simulator and endpoint details are lower-priority diagnostics instead of +primary operator-facing content. Recommendation and draft screens still state +that human review is required before external execution or quality submission. + +### How data flows through it + +The Workbench continues to load site context, detections, evidence, +recommendations, and RCA/CAPA drafts through the FIP API. The frontend only +changes labels, grouping of status information, and empty/error copy; no +writeback or external submission path was added. + +### How to run or test it + +```bash +cd apps/web && npm test +cd apps/web && npm run build +cd apps/web && npm run lint +cd apps/web && npm run typecheck +make test +make lint +make typecheck +PLAYWRIGHT_API_PORT=8001 PLAYWRIGHT_WEB_PORT=3001 PLAYWRIGHT_BASE_URL=http://127.0.0.1:3001 npm run test:e2e +``` + +### What to learn next + +Review the operator screens with a narrower viewport and verify that diagnostic +endpoint details remain available where useful without competing with the case, +evidence, recommendation, and quality-documentation workflow. diff --git a/services/api/factory_api/domain.py b/services/api/factory_api/domain.py index 3557053..e7201cc 100644 --- a/services/api/factory_api/domain.py +++ b/services/api/factory_api/domain.py @@ -190,10 +190,10 @@ def build_demo_domain_data() -> DomainData: sites=[ Site( site_id="greenville_demo_site", - name="Greenville Demo Site", + name="Greenville Manufacturing Site", timezone="America/New_York", description=( - "External-source Greenville site context used for the manufacturer demo." + "Process quality monitoring and recommendation review workspace." ), ) ], @@ -244,7 +244,7 @@ def build_demo_domain_data() -> DomainData: batch_id="BATCH-DEMO-1007", site_id="greenville_demo_site", area_id="packaging_area", - product_name="OFI Demo Beverage", + product_name="Beverage Line Product", status="held", started_at=started_at, ended_at=None, diff --git a/services/api/tests/test_api.py b/services/api/tests/test_api.py index 94fc7f5..dd61e02 100644 --- a/services/api/tests/test_api.py +++ b/services/api/tests/test_api.py @@ -58,16 +58,13 @@ def test_context_question_endpoint_answers_supported_detection_question( assert "highest-priority finding" in body["answer"] assert "det_fill_weight_gradual_drift" in body["answer"] assert body["sources"] == [ - { - "type": "detection", - "id": "det_fill_weight_gradual_drift", - "label": ( - "Advisory: fill weight is trending upward, which may move the affected " - "work order toward the upper quality limit." - ), - "href": "/detections/det_fill_weight_gradual_drift", - } - ] + { + "type": "detection", + "id": "det_fill_weight_gradual_drift", + "label": "Fill weight is trending upward toward the upper quality limit.", + "href": "/detections/det_fill_weight_gradual_drift", + } + ] def test_context_question_endpoint_uses_evidence_for_why_question( @@ -224,8 +221,7 @@ def test_demo_detection_detail_endpoint_exposes_expected_lookup_fields( ] assert detail_response.json()["detection_id"] == "det_fill_weight_gradual_drift" assert detail_response.json()["summary"] == ( - "Advisory: fill weight is trending upward, which may move the affected " - "work order toward the upper quality limit." + "Fill weight is trending upward toward the upper quality limit." ) assert "root cause" not in detail_response.json()["summary"].lower() assert "caused by" not in detail_response.json()["summary"].lower() @@ -322,7 +318,7 @@ def test_domain_model_query_endpoints(tmp_path: Path) -> None: investigation_response = client.get("/investigations/inv_fill_weight_drift_WO_DEMO_1007") assert site_response.status_code == 200 - assert site_response.json()["name"] == "Greenville Demo Site" + assert site_response.json()["name"] == "Greenville Manufacturing Site" assert signal_response.status_code == 200 assert signal_response.json()["equipment_id"] == "filler_f_201" assert quality_response.status_code == 200 @@ -387,7 +383,7 @@ def test_demo_recommendation_endpoints_expose_review_fields(tmp_path: Path) -> N assert recommendation["detection_id"] == "det_fill_weight_gradual_drift" assert recommendation["status"] == "needs_review" assert recommendation["recommended_action"] == ( - "Inspect filler calibration and increase quality checks for the affected demo work " + "Inspect filler calibration and increase quality checks for the affected work " "order." ) assert recommendation["rationale"] == ( diff --git a/services/api/tests/test_domain.py b/services/api/tests/test_domain.py index 0f5753e..6867112 100644 --- a/services/api/tests/test_domain.py +++ b/services/api/tests/test_domain.py @@ -17,7 +17,7 @@ def test_demo_domain_links_process_signals_to_quality_and_investigation() -> Non investigation = domain.get_investigation_detail("inv_fill_weight_drift_WO_DEMO_1007") assert site is not None - assert site.name == "Greenville Demo Site" + assert site.name == "Greenville Manufacturing Site" assert area is not None assert area.name == "Packaging Area" assert filler is not None diff --git a/services/process-sentinel/process_sentinel/rules.py b/services/process-sentinel/process_sentinel/rules.py index ccc848c..8443d3f 100644 --- a/services/process-sentinel/process_sentinel/rules.py +++ b/services/process-sentinel/process_sentinel/rules.py @@ -67,8 +67,7 @@ def _detect_gradual_fill_weight_drift( time_window_start=baseline[0].timestamp, time_window_end=recent[-1].timestamp, summary=( - "Advisory: fill weight is trending upward, which may move the affected " - "work order toward the upper quality limit." + "Fill weight is trending upward toward the upper quality limit." ), confidence=min(0.95, 0.55 + delta / 10), related_work_order_id=recent[-1].context.work_order_id, @@ -176,11 +175,11 @@ def _recommendation_for_detection( detection: Detection, evidence_items: Sequence[EvidenceItem] ) -> Recommendation: if detection.severity == "high": - action = "Inspect filler operation and hold affected demo work order for quality review." + action = "Inspect filler operation and hold the affected work order for quality review." rationale = "A process excursion can affect product quality and requires human review." else: action = ( - "Inspect filler calibration and increase quality checks for the affected demo work " + "Inspect filler calibration and increase quality checks for the affected work " "order." ) rationale = "The trend is moving toward the quality limit before confirmed broad failure." diff --git a/services/process-sentinel/tests/test_rules.py b/services/process-sentinel/tests/test_rules.py index 2ddd14f..62b3228 100644 --- a/services/process-sentinel/tests/test_rules.py +++ b/services/process-sentinel/tests/test_rules.py @@ -89,8 +89,7 @@ def test_fill_weight_drift_demo_supports_detection_recommendation_and_rca_draft( assert [item.detection_type for item in result.detections] == ["quality_drift"] assert detection.detection_id == "det_fill_weight_gradual_drift" assert detection.summary == ( - "Advisory: fill weight is trending upward, which may move the affected " - "work order toward the upper quality limit." + "Fill weight is trending upward toward the upper quality limit." ) assert "root cause" not in detection.summary.lower() assert "caused by" not in detection.summary.lower() diff --git a/services/simulator/tests/test_connection_management_epic_readiness.py b/services/simulator/tests/test_connection_management_epic_readiness.py index 76e1d30..08cdb25 100644 --- a/services/simulator/tests/test_connection_management_epic_readiness.py +++ b/services/simulator/tests/test_connection_management_epic_readiness.py @@ -85,9 +85,10 @@ def test_issue_206_operator_console_includes_protocol_and_sentinel_workflows() - 'href: "/connections"', 'href: "/protocol-diagnostics"', 'href: "/tag-source-browser"', - "Read-only diagnostics", - "Writeback", - "Disabled", + "Environment", + "Data health", + "Writeback policy", + "productCopy.writebackPolicyLabel", ]: assert expected in layout From d78aea0e24bc0805a228c56cfe88b76690e10ccb Mon Sep 17 00:00:00 2001 From: Aldon Smith Date: Wed, 27 May 2026 14:40:50 -0400 Subject: [PATCH 3/6] Fix workbench API routing in Docker --- apps/web/README.md | 16 +++++- apps/web/lib/api-client.ts | 4 +- apps/web/lib/api-config.ts | 16 +++++- apps/web/tests/app-shell.test.mjs | 4 ++ docs/LEARNING_LOG.md | 52 +++++++++++++++++++ docs/runtime/DOCKER_COMPOSE.md | 8 +++ .../tests/test_docker_compose_runtime_docs.py | 5 ++ 7 files changed, 100 insertions(+), 5 deletions(-) diff --git a/apps/web/README.md b/apps/web/README.md index b881834..7811b5a 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -28,8 +28,12 @@ http://127.0.0.1:3000 ## API Base URL -The frontend reads the local FastAPI backend URL from -`NEXT_PUBLIC_API_BASE_URL`. +The Workbench uses two API targets in Docker-aware runtimes: + +- `NEXT_PUBLIC_API_BASE_URL` is the browser-visible FastAPI URL shown in the UI + and used by client-side requests. +- `FIP_API_URL` is the server-side container-to-container API URL used by + server-rendered Next.js requests. Default: @@ -43,6 +47,14 @@ Override without code changes: NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8001 npm run dev ``` +When running through Docker Compose, keep the public URL pointed at the host +published API port and use the Compose service name for server-side requests: + +```text +NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 +FIP_API_URL=http://api:8000 +``` + ## Runtime Direction Use `../../docs/runtime/DOCKER_COMPOSE.md` for the detailed local runtime guide diff --git a/apps/web/lib/api-client.ts b/apps/web/lib/api-client.ts index 0be95f2..b8927b6 100644 --- a/apps/web/lib/api-client.ts +++ b/apps/web/lib/api-client.ts @@ -1,4 +1,4 @@ -import { apiBaseUrl } from "./api-config"; +import { apiBaseUrl, getRequestApiBaseUrl } from "./api-config"; export type HealthResponse = { status: string; @@ -385,7 +385,7 @@ function postDecision( } async function requestJson(path: string, init: RequestInit = {}): Promise { - const response = await fetch(`${apiBaseUrl}${path}`, { + const response = await fetch(`${getRequestApiBaseUrl()}${path}`, { ...init, cache: "no-store", headers: { diff --git a/apps/web/lib/api-config.ts b/apps/web/lib/api-config.ts index 857a090..7c7d737 100644 --- a/apps/web/lib/api-config.ts +++ b/apps/web/lib/api-config.ts @@ -1,2 +1,16 @@ +const fallbackApiBaseUrl = "http://127.0.0.1:8000"; + +function cleanBaseUrl(value: string | undefined): string | undefined { + return value?.replace(/\/$/, ""); +} + export const apiBaseUrl = - process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:8000"; + cleanBaseUrl(process.env.NEXT_PUBLIC_API_BASE_URL) ?? fallbackApiBaseUrl; + +export function getRequestApiBaseUrl(): string { + if (typeof window !== "undefined") { + return apiBaseUrl; + } + + return cleanBaseUrl(process.env.FIP_API_URL) ?? apiBaseUrl; +} diff --git a/apps/web/tests/app-shell.test.mjs b/apps/web/tests/app-shell.test.mjs index 2b954f0..1b3a248 100644 --- a/apps/web/tests/app-shell.test.mjs +++ b/apps/web/tests/app-shell.test.mjs @@ -438,7 +438,11 @@ test("app shell keeps integration endpoint details in diagnostics copy", () => { const readme = readFileSync(join(root, "README.md"), "utf8"); assert.match(config, /NEXT_PUBLIC_API_BASE_URL/); + assert.match(config, /FIP_API_URL/); + assert.match(config, /getRequestApiBaseUrl/); + assert.match(config, /typeof window !== "undefined"/); assert.match(client, /apiBaseUrl/); + assert.match(client, /getRequestApiBaseUrl\(\)/); assert.match(client, /local FIP API/); assert.match(config, /http:\/\/127\.0\.0\.1:8000/); assert.match(demoState, /Integration details/); diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 6dc9505..f3b5bb3 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -22,6 +22,58 @@ This file should be updated by Codex after each meaningful change. ### What to learn next ``` +## 2026-05-27 - Docker Workbench API routing fix + +### What changed + +Updated the Workbench API URL resolution so server-rendered requests use the +Compose-internal API service URL while browser-facing UI keeps the public host +API URL. + +### Why it matters + +The Dockerized Workbench runs Next.js inside the `web` container. From that +container, `localhost:8000` points back at the web container, not the FastAPI +service. Using `FIP_API_URL=http://api:8000` for server-side requests prevents +false API connection errors when the host API health check is already passing. + +### How it works + +`NEXT_PUBLIC_API_BASE_URL` remains the public browser target and integration +display value. Server-side fetches call a runtime resolver that prefers +`FIP_API_URL` when no browser `window` is present, then falls back to the public +API URL for direct local development. + +### How to run it + +```bash +docker compose -f infra/docker/docker-compose.yml up -d --build web +curl http://localhost:8000/health +curl http://localhost:3000 +``` + +### How to test it + +```bash +cd apps/web && npm test +cd apps/web && npm run typecheck +cd apps/web && npm run build +.venv/bin/python -m pytest services/simulator/tests/test_docker_compose_runtime_docs.py +``` + +### Key files + +- `apps/web/lib/api-config.ts` +- `apps/web/lib/api-client.ts` +- `apps/web/README.md` +- `docs/runtime/DOCKER_COMPOSE.md` +- `services/simulator/tests/test_docker_compose_runtime_docs.py` + +### What to learn next + +Review other browser/server shared configuration values for the same +container-networking split before moving the Compose smoke path into CI. + ## 2026-05-27 - Overview context questions ### What changed diff --git a/docs/runtime/DOCKER_COMPOSE.md b/docs/runtime/DOCKER_COMPOSE.md index 313ff4d..45ea4ab 100644 --- a/docs/runtime/DOCKER_COMPOSE.md +++ b/docs/runtime/DOCKER_COMPOSE.md @@ -105,6 +105,14 @@ The FIP stack includes these default services: It does not start an in-repo simulator service. Start Demo-Factory separately when connector source data is required. +The Workbench intentionally separates browser-visible and server-side API +targets. Browser requests and displayed integration details use +`FIP_PUBLIC_API_URL` through `NEXT_PUBLIC_API_BASE_URL`, while server-rendered +Next.js requests inside the `web` container use `FIP_API_URL`. In Compose, that +server-side URL should remain `http://api:8000`; `http://localhost:8000` points +back at the `web` container from inside Docker and can cause the overview to +show an API connection issue even when the host can reach API health. + The `connector-worker` service runs: ```bash diff --git a/services/simulator/tests/test_docker_compose_runtime_docs.py b/services/simulator/tests/test_docker_compose_runtime_docs.py index 5e25667..809e133 100644 --- a/services/simulator/tests/test_docker_compose_runtime_docs.py +++ b/services/simulator/tests/test_docker_compose_runtime_docs.py @@ -106,6 +106,8 @@ def test_docker_compose_runtime_defines_fip_services_without_simulator() -> None "${FIP_WEB_PORT:-3000}:3000", "FIP_PUBLIC_API_URL", "FIP_API_URL: http://api:8000", + "http://localhost:8000", + "http://api:8000", "FACTORY_CONNECTION_PROFILES_STORE", "DEMO_FACTORY_HOST", "DEMO_FACTORY_OPCUA_ENDPOINT", @@ -134,6 +136,9 @@ def test_docker_compose_runtime_defines_fip_services_without_simulator() -> None ]: assert service in runtime_doc assert "It does not start an in-repo simulator service." in runtime_doc + assert "server-rendered\nNext.js requests" in runtime_doc + assert "`web` container use `FIP_API_URL`" in runtime_doc + assert "`http://localhost:8000` points\nback at the `web` container" in runtime_doc def test_makefile_includes_compose_wrappers() -> None: From 0893866412bbfbb16595d9dfa92792c2e36305ec Mon Sep 17 00:00:00 2001 From: Aldon Smith Date: Wed, 27 May 2026 14:57:13 -0400 Subject: [PATCH 4/6] ui: align console with operations reference --- .../app/components/operator-navigation.tsx | 75 ++ apps/web/app/globals.css | 653 ++++++++++++++++-- apps/web/app/layout.tsx | 112 ++- apps/web/app/process-sentinel/page.tsx | 420 ++++++++++- .../web/e2e/operations-workbench-demo.spec.ts | 9 +- apps/web/tests/app-shell.test.mjs | 79 ++- docs/LEARNING_LOG.md | 45 ++ ...st_connection_management_epic_readiness.py | 21 +- 8 files changed, 1216 insertions(+), 198 deletions(-) create mode 100644 apps/web/app/components/operator-navigation.tsx diff --git a/apps/web/app/components/operator-navigation.tsx b/apps/web/app/components/operator-navigation.tsx new file mode 100644 index 0000000..40e946c --- /dev/null +++ b/apps/web/app/components/operator-navigation.tsx @@ -0,0 +1,75 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +type NavItem = { + href: string; + icon: string; + label: string; +}; + +type NavGroup = { + items: NavItem[]; + label: string; +}; + +const navGroups: NavGroup[] = [ + { + items: [{ href: "/", icon: "O", label: "Overview" }], + label: "Platform", + }, + { + items: [ + { href: "/process-sentinel", icon: "P", label: "Process Sentinel" }, + { href: "/detections", icon: "D", label: "Detections" }, + { href: "/recommendations", icon: "R", label: "Recommendations" }, + { href: "/rca-capa-draft", icon: "C", label: "RCA/CAPA" }, + ], + label: "Sentinel workflows", + }, + { + items: [ + { href: "/connections", icon: "N", label: "Connections" }, + { href: "/protocol-diagnostics", icon: "H", label: "Protocol Diagnostics" }, + { href: "/tag-source-browser", icon: "T", label: "Tag/Source Browser" }, + ], + label: "Protocol operations", + }, +]; + +export function OperatorNavigation() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index a842a9f..142c1b4 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,5 +1,5 @@ :root { - --background: #e8eef3; + --background: #eef2f6; --surface: #ffffff; --surface-muted: #f5f8fa; --surface-subtle: #f0f5f8; @@ -11,8 +11,8 @@ --accent: #0f766e; --accent-strong: #0b5d57; --accent-bg: #d7f4eb; - --nav: #101820; - --nav-2: #1d2b36; + --nav: #07111b; + --nav-2: #101f2b; --nav-text: #d8e2ea; --nav-muted: #91a4b3; --warning: #b45309; @@ -52,7 +52,7 @@ a { .operator-shell { display: grid; - grid-template-columns: 276px minmax(0, 1fr); + grid-template-columns: 248px minmax(0, 1fr); min-height: 100vh; } @@ -81,11 +81,11 @@ a { display: flex; height: 100vh; flex-direction: column; - gap: 24px; + gap: 22px; border-right: 1px solid #243541; background: var(--nav); color: var(--nav-text); - padding: 22px 18px; + padding: 18px 14px; } .sidebar-branding { @@ -94,8 +94,9 @@ a { } .brand { - display: grid; - gap: 2px; + display: flex; + align-items: center; + gap: 12px; border-radius: 7px; } @@ -104,6 +105,21 @@ a { outline-offset: 4px; } +.brand-mark { + display: inline-flex; + width: 34px; + height: 34px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid #315064; + border-radius: 8px; + background: #0b2634; + color: #dff7ff; + font-size: 0.66rem; + font-weight: 860; +} + .brand-name { color: #ffffff; font-size: 1rem; @@ -111,15 +127,9 @@ a { letter-spacing: 0; } -.brand-context { - color: var(--nav-muted); - font-size: 0.82rem; - font-weight: 560; -} - .primary-nav { display: grid; - gap: 22px; + gap: 14px; } .nav-group { @@ -128,7 +138,7 @@ a { } .nav-group h2 { - margin: 0; + margin: 0 8px; color: var(--nav-muted); font-size: 0.72rem; font-weight: 780; @@ -141,10 +151,10 @@ a { } .nav-link { - display: flex; + display: grid; + grid-template-columns: 30px minmax(0, 1fr); min-height: 42px; align-items: center; - justify-content: space-between; gap: 10px; border: 1px solid transparent; border-radius: 7px; @@ -156,38 +166,42 @@ a { } .nav-link:hover, -.nav-link:focus-visible { +.nav-link:focus-visible, +.nav-link-active { border-color: #2d4352; background: var(--nav-2); color: #ffffff; - outline: 3px solid var(--focus-ring); - outline-offset: 2px; } -.nav-link-disabled { - color: var(--nav-muted); - cursor: default; +.nav-link:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 2px; } -.nav-link-disabled:hover { - background: transparent; - color: var(--nav-muted); +.nav-link-active { + border-color: #0b8b94; + background: linear-gradient(90deg, #0f7f86, #134050); + box-shadow: inset 3px 0 0 #17b7bc; } -.nav-link-meta { - border: 1px solid #314756; +.nav-icon { + display: inline-flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 1px solid #2a4556; border-radius: 999px; - background: var(--nav-2); - color: var(--nav-text); - font-size: 0.68rem; - font-weight: 760; - line-height: 1; - padding: 5px 7px; - text-transform: uppercase; + color: #d8e2ea; + font-size: 0.72rem; + font-weight: 820; } .operator-workspace { + display: grid; + min-height: 100vh; min-width: 0; + grid-template-rows: auto minmax(0, 1fr) auto; } .status-strip { @@ -200,15 +214,23 @@ a { .status-strip div { display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; min-width: 0; + align-items: center; gap: 4px; - border: 1px solid #243541; - border-radius: 7px; - background: var(--nav-2); - padding: 10px 11px; + border: 0; + border-radius: 0; + background: transparent; + padding: 3px 0; } -.status-strip .status-label { +.status-strip-heading { + grid-template-columns: minmax(0, 1fr) auto !important; + margin-bottom: 4px; +} + +.status-strip .status-label, +.status-strip span { color: var(--nav-muted); } @@ -219,10 +241,119 @@ a { line-height: 1.25; } +.status-dot { + width: 9px; + height: 9px; + border-radius: 999px; + background: #35c759; + box-shadow: 0 0 0 3px rgb(53 199 89 / 14%); +} + +.sidebar-config-link { + display: flex; + align-items: center; + justify-content: center; + margin-top: 8px; + border: 1px solid #2d4352; + border-radius: 7px; + color: #ffffff; + font-size: 0.86rem; + font-weight: 760; + padding: 10px; +} + +.sidebar-config-link:hover, +.sidebar-config-link:focus-visible { + border-color: #17b7bc; + outline: 3px solid var(--focus-ring); + outline-offset: 2px; +} + +.operator-topbar { + display: flex; + min-height: 58px; + align-items: center; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid #d6dee7; + background: #ffffff; + padding: 0 24px; +} + +.topbar-actions { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.topbar-config-link, +.topbar-site-button, +.topbar-icon-button, +.topbar-avatar { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 7px; + background: #ffffff; + color: var(--text); + font: inherit; + font-size: 0.88rem; + font-weight: 720; + padding: 8px 11px; +} + +.topbar-config-link { + border-color: #0f766e; + color: #0b5d57; +} + +.topbar-icon-button, +.topbar-avatar { + width: 36px; + padding: 0; +} + +.topbar-avatar { + border-radius: 999px; + background: #0f1e2b; + color: #ffffff; + font-size: 0.8rem; +} + +.topbar-config-link:hover, +.topbar-config-link:focus-visible, +.topbar-icon-button:hover, +.topbar-icon-button:focus-visible { + border-color: var(--accent); + outline: 3px solid var(--focus-ring); + outline-offset: 2px; +} + +.topbar-icon-button:disabled { + cursor: default; + opacity: 0.7; +} + .page-shell { - width: min(1180px, calc(100% - 32px)); + width: min(1420px, calc(100% - 48px)); margin: 0 auto; - padding: 28px 0 48px; + padding: 22px 0 24px; +} + +.operator-footer { + display: flex; + min-height: 50px; + align-items: center; + justify-content: flex-end; + gap: 28px; + border-top: 1px solid #d6dee7; + background: #07111b; + color: #c3d0dc; + font-size: 0.82rem; + padding: 0 24px; } .page-shell:focus-visible { @@ -519,9 +650,9 @@ p { h1 { max-width: 780px; - margin-bottom: 14px; - font-size: clamp(2.15rem, 4vw, 4rem); - line-height: 1.02; + margin-bottom: 10px; + font-size: clamp(2rem, 3vw, 2.75rem); + line-height: 1.08; letter-spacing: 0; } @@ -1236,6 +1367,362 @@ h3 { outline-offset: 1px; } +.sentinel-console { + display: grid; + gap: 16px; +} + +.sentinel-commandbar, +.sentinel-card-heading, +.sentinel-finding-header, +.sentinel-action-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.sentinel-commandbar { + align-items: center; +} + +.sentinel-commandbar h1 { + margin-bottom: 0; +} + +.sentinel-commandbar-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + +.toolbar-button, +.sentinel-tune-link { + display: inline-flex; + min-height: 38px; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 7px; + background: #ffffff; + color: var(--text); + cursor: pointer; + font: inherit; + font-size: 0.9rem; + font-weight: 720; + line-height: 1; + padding: 10px 13px; +} + +.toolbar-button-primary { + border-color: var(--accent); + background: var(--accent); + color: #ffffff; +} + +.toolbar-button:hover, +.toolbar-button:focus-visible, +.sentinel-tune-link:hover, +.sentinel-tune-link:focus-visible { + border-color: var(--accent); + outline: 3px solid var(--focus-ring); + outline-offset: 2px; +} + +.sentinel-search-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; +} + +.sentinel-search-field input { + width: 100%; + min-height: 46px; + border: 1px solid var(--border); + border-radius: 7px; + background: #ffffff; + color: var(--text); + font: inherit; + padding: 12px 14px; +} + +.sentinel-stage-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + overflow: hidden; + border: 1px solid var(--border); + border-radius: 8px; + background: #ffffff; +} + +.sentinel-stage-strip article { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 2px 12px; + align-items: center; + border-right: 1px solid var(--border); + padding: 18px 22px; +} + +.sentinel-stage-strip article:last-child { + border-right: 0; +} + +.sentinel-stage-strip article.active { + box-shadow: inset 0 -4px 0 var(--accent); +} + +.sentinel-stage-strip strong { + display: inline-flex; + width: 36px; + height: 36px; + grid-row: span 2; + align-items: center; + justify-content: center; + border-radius: 999px; + background: #e8edf3; + color: var(--text); +} + +.sentinel-stage-strip .active strong { + background: var(--accent); + color: #ffffff; +} + +.sentinel-stage-strip span { + font-weight: 800; +} + +.sentinel-stage-strip small { + color: var(--muted); + font-size: 0.84rem; + font-weight: 650; +} + +.sentinel-workbench-grid { + display: grid; + grid-template-columns: minmax(420px, 0.92fr) minmax(0, 1.18fr); + gap: 8px; +} + +.sentinel-queue-card, +.sentinel-finding-card, +.sentinel-recommendation-card { + min-width: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: #ffffff; + box-shadow: var(--shadow); +} + +.sentinel-queue-card { + display: grid; + align-content: start; + overflow: hidden; +} + +.sentinel-card-heading { + align-items: center; + border-bottom: 1px solid var(--border); + padding: 14px 16px; +} + +.sentinel-card-heading h2, +.sentinel-card-heading h3 { + margin-bottom: 0; + font-size: 1rem; +} + +.sentinel-card-heading label { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 0.84rem; + font-weight: 700; +} + +.sentinel-card-heading select { + border: 1px solid var(--border); + border-radius: 7px; + background: #ffffff; + color: var(--text); + font: inherit; + padding: 8px 28px 8px 10px; +} + +.sentinel-table-wrap { + overflow-x: auto; +} + +.sentinel-table, +.sentinel-evidence-table { + width: 100%; + min-width: 620px; + border-collapse: collapse; +} + +.sentinel-table th, +.sentinel-table td, +.sentinel-evidence-table th, +.sentinel-evidence-table td { + border-bottom: 1px solid var(--border); + padding: 12px 14px; + text-align: left; + vertical-align: top; +} + +.sentinel-table th, +.sentinel-evidence-table th { + background: var(--surface-muted); + color: var(--muted); + font-size: 0.74rem; + font-weight: 800; + text-transform: uppercase; +} + +.sentinel-table td { + font-size: 0.9rem; + line-height: 1.35; +} + +.sentinel-table tr.selected td { + background: #e8f7f6; + box-shadow: inset 4px 0 0 var(--accent); +} + +.sentinel-table a { + font-weight: 760; +} + +.sentinel-status-dot { + display: inline-block; + width: 8px; + height: 8px; + margin-right: 8px; + border-radius: 999px; + background: var(--accent); +} + +.sentinel-pagination { + display: flex; + justify-content: space-between; + gap: 12px; + color: var(--muted); + font-size: 0.86rem; + padding: 14px 16px; +} + +.sentinel-detail-stack { + display: grid; + gap: 10px; + min-width: 0; +} + +.sentinel-finding-card, +.sentinel-recommendation-card { + display: grid; + gap: 16px; + padding: 18px; +} + +.sentinel-finding-header h2 { + margin: 4px 0 0; + font-size: 1.08rem; +} + +.sentinel-finding-meta { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + margin: 0; +} + +.sentinel-finding-meta dt { + color: var(--muted); + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; +} + +.sentinel-finding-meta dd { + margin: 5px 0 0; + color: var(--text); + font-size: 0.88rem; + font-weight: 700; +} + +.sentinel-finding-card p, +.sentinel-recommendation-card p { + margin-bottom: 0; + color: var(--muted); + line-height: 1.5; +} + +.sentinel-tabs { + display: flex; + flex-wrap: wrap; + gap: 18px; + border-bottom: 1px solid var(--border); +} + +.sentinel-tabs span { + color: var(--muted); + font-size: 0.88rem; + font-weight: 700; + padding-bottom: 11px; +} + +.sentinel-tabs span.active { + border-bottom: 3px solid var(--accent); + color: var(--accent-strong); +} + +.sentinel-evidence-panel { + display: grid; + gap: 12px; +} + +.sentinel-evidence-table { + min-width: 560px; +} + +.sentinel-evidence-table td strong, +.sentinel-evidence-table td span { + display: block; +} + +.sentinel-evidence-table td span { + margin-top: 4px; + color: var(--muted); + font-size: 0.84rem; + line-height: 1.35; +} + +.sentinel-recommendation-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.sentinel-recommendation-grid section:first-child { + border-right: 1px solid var(--border); + padding-right: 18px; +} + +.sentinel-recommendation-grid h3 { + margin: 0 0 6px; + font-size: 0.9rem; +} + +.sentinel-action-row { + justify-content: flex-start; + flex-wrap: wrap; + border-top: 1px solid var(--border); + padding-top: 14px; +} + .connections-workspace { display: grid; gap: 18px; @@ -2055,11 +2542,44 @@ code { border-bottom: 1px solid var(--border); } + .operator-topbar { + display: grid; + padding: 12px 16px; + } + + .topbar-actions { + flex-wrap: wrap; + } + .status-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 0; } + .sentinel-commandbar, + .sentinel-card-heading, + .sentinel-finding-header { + display: grid; + } + + .sentinel-search-row, + .sentinel-workbench-grid, + .sentinel-recommendation-grid { + grid-template-columns: 1fr; + } + + .sentinel-stage-strip, + .sentinel-finding-meta { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .sentinel-recommendation-grid section:first-child { + border-right: 0; + border-bottom: 1px solid var(--border); + padding-right: 0; + padding-bottom: 16px; + } + .hero { grid-template-columns: 1fr; } @@ -2117,11 +2637,11 @@ code { @media (max-width: 1340px) { .page-shell { - width: min(1100px, calc(100% - 28px)); + width: min(1180px, calc(100% - 28px)); } h1 { - font-size: clamp(2rem, 3vw, 3.2rem); + font-size: clamp(1.9rem, 3vw, 2.5rem); } .hero-copy { @@ -2140,6 +2660,14 @@ code { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .sentinel-workbench-grid { + grid-template-columns: minmax(360px, 0.9fr) minmax(0, 1.1fr); + } + + .sentinel-finding-meta { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .source-search-field { grid-column: 1 / -1; } @@ -2149,6 +2677,17 @@ code { } } +@media (max-width: 880px) { + .sentinel-workbench-grid, + .sentinel-recommendation-grid { + grid-template-columns: 1fr; + } + + .sentinel-finding-meta { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + @media (max-width: 560px) { .operator-sidebar { padding: 18px 16px; @@ -2158,6 +2697,24 @@ code { grid-template-columns: 1fr; } + .page-shell { + width: min(100% - 20px, 100%); + } + + .sentinel-stage-strip, + .sentinel-finding-meta { + grid-template-columns: 1fr; + } + + .sentinel-stage-strip article { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .sentinel-stage-strip article:last-child { + border-bottom: 0; + } + .content-grid { grid-template-columns: 1fr; } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 0a739f6..36ffda1 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import type { ReactNode } from "react"; +import { OperatorNavigation } from "./components/operator-navigation"; import { productCopy } from "../lib/product-copy"; import "./globals.css"; @@ -10,39 +11,6 @@ export const metadata: Metadata = { description: "External-source Factory Intelligence Platform workbench shell.", }; -type NavItem = - | { href: string; label: string } - | { label: string; status: "Planned" }; - -type NavGroup = { - items: NavItem[]; - label: string; -}; - -const navGroups: NavGroup[] = [ - { - items: [{ href: "/", label: "Overview" }], - label: "Platform", - }, - { - items: [ - { href: "/process-sentinel", label: "Process Sentinel" }, - { href: "/detections", label: "Detections" }, - { href: "/recommendations", label: "Recommendations" }, - { href: "/rca-capa-draft", label: "RCA/CAPA Draft" }, - ], - label: "Sentinel workflows", - }, - { - items: [ - { href: "/connections", label: "Connections" }, - { href: "/protocol-diagnostics", label: "Protocol Diagnostics" }, - { href: "/tag-source-browser", label: "Tag/Source Browser" }, - ], - label: "Protocol operations", - }, -]; - export default function RootLayout({ children }: { children: ReactNode }) { return ( @@ -54,59 +22,65 @@ export default function RootLayout({ children }: { children: ReactNode }) {
+
+
+
+ + Configure Connections + + + Plant 7 - Northview + + + + + AS + +
+
{children}
+
+ Time Zone: America/Chicago (CDT) + (c) 2026 Factory Intelligence Platform + v2.8.1 +
diff --git a/apps/web/app/process-sentinel/page.tsx b/apps/web/app/process-sentinel/page.tsx index 220fc43..5f65f4b 100644 --- a/apps/web/app/process-sentinel/page.tsx +++ b/apps/web/app/process-sentinel/page.tsx @@ -1,50 +1,396 @@ import Link from "next/link"; +import { + ApiErrorPanel, + EmptyState, + StatusBadge, +} from "../components/demo-state"; +import { + type Detection, + type Equipment, + type EvidenceItem, + type Recommendation, + formatApiError, + workbenchApi, +} from "../../lib/api-client"; + export const dynamic = "force-dynamic"; -export default function ProcessSentinelPage() { +export default async function ProcessSentinelPage() { + const result = await loadProcessSentinelWorkbench(); + return ( -
-
-

Process Sentinel

-

- Follow the read-only Process Sentinel workflow from detection, to - evidence explanation, to governed recommendation review, to RCA/CAPA - draft preview. -

-
-
- Process Sentinel is advisory decision support over the current - operational event store. It is read-only and does not perform external - execution, product disposition, QMS/MES updates, or production CAPA - creation. -
-
-
- Detect -

Detections

-

Review active Process Sentinel findings and open the primary evidence path.

- - Open detections +
+
+
+

Process Sentinel

+
+
+ + + + Configure Connections +
+
+ +
+ + + Source Health + +
+ +
+
+ 1 + Detect + {result.ok ? `${result.newCount} new` : "Unavailable"}
-
- Review -

Recommendations

-

Record a human approval, rejection, or deferral for advisory recommendations.

- - Open recommendations - +
+ 2 + Explain + {result.ok ? `${result.explainCount} explained` : "Unavailable"}
-
- Draft -

RCA/CAPA Draft

-

Preview investigation draft language generated from current Sentinel state.

- - Open RCA/CAPA draft - +
+ 3 + Review + {result.ok ? `${result.reviewCount} pending` : "Unavailable"} +
+
+ 4 + Draft + {result.ok ? `${result.draftCount} in progress` : "Unavailable"}
+ + {!result.ok ? : null} + + {result.ok && result.detections.length === 0 ? ( + + ) : null} + + {result.ok && result.selectedDetection ? ( +
+
+
+

Detection Queue ({result.detections.length})

+ +
+
+ + + + + + + + + + + + {result.detections.map((detection) => { + const isSelected = + detection.detection_id === result.selectedDetection?.detection_id; + + return ( + + + + + + + + ); + })} + +
SeverityDetection TitleUnitDetected AtStatus
+ + + + {detection.summary} + + {formatAssetLabel(detection, result.equipmentById)}{formatClock(detection.created_at)} +
+
+
+ 1-{result.detections.length} of {result.detections.length} + Rows per page 10 +
+
+ +
+
+
+
+ Selected Finding +

{result.selectedDetection.summary}

+
+ +
+
+
+
Unit
+
{formatAssetLabel(result.selectedDetection, result.equipmentById)}
+
+
+
Detected At
+
{formatDateTime(result.selectedDetection.created_at)}
+
+
+
Duration
+
{formatDuration(result.selectedDetection)}
+
+
+
Status
+
{formatStatus(result.selectedDetection.status)}
+
+
+
Owner
+
Unassigned
+
+
+

+ {result.selectedDetection.summary} Confidence is{" "} + {Math.round(result.selectedDetection.confidence * 100)}% for the + current operating window. +

+ +
+
+

Evidence Timeline

+ + Open Trend + +
+ {result.evidence.length > 0 ? ( + + + + + + + + + + + {result.evidence.map((item) => ( + + + + + + + ))} + +
TimeEvidenceScoreSeverity
{formatClock(item.timestamp)} + {item.title} + {item.description} + {Math.round(item.score * 100)}% + +
+ ) : ( +

No evidence items are linked to this finding yet.

+ )} +
+
+ +
+
+
+ Governed Recommendation +

{result.selectedRecommendation ? "Recommendation Draft" : "No recommendation linked"}

+
+ +
+ {result.selectedRecommendation ? ( +
+
+

Recommendation

+

{result.selectedRecommendation.recommended_action}

+

Expected Outcome

+

+ Reduce quality risk while the affected work order is under + human review. +

+
+
+

Rationale

+

{result.selectedRecommendation.rationale}

+

Reference Evidence

+

{result.selectedRecommendation.evidence_ids.join(", ")}

+
+
+ ) : ( +

No governed recommendation has been generated for this case yet.

+ )} +
+ + Review & Edit + + + Request More Analysis + + + Configure Connections + +
+
+
+
+ ) : null}
); } + +async function loadProcessSentinelWorkbench() { + try { + const [detections, recommendations, equipment] = await Promise.all([ + workbenchApi.listDetections(), + workbenchApi.listRecommendations(), + workbenchApi.listEquipment(), + ]); + const selectedDetection = selectDetection(detections); + const evidence = selectedDetection + ? await workbenchApi.listDetectionEvidence(selectedDetection.detection_id) + : []; + const selectedRecommendation = selectedDetection + ? recommendations.find( + (recommendation) => + recommendation.detection_id === selectedDetection.detection_id, + ) ?? null + : null; + + return { + detections, + draftCount: recommendations.filter((item) => item.status === "draft").length, + equipmentById: new Map(equipment.map((item) => [item.equipment_id, item])), + evidence, + explainCount: detections.filter((item) => item.status === "investigating").length, + newCount: detections.filter((item) => item.status === "new").length, + ok: true as const, + reviewCount: recommendations.filter((item) => item.status === "needs_review").length, + selectedDetection, + selectedRecommendation, + }; + } catch (error) { + return { message: formatApiError(error), ok: false as const }; + } +} + +function selectDetection(detections: Detection[]): Detection | null { + return [...detections].sort((left, right) => { + const severityDelta = severityRank(right.severity) - severityRank(left.severity); + if (severityDelta !== 0) { + return severityDelta; + } + return Date.parse(right.created_at) - Date.parse(left.created_at); + })[0] ?? null; +} + +function severityRank(severity: Detection["severity"] | EvidenceItem["severity"]) { + if (severity === "high") { + return 3; + } + if (severity === "medium") { + return 2; + } + return 1; +} + +function severityTone(severity: Detection["severity"] | EvidenceItem["severity"]) { + if (severity === "high") { + return "danger"; + } + if (severity === "medium") { + return "warning"; + } + return "info"; +} + +function formatAssetLabel( + detection: Detection, + equipmentById: Map, +): string { + const assetId = detection.related_asset_ids[0]; + return assetId ? equipmentById.get(assetId)?.name ?? assetId : "Unassigned"; +} + +function formatStatus(status: Detection["status"]): string { + if (status === "recommendation_created") { + return "Review"; + } + if (status === "investigating") { + return "Explain"; + } + return status.replaceAll("_", " "); +} + +function formatClock(value: string): string { + return new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + hour12: false, + minute: "2-digit", + timeZone: "UTC", + }).format(new Date(value)); +} + +function formatDateTime(value: string): string { + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", + }).format(new Date(value)); +} + +function formatDuration(detection: Detection): string { + const start = Date.parse(detection.time_window_start); + const end = Date.parse(detection.time_window_end); + if (Number.isNaN(start) || Number.isNaN(end) || end <= start) { + return "Current window"; + } + return `${Math.round((end - start) / 60_000)} min`; +} diff --git a/apps/web/e2e/operations-workbench-demo.spec.ts b/apps/web/e2e/operations-workbench-demo.spec.ts index cdaad63..07ad841 100644 --- a/apps/web/e2e/operations-workbench-demo.spec.ts +++ b/apps/web/e2e/operations-workbench-demo.spec.ts @@ -21,11 +21,10 @@ test("walks the external-source Operations Workbench demo path", async ({ page } await expect(page.getByText("Tag/Source Browser", { exact: true })).toBeVisible(); const statusStrip = page.getByRole("region", { name: "Workbench status strip" }); await expect(statusStrip).toBeVisible(); - await expect(statusStrip.getByText("Environment")).toBeVisible(); - await expect(statusStrip.getByText("Validation")).toBeVisible(); - await expect(statusStrip.getByText("Data health")).toBeVisible(); - await expect(statusStrip.getByText("Writeback policy")).toBeVisible(); - await expect(statusStrip.getByText("Human approval required")).toBeVisible(); + await expect(statusStrip.getByText("Data freshness")).toBeVisible(); + await expect(statusStrip.getByText("Historian")).toBeVisible(); + await expect(statusStrip.getByText("LIMS")).toBeVisible(); + await expect(page.getByRole("link", { name: "Configure Connections" }).first()).toBeVisible(); const contextQuestion = page.getByRole("textbox", { name: "Ask about current factory context", }); diff --git a/apps/web/tests/app-shell.test.mjs b/apps/web/tests/app-shell.test.mjs index 1b3a248..5e2de61 100644 --- a/apps/web/tests/app-shell.test.mjs +++ b/apps/web/tests/app-shell.test.mjs @@ -31,38 +31,41 @@ test("workbench placeholder routes exist", async () => { test("navigation includes the required workbench routes", () => { const layout = readFileSync(join(root, "app/layout.tsx"), "utf8"); + const navigation = readFileSync(join(root, "app/components/operator-navigation.tsx"), "utf8"); + const shell = `${layout}\n${navigation}`; assert.match(layout, /Skip to main content/); assert.match(layout, /id="main-content"/); assert.match(layout, /operator-shell/); assert.match(layout, /operator-sidebar/); assert.match(layout, /Workbench navigation/); - assert.match(layout, /Operator Console/); - assert.match(layout, /Platform/); - assert.match(layout, /label: "Platform"/); - assert.match(layout, /Sentinel workflows/); - assert.match(layout, /Overview/); - assert.match(layout, /href: "\/"/); - assert.match(layout, /Process Sentinel/); - assert.match(layout, /href: "\/process-sentinel"/); - assert.match(layout, /Detections/); - assert.match(layout, /Recommendations/); - assert.match(layout, /RCA\/CAPA Draft/); - assert.match(layout, /Protocol operations/); - assert.match(layout, /Connections/); - assert.match(layout, /href: "\/connections"/); - assert.match(layout, /Protocol Diagnostics/); - assert.match(layout, /href: "\/protocol-diagnostics"/); - assert.match(layout, /Tag\/Source Browser/); - assert.match(layout, /href: "\/tag-source-browser"/); + assert.match(layout, /Global operations toolbar/); + assert.match(layout, /Configure Connections/); + assert.match(shell, /Platform/); + assert.match(shell, /label: "Platform"/); + assert.match(shell, /Sentinel workflows/); + assert.match(shell, /Overview/); + assert.match(shell, /href: "\/"/); + assert.match(shell, /Process Sentinel/); + assert.match(shell, /href: "\/process-sentinel"/); + assert.match(shell, /Detections/); + assert.match(shell, /Recommendations/); + assert.match(shell, /RCA\/CAPA/); + assert.match(shell, /Protocol operations/); + assert.match(shell, /Connections/); + assert.match(shell, /href: "\/connections"/); + assert.match(shell, /Protocol Diagnostics/); + assert.match(shell, /href: "\/protocol-diagnostics"/); + assert.match(shell, /Tag\/Source Browser/); + assert.match(shell, /href: "\/tag-source-browser"/); + assert.match(navigation, /usePathname/); + assert.match(navigation, /aria-current/); assert.match(layout, /Workbench status strip/); assert.doesNotMatch(layout, /
{ assert.doesNotMatch(workspace, /connections-actions/); }); -test("operator shell uses the Demo Factory console palette", () => { +test("operator shell uses the production operations console palette", () => { const styles = readFileSync(join(root, "app/globals.css"), "utf8"); - assert.match(styles, /--background: #e8eef3/); + assert.match(styles, /--background: #eef2f6/); assert.match(styles, /--surface: #ffffff/); assert.match(styles, /--text: #16202a/); assert.match(styles, /--muted: #526579/); assert.match(styles, /--border: #d3dee7/); - assert.match(styles, /--nav: #101820/); - assert.match(styles, /--nav-2: #1d2b36/); + assert.match(styles, /--nav: #07111b/); + assert.match(styles, /--nav-2: #101f2b/); assert.match(styles, /--accent: #0f766e/); assert.match(styles, /--warning: #b45309/); assert.match(styles, /--danger: #b91c1c/); assert.match(styles, /background: var\(--nav\)/); assert.match(styles, /background: var\(--nav-2\)/); + assert.match(styles, /operator-topbar/); + assert.match(styles, /sentinel-workbench-grid/); }); test("overview page contains production operations dashboard content", () => { @@ -285,7 +290,7 @@ test("overview page contains production operations dashboard content", () => { assert.match(demoState, /validation data source/); }); -test("process sentinel has a workflow entry route separate from overview", () => { +test("process sentinel has an operations workbench route separate from overview", () => { const route = readFileSync(join(root, "app/process-sentinel/page.tsx"), "utf8"); const readme = readFileSync(join(root, "README.md"), "utf8"); const ia = readFileSync( @@ -294,11 +299,14 @@ test("process sentinel has a workflow entry route separate from overview", () => ); assert.match(route, /Process Sentinel/); - assert.match(route, /Process Sentinel workflow/); - assert.match(route, /Open detections/); - assert.match(route, /Open recommendations/); - assert.match(route, /Open RCA\/CAPA draft/); - assert.match(route, /does not perform external\s+execution/); + assert.match(route, /Detection Queue/); + assert.match(route, /Selected Finding/); + assert.match(route, /Evidence Timeline/); + assert.match(route, /Governed Recommendation/); + assert.match(route, /Configure Connections/); + assert.match(route, /listDetectionEvidence/); + assert.match(route, /listRecommendations/); + assert.match(route, /listEquipment/); assert.match(readme, /\/process-sentinel/); assert.match(ia, /\/process-sentinel/); assert.match(ia, /Overview is global factory context/); @@ -508,6 +516,7 @@ test("workbench runtime copy points to operational sources and integration recov test("accessibility baseline covers landmarks, focus, forms, badges, and timeline order", () => { const layout = readFileSync(join(root, "app/layout.tsx"), "utf8"); + const navigation = readFileSync(join(root, "app/components/operator-navigation.tsx"), "utf8"); const demoState = readFileSync(join(root, "app/components/demo-state.tsx"), "utf8"); const detail = readFileSync(join(root, "app/detections/[detectionId]/page.tsx"), "utf8"); const panel = readFileSync( @@ -518,7 +527,7 @@ test("accessibility baseline covers landmarks, focus, forms, badges, and timelin assert.match(layout, /className="skip-link"/); assert.match(layout, /
None: layout = _content("apps/web/app/layout.tsx") + navigation = _content("apps/web/app/components/operator-navigation.tsx") + shell = f"{layout}\n{navigation}" connections = _content("apps/web/app/connections/connections-workspace.tsx") diagnostics = _content( "apps/web/app/protocol-diagnostics/protocol-diagnostics-workspace.tsx" @@ -80,15 +82,26 @@ def test_issue_206_operator_console_includes_protocol_and_sentinel_workflows() - for expected in [ "operator-sidebar", "Workbench status strip", + "Global operations toolbar", + "Configure Connections", + ]: + assert expected in layout + + for expected in [ "Sentinel workflows", "Protocol operations", 'href: "/connections"', 'href: "/protocol-diagnostics"', 'href: "/tag-source-browser"', - "Environment", - "Data health", - "Writeback policy", - "productCopy.writebackPolicyLabel", + 'href: "/process-sentinel"', + ]: + assert expected in shell + + for expected in [ + "Data freshness", + "Historian", + "productCopy.dataHealthLabel", + "Configure Connections", ]: assert expected in layout From 414eddea27eb4aae78c78db92c15c987e170b428 Mon Sep 17 00:00:00 2001 From: Aldon Smith Date: Wed, 27 May 2026 15:35:46 -0400 Subject: [PATCH 5/6] ui: remove sidebar bottom status panel --- apps/web/app/globals.css | 74 ------------------- apps/web/app/layout.tsx | 25 ------- .../web/e2e/operations-workbench-demo.spec.ts | 8 +- apps/web/tests/app-shell.test.mjs | 14 ++-- docs/LEARNING_LOG.md | 34 +++++++++ ...st_connection_management_epic_readiness.py | 12 +-- 6 files changed, 48 insertions(+), 119 deletions(-) diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 142c1b4..f19f863 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -204,71 +204,6 @@ a { grid-template-rows: auto minmax(0, 1fr) auto; } -.status-strip { - display: grid; - gap: 8px; - margin-top: auto; - border-top: 1px solid #243541; - padding-top: 16px; -} - -.status-strip div { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - min-width: 0; - align-items: center; - gap: 4px; - border: 0; - border-radius: 0; - background: transparent; - padding: 3px 0; -} - -.status-strip-heading { - grid-template-columns: minmax(0, 1fr) auto !important; - margin-bottom: 4px; -} - -.status-strip .status-label, -.status-strip span { - color: var(--nav-muted); -} - -.status-strip strong { - color: #ffffff; - overflow-wrap: anywhere; - font-size: 0.9rem; - line-height: 1.25; -} - -.status-dot { - width: 9px; - height: 9px; - border-radius: 999px; - background: #35c759; - box-shadow: 0 0 0 3px rgb(53 199 89 / 14%); -} - -.sidebar-config-link { - display: flex; - align-items: center; - justify-content: center; - margin-top: 8px; - border: 1px solid #2d4352; - border-radius: 7px; - color: #ffffff; - font-size: 0.86rem; - font-weight: 760; - padding: 10px; -} - -.sidebar-config-link:hover, -.sidebar-config-link:focus-visible { - border-color: #17b7bc; - outline: 3px solid var(--focus-ring); - outline-offset: 2px; -} - .operator-topbar { display: flex; min-height: 58px; @@ -2551,11 +2486,6 @@ code { flex-wrap: wrap; } - .status-strip { - grid-template-columns: repeat(2, minmax(0, 1fr)); - margin-top: 0; - } - .sentinel-commandbar, .sentinel-card-heading, .sentinel-finding-header { @@ -2693,10 +2623,6 @@ code { padding: 18px 16px; } - .status-strip { - grid-template-columns: 1fr; - } - .page-shell { width: min(100% - 20px, 100%); } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 36ffda1..e5a2d7d 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import type { ReactNode } from "react"; import { OperatorNavigation } from "./components/operator-navigation"; -import { productCopy } from "../lib/product-copy"; import "./globals.css"; export const metadata: Metadata = { @@ -27,30 +26,6 @@ export default function RootLayout({ children }: { children: ReactNode }) { -
-
- Data freshness - {productCopy.dataHealthLabel} -
-
-
-
-
-
-
- - Configure Connections - -
diff --git a/apps/web/e2e/operations-workbench-demo.spec.ts b/apps/web/e2e/operations-workbench-demo.spec.ts index 07ad841..898f54f 100644 --- a/apps/web/e2e/operations-workbench-demo.spec.ts +++ b/apps/web/e2e/operations-workbench-demo.spec.ts @@ -19,11 +19,9 @@ test("walks the external-source Operations Workbench demo path", async ({ page } await expect(page.getByText("Connections", { exact: true })).toBeVisible(); await expect(page.getByText("Protocol Diagnostics", { exact: true })).toBeVisible(); await expect(page.getByText("Tag/Source Browser", { exact: true })).toBeVisible(); - const statusStrip = page.getByRole("region", { name: "Workbench status strip" }); - await expect(statusStrip).toBeVisible(); - await expect(statusStrip.getByText("Data freshness")).toBeVisible(); - await expect(statusStrip.getByText("Historian")).toBeVisible(); - await expect(statusStrip.getByText("LIMS")).toBeVisible(); + await expect(page.getByRole("region", { name: "Workbench status strip" })).toHaveCount(0); + await expect(page.getByText("Data freshness")).toHaveCount(0); + await expect(page.getByText("Historian")).toHaveCount(0); await expect(page.getByRole("link", { name: "Configure Connections" }).first()).toBeVisible(); const contextQuestion = page.getByRole("textbox", { name: "Ask about current factory context", diff --git a/apps/web/tests/app-shell.test.mjs b/apps/web/tests/app-shell.test.mjs index 5e2de61..d2e1814 100644 --- a/apps/web/tests/app-shell.test.mjs +++ b/apps/web/tests/app-shell.test.mjs @@ -60,11 +60,11 @@ test("navigation includes the required workbench routes", () => { assert.match(shell, /href: "\/tag-source-browser"/); assert.match(navigation, /usePathname/); assert.match(navigation, /aria-current/); - assert.match(layout, /Workbench status strip/); - assert.doesNotMatch(layout, /
Date: Wed, 27 May 2026 15:52:44 -0400 Subject: [PATCH 6/6] ui: redesign dashboard overview --- apps/web/app/context-question-panel.tsx | 8 +- apps/web/app/globals.css | 323 ++++++++++++++- apps/web/app/page.tsx | 377 +++++++++++------- .../web/e2e/operations-workbench-demo.spec.ts | 6 +- apps/web/tests/app-shell.test.mjs | 7 + docs/LEARNING_LOG.md | 38 ++ 6 files changed, 611 insertions(+), 148 deletions(-) diff --git a/apps/web/app/context-question-panel.tsx b/apps/web/app/context-question-panel.tsx index 0aba1b4..a7fe5ad 100644 --- a/apps/web/app/context-question-panel.tsx +++ b/apps/web/app/context-question-panel.tsx @@ -54,9 +54,13 @@ export function ContextQuestionPanel() { aria-labelledby="context-question-heading" className="context-question-panel" > -

Ask about current factory context

+

+ Ask about current factory context +

- +
-
-
+
+
+
+ Operations Overview

{overview.ok ? overview.context.siteName : productCopy.siteName}

- -

- {overview.ok - ? overview.context.siteDescription - : productCopy.siteSubtitle} -

-
- - View active case - -
+

{overview.ok ? overview.context.siteDescription : productCopy.siteSubtitle}

+
+
+ + Configure Connections + + + Open Process Sentinel +
- -
+
+ + {!overview.ok ? : null} {overview.ok ? ( -
-
- Active Cases - {overview.activeDetections.length} -
-
- Pending Reviews - {overview.pendingRecommendations.length} -
-
- Data Health - {overview.dataHealthLabel} -
-
- Last Updated - {overview.context.lastUpdated} -
-
- ) : null} + <> +
+
+ Active Cases + {overview.activeDetections.length} + Process Sentinel +
+
+ Pending Reviews + {overview.pendingRecommendations.length} + Human disposition +
+
+ Data Health + {overview.dataHealthLabel} + {productCopy.dataSourceLabel} +
+
+ Last Updated + {overview.context.lastUpdated} + Current session +
+
- {overview.ok ? ( -
-
- Operational Context -

{overview.context.areaName}

-
-
-
Line
-
{overview.context.lineDescription}
-
-
-
Asset
-
{overview.context.assetName}
-
-
-
Work order
-
{overview.context.workOrderId}
+
+
+
+
+ Factory Context +

Connected operational graph

+
+ + Browse sources +
-
-
Product
-
{overview.context.productName}
-
-
-
- -
-
- Data source - {productCopy.dataSourceLabel} -

Status: {productCopy.integrationStatusLabel}

-
-
- Writeback policy - {productCopy.writebackPolicyLabel} -

{productCopy.writebackPolicyDetail}

+
-
-
-
- Priority case +
+ Priority Case {overview.importantDetection ? ( <>

{overview.importantDetection.summary}

-
- - - +
+
+
Severity
+
+ +
+
+
+
Status
+
+ +
+
+
+
Confidence
+
{Math.round(overview.importantDetection.confidence * 100)}%
+
+
+
Work order
+
{overview.context.workOrderId}
+
+
+
+ + Review case + + + Review recommendation +
) : ( <> -

No active detections

-

- No active Process Sentinel cases are available in the - current operational state. -

+

No active cases

+

No active Process Sentinel cases are available in the current operational state.

+ + Open Process Sentinel + )} -
- - Review case - -
-
+
+ +
+ Operational Context +

{overview.context.areaName}

+
+
+
Line
+
{overview.context.lineDescription}
+
+
+
Asset
+
{overview.context.assetName}
+
+
+
Signal
+
{overview.context.signalName}
+
+
+
Product
+
{overview.context.productName}
+
+
+
+ +
+ Integration & Governance +
+
+
Data source
+
{productCopy.dataSourceLabel}
+
+
+
Status
+
{productCopy.integrationStatusLabel}
+
+
+
Writeback policy
+
{productCopy.writebackPolicyLabel}
+
+
+
External submission
+
Not configured
+
+
+
+
+ ) : null} - +
+ ); +} + +function FactoryContextGraph({ context }: { context: OverviewContext }) { + const nodes = [ + { className: "site", label: context.siteName, type: "Site" }, + { className: "area", label: context.areaName, type: "Area" }, + { className: "asset", label: context.assetName, type: "Asset" }, + { className: "signal", label: context.signalName, type: "Signal" }, + { className: "case", label: "Priority case", type: "Process Sentinel" }, + { className: "work-order", label: context.workOrderId, type: "Work order" }, + { className: "recommendation", label: "Recommendation", type: "Governed review" }, + { className: "historian", label: "Historian", type: "Connection" }, + { className: "qms", label: "QMS / LIMS", type: "Quality" }, + { className: "mes", label: "MES", type: "Production" }, + ]; + + return ( +
+ + {nodes.map((node) => ( +
+ + {node.label} + {node.type} +
+ ))} +
); } +type OverviewContext = { + areaName: string; + assetName: string; + lastUpdated: string; + lineDescription: string; + productName: string; + signalName: string; + siteDescription: string; + siteName: string; + workOrderId: string; +}; + async function loadOverview() { try { - const [health, sites, areas, equipment, batches, detections, recommendations] = - await Promise.all([ - workbenchApi.getHealth(), - workbenchApi.listSites(), - workbenchApi.listAreas(), - workbenchApi.listEquipment(), - workbenchApi.listBatches(), - workbenchApi.listDetections(), - workbenchApi.listRecommendations(), - ]); + const [ + health, + sites, + areas, + equipment, + processSignals, + batches, + detections, + recommendations, + ] = await Promise.all([ + workbenchApi.getHealth(), + workbenchApi.listSites(), + workbenchApi.listAreas(), + workbenchApi.listEquipment(), + workbenchApi.listProcessSignals(), + workbenchApi.listBatches(), + workbenchApi.listDetections(), + workbenchApi.listRecommendations(), + ]); const activeDetections = detections.filter((detection) => activeDetectionStatuses.has(detection.status), ); @@ -206,7 +286,14 @@ async function loadOverview() { return { activeDetections, - context: buildOverviewContext({ areas, batches, equipment, importantDetection, sites }), + context: buildOverviewContext({ + areas, + batches, + equipment, + importantDetection, + processSignals, + sites, + }), dataHealthLabel: health.status === "ok" ? productCopy.dataHealthLabel : health.status, importantDetection, ok: true as const, @@ -220,8 +307,8 @@ async function loadOverview() { function selectImportantDetection(detections: Detection[]): Detection | null { const severityRank: Record = { high: 3, - medium: 2, low: 1, + medium: 2, }; return ( @@ -240,19 +327,24 @@ function buildOverviewContext({ batches, equipment, importantDetection, + processSignals, sites, }: { areas: Area[]; batches: Batch[]; equipment: Equipment[]; importantDetection: Detection | null; + processSignals: ProcessSignal[]; sites: Site[]; -}) { +}): OverviewContext { const currentBatch = batches.find((batch) => batch.status === "running" || batch.status === "held") ?? batches[0]; const relatedAsset = importantDetection?.related_asset_ids[0]; const currentEquipment = equipment.find((asset) => asset.equipment_id === relatedAsset) ?? equipment[0]; + const currentSignal = + processSignals.find((signal) => signal.equipment_id === currentEquipment?.equipment_id) ?? + processSignals[0]; const currentArea = areas.find((area) => area.area_id === currentEquipment?.area_id) ?? areas.find((area) => area.area_id === currentBatch?.area_id) ?? @@ -270,6 +362,7 @@ function buildOverviewContext({ : "Current session", lineDescription: currentArea?.description ?? "Line context unavailable", productName: currentBatch?.product_name ?? "Product unavailable", + signalName: currentSignal?.name ?? "Signal unavailable", siteDescription: currentSite?.description ?? productCopy.siteSubtitle, siteName: currentSite?.name ?? productCopy.siteName, workOrderId: importantDetection?.related_work_order_id ?? "Work order unavailable", diff --git a/apps/web/e2e/operations-workbench-demo.spec.ts b/apps/web/e2e/operations-workbench-demo.spec.ts index 898f54f..8fabf59 100644 --- a/apps/web/e2e/operations-workbench-demo.spec.ts +++ b/apps/web/e2e/operations-workbench-demo.spec.ts @@ -21,7 +21,6 @@ test("walks the external-source Operations Workbench demo path", async ({ page } await expect(page.getByText("Tag/Source Browser", { exact: true })).toBeVisible(); await expect(page.getByRole("region", { name: "Workbench status strip" })).toHaveCount(0); await expect(page.getByText("Data freshness")).toHaveCount(0); - await expect(page.getByText("Historian")).toHaveCount(0); await expect(page.getByRole("link", { name: "Configure Connections" }).first()).toBeVisible(); const contextQuestion = page.getByRole("textbox", { name: "Ask about current factory context", @@ -101,9 +100,12 @@ test("walks the external-source Operations Workbench demo path", async ({ page } await expect(page.getByRole("heading", { name: "Greenville Manufacturing Site" })).toBeVisible(); await expect(page.getByText("Active Cases")).toBeVisible(); await expect(page.getByText("Pending Reviews")).toBeVisible(); + await expect(page.getByText("Factory Context", { exact: true })).toBeVisible(); + await expect(page.getByRole("img", { name: "Factory context graph" })).toBeVisible(); await expect(page.getByText("Operational Context")).toBeVisible(); - await expect(page.getByText("Priority case")).toBeVisible(); + await expect(page.getByText("Priority Case", { exact: true })).toBeVisible(); await expect(page.getByRole("link", { name: "Review case" })).toBeVisible(); + await expect(page.getByText("Integration & Governance")).toBeVisible(); await expect(page.getByText("Severity", { exact: true })).toBeVisible(); await expect(page.getByText("medium", { exact: true })).toBeVisible(); diff --git a/apps/web/tests/app-shell.test.mjs b/apps/web/tests/app-shell.test.mjs index d2e1814..88bb91d 100644 --- a/apps/web/tests/app-shell.test.mjs +++ b/apps/web/tests/app-shell.test.mjs @@ -278,10 +278,17 @@ test("overview page contains production operations dashboard content", () => { assert.match(overview, /Current operational state/); assert.match(overview, /Active Cases/); assert.match(overview, /Pending Reviews/); + assert.match(overview, /Factory Context/); + assert.match(overview, /FactoryContextGraph/); + assert.match(overview, /Connected operational graph/); assert.match(overview, /Operational Context/); assert.match(overview, /Priority case/); assert.match(overview, /Review case/); + assert.match(overview, /Integration & Governance/); + assert.match(overview, /listProcessSignals/); assert.match(overview, /selectImportantDetection/); + assert.doesNotMatch(overview, /hero/); + assert.doesNotMatch(overview, /overview-kpi-strip/); assert.doesNotMatch(overview, /ApiConnectionBanner/); assert.doesNotMatch(overview, /API base URL/); assert.match(demoState, /Integration details/); diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 266d0cf..2adcf79 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -5401,3 +5401,41 @@ PLAYWRIGHT_API_PORT=8001 PLAYWRIGHT_WEB_PORT=3001 PLAYWRIGHT_BASE_URL=http://127 Check whether the top toolbar connection action is enough, or whether the Connections nav item should be visually emphasized instead. + +## 2026-05-27 - Dashboard overview redesign + +### What changed + +Replaced the oversized Overview hero and large metric cards with a compact +operations dashboard. The new layout includes a command header, single context +question input, compact KPI row, priority case panel, operational context, +integration/governance summary, and an Obsidian-style factory context graph. + +### Why it was built that way + +The previous Overview repeated the same prompt label and made secondary status +values visually larger than the actual work. The new design makes factory +relationships visible first while keeping case review and connection +configuration one click away. + +### How data flows through it + +The Overview still reads the same FIP API data, with one added read of process +signals so the graph can show the asset-to-signal relationship. The graph is a +read-only visualization; it does not change mappings, connections, or +recommendations. + +### How to run or test it + +```bash +cd apps/web && npm test +cd apps/web && npm run build +cd apps/web && npm run lint +cd apps/web && npm run typecheck +PLAYWRIGHT_API_PORT=8001 PLAYWRIGHT_WEB_PORT=3001 PLAYWRIGHT_BASE_URL=http://127.0.0.1:3001 npm run test:e2e +``` + +### What to learn next + +Replace the static graph positioning with a small reusable graph layout helper +once more assets, connections, and mappings are available on the Overview.