diff --git a/PROGRESS.md b/PROGRESS.md index 1f353da..3340442 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -27,6 +27,7 @@ - **#13** — record real-provider eval numbers (M9 follow-up). Stays open until keys are wired and `make eval` is run for real. - **Backlog (MILESTONES.md):** multi-tenant + RBAC, eval set expansion, OTel traces, Multi-AZ + private subnets + ACM TLS + S3/DynamoDB Terraform backend. +- **Design system** — dual-theme (dark default + light) audit-grade visual layer for the frontend + a real `GET /dashboard/kpis` endpoint, on branch `claude/serene-maxwell-54yMC` (draft PR). Net-new work beyond the M0–M11 roadmap; `make check` green (201 backend pytest, 7 frontend Vitest, ruff/mypy/tsc/build clean). --- @@ -92,6 +93,8 @@ Status key: ☐ not started · ◐ in progress · ☑ merged - 2026-05-28 (M4) — Extraction failures (`parse_error`, `schema_invalid`, `invalid_citation`, `document_not_found`, `no_chunks`, `unknown_schema`) **never persist** an `extractions` row. Surfacing the typed reason to the caller is enough for M5 guardrails / M7 audit / M9 eval to bucket failures without polluting the success-only table. - 2026-05-28 (M4) — Citation validation reuses the M3 posture: a `source_chunk_id` not in the supplied chunk set is a hard failure (`invalid_citation`), not a silent drop. Same invariant the M3 RAG layer enforces with `[chunk:N]` markers. - 2026-05-28 (M4) — Invoice `issue_date` remains persisted as a string but is schema-constrained to a real ISO `YYYY-MM-DD` date; non-ISO or impossible dates fail schema validation before persistence. +- 2026-05-29 (design system) — Applied the dual-theme "audit-grade" design system to the frontend: lifted the token layer into `styles.css` (`:root` dark default + `[data-theme="light"]`), self-hosted IBM Plex Sans/Mono via `@fontsource` (latin subset, offline-safe), added `lucide-react`, and a persisted `localStorage["sentinel-theme"]` toggle set before first paint (inline script in `index.html`, no FOUC). Real IA/routes/API client/Recharts unchanged; charts restyled with token `var()` fills + per-bar `` colors so they re-theme live with the toggle. +- 2026-05-29 (design system) — Dashboard KPIs are powered by a new real endpoint `GET /dashboard/kpis` (docs ingested, auto-approved rate, avg confidence, SLA-at-risk). Every figure derives from real rows; 24h-vs-prior deltas are emitted only when a comparison window has data (otherwise `null`/flat) — no fabricated numbers or deltas. The Review row renders only fields the `/review` payload returns; the prototype's `schema.field`/`value`/`confidence` are extraction-level details absent from the queue API, so they are not invented. --- diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index a612713..de84063 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -18,7 +18,7 @@ from sqlalchemy.orm import Session from backend.app.db import get_session -from backend.app.models import Extraction, WorkflowItem, WorkflowStatus +from backend.app.models import Document, Extraction, WorkflowItem, WorkflowStatus router = APIRouter(prefix="/dashboard", tags=["dashboard"]) @@ -77,6 +77,30 @@ class SlaResponse(BaseModel): buckets: list[SlaBucket] +class Kpi(BaseModel): + """One operational KPI tile for the dashboard header. + + ``value``/``delta`` are the raw numbers (stable to assert on and to re-format); + ``display``/``delta_display`` are the server-rendered strings the UI shows verbatim. + ``direction`` drives the up/down/flat color (up=success, down=danger, flat=muted) and + is keyed purely off the sign of ``delta`` — matching the design system's KPI semantics. + """ + + key: str + label: str + value: float + display: str + delta: float | None = None + delta_display: str | None = None + direction: Literal["up", "down", "flat"] + + +class KpiResponse(BaseModel): + kpis: list[Kpi] + threshold_hours: int = Field(ge=1) + generated_at: str # ISO-8601 UTC; lets the UI footnote show a real refresh time + + # --- helpers ------------------------------------------------------------------------ @@ -84,6 +108,40 @@ def _utcnow() -> datetime: return datetime.now(UTC) +def _direction(delta: float | None, *, eps: float = 1e-9) -> Literal["up", "down", "flat"]: + """Color direction from the sign of a delta; ``None`` or near-zero reads as flat.""" + if delta is None or abs(delta) <= eps: + return "flat" + return "up" if delta > 0 else "down" + + +def _mean(values: list[float]) -> float | None: + """Arithmetic mean, or ``None`` for an empty list (so callers can omit, not fake, it).""" + return sum(values) / len(values) if values else None + + +def _workflow_counts( + session: Session, *, since: datetime | None = None, until: datetime | None = None +) -> tuple[int, int]: + """Return ``(auto_approved, total)`` workflow-item counts in the optional ``[since, until)`` + creation window (open bounds when an endpoint is ``None``).""" + clauses = [] + if since is not None: + clauses.append(WorkflowItem.created_at >= since) + if until is not None: + clauses.append(WorkflowItem.created_at < until) + total_stmt = select(func.count(WorkflowItem.id)) + approved_stmt = select(func.count(WorkflowItem.id)).where( + WorkflowItem.status == WorkflowStatus.AUTO_APPROVED + ) + if clauses: + total_stmt = total_stmt.where(*clauses) + approved_stmt = approved_stmt.where(*clauses) + total = int(session.scalar(total_stmt) or 0) + approved = int(session.scalar(approved_stmt) or 0) + return approved, total + + # Bucket boundaries for confidence: ten 0.1-wide bins covering [0.0, 1.0]. Values # exactly equal to 1.0 land in the last bucket. _CONF_BOUNDARIES: list[tuple[float, float]] = [ @@ -234,6 +292,108 @@ def get_sla( ) +@router.get("/kpis", response_model=KpiResponse) +def get_kpis( + session: Annotated[Session, Depends(get_session)], + threshold_hours: Annotated[int, Query(ge=1, le=720)] = 24, +) -> KpiResponse: + """Four operational KPIs for the dashboard header. + + Every figure is derived from real rows. Deltas compare the last 24h against the + preceding 24h and are reported as ``None`` whenever a comparison window has no data, + so the UI shows nothing fabricated. ``generated_at`` is a real UTC timestamp. + """ + now = _utcnow() + last_24h = now - timedelta(hours=24) + prev_24h = now - timedelta(hours=48) + + # 1) Docs ingested — total, plus how many landed in the last 24h. + total_docs = int(session.scalar(select(func.count(Document.id))) or 0) + docs_24h = int( + session.scalar(select(func.count(Document.id)).where(Document.created_at >= last_24h)) or 0 + ) + docs_kpi = Kpi( + key="docs_ingested", + label="Docs ingested", + value=float(total_docs), + display=f"{total_docs:,}", + delta=float(docs_24h), + delta_display=f"+{docs_24h} (24h)", + direction=_direction(float(docs_24h)), + ) + + # 2) Auto-approved rate — share of all workflow items auto-approved, with the delta in + # percentage points between the last-24h and preceding-24h cohorts. + approved_all, total_all = _workflow_counts(session) + rate_all = approved_all / total_all if total_all else 0.0 + approved_last, total_last = _workflow_counts(session, since=last_24h) + approved_prev, total_prev = _workflow_counts(session, since=prev_24h, until=last_24h) + rate_delta: float | None = None + if total_last and total_prev: + rate_delta = (approved_last / total_last) - (approved_prev / total_prev) + auto_kpi = Kpi( + key="auto_approved_rate", + label="Auto-approved", + value=rate_all, + display=f"{rate_all * 100:.1f}%", + delta=rate_delta, + delta_display=(f"{rate_delta * 100:+.1f}pp" if rate_delta is not None else None), + direction=_direction(rate_delta), + ) + + # 3) Avg confidence — mean of every per-field confidence value, with a 24h-vs-prior delta. + rows = session.execute(select(Extraction.created_at, Extraction.field_confidence)).all() + all_vals: list[float] = [] + last_vals: list[float] = [] + prev_vals: list[float] = [] + for created_at, field_confidence in rows: + if not isinstance(field_confidence, dict): + continue + for value in field_confidence.values(): + try: + v = float(value) + except (TypeError, ValueError): + continue + all_vals.append(v) + if created_at is None: + continue + if created_at >= last_24h: + last_vals.append(v) + elif prev_24h <= created_at < last_24h: + prev_vals.append(v) + mean_all = _mean(all_vals) + mean_last = _mean(last_vals) + mean_prev = _mean(prev_vals) + conf_delta = mean_last - mean_prev if mean_last is not None and mean_prev is not None else None + conf_kpi = Kpi( + key="avg_confidence", + label="Avg confidence", + value=mean_all if mean_all is not None else 0.0, + display=f"{mean_all:.3f}" if mean_all is not None else "—", + delta=conf_delta, + delta_display=(f"{conf_delta:+.3f}" if conf_delta is not None else None), + direction=_direction(conf_delta), + ) + + # 4) SLA at risk — items past the threshold over the needs-review total (reuses /sla). + sla = get_sla(session, threshold_hours=threshold_hours) + sla_kpi = Kpi( + key="sla_at_risk", + label="SLA at risk", + value=float(sla.over_sla), + display=f"{sla.over_sla} / {sla.total_needs_review}", + delta=None, + delta_display=f"threshold {threshold_hours}h", + direction="flat", + ) + + return KpiResponse( + kpis=[docs_kpi, auto_kpi, conf_kpi, sla_kpi], + threshold_hours=threshold_hours, + generated_at=now.isoformat(), + ) + + # Re-export the literal type used by the frontend's API client so a future schema # evolution surfaces in one place. SlaThresholdLiteral = Literal[1, 4, 24, 168, 720] diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index 465c24b..c484a8b 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -230,6 +230,110 @@ def test_sla_rejects_invalid_threshold(client: TestClient) -> None: assert client.get("/dashboard/sla?threshold_hours=10000").status_code == 422 +# --- /dashboard/kpis ---------------------------------------------------------------- + + +def test_kpis_empty_corpus(client: TestClient) -> None: + """No rows: four KPIs, honest zeros, and no fabricated deltas.""" + resp = client.get("/dashboard/kpis") + assert resp.status_code == 200 + body = resp.json() + kpis = {k["key"]: k for k in body["kpis"]} + assert set(kpis) == {"docs_ingested", "auto_approved_rate", "avg_confidence", "sla_at_risk"} + + assert kpis["docs_ingested"]["display"] == "0" + assert kpis["auto_approved_rate"]["display"] == "0.0%" + assert kpis["avg_confidence"]["display"] == "—" # em dash: no fields, no fake number + assert kpis["sla_at_risk"]["display"] == "0 / 0" + + # Deltas that need a comparison window are omitted (None), not invented. + assert kpis["auto_approved_rate"]["delta"] is None + assert kpis["avg_confidence"]["delta"] is None + assert all(k["direction"] == "flat" for k in kpis.values()) + + +def test_kpis_values_and_formatting(client: TestClient, session: Session) -> None: + now = datetime.now(UTC) + # Two extractions (=> two documents) created now; per-field confidences mean to 0.800. + _make_extraction( + session, hash_suffix="kv1", field_confidence={"a": 0.80, "b": 0.90}, created_at=now + ) + ex2 = _make_extraction(session, hash_suffix="kv2", field_confidence={"a": 0.70}, created_at=now) + # Workflow mix: 3 auto-approved + 1 needs_review => 75.0% auto-approved. + for i in range(3): + _make_workflow_item( + session, extraction_id=ex2.id, idem_suffix=f"a{i}", status=WorkflowStatus.AUTO_APPROVED + ) + _make_workflow_item( + session, extraction_id=ex2.id, idem_suffix="nr", status=WorkflowStatus.NEEDS_REVIEW + ) + + kpis = {k["key"]: k for k in client.get("/dashboard/kpis").json()["kpis"]} + + assert kpis["docs_ingested"]["value"] == 2.0 + assert kpis["docs_ingested"]["display"] == "2" + assert kpis["docs_ingested"]["delta"] == 2.0 # both landed within the last 24h + assert kpis["docs_ingested"]["delta_display"] == "+2 (24h)" + + assert kpis["avg_confidence"]["value"] == pytest.approx(0.8) + assert kpis["avg_confidence"]["display"] == "0.800" + + assert kpis["auto_approved_rate"]["value"] == pytest.approx(0.75) + assert kpis["auto_approved_rate"]["display"] == "75.0%" + + # The single needs_review item is age ~0, so it is not yet over the 24h threshold. + assert kpis["sla_at_risk"]["display"] == "0 / 1" + + +def test_kpis_auto_approved_delta(client: TestClient, session: Session) -> None: + """The auto-approved delta compares the last-24h cohort against the preceding 24h.""" + ext = _make_extraction(session, hash_suffix="kad", field_confidence={"a": 0.9}) + # Preceding 24–48h window: 2 items, 1 auto-approved => rate 0.50. + _make_workflow_item( + session, + extraction_id=ext.id, + idem_suffix="p1", + status=WorkflowStatus.AUTO_APPROVED, + age_hours=36, + ) + _make_workflow_item( + session, + extraction_id=ext.id, + idem_suffix="p2", + status=WorkflowStatus.NEEDS_REVIEW, + age_hours=36, + ) + # Last-24h window: 4 items, 3 auto-approved => rate 0.75. + for i in range(3): + _make_workflow_item( + session, + extraction_id=ext.id, + idem_suffix=f"l{i}", + status=WorkflowStatus.AUTO_APPROVED, + age_hours=2, + ) + _make_workflow_item( + session, + extraction_id=ext.id, + idem_suffix="lnr", + status=WorkflowStatus.NEEDS_REVIEW, + age_hours=2, + ) + + auto = next( + k for k in client.get("/dashboard/kpis").json()["kpis"] if k["key"] == "auto_approved_rate" + ) + assert auto["delta"] == pytest.approx(0.25) # 0.75 - 0.50 + assert auto["delta_display"] == "+25.0pp" + assert auto["direction"] == "up" + + +def test_kpis_rejects_invalid_threshold(client: TestClient) -> None: + assert client.get("/dashboard/kpis?threshold_hours=0").status_code == 422 + assert client.get("/dashboard/kpis?threshold_hours=-1").status_code == 422 + assert client.get("/dashboard/kpis?threshold_hours=10000").status_code == 422 + + # --- shape sanity (frontend depends on these keys) ---------------------------------- @@ -243,6 +347,18 @@ def test_response_keys_match_frontend_contract(client: TestClient) -> None: sla = client.get("/dashboard/sla").json() assert set(sla.keys()) == {"threshold_hours", "total_needs_review", "over_sla", "buckets"} assert set(sla["buckets"][0].keys()) == {"label", "count"} + kpis = client.get("/dashboard/kpis").json() + assert set(kpis.keys()) == {"kpis", "threshold_hours", "generated_at"} + assert len(kpis["kpis"]) == 4 + assert set(kpis["kpis"][0].keys()) == { + "key", + "label", + "value", + "display", + "delta", + "delta_display", + "direction", + } # --- silence the unused-import warning on Chunk (used by other test modules) ------- diff --git a/frontend/index.html b/frontend/index.html index 60f6e6d..40d7c0a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,8 +3,23 @@ - + + Sentinel +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 483a364..c0f5bb0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "sentinel-frontend", "version": "0.1.0", "dependencies": { + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/ibm-plex-sans": "^5.2.8", + "lucide-react": "^1.17.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0", @@ -851,6 +854,24 @@ "node": ">=12" } }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz", + "integrity": "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.2.8.tgz", + "integrity": "sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1009,9 +1030,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1026,9 +1044,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1043,9 +1058,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1060,9 +1072,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1077,9 +1086,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1094,9 +1100,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1111,9 +1114,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1128,9 +1128,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1145,9 +1142,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1162,9 +1156,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1179,9 +1170,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1196,9 +1184,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1213,9 +1198,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2648,6 +2630,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", + "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 30b0783..6262125 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,9 @@ "preview": "vite preview" }, "dependencies": { + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/ibm-plex-sans": "^5.2.8", + "lucide-react": "^1.17.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0", diff --git a/frontend/public/brand/sentinel-mark.svg b/frontend/public/brand/sentinel-mark.svg new file mode 100644 index 0000000..dca6990 --- /dev/null +++ b/frontend/public/brand/sentinel-mark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/brand/sentinel-wordmark-light.svg b/frontend/public/brand/sentinel-wordmark-light.svg new file mode 100644 index 0000000..97c2261 --- /dev/null +++ b/frontend/public/brand/sentinel-wordmark-light.svg @@ -0,0 +1,11 @@ + + + + + + + + + + Sentinel + \ No newline at end of file diff --git a/frontend/public/brand/sentinel-wordmark.svg b/frontend/public/brand/sentinel-wordmark.svg new file mode 100644 index 0000000..64b5fe4 --- /dev/null +++ b/frontend/public/brand/sentinel-wordmark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + Sentinel + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6e506da..ba149b8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,7 @@ import { Suspense, lazy } from "react"; import { NavLink, Route, Routes, useLocation } from "react-router-dom"; +import { BarChart3, Moon, Search, ShieldCheck, Sun } from "lucide-react"; +import { useTheme } from "./theme"; import { Query } from "./views/Query"; import { Review } from "./views/Review"; @@ -12,30 +14,48 @@ const Dashboard = lazy(() => export function App(): JSX.Element { const location = useLocation(); + const { theme, toggleTheme } = useTheme(); const queryTarget = { pathname: "/", search: location.search }; const reviewTarget = { pathname: "/review", search: location.search }; const dashboardTarget = { pathname: "/dashboard", search: location.search }; + const wordmark = + theme === "light" ? "/brand/sentinel-wordmark-light.svg" : "/brand/sentinel-wordmark.svg"; return (
-
-

Sentinel

+
+ + Sentinel + - + + synthetic data only +
-
+
Loading…

}> } /> diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index f736bb2..7c44576 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -39,6 +39,8 @@ describe("App navigation", () => { let body: unknown; if (url.includes("/review")) { body = { items: [] }; + } else if (url.includes("/dashboard/kpis")) { + body = { kpis: [], threshold_hours: 24, generated_at: "2026-05-29T00:00:00Z" }; } else if (url.includes("/dashboard/volume")) { body = { days: 30, points: [] }; } else if (url.includes("/dashboard/categories")) { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 926a7ce..6af040f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -195,3 +195,25 @@ export function getConfidence(): Promise { export function getSla(thresholdHours = 24): Promise { return request("GET", `/dashboard/sla?threshold_hours=${thresholdHours}`); } + +export type KpiDirection = "up" | "down" | "flat"; + +export interface Kpi { + key: string; + label: string; + value: number; + display: string; + delta: number | null; + delta_display: string | null; + direction: KpiDirection; +} + +export interface KpiResponse { + kpis: Kpi[]; + threshold_hours: number; + generated_at: string; +} + +export function getKpis(thresholdHours = 24): Promise { + return request("GET", `/dashboard/kpis?threshold_hours=${thresholdHours}`); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 6cf87cc..55831b9 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,16 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; +// IBM Plex, self-hosted via @fontsource so builds/CI stay offline (no font CDN). +// Only the weights the design system uses, and only the latin subset (the corpus +// is English synthetic data) — keeps the emitted font assets minimal. +import "@fontsource/ibm-plex-sans/latin-400.css"; +import "@fontsource/ibm-plex-sans/latin-500.css"; +import "@fontsource/ibm-plex-sans/latin-600.css"; +import "@fontsource/ibm-plex-sans/latin-700.css"; +import "@fontsource/ibm-plex-mono/latin-400.css"; +import "@fontsource/ibm-plex-mono/latin-500.css"; +import "@fontsource/ibm-plex-mono/latin-600.css"; import { App } from "./App"; import "./styles.css"; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 0f12e03..1024864 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,208 +1,769 @@ +/* ============================================================================ + SENTINEL DESIGN SYSTEM + One token layer, two themes. Every surface/text role is a CSS variable; + switching theme (data-theme="light" on ) only remaps the variables, so + no component branches on theme. Tokens lifted from the design-system handoff + (colors_and_type.css); component styles ported from its console.css kit. + Fonts (IBM Plex Sans/Mono) are self-hosted via @fontsource in main.tsx. + ============================================================================ */ + :root { - --fg: #1a1a1a; - --bg: #ffffff; - --muted: #666; - --border: #d6d6d6; - --accent: #2a5db0; - --error: #b3261e; - --success: #1f6f3d; - --warn: #a86a00; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; - line-height: 1.45; + /* ---- NEUTRALS — navy/slate scale (backgrounds → text) ------------------ */ + --navy-950: #070a11; + --navy-900: #0b0f17; /* base canvas */ + --navy-850: #0e131d; /* sunken wells / inputs */ + --navy-800: #111726; /* primary panel surface */ + --navy-750: #161d2d; /* raised surface / card */ + --navy-700: #1c2435; /* hover surface */ + --navy-650: #232c3d; /* hairline borders (strong) */ + --navy-600: #2c3650; /* outlines on raised surfaces */ + --slate-500: #3a4659; + + --line: #1f2738; + --line-strong: #2b3447; + + --fg: #e7ecf3; + --fg-soft: #b6c0d0; + --fg-muted: #7d8a9c; + --fg-faint: #5a6678; + + --header-bg: rgba(11, 15, 23, 0.85); + --scanline: rgba(255, 255, 255, 0.012); + + --accent: #3d8bfd; + --accent-hover: #5b9ffd; + --accent-press: #2f6fd6; + --accent-weak: #11203a; + --accent-line: #294a7d; + + --success: #34c27a; + --success-weak: #0f2418; + --success-line: #1f5238; + + --warn: #e0a13a; + --warn-weak: #2a2010; + --warn-line: #5a4419; + + --danger: #f0635a; + --danger-weak: #2a1313; + --danger-line: #5e2723; + + --info: var(--accent); + + /* ---- TYPE -------------------------------------------------------------- */ + --font-sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, + sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace; + + --text-2xs: 11px; + --text-xs: 12px; + --text-sm: 13px; + --text-base: 14px; + --text-md: 15px; + --text-lg: 18px; + --text-xl: 22px; + --text-2xl: 28px; + --text-3xl: 36px; + + --lh-tight: 1.2; + --lh-snug: 1.35; + --lh-base: 1.5; + + --weight-regular: 400; + --weight-medium: 500; + --weight-semibold: 600; + --weight-bold: 700; + + --tracking-tight: -0.01em; + --tracking-wide: 0.02em; + --tracking-caps: 0.08em; + + /* ---- RADII (sharp / low — audit-grade, not playful) -------------------- */ + --r-0: 0px; + --r-1: 2px; /* controls, badges (not pills) */ + --r-2: 3px; /* cards / panels */ + --r-3: 4px; /* large containers */ + --r-pill: 2px; + + /* ---- SPACING (4px grid) ------------------------------------------------ */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 20px; + --sp-6: 24px; + --sp-8: 32px; + --sp-10: 40px; + --sp-12: 48px; + --sp-16: 64px; + + /* ---- ELEVATION (depth from hairlines first) ---------------------------- */ + --shadow-0: none; + --shadow-1: 0 1px 0 rgba(0, 0, 0, 0.4); + --shadow-2: 0 2px 8px rgba(0, 0, 0, 0.45); + --shadow-3: 0 8px 28px rgba(0, 0, 0, 0.55); + --ring-accent: 0 0 0 2px rgba(61, 139, 253, 0.35); + + /* ---- MOTION ------------------------------------------------------------ */ + --ease-out: cubic-bezier(0.2, 0.6, 0.2, 1); + --dur-fast: 90ms; + --dur-base: 140ms; } +/* LIGHT THEME — navy-* names keep their role; values invert to light. */ +[data-theme="light"] { + --navy-950: #dfe4ec; + --navy-900: #f4f6f9; + --navy-850: #eef1f6; + --navy-800: #ffffff; + --navy-750: #ffffff; + --navy-700: #eef2f8; + --navy-650: #d2d9e4; + --navy-600: #ccd4e0; + --slate-500: #aab4c4; + + --line: #e3e8f0; + --line-strong: #d2d9e4; + + --fg: #0e131d; + --fg-soft: #3a4659; + --fg-muted: #687587; + --fg-faint: #9aa6b6; + + --header-bg: rgba(255, 255, 255, 0.85); + --scanline: rgba(16, 24, 40, 0.022); + + --accent: #2f6fd6; + --accent-hover: #3d8bfd; + --accent-press: #2456ad; + --accent-weak: #eaf1fd; + --accent-line: #bcd5fb; + + --success: #1f8a52; + --success-weak: #e7f6ee; + --success-line: #b8e2cb; + + --warn: #b07908; + --warn-weak: #fbf2dd; + --warn-line: #ecd9a3; + + --danger: #cf3b34; + --danger-weak: #fdeceb; + --danger-line: #f3c2bf; + + --shadow-1: 0 1px 0 rgba(16, 24, 40, 0.04); + --shadow-2: 0 2px 8px rgba(16, 24, 40, 0.1); + --shadow-3: 0 8px 28px rgba(16, 24, 40, 0.16); +} + +/* ---- BASE --------------------------------------------------------------- */ * { box-sizing: border-box; } - +html, body { margin: 0; + height: 100%; +} +body { + background: var(--navy-900); color: var(--fg); - background: var(--bg); + font-family: var(--font-sans); + font-size: var(--text-base); + line-height: var(--lh-base); + -webkit-font-smoothing: antialiased; +} +#root { + min-height: 100%; +} +button { + font-family: inherit; } - a { color: var(--accent); text-decoration: none; } - a:hover { text-decoration: underline; } .app { + min-height: 100vh; display: flex; flex-direction: column; - min-height: 100vh; + background-image: repeating-linear-gradient( + to bottom, + transparent 0, + transparent 31px, + var(--scanline) 31px, + var(--scanline) 32px + ); } -.app-header { +/* ---- HEADER / NAV ------------------------------------------------------- */ +.hdr { + display: flex; + align-items: center; + gap: var(--sp-6); + padding: 0 var(--sp-6); + height: 56px; + background: var(--header-bg); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--line); + position: sticky; + top: 0; + z-index: 20; +} +.hdr .brand { display: flex; align-items: center; - gap: 1.25rem; - padding: 0.75rem 1.25rem; - border-bottom: 1px solid var(--border); } - -.app-header h1 { - font-size: 1.05rem; - margin: 0; - letter-spacing: 0.02em; +.hdr .brand img { + height: 26px; + display: block; } - -.app-header nav { +.hdr nav { + display: flex; + gap: 2px; +} +.nav-link { + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--fg-soft); + background: none; + border: 1px solid transparent; + border-radius: var(--r-1); + padding: 6px 12px; + cursor: pointer; display: flex; - gap: 1rem; + align-items: center; + gap: 7px; + text-decoration: none; + transition: + background var(--dur-fast) var(--ease-out), + color var(--dur-fast) var(--ease-out); } - -.app-header nav a.active { - font-weight: 600; - text-decoration: underline; +.nav-link:hover { + background: var(--navy-700); + color: var(--fg); + text-decoration: none; +} +.nav-link.active { + color: var(--accent); + background: var(--accent-weak); + font-weight: var(--weight-semibold); +} +.nav-link:focus-visible { + outline: none; + box-shadow: var(--ring-accent); +} +.hdr .tag { + margin-left: auto; + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--fg-muted); + display: flex; + align-items: center; + gap: 7px; +} +.hdr .tag .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--warn); +} +.theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex: none; + background: var(--navy-800); + color: var(--fg-soft); + border: 1px solid var(--navy-600); + border-radius: var(--r-1); + cursor: pointer; + transition: + background var(--dur-fast) var(--ease-out), + border-color var(--dur-fast) var(--ease-out), + color var(--dur-fast) var(--ease-out); +} +.theme-toggle:hover { + background: var(--navy-700); + border-color: var(--accent-line); + color: var(--fg); +} +.theme-toggle:focus-visible { + outline: none; + box-shadow: var(--ring-accent); } -.app-main { - padding: 1.25rem; +/* ---- MAIN / VIEW HEADER ------------------------------------------------- */ +.main { flex: 1; - max-width: 1100px; width: 100%; + max-width: 1080px; margin: 0 auto; + padding: var(--sp-8) var(--sp-6) var(--sp-12); +} +.view-head { + margin-bottom: var(--sp-6); +} +.eyebrow { + font-size: var(--text-2xs); + font-weight: var(--weight-semibold); + letter-spacing: var(--tracking-caps); + text-transform: uppercase; + color: var(--fg-muted); + margin: 0 0 6px; +} +.view-title { + font-size: var(--text-2xl); + font-weight: var(--weight-semibold); + letter-spacing: var(--tracking-tight); + margin: 0; } - -h2 { - font-size: 1.2rem; - margin: 0 0 0.75rem; +.view-sub { + font-size: var(--text-sm); + color: var(--fg-muted); + margin: 6px 0 0; } -button { +/* ---- CONTROLS ----------------------------------------------------------- */ +.btn { font: inherit; - padding: 0.4rem 0.9rem; - border: 1px solid var(--border); - border-radius: 4px; - background: white; + font-size: var(--text-sm); + font-weight: var(--weight-medium); + padding: 8px 16px; + border-radius: var(--r-1); cursor: pointer; + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid var(--navy-600); + background: var(--navy-750); + color: var(--fg); + transition: + background var(--dur-fast) var(--ease-out), + border-color var(--dur-fast) var(--ease-out); } - -button.primary { +.btn:hover { + background: var(--navy-700); + border-color: var(--accent-line); +} +.btn:focus-visible { + outline: none; + box-shadow: var(--ring-accent); +} +.btn.primary { background: var(--accent); - color: white; border-color: var(--accent); + color: #fff; } - -button.danger { - background: var(--error); - color: white; - border-color: var(--error); +.btn.primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); } - -button:disabled { - opacity: 0.55; +.btn.primary:active { + background: var(--accent-press); + border-color: var(--accent-press); +} +.btn.danger { + background: var(--danger); + border-color: var(--danger); + color: #fff; +} +.btn.danger:hover { + filter: brightness(1.08); +} +.btn:disabled { + opacity: 0.5; cursor: not-allowed; } +.btn.sm { + padding: 5px 11px; + font-size: var(--text-xs); +} -input, -textarea { +textarea, +input[type="text"] { font: inherit; - padding: 0.5rem 0.6rem; - border: 1px solid var(--border); - border-radius: 4px; + font-size: var(--text-base); + color: var(--fg); + background: var(--navy-850); + border: 1px solid var(--navy-600); + border-radius: var(--r-1); + padding: 11px 13px; width: 100%; + transition: + border-color var(--dur-fast), + box-shadow var(--dur-fast); } - textarea { - min-height: 5rem; + min-height: 76px; resize: vertical; + line-height: 1.5; +} +textarea::placeholder, +input::placeholder { + color: var(--fg-faint); +} +textarea:focus, +input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(61, 139, 253, 0.28); +} +label.field-label { + display: block; + font-size: var(--text-2xs); + font-weight: var(--weight-semibold); + letter-spacing: var(--tracking-caps); + text-transform: uppercase; + color: var(--fg-muted); + margin-bottom: 7px; } .row { display: flex; - gap: 0.6rem; align-items: center; + gap: var(--sp-3); } - .muted { - color: var(--muted); - font-size: 0.9rem; + color: var(--fg-muted); + font-size: var(--text-sm); } - -.empty { - padding: 1rem; - border: 1px dashed var(--border); - border-radius: 6px; - color: var(--muted); - text-align: center; +.mono { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} +.code { + font-family: var(--font-mono); + font-size: 0.9em; + color: var(--accent); + background: var(--accent-weak); + border: 1px solid var(--accent-line); + border-radius: var(--r-1); + padding: 1px 5px; } - .error { - padding: 0.6rem 0.8rem; - border: 1px solid var(--error); - border-radius: 4px; - color: var(--error); - background: #fdf3f2; + padding: 10px 13px; + border: 1px solid var(--danger-line); + border-radius: var(--r-1); + color: var(--danger); + background: var(--danger-weak); + font-size: var(--text-sm); } -.card { - border: 1px solid var(--border); - border-radius: 6px; - padding: 0.9rem 1rem; - margin-bottom: 0.75rem; - background: white; +/* ---- CHIP suggestions --------------------------------------------------- */ +.chips { + display: flex; + flex-wrap: wrap; + gap: var(--sp-2); + margin-top: var(--sp-3); +} +.chip { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--fg-soft); + background: var(--navy-800); + border: 1px solid var(--navy-600); + border-radius: var(--r-1); + padding: 6px 10px; + cursor: pointer; + text-align: left; + transition: + border-color var(--dur-fast), + background var(--dur-fast); +} +.chip:hover { + border-color: var(--accent-line); + background: var(--navy-700); + color: var(--fg); +} +.chip:disabled { + opacity: 0.5; + cursor: not-allowed; } +/* ---- CARD --------------------------------------------------------------- */ +.card { + background: var(--navy-750); + border: 1px solid var(--line); + border-radius: var(--r-2); + padding: var(--sp-4) var(--sp-5); +} +.card + .card { + margin-top: var(--sp-3); +} .card h3 { - margin: 0 0 0.4rem; - font-size: 1rem; + margin: 0 0 5px; + font-size: var(--text-lg); + font-weight: var(--weight-semibold); +} +.card h3.result-title { + display: flex; + align-items: center; + gap: 9px; +} +.card .answer { + font-size: var(--text-md); + line-height: 1.55; + color: var(--fg); + margin: 0; + white-space: pre-wrap; +} +.section-label { + font-size: var(--text-2xs); + font-weight: var(--weight-semibold); + letter-spacing: var(--tracking-caps); + text-transform: uppercase; + color: var(--fg-muted); + margin: var(--sp-4) 0 var(--sp-2); +} + +/* ---- CITATION ----------------------------------------------------------- */ +.cite { + border-left: 3px solid var(--accent); + background: var(--accent-weak); + border-radius: 0 var(--r-1) var(--r-1) 0; + padding: 10px 13px; + margin-bottom: var(--sp-2); +} +.cite:last-child { + margin-bottom: 0; +} +.cite .cmeta { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--accent); + margin-bottom: 5px; +} +.cite .ctext { + font-size: var(--text-sm); + line-height: 1.5; + color: var(--fg-soft); + white-space: pre-wrap; +} +.refusal { + border-left-color: var(--danger); + background: var(--danger-weak); +} +.refusal .cmeta { + color: var(--danger); } +/* ---- BADGE -------------------------------------------------------------- */ .badge { + font-family: var(--font-mono); + font-size: var(--text-2xs); + font-weight: var(--weight-medium); + padding: 3px 8px; + border-radius: var(--r-pill); + border: 1px solid; display: inline-block; - padding: 0.1rem 0.5rem; - border-radius: 999px; - font-size: 0.75rem; - border: 1px solid var(--border); - color: var(--muted); + white-space: nowrap; +} +.badge.auto_approved { + color: var(--success); + background: var(--success-weak); + border-color: var(--success-line); } - .badge.needs_review { - border-color: var(--warn); color: var(--warn); - background: #fff8ec; + background: var(--warn-weak); + border-color: var(--warn-line); +} +.badge.rejected { + color: var(--danger); + background: var(--danger-weak); + border-color: var(--danger-line); +} +.badge.neutral { + color: var(--fg-muted); + background: var(--navy-800); + border-color: var(--navy-600); } -.badge.auto_approved { - border-color: var(--success); +/* ---- REVIEW QUEUE ------------------------------------------------------- */ +.queue-bar { + display: flex; + align-items: center; + gap: var(--sp-3); + margin-bottom: var(--sp-4); +} +.queue-bar input { + max-width: 240px; +} +.flash { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--fg-soft); + background: var(--navy-800); + border: 1px solid var(--line); + border-radius: var(--r-1); + padding: 8px 11px; + margin-bottom: var(--sp-3); +} +.review-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-4); +} +.review-item > div:first-child { + flex: 1; + min-width: 0; +} +.review-item h3 { + display: flex; + align-items: center; + gap: 10px; +} +.review-item .meta { + margin-top: 7px; +} +.review-item .meta .kv { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 3px 7px; + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--fg-muted); + margin-bottom: 6px; + line-height: 1.5; +} +.review-item .meta .kv:last-child { + margin-bottom: 0; +} +.review-item .meta .kv .mono { + white-space: nowrap; +} +.review-item .meta .kv .sep { + color: var(--fg-faint); +} +.review-item .meta .kv.reason { + color: var(--fg-muted); +} +.review-actions { + display: flex; + gap: var(--sp-2); + flex: none; +} +.empty { + padding: var(--sp-8); + border: 1px dashed var(--navy-600); + border-radius: var(--r-2); + color: var(--fg-muted); + text-align: center; + font-size: var(--text-sm); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-3); +} +.empty svg { color: var(--success); - background: #f1faf3; } -.badge.rejected { - border-color: var(--error); - color: var(--error); - background: #fdf3f2; +/* ---- DASHBOARD ---------------------------------------------------------- */ +.kpis { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--sp-3); + margin-bottom: var(--sp-4); +} +.kpi { + background: var(--navy-750); + border: 1px solid var(--line); + border-radius: var(--r-2); + padding: var(--sp-4); +} +.kpi .klabel { + font-size: var(--text-2xs); + font-weight: var(--weight-semibold); + letter-spacing: var(--tracking-caps); + text-transform: uppercase; + color: var(--fg-muted); +} +.kpi .kval { + font-family: var(--font-mono); + font-size: 26px; + font-weight: var(--weight-semibold); + color: var(--fg); + margin-top: 8px; + font-variant-numeric: tabular-nums; +} +.kpi .kdelta { + font-family: var(--font-mono); + font-size: var(--text-xs); + margin-top: 4px; +} +.kpi .kdelta.up { + color: var(--success); +} +.kpi .kdelta.down { + color: var(--danger); +} +.kpi .kdelta.flat { + color: var(--fg-muted); } .charts { display: grid; grid-template-columns: 1fr 1fr; - gap: 1rem; + gap: var(--sp-3); } - .chart-card { - border: 1px solid var(--border); - border-radius: 6px; - padding: 0.75rem 1rem 0.5rem; - background: white; + background: var(--navy-750); + border: 1px solid var(--line); + border-radius: var(--r-2); + padding: var(--sp-4) var(--sp-5) var(--sp-3); } - .chart-card h3 { - margin: 0 0 0.5rem; - font-size: 0.95rem; + margin: 0 0 var(--sp-4); + font-size: var(--text-md); + font-weight: var(--weight-semibold); + display: flex; + align-items: baseline; + gap: 8px; +} +.chart-card h3 .sub { + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: 400; + color: var(--fg-muted); +} +.footnote { + margin-top: var(--sp-8); + padding-top: var(--sp-4); + border-top: 1px solid var(--line); + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--fg-muted); } -.citation { - border-left: 3px solid var(--accent); - padding: 0.4rem 0.6rem; - background: #f6f8fc; - border-radius: 0 4px 4px 0; - margin: 0.4rem 0; - font-size: 0.92rem; +/* Recharts axis labels inherit the muted token even where props don't reach. */ +.recharts-cartesian-axis-tick text { + fill: var(--fg-muted); +} + +@media (max-width: 720px) { + .kpis { + grid-template-columns: repeat(2, 1fr); + } + .charts { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } } diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts new file mode 100644 index 0000000..91bd672 --- /dev/null +++ b/frontend/src/theme.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react"; + +/** + * Dark/light theme state for the design system. + * + * The token layer in `styles.css` is theme-agnostic: every surface/text role is a + * CSS variable that is remapped under `[data-theme="light"]`. Switching theme is + * therefore a single attribute flip on `` — no component branches on theme. + * + * Default is dark. The attribute is also set by a pre-paint inline script in + * `index.html` so the first paint already matches the persisted choice (no flash); + * this hook reads that attribute first so its initial state never disagrees with it. + */ + +export type Theme = "dark" | "light"; + +const STORAGE_KEY = "sentinel-theme"; + +function readInitialTheme(): Theme { + const attr = document.documentElement.dataset.theme; + if (attr === "light" || attr === "dark") { + return attr; + } + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === "light" || stored === "dark") { + return stored; + } + } catch { + /* localStorage may be unavailable (private mode); fall through to the default */ + } + return "dark"; +} + +export function useTheme(): { theme: Theme; toggleTheme: () => void } { + const [theme, setTheme] = useState(readInitialTheme); + + useEffect(() => { + document.documentElement.dataset.theme = theme; + try { + localStorage.setItem(STORAGE_KEY, theme); + } catch { + /* persistence failed; the attribute still applies for this session */ + } + }, [theme]); + + return { + theme, + toggleTheme: () => setTheme((current) => (current === "light" ? "dark" : "light")), + }; +} diff --git a/frontend/src/views/Dashboard.tsx b/frontend/src/views/Dashboard.tsx index 4adf998..26d4302 100644 --- a/frontend/src/views/Dashboard.tsx +++ b/frontend/src/views/Dashboard.tsx @@ -3,10 +3,12 @@ import { ApiError, getCategories, getConfidence, + getKpis, getSla, getVolume, type CategoryResponse, type ConfidenceResponse, + type KpiResponse, type SlaResponse, type VolumeResponse, } from "../api"; @@ -16,6 +18,7 @@ import { SlaChart } from "./charts/SlaChart"; import { VolumeChart } from "./charts/VolumeChart"; interface Loaded { + kpis: KpiResponse; volume: VolumeResponse; categories: CategoryResponse; confidence: ConfidenceResponse; @@ -27,6 +30,12 @@ type State = | { kind: "loaded"; data: Loaded } | { kind: "error"; message: string }; +function formatGeneratedAt(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return `${d.toISOString().slice(0, 16).replace("T", " ")} UTC`; +} + export function Dashboard(): JSX.Element { const [state, setState] = useState({ kind: "loading" }); @@ -34,14 +43,15 @@ export function Dashboard(): JSX.Element { let cancelled = false; (async () => { try { - const [volume, categories, confidence, sla] = await Promise.all([ + const [kpis, volume, categories, confidence, sla] = await Promise.all([ + getKpis(24), getVolume(30), getCategories(), getConfidence(), getSla(24), ]); if (!cancelled) { - setState({ kind: "loaded", data: { volume, categories, confidence, sla } }); + setState({ kind: "loaded", data: { kpis, volume, categories, confidence, sla } }); } } catch (err) { if (cancelled) return; @@ -54,33 +64,50 @@ export function Dashboard(): JSX.Element { }; }, []); - if (state.kind === "loading") { - return ( -
-

Dashboard

-

Loading metrics…

-
- ); - } - if (state.kind === "error") { - return ( -
-

Dashboard

+ return ( +
+
+

Operational overview

+

+ Dashboard +

+

+ Ingestion, extraction mix, confidence and SLA health across the governed pipeline. +

+
+ {state.kind === "loading" &&

Loading metrics…

} + {state.kind === "error" && (
{state.message}
-
- ); - } + )} + {state.kind === "loaded" && } +
+ ); +} + +function DashboardBody({ data }: { data: Loaded }): JSX.Element { return ( -
-

Dashboard

+ <> +
+ {data.kpis.kpis.map((k) => ( +
+
{k.label}
+
{k.display}
+ {k.delta_display &&
{k.delta_display}
} +
+ ))} +
- - - - + + + +
-
+

+ All figures synthetic · derived from the data/sample corpus · refreshed{" "} + {formatGeneratedAt(data.kpis.generated_at)} +

+ ); } diff --git a/frontend/src/views/Query.tsx b/frontend/src/views/Query.tsx index eaa09fb..5f8b2eb 100644 --- a/frontend/src/views/Query.tsx +++ b/frontend/src/views/Query.tsx @@ -1,6 +1,16 @@ -import { useState, type FormEvent } from "react"; +import { useState, type FormEvent, type KeyboardEvent } from "react"; +import { Search } from "lucide-react"; import { ApiError, postQuery, type QueryResponse } from "../api"; +// Example questions sourced from the synthetic corpus. Clicking one fills and submits. +// The last is a deliberate refusal case — by design, not an error. +const SUGGESTIONS = [ + "What is the total amount due on the Initech Components invoice issued on 2026-01-22?", + "Summarize incident INC-0700 and its duration.", + "Who owns the supplier onboarding policy and when is it effective?", + "What was our Q3 revenue and projected churn for next year?", +]; + type State = | { kind: "idle" } | { kind: "loading" } @@ -10,11 +20,11 @@ type State = export function Query(): JSX.Element { const [question, setQuestion] = useState(""); const [state, setState] = useState({ kind: "idle" }); + const loading = state.kind === "loading"; - async function onSubmit(event: FormEvent): Promise { - event.preventDefault(); - const trimmed = question.trim(); - if (!trimmed) return; + async function submitQuestion(text: string): Promise { + const trimmed = text.trim(); + if (!trimmed || loading) return; setState({ kind: "loading" }); try { const result = await postQuery({ query: trimmed }); @@ -25,36 +35,78 @@ export function Query(): JSX.Element { } } + function onSubmit(event: FormEvent): void { + event.preventDefault(); + void submitQuestion(question); + } + + function onKeyDown(event: KeyboardEvent): void { + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + void submitQuestion(question); + } + } + + function onChipClick(text: string): void { + setQuestion(text); + void submitQuestion(text); + } + return (
-

Ask a question

-
+
+

Retrieval-augmented · citation-grounded

+

+ Ask a question +

+

+ Citations are required — Sentinel refuses to answer without them. +

+
+ + +