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
23 changes: 21 additions & 2 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down Expand Up @@ -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:

Expand All @@ -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
Expand Down
37 changes: 20 additions & 17 deletions apps/web/app/components/demo-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -46,30 +47,31 @@ export function ApiConnectionBanner({
health,
}: ApiConnectionBannerProps) {
return (
<section className="api-connection-banner" aria-label="Local API connection state">
<section className="api-connection-banner" aria-label="Integration connection state">
<div>
<strong>Local API connected</strong>
<strong>Integration details</strong>
<span>
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.
</span>
</div>
<dl className="api-connection-details">
<div>
<dt>API target</dt>
<dt>{productCopy.integrationEndpointLabel}</dt>
<dd>{apiBaseUrl}</dd>
</div>
<div>
<dt>Health</dt>
<dt>Data health</dt>
<dd>{health?.status ?? "Not checked"}</dd>
</div>
<div>
<dt>Source</dt>
<dd>{health?.source_mode ?? "External source expected"}</dd>
<dt>Data source</dt>
<dd>{productCopy.dataSourceLabel}</dd>
</div>
<div>
<dt>Connector mode</dt>
<dd>{health?.connector_mode ?? "Read-only runtime expected"}</dd>
<dt>Writeback policy</dt>
<dd>{productCopy.writebackPolicyDetail}</dd>
</div>
</dl>
</section>
Expand All @@ -84,13 +86,14 @@ export function ApiErrorPanel({
<div className="state-panel error-panel" role="alert">
<strong>API connection issue</strong>
<span>
The Workbench could not reach the local FIP API at <code>{apiBaseUrl}</code>.
The Workbench could not reach the configured manufacturing data service.
</span>
<span>
Start Demo-Factory, then start the FIP Docker Compose stack. Check{" "}
<code>curl http://localhost:8000/health</code>, then refresh this page.
If the API is using a different port, restart the Workbench with{" "}
<code>NEXT_PUBLIC_API_BASE_URL</code> set to that target.
Verify the validation data source, service health, and integration
endpoint configuration, then refresh this page.
</span>
<span>
Integration endpoint: <code>{apiBaseUrl}</code>
</span>
<span>Details: {message}</span>
</div>
Expand All @@ -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 (
<section className="content-panel loading-panel" aria-busy="true">
<div className="state-panel" role="status">
<strong>{title}</strong>
<span>Connecting to the local FIP API runtime.</span>
<span>Connecting to the configured manufacturing data service.</span>
</div>
</section>
);
Expand Down
75 changes: 75 additions & 0 deletions apps/web/app/components/operator-navigation.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<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) => {
const isActive =
item.href === "/"
? pathname === item.href
: pathname === item.href || pathname.startsWith(`${item.href}/`);

return (
<Link
aria-current={isActive ? "page" : undefined}
className={isActive ? "nav-link nav-link-active" : "nav-link"}
href={item.href}
key={item.href}
>
<span className="nav-icon" aria-hidden="true">
{item.icon}
</span>
<span>{item.label}</span>
</Link>
);
})}
</div>
</section>
))}
</nav>
);
}
118 changes: 118 additions & 0 deletions apps/web/app/context-question-panel.tsx
Original file line number Diff line number Diff line change
@@ -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<ContextQuestionResponse["confidence"], string> = {
partial: "partial",
supported: "supported",
unsupported: "unsupported",
};

export function ContextQuestionPanel() {
const questionId = useId();
const [question, setQuestion] = useState("");
const [answer, setAnswer] = useState<ContextQuestionResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);

async function onSubmit(event: FormEvent<HTMLFormElement>) {
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 (
<section
aria-labelledby="context-question-heading"
className="context-question-panel"
>
<h2 className="sr-only" id="context-question-heading">
Ask about current factory context
</h2>
<form className="context-question-form" onSubmit={onSubmit}>
<label className="sr-only" htmlFor={questionId}>
Ask about current factory context
</label>
<div>
<input
id={questionId}
onChange={(event) => setQuestion(event.target.value)}
placeholder="Ask about detections, evidence, recommendations, source health, or current batch..."
type="text"
value={question}
/>
<button disabled={isSubmitting || question.trim().length === 0} type="submit">
{isSubmitting ? "Checking..." : "Ask"}
</button>
</div>
</form>

<div aria-live="polite" className="context-question-result">
{error ? (
<p role="status">Question failed: {error}</p>
) : null}
{answer ? (
<article>
<div className="context-question-result-heading">
<span className={`context-confidence context-confidence-${answer.confidence}`}>
{confidenceLabels[answer.confidence]}
</span>
<strong>{answer.question}</strong>
</div>
<p>{answer.answer}</p>
{answer.sources.length > 0 ? (
<ul aria-label="Context answer sources" className="context-source-list">
{answer.sources.map((source) => (
<li key={`${source.type}-${source.id}`}>
<Link href={source.href}>
{source.label}
<span>{source.type}</span>
</Link>
</li>
))}
</ul>
) : null}
{answer.confidence === "unsupported" ? (
<div className="context-question-suggestions">
<span className="status-label">Try asking</span>
<ul>
{suggestions.map((suggestion) => (
<li key={suggestion}>{suggestion}</li>
))}
</ul>
</div>
) : null}
</article>
) : null}
</div>
</section>
);
}
Loading
Loading