Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions apps/web/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Operations Workbench

Minimal Next.js app shell for the simulator-backed Process Sentinel demo. The
Workbench reads from the local FastAPI backend through a small typed API client
in `lib/api-client.ts`.
Next.js operator-console shell for the simulator-backed Process Sentinel demo.
The Workbench reads from the local FastAPI backend through a small typed API
client in `lib/api-client.ts`.

## Local Startup

Expand Down Expand Up @@ -76,6 +76,11 @@ is running on a non-default port.

## Routes

The shell uses a persistent sidebar and status strip. Existing Sentinel demo
routes remain available, and the sidebar includes planned navigation slots for
Connections, Protocol Diagnostics, and Tag/Source Browser. Those planned slots
do not add production writeback controls.

- `/` - Factory overview dashboard with site, line, asset, work order, product,
active detection count, pending recommendation count, and primary detection CTA
- `/detections` - Process Sentinel detection list with summary, severity,
Expand Down
169 changes: 141 additions & 28 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ a {
text-decoration: none;
}

.shell {
.operator-shell {
display: grid;
grid-template-columns: 276px minmax(0, 1fr);
min-height: 100vh;
}

Expand All @@ -60,19 +62,21 @@ a {
outline: 3px solid #cfe7db;
}

.site-header {
border-bottom: 1px solid var(--border);
background: rgb(255 255 255 / 92%);
}

.header-inner {
.operator-sidebar {
position: sticky;
top: 0;
display: flex;
align-items: center;
justify-content: space-between;
height: 100vh;
flex-direction: column;
gap: 24px;
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 18px 0;
border-right: 1px solid var(--border);
background: #fdfefd;
padding: 22px 18px;
}

.sidebar-branding {
display: grid;
gap: 14px;
}

.brand {
Expand All @@ -99,19 +103,41 @@ a {
}

.primary-nav {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
display: grid;
gap: 22px;
}

.nav-group {
display: grid;
gap: 9px;
}

.nav-group h2 {
margin: 0;
color: var(--muted);
font-size: 0.72rem;
font-weight: 780;
text-transform: uppercase;
}

.nav-group-links {
display: grid;
gap: 6px;
}

.nav-link {
border: 1px solid transparent;
display: flex;
min-height: 42px;
align-items: center;
justify-content: space-between;
gap: 10px;
border: 1px solid var(--border);
border-radius: 7px;
color: #35423a;
background: var(--surface);
color: var(--text);
font-size: 0.9rem;
font-weight: 650;
padding: 9px 11px;
padding: 10px 11px;
}

.nav-link:hover,
Expand All @@ -122,6 +148,59 @@ a {
outline-offset: 2px;
}

.nav-link-disabled {
color: var(--muted);
cursor: default;
}

.nav-link-disabled:hover {
background: var(--surface);
}

.nav-link-meta {
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface-muted);
color: var(--muted);
font-size: 0.68rem;
font-weight: 760;
line-height: 1;
padding: 5px 7px;
text-transform: uppercase;
}

.operator-workspace {
min-width: 0;
}

.status-strip {
position: sticky;
z-index: 10;
top: 0;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border-bottom: 1px solid var(--border);
background: rgb(255 255 255 / 94%);
}

.status-strip div {
display: grid;
min-width: 0;
gap: 4px;
border-right: 1px solid var(--border);
padding: 12px 16px;
}

.status-strip div:last-child {
border-right: 0;
}

.status-strip strong {
overflow-wrap: anywhere;
font-size: 0.9rem;
line-height: 1.25;
}

.page-shell {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
Expand Down Expand Up @@ -1067,18 +1146,32 @@ code {
}

@media (max-width: 880px) {
.header-inner,
.hero {
grid-template-columns: 1fr;
.operator-shell {
display: block;
}

.operator-sidebar {
position: relative;
height: auto;
border-right: 0;
border-bottom: 1px solid var(--border);
}

.status-strip {
position: relative;
grid-template-columns: repeat(2, minmax(0, 1fr));
}

.status-strip div:nth-child(2) {
border-right: 0;
}

.header-inner {
align-items: flex-start;
flex-direction: column;
.status-strip div:nth-child(-n + 2) {
border-bottom: 1px solid var(--border);
}

.primary-nav {
justify-content: flex-start;
.hero {
grid-template-columns: 1fr;
}

.api-connection-banner {
Expand Down Expand Up @@ -1121,8 +1214,7 @@ code {
}

@media (min-width: 881px) and (max-width: 1240px) {
.page-shell,
.header-inner {
.page-shell {
width: min(1100px, calc(100% - 28px));
}

Expand All @@ -1140,6 +1232,27 @@ code {
}

@media (max-width: 560px) {
.operator-sidebar {
padding: 18px 16px;
}

.status-strip {
grid-template-columns: 1fr;
}

.status-strip div,
.status-strip div:nth-child(2) {
border-right: 0;
}

.status-strip div {
border-bottom: 1px solid var(--border);
}

.status-strip div:last-child {
border-bottom: 0;
}

.content-grid {
grid-template-columns: 1fr;
}
Expand Down
93 changes: 73 additions & 20 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,100 @@ import Link from "next/link";
import type { ReactNode } from "react";

import { DemoDataBadge } from "./components/demo-state";
import { getApiBaseUrl } from "../lib/api-client";
import "./globals.css";

export const metadata: Metadata = {
title: "Operations Workbench",
description: "Simulator-backed Factory Intelligence Platform workbench shell.",
};

const navItems = [
{ href: "/", label: "Overview" },
{ href: "/detections", label: "Detections" },
{ href: "/recommendations", label: "Recommendations" },
{ href: "/rca-capa-draft", label: "RCA/CAPA Draft" },
const navGroups = [
{
items: [
{ href: "/", label: "Overview" },
{ href: "/detections", label: "Detections" },
{ href: "/recommendations", label: "Recommendations" },
{ href: "/rca-capa-draft", label: "RCA/CAPA Draft" },
],
label: "Sentinel workflows",
},
{
items: [
{ label: "Connections", status: "Planned" },
{ label: "Protocol Diagnostics", status: "Planned" },
{ label: "Tag/Source Browser", status: "Planned" },
],
label: "Protocol operations",
},
];

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<div className="shell">
<div className="operator-shell">
<a className="skip-link" href="#main-content">
Skip to main content
</a>
<header className="site-header">
<div className="header-inner">
<aside className="operator-sidebar" aria-label="Workbench navigation">
<div className="sidebar-branding">
<Link className="brand" href="/">
<span className="brand-name">Factory Intelligence Platform</span>
<span className="brand-context">Operations Workbench</span>
<span className="brand-context">Operator Console</span>
</Link>
<nav aria-label="Primary navigation" className="primary-nav">
{navItems.map((item) => (
<Link className="nav-link" href={item.href} key={item.href}>
{item.label}
</Link>
))}
</nav>
<DemoDataBadge />
</div>
</header>
<main className="page-shell" id="main-content" tabIndex={-1}>
{children}
</main>
<nav aria-label="Primary navigation" className="primary-nav">
{navGroups.map((group) => (
<section className="nav-group" key={group.label}>
<h2>{group.label}</h2>
<div className="nav-group-links">
{group.items.map((item) =>
"href" in item ? (
<Link className="nav-link" href={item.href} key={item.label}>
<span>{item.label}</span>
</Link>
) : (
<span
aria-disabled="true"
className="nav-link nav-link-disabled"
key={item.label}
role="link"
>
<span>{item.label}</span>
<span className="nav-link-meta">{item.status}</span>
</span>
),
)}
</div>
</section>
))}
</nav>
</aside>
<div className="operator-workspace">
<header className="status-strip" aria-label="Workbench status strip">
<div>
<span className="status-label">Mode</span>
<strong>Simulator-backed demo</strong>
</div>
<div>
<span className="status-label">API target</span>
<strong>{getApiBaseUrl()}</strong>
</div>
<div>
<span className="status-label">Connection policy</span>
<strong>Read-only diagnostics</strong>
</div>
<div>
<span className="status-label">Writeback</span>
<strong>Disabled</strong>
</div>
</header>
<main className="page-shell" id="main-content" tabIndex={-1}>
{children}
</main>
</div>
</div>
</body>
</html>
Expand Down
9 changes: 9 additions & 0 deletions apps/web/e2e/operations-workbench-demo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ test("walks the simulator-backed Operations Workbench demo path", async ({ page
await expect(page.getByRole("link", { name: "Skip to main content" })).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.locator("#main-content")).toBeFocused();
await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible();
await expect(page.getByRole("link", { name: "Overview" })).toBeVisible();
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("banner", { 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.getByText("Simulator-backed demo data").first()).toBeVisible();
await expect(page.getByText("Synthetic local scenario; not real plant data.").first()).toBeVisible();
await expect(page.getByRole("region", { name: "Local API connection state" })).toBeVisible();
Expand Down
Loading
Loading