diff --git a/apps/web/README.md b/apps/web/README.md
index 199daea..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
@@ -86,6 +98,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 +114,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/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/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 (
+
+ {navGroups.map((group) => (
+
+ {group.label}
+
+ {group.items.map((item) => {
+ const isActive =
+ item.href === "/"
+ ? pathname === item.href
+ : pathname === item.href || pathname.startsWith(`${item.href}/`);
+
+ return (
+
+
+ {item.icon}
+
+ {item.label}
+
+ );
+ })}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/app/context-question-panel.tsx b/apps/web/app/context-question-panel.tsx
new file mode 100644
index 0000000..a7fe5ad
--- /dev/null
+++ b/apps/web/app/context-question-panel.tsx
@@ -0,0 +1,118 @@
+"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
+
+
+
+
+ {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/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 0ef5116..a24efe7 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,73 +166,129 @@ 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 {
- display: grid;
- gap: 8px;
- margin-top: auto;
- border-top: 1px solid #243541;
- padding-top: 16px;
+.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;
}
-.status-strip div {
- display: grid;
+.topbar-actions {
+ display: flex;
min-width: 0;
- gap: 4px;
- border: 1px solid #243541;
+ 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: var(--nav-2);
- padding: 10px 11px;
+ background: #ffffff;
+ color: var(--text);
+ font: inherit;
+ font-size: 0.88rem;
+ font-weight: 720;
+ padding: 8px 11px;
}
-.status-strip .status-label {
- color: var(--nav-muted);
+.topbar-config-link {
+ border-color: #0f766e;
+ color: #0b5d57;
}
-.status-strip strong {
+.topbar-icon-button,
+.topbar-avatar {
+ width: 36px;
+ padding: 0;
+}
+
+.topbar-avatar {
+ border-radius: 999px;
+ background: #0f1e2b;
color: #ffffff;
- overflow-wrap: anywhere;
- font-size: 0.9rem;
- line-height: 1.25;
+ 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 {
@@ -270,6 +336,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;
@@ -352,9 +585,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;
}
@@ -480,6 +713,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);
@@ -861,201 +1105,837 @@ h3 {
gap: 14px;
}
-.timeline-heading h3 {
+.timeline-heading h3 {
+ margin-bottom: 0;
+}
+
+.timeline-heading time {
+ flex: 0 0 auto;
+ color: var(--muted);
+ font-size: 0.84rem;
+ font-weight: 650;
+}
+
+.evidence-type {
+ display: inline-flex;
+ width: fit-content;
+ margin-bottom: 7px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface-muted);
+ color: #405263;
+ font-size: 0.74rem;
+ font-weight: 760;
+ line-height: 1;
+ padding: 6px 7px;
+ text-transform: uppercase;
+}
+
+.evidence-process-signal .evidence-type {
+ border-color: #9bd3ca;
+ background: var(--accent-bg);
+ color: var(--accent-strong);
+}
+
+.evidence-quality-result .evidence-type {
+ border-color: #f5d38d;
+ background: var(--warning-bg);
+ color: #7a4f0f;
+}
+
+.evidence-correlation-window .evidence-type {
+ border-color: #b9c1dc;
+ background: var(--info-bg);
+ color: #35436f;
+}
+
+.evidence-meta {
+ display: grid;
+ grid-template-columns: minmax(140px, 0.3fr) minmax(0, 0.7fr);
+ gap: 10px;
+ margin: 0;
+}
+
+.evidence-meta div {
+ display: grid;
+ gap: 4px;
+ border-top: 1px solid var(--border);
+ padding-top: 10px;
+}
+
+.evidence-meta dt {
+ color: var(--muted);
+ font-size: 0.74rem;
+ font-weight: 760;
+ text-transform: uppercase;
+}
+
+.evidence-meta dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+ font-weight: 700;
+ line-height: 1.45;
+}
+
+.metric-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+ margin: 0;
+}
+
+.metric-grid div {
+ display: grid;
+ gap: 4px;
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ background: var(--surface);
+ padding: 12px;
+}
+
+.metric-grid dt {
+ color: var(--muted);
+ font-size: 0.76rem;
+ font-weight: 760;
+ text-transform: uppercase;
+}
+
+.metric-grid dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+ font-weight: 720;
+}
+
+.inline-status {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+}
+
+.meta-line {
+ color: var(--muted);
+ font-size: 0.86rem;
+}
+
+.state-panel {
+ display: grid;
+ gap: 5px;
+ padding: 16px;
+}
+
+.state-panel span {
+ color: var(--muted);
+ line-height: 1.5;
+}
+
+.error-panel {
+ border-color: #f2b6b6;
+ background: var(--danger-bg);
+}
+
+.error-panel strong {
+ color: var(--danger);
+}
+
+.missing-data-panel {
+ border-color: #f5d38d;
+ background: var(--warning-bg);
+}
+
+.missing-data-panel strong {
+ color: var(--warning);
+}
+
+.decision-note {
+ border-left: 4px solid var(--accent);
+ background: #e7f3f1;
+ color: #21423e;
+ line-height: 1.5;
+ padding: 16px;
+}
+
+.recommendation-review-panel {
+ gap: 20px;
+}
+
+.review-form {
+ display: grid;
+ gap: 14px;
+}
+
+.form-help {
+ margin-bottom: 0;
+ color: var(--muted);
+ font-size: 0.9rem;
+ line-height: 1.5;
+}
+
+.review-form div {
+ display: grid;
+ gap: 7px;
+}
+
+.review-form label {
+ color: var(--muted);
+ font-size: 0.78rem;
+ font-weight: 760;
+ text-transform: uppercase;
+}
+
+.review-form input,
+.review-form textarea {
+ width: 100%;
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ background: var(--surface);
+ color: var(--text);
+ font: inherit;
+ line-height: 1.5;
+ padding: 11px 12px;
+}
+
+.review-form input:focus,
+.review-form textarea:focus {
+ border-color: var(--accent);
+ outline: 3px solid var(--focus-ring);
+ outline-offset: 1px;
+}
+
+.overview-dashboard {
+ display: grid;
+ gap: 16px;
+}
+
+.overview-dashboard h1 {
+ max-width: none;
+ margin-bottom: 5px;
+ font-size: clamp(1.55rem, 2.4vw, 2.15rem);
+ line-height: 1.15;
+}
+
+.overview-dashboard h2 {
+ margin-bottom: 0;
+ font-size: 1.08rem;
+}
+
+.overview-dashboard p {
+ margin-bottom: 0;
+}
+
+.overview-commandbar {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 18px;
+}
+
+.overview-commandbar p {
+ max-width: 680px;
+ color: var(--muted);
+ line-height: 1.45;
+}
+
+.overview-commandbar-actions,
+.overview-action-row {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+.overview-dashboard .context-question-panel {
+ margin: 0;
+}
+
+.overview-dashboard .context-question-form {
+ grid-template-columns: 1fr;
+}
+
+.overview-dashboard .context-question-form div {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: #ffffff;
+ padding: 0;
+}
+
+.overview-dashboard .context-question-form input {
+ min-height: 48px;
+ border: 0;
+ border-radius: 8px 0 0 8px;
+ padding-left: 16px;
+}
+
+.overview-dashboard .context-question-form button {
+ min-width: 94px;
+ border-radius: 0 7px 7px 0;
+}
+
+.overview-summary-row {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.overview-summary-row article,
+.factory-context-card,
+.priority-case-card,
+.overview-side-card {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: #ffffff;
+ box-shadow: var(--shadow);
+}
+
+.overview-summary-row article {
+ display: grid;
+ min-height: 98px;
+ gap: 5px;
+ align-content: start;
+ padding: 16px;
+}
+
+.overview-summary-row strong {
+ color: var(--text);
+ font-size: clamp(1.35rem, 2.2vw, 1.95rem);
+ line-height: 1.05;
+}
+
+.overview-summary-row small {
+ color: var(--muted);
+ font-size: 0.82rem;
+ font-weight: 650;
+}
+
+.overview-main-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.8fr);
+ gap: 14px;
+ align-items: start;
+}
+
+.factory-context-card {
+ display: grid;
+ min-height: 520px;
+ gap: 12px;
+ padding: 18px;
+}
+
+.factory-context-card .section-heading {
+ align-items: center;
+}
+
+.factory-context-card .secondary-action {
+ flex: 0 0 auto;
+}
+
+.factory-context-graph {
+ position: relative;
+ min-height: 430px;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background:
+ radial-gradient(circle at 18px 18px, rgb(82 101 121 / 10%) 1px, transparent 1px),
+ #fbfcfd;
+ background-size: 34px 34px;
+}
+
+.factory-context-graph svg {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+}
+
+.factory-context-graph line {
+ stroke: #b7c5d0;
+ stroke-width: 0.32;
+}
+
+.factory-graph-node {
+ position: absolute;
+ display: grid;
+ min-width: 118px;
+ max-width: 180px;
+ justify-items: center;
+ gap: 5px;
+ color: #46596b;
+ text-align: center;
+ transform: translate(-50%, -50%);
+}
+
+.factory-graph-node span {
+ width: 15px;
+ height: 15px;
+ border-radius: 999px;
+ background: #58616a;
+ box-shadow: 0 0 0 5px rgb(88 97 106 / 8%);
+}
+
+.factory-graph-node strong {
+ color: #3c4d5d;
+ font-size: 0.88rem;
+ line-height: 1.2;
+}
+
+.factory-graph-node small {
+ color: var(--muted);
+ font-size: 0.72rem;
+ font-weight: 720;
+ text-transform: uppercase;
+}
+
+.factory-graph-node.site {
+ top: 16%;
+ left: 48%;
+}
+
+.factory-graph-node.area {
+ top: 34%;
+ left: 38%;
+}
+
+.factory-graph-node.asset {
+ top: 52%;
+ left: 48%;
+}
+
+.factory-graph-node.signal {
+ top: 66%;
+ left: 60%;
+}
+
+.factory-graph-node.case {
+ top: 70%;
+ left: 36%;
+}
+
+.factory-graph-node.work-order {
+ top: 48%;
+ left: 71%;
+}
+
+.factory-graph-node.recommendation {
+ top: 76%;
+ left: 79%;
+}
+
+.factory-graph-node.historian {
+ top: 52%;
+ left: 22%;
+}
+
+.factory-graph-node.qms {
+ top: 78%;
+ left: 18%;
+}
+
+.factory-graph-node.mes {
+ top: 35%;
+ left: 86%;
+}
+
+.factory-graph-node.asset span,
+.factory-graph-node.case span,
+.factory-graph-node.recommendation span {
+ background: var(--accent);
+}
+
+.priority-case-card,
+.overview-side-card {
+ display: grid;
+ gap: 14px;
+ padding: 18px;
+}
+
+.priority-case-card h2 {
+ font-size: 1.18rem;
+ line-height: 1.25;
+}
+
+.compact-detail-list {
+ display: grid;
+ gap: 10px;
+ margin: 0;
+}
+
+.compact-detail-list div {
+ display: grid;
+ gap: 4px;
+ border-top: 1px solid var(--border);
+ padding-top: 10px;
+}
+
+.compact-detail-list dt {
+ color: var(--muted);
+ font-size: 0.72rem;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.compact-detail-list dd {
+ margin: 0;
+ color: var(--text);
+ font-size: 0.94rem;
+ font-weight: 700;
+ line-height: 1.38;
+}
+
+.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;
}
-.timeline-heading time {
- flex: 0 0 auto;
+.sentinel-card-heading label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
color: var(--muted);
font-size: 0.84rem;
- font-weight: 650;
+ font-weight: 700;
}
-.evidence-type {
- display: inline-flex;
- width: fit-content;
- margin-bottom: 7px;
+.sentinel-card-heading select {
border: 1px solid var(--border);
- border-radius: 6px;
- background: var(--surface-muted);
- color: #405263;
- font-size: 0.74rem;
- font-weight: 760;
- line-height: 1;
- padding: 6px 7px;
- text-transform: uppercase;
+ border-radius: 7px;
+ background: #ffffff;
+ color: var(--text);
+ font: inherit;
+ padding: 8px 28px 8px 10px;
}
-.evidence-process-signal .evidence-type {
- border-color: #9bd3ca;
- background: var(--accent-bg);
- color: var(--accent-strong);
+.sentinel-table-wrap {
+ overflow-x: auto;
}
-.evidence-quality-result .evidence-type {
- border-color: #f5d38d;
- background: var(--warning-bg);
- color: #7a4f0f;
+.sentinel-table,
+.sentinel-evidence-table {
+ width: 100%;
+ min-width: 620px;
+ border-collapse: collapse;
}
-.evidence-correlation-window .evidence-type {
- border-color: #b9c1dc;
- background: var(--info-bg);
- color: #35436f;
+.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;
}
-.evidence-meta {
- display: grid;
- grid-template-columns: minmax(140px, 0.3fr) minmax(0, 0.7fr);
- gap: 10px;
- margin: 0;
+.sentinel-table th,
+.sentinel-evidence-table th {
+ background: var(--surface-muted);
+ color: var(--muted);
+ font-size: 0.74rem;
+ font-weight: 800;
+ text-transform: uppercase;
}
-.evidence-meta div {
- display: grid;
- gap: 4px;
- border-top: 1px solid var(--border);
- padding-top: 10px;
+.sentinel-table td {
+ font-size: 0.9rem;
+ line-height: 1.35;
}
-.evidence-meta dt {
- color: var(--muted);
- font-size: 0.74rem;
+.sentinel-table tr.selected td {
+ background: #e8f7f6;
+ box-shadow: inset 4px 0 0 var(--accent);
+}
+
+.sentinel-table a {
font-weight: 760;
- text-transform: uppercase;
}
-.evidence-meta dd {
- margin: 0;
- overflow-wrap: anywhere;
- font-weight: 700;
- line-height: 1.45;
+.sentinel-status-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ margin-right: 8px;
+ border-radius: 999px;
+ background: var(--accent);
}
-.metric-grid {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
+.sentinel-pagination {
+ display: flex;
+ justify-content: space-between;
gap: 12px;
- margin: 0;
+ color: var(--muted);
+ font-size: 0.86rem;
+ padding: 14px 16px;
}
-.metric-grid div {
+.sentinel-detail-stack {
display: grid;
- gap: 4px;
- border: 1px solid var(--border);
- border-radius: 7px;
- background: var(--surface);
- padding: 12px;
+ gap: 10px;
+ min-width: 0;
}
-.metric-grid dt {
- color: var(--muted);
- font-size: 0.76rem;
- font-weight: 760;
- text-transform: uppercase;
+.sentinel-finding-card,
+.sentinel-recommendation-card {
+ display: grid;
+ gap: 16px;
+ padding: 18px;
}
-.metric-grid dd {
- margin: 0;
- overflow-wrap: anywhere;
- font-weight: 720;
+.sentinel-finding-header h2 {
+ margin: 4px 0 0;
+ font-size: 1.08rem;
}
-.inline-status {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 6px;
+.sentinel-finding-meta {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 12px;
+ margin: 0;
}
-.meta-line {
+.sentinel-finding-meta dt {
color: var(--muted);
- font-size: 0.86rem;
+ font-size: 0.72rem;
+ font-weight: 800;
+ text-transform: uppercase;
}
-.state-panel {
- display: grid;
- gap: 5px;
- padding: 16px;
+.sentinel-finding-meta dd {
+ margin: 5px 0 0;
+ color: var(--text);
+ font-size: 0.88rem;
+ font-weight: 700;
}
-.state-panel span {
+.sentinel-finding-card p,
+.sentinel-recommendation-card p {
+ margin-bottom: 0;
color: var(--muted);
line-height: 1.5;
}
-.error-panel {
- border-color: #f2b6b6;
- background: var(--danger-bg);
-}
-
-.error-panel strong {
- color: var(--danger);
+.sentinel-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 18px;
+ border-bottom: 1px solid var(--border);
}
-.missing-data-panel {
- border-color: #f5d38d;
- background: var(--warning-bg);
+.sentinel-tabs span {
+ color: var(--muted);
+ font-size: 0.88rem;
+ font-weight: 700;
+ padding-bottom: 11px;
}
-.missing-data-panel strong {
- color: var(--warning);
+.sentinel-tabs span.active {
+ border-bottom: 3px solid var(--accent);
+ color: var(--accent-strong);
}
-.decision-note {
- border-left: 4px solid var(--accent);
- background: #e7f3f1;
- color: #21423e;
- line-height: 1.5;
- padding: 16px;
+.sentinel-evidence-panel {
+ display: grid;
+ gap: 12px;
}
-.recommendation-review-panel {
- gap: 20px;
+.sentinel-evidence-table {
+ min-width: 560px;
}
-.review-form {
- display: grid;
- gap: 14px;
+.sentinel-evidence-table td strong,
+.sentinel-evidence-table td span {
+ display: block;
}
-.form-help {
- margin-bottom: 0;
+.sentinel-evidence-table td span {
+ margin-top: 4px;
color: var(--muted);
- font-size: 0.9rem;
- line-height: 1.5;
+ font-size: 0.84rem;
+ line-height: 1.35;
}
-.review-form div {
+.sentinel-recommendation-grid {
display: grid;
- gap: 7px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px;
}
-.review-form label {
- color: var(--muted);
- font-size: 0.78rem;
- font-weight: 760;
- text-transform: uppercase;
+.sentinel-recommendation-grid section:first-child {
+ border-right: 1px solid var(--border);
+ padding-right: 18px;
}
-.review-form input,
-.review-form textarea {
- width: 100%;
- border: 1px solid var(--border);
- border-radius: 7px;
- background: var(--surface);
- color: var(--text);
- font: inherit;
- line-height: 1.5;
- padding: 11px 12px;
+.sentinel-recommendation-grid h3 {
+ margin: 0 0 6px;
+ font-size: 0.9rem;
}
-.review-form input:focus,
-.review-form textarea:focus {
- border-color: var(--accent);
- outline: 3px solid var(--focus-ring);
- outline-offset: 1px;
+.sentinel-action-row {
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ border-top: 1px solid var(--border);
+ padding-top: 14px;
}
.connections-workspace {
@@ -1877,9 +2757,51 @@ code {
border-bottom: 1px solid var(--border);
}
- .status-strip {
+ .operator-topbar {
+ display: grid;
+ padding: 12px 16px;
+ }
+
+ .topbar-actions {
+ flex-wrap: wrap;
+ }
+
+ .sentinel-commandbar,
+ .sentinel-card-heading,
+ .sentinel-finding-header {
+ display: grid;
+ }
+
+ .sentinel-search-row,
+ .sentinel-workbench-grid,
+ .sentinel-recommendation-grid,
+ .overview-commandbar,
+ .overview-main-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .overview-commandbar {
+ display: grid;
+ }
+
+ .overview-commandbar-actions {
+ justify-content: flex-start;
+ }
+
+ .overview-summary-row {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .sentinel-stage-strip,
+ .sentinel-finding-meta {
grid-template-columns: repeat(2, minmax(0, 1fr));
- margin-top: 0;
+ }
+
+ .sentinel-recommendation-grid section:first-child {
+ border-right: 0;
+ border-bottom: 1px solid var(--border);
+ padding-right: 0;
+ padding-bottom: 16px;
}
.hero {
@@ -1914,6 +2836,10 @@ code {
grid-template-columns: 1fr;
}
+ .overview-kpi-strip {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
.context-panel,
.detection-panel {
grid-column: auto;
@@ -1935,11 +2861,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 {
@@ -1958,6 +2884,18 @@ 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));
+ }
+
+ .overview-main-grid {
+ grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr);
+ }
+
.source-search-field {
grid-column: 1 / -1;
}
@@ -1967,15 +2905,41 @@ code {
}
}
+@media (max-width: 880px) {
+ .sentinel-workbench-grid,
+ .sentinel-recommendation-grid,
+ .overview-main-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;
}
- .status-strip {
+ .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;
}
@@ -1993,6 +2957,30 @@ code {
grid-template-columns: 1fr;
}
+ .overview-kpi-strip {
+ grid-template-columns: 1fr;
+ }
+
+ .overview-summary-row {
+ grid-template-columns: 1fr;
+ }
+
+ .factory-context-card {
+ min-height: 460px;
+ }
+
+ .factory-context-graph {
+ min-height: 360px;
+ }
+
+ .factory-graph-node {
+ min-width: 94px;
+ }
+
+ .factory-graph-node strong {
+ font-size: 0.78rem;
+ }
+
.diagnostics-card-grid {
grid-template-columns: 1fr;
}
@@ -2014,6 +3002,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..e5a2d7d 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 { OperatorNavigation } from "./components/operator-navigation";
import "./globals.css";
export const metadata: Metadata = {
@@ -10,35 +10,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" },
- { 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 (
@@ -50,59 +21,41 @@ export default function RootLayout({ children }: { children: ReactNode }) {
+ FIP
Factory Intelligence Platform
- Operator Console
-
- {navGroups.map((group) => (
-
- {group.label}
-
- {group.items.map((item) =>
- "href" in item ? (
-
- {item.label}
-
- ) : (
-
- {item.label}
- {item.status}
-
- ),
- )}
-
-
- ))}
-
-
-
- Mode
- External-source runtime
-
-
- API target
- {getApiBaseUrl()}
-
-
- Connection policy
- Read-only diagnostics
-
-
- Writeback
- Disabled
-
-
+
+
{children}
+
+ Time Zone: America/Chicago (CDT)
+ (c) 2026 Factory Intelligence Platform
+ v2.8.1
+
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 34d2886..a850b3d 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -1,21 +1,19 @@
import Link from "next/link";
-import {
- ApiConnectionBanner,
- ApiErrorPanel,
- StatusBadge,
-} from "./components/demo-state";
+import { ApiErrorPanel, StatusBadge } from "./components/demo-state";
+import { ContextQuestionPanel } from "./context-question-panel";
import {
type Area,
type Batch,
type Detection,
type Equipment,
+ type ProcessSignal,
type Recommendation,
type Site,
formatApiError,
- getApiBaseUrl,
workbenchApi,
} from "../lib/api-client";
+import { productCopy } from "../lib/product-copy";
export const dynamic = "force-dynamic";
@@ -36,153 +34,248 @@ export default async function OverviewPage() {
const overview = await loadOverview();
return (
- <>
-
-
-
{overview.ok ? overview.context.siteName : "Operations Workbench"}
-
- {overview.ok
- ? overview.context.siteDescription
- : "The manufacturer demo overview needs the local FIP API to show current factory context."}
-
-
-
- Open detection
-
-
+
+
+
+
Operations Overview
+
{overview.ok ? overview.context.siteName : productCopy.siteName}
+
{overview.ok ? overview.context.siteDescription : productCopy.siteSubtitle}
+
+
+
+ Configure Connections
+
+
+ Open Process Sentinel
+
-
-
- API base URL
- {getApiBaseUrl()}
-
-
- Source mode
-
- {overview.ok ? overview.health.source_mode : "Unavailable"}
-
-
-
- Connector mode
-
- {overview.ok ? overview.health.connector_mode : "Unavailable"}
-
-
-
- API health
-
-
-
-
-
-
+
- {overview.ok ? (
-
- ) : null}
+
{!overview.ok ?
: null}
{overview.ok ? (
-
-
- Current demo context
- {overview.context.areaName}
-
-
-
Line
- {overview.context.lineDescription}
-
-
-
Asset
- {overview.context.assetName}
-
-
-
Work order
- {overview.context.workOrderId}
-
-
-
Product
- {overview.context.productName}
-
-
-
-
-
-
- Active detections
+ <>
+
+
+ Active Cases
{overview.activeDetections.length}
- Open Process Sentinel cases from external-source event data.
+ Process Sentinel
-
- Pending recommendations
+
+ Pending Reviews
{overview.pendingRecommendations.length}
- Human-reviewed recommendations awaiting a demo decision.
+ Human disposition
+
+
+ Data Health
+ {overview.dataHealthLabel}
+ {productCopy.dataSourceLabel}
+
+
+ Last Updated
+ {overview.context.lastUpdated}
+ Current session
+
+
+
+
+
+
+
+ Factory Context
+
Connected operational graph
+
+
+ Browse sources
+
+
+
-
-
-
-
Most important detection
+
+ 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
-
- Start Demo-Factory, then start the FIP Docker Compose stack
- and refresh this page after Process Sentinel stores runtime
- detections.
-
+
No active cases
+
No active Process Sentinel cases are available in the current operational state.
+
+ Open Process Sentinel
+
>
)}
-
-
- Open detection
-
-
-
+
+
+
+ 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),
);
@@ -193,8 +286,15 @@ async function loadOverview() {
return {
activeDetections,
- context: buildOverviewContext({ areas, batches, equipment, importantDetection, sites }),
- health,
+ context: buildOverviewContext({
+ areas,
+ batches,
+ equipment,
+ importantDetection,
+ processSignals,
+ sites,
+ }),
+ dataHealthLabel: health.status === "ok" ? productCopy.dataHealthLabel : health.status,
importantDetection,
ok: true as const,
pendingRecommendations,
@@ -207,8 +307,8 @@ async function loadOverview() {
function selectImportantDetection(detections: Detection[]): Detection | null {
const severityRank: Record
= {
high: 3,
- medium: 2,
low: 1,
+ medium: 2,
};
return (
@@ -227,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) ??
@@ -250,18 +355,24 @@ 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",
+ signalName: currentSignal?.name ?? "Signal 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
new file mode 100644
index 0000000..5f65f4b
--- /dev/null
+++ b/apps/web/app/process-sentinel/page.tsx
@@ -0,0 +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 async function ProcessSentinelPage() {
+ const result = await loadProcessSentinelWorkbench();
+
+ return (
+
+
+
+
+
+ Ask about current factory context
+
+
+
+ Source Health
+
+
+
+
+
+ 1
+ Detect
+ {result.ok ? `${result.newCount} new` : "Unavailable"}
+
+
+ 2
+ Explain
+ {result.ok ? `${result.explainCount} explained` : "Unavailable"}
+
+
+ 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})
+
+ View
+
+ All Open
+ Review
+ Explain
+
+
+
+
+
+
+
+ Severity
+ Detection Title
+ Unit
+ Detected At
+ Status
+
+
+
+ {result.detections.map((detection) => {
+ const isSelected =
+ detection.detection_id === result.selectedDetection?.detection_id;
+
+ return (
+
+
+
+
+
+
+ {detection.summary}
+
+
+ {formatAssetLabel(detection, result.equipmentById)}
+ {formatClock(detection.created_at)}
+
+
+ {formatStatus(detection.status)}
+
+
+ );
+ })}
+
+
+
+
+ 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
+ Signals
+ KPIs
+ Context
+ Notes (0)
+ Linked Items ({result.selectedRecommendation ? 1 : 0})
+
+
+
+
Evidence Timeline
+
+ Open Trend
+
+
+ {result.evidence.length > 0 ? (
+
+
+
+ Time
+ Evidence
+ Score
+ Severity
+
+
+
+ {result.evidence.map((item) => (
+
+ {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/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 (
- Copy draft text
+ Copy draft
{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.
- Reviewer name
+ Reviewer Name
submitDecision("approve")}
type="button"
>
- {submittingAction === "approve" ? "Approving..." : "Approve"}
+ {submittingAction === "approve" ? "Approving..." : "Approve recommendation"}
- Demo audit feedback: {formatEnum(decisionResult.decision)}
+ Disposition recorded: {formatEnum(decisionResult.decision)}
Recommendation ID: {decisionResult.recommendation_id}
Reviewer: {decisionResult.reviewer}
@@ -195,8 +195,8 @@ export function RecommendationReviewPanel({
/>
- This is a demo audit trail from the decision response, not a
- validated production audit record or electronic signature.
+ This disposition record is not a validated production audit record
+ or electronic signature.
) : null}
diff --git a/apps/web/e2e/operations-workbench-demo.spec.ts b/apps/web/e2e/operations-workbench-demo.spec.ts
index 5fc2759..8fabf59 100644
--- a/apps/web/e2e/operations-workbench-demo.spec.ts
+++ b/apps/web/e2e/operations-workbench-demo.spec.ts
@@ -19,10 +19,22 @@ 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();
+ await expect(page.getByRole("region", { name: "Workbench status strip" })).toHaveCount(0);
+ await expect(page.getByText("Data freshness")).toHaveCount(0);
+ await expect(page.getByRole("link", { name: "Configure Connections" }).first()).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();
@@ -84,20 +96,23 @@ 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")).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("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", { 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();
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();
@@ -142,7 +157,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) {
@@ -206,10 +221,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.",
@@ -218,7 +233,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/api-client.ts b/apps/web/lib/api-client.ts
index 760e482..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;
@@ -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)}`),
@@ -361,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/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/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..88bb91d 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",
@@ -27,33 +29,43 @@ 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");
+ 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, /Sentinel workflows/);
- assert.match(layout, /Overview/);
- 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, /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 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");
-
- 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/);
+ const productCopy = readFileSync(join(root, "lib/product-copy.ts"), "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(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, /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.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, /hero/);
+ assert.doesNotMatch(overview, /overview-kpi-strip/);
+ 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 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(
+ join(root, "../../docs/demo/OPERATIONS_WORKBENCH_INFORMATION_ARCHITECTURE.md"),
+ "utf8",
+ );
+
+ assert.match(route, /Process Sentinel/);
+ 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/);
});
test("detections pages contain list and detail content", () => {
@@ -271,10 +326,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/);
@@ -285,7 +337,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/);
@@ -318,7 +371,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/);
@@ -340,7 +393,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/);
@@ -348,7 +401,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:/);
@@ -380,36 +433,40 @@ 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");
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, /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");
@@ -423,23 +480,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",
@@ -453,10 +510,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"));
@@ -465,6 +523,7 @@ test("workbench runtime copy points to external sources and Docker recovery", ()
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(
@@ -475,9 +534,9 @@ test("accessibility baseline covers landmarks, focus, forms, badges, and timelin
assert.match(layout, /className="skip-link"/);
assert.match(layout, / 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/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/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..dd61e02 100644
--- a/services/api/tests/test_api.py
+++ b/services/api/tests/test_api.py
@@ -38,6 +38,156 @@ 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": "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(
+ 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(
@@ -71,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()
@@ -169,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
@@ -234,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..96bbaf9 100644
--- a/services/simulator/tests/test_connection_management_epic_readiness.py
+++ b/services/simulator/tests/test_connection_management_epic_readiness.py
@@ -71,6 +71,8 @@ def test_issue_206_test_connection_results_are_read_only_structured_and_redacted
def test_issue_206_operator_console_includes_protocol_and_sentinel_workflows() -> 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"
@@ -79,17 +81,25 @@ 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"',
- "Read-only diagnostics",
- "Writeback",
- "Disabled",
+ 'href: "/process-sentinel"',
]:
- assert expected in layout
+ assert expected in shell
+
+ assert "Workbench status strip" not in layout
+ assert "Data freshness" not in layout
+ assert "Historian" not in layout
+ assert "sidebar-config-link" not in layout
assert "OPC-UA controls" in connections
assert "MQTT controls" in connections
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: