diff --git a/src/app/(operator)/CockpitHeader.tsx b/src/app/(operator)/CockpitHeader.tsx new file mode 100644 index 00000000..21af1b98 --- /dev/null +++ b/src/app/(operator)/CockpitHeader.tsx @@ -0,0 +1,162 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; +import { cn } from "@/shared/utils/cn"; + +const TABS: Array<{ href: string; label: string }> = [ + { href: "/approvals", label: "Inbox" }, + { href: "/calibration", label: "Calibration" }, + { href: "/activity", label: "Activity" }, +]; + +interface StatusSnapshot { + gateCount: number; + sendCount: number; + matchPct: number | null; + ok: boolean; +} + +/** + * Slim cockpit chrome — replaces Graze's DashboardLayout for the (operator) + * route group. One sticky row: brand, tabs (with active fill), live status + * pill, theme toggle. No left sidebar, no breadcrumbs, no page H1 noise. + */ +export default function CockpitHeader() { + const pathname = usePathname() ?? ""; + const [status, setStatus] = useState({ + gateCount: 0, + sendCount: 0, + matchPct: null, + ok: true, + }); + const [refreshedAt, setRefreshedAt] = useState(null); + + // Lightweight 5s poll of /api/operator/status. Server-side aggregates the + // three counts we want without going all the way back to gary-ui via SSR. + useEffect(() => { + let alive = true; + async function tick() { + try { + const res = await fetch("/api/operator/status", { cache: "no-store" }); + if (!res.ok) { + if (alive) setStatus((s) => ({ ...s, ok: false })); + return; + } + const body = (await res.json()) as StatusSnapshot; + if (alive) { + setStatus({ ...body, ok: true }); + setRefreshedAt(new Date()); + } + } catch { + if (alive) setStatus((s) => ({ ...s, ok: false })); + } + } + void tick(); + const id = setInterval(tick, 5000); + return () => { + alive = false; + clearInterval(id); + }; + }, []); + + return ( +
+
+ +
+
+ ); +} + +function RefreshIndicator({ at, ok }: { at: Date | null; ok: boolean }) { + const [, force] = useState(0); + useEffect(() => { + const id = setInterval(() => force((n) => n + 1), 1000); + return () => clearInterval(id); + }, []); + + if (!ok) return offline; + if (!at) return ; + const sec = Math.max(0, Math.round((Date.now() - at.getTime()) / 1000)); + const label = sec < 5 ? "just now" : sec < 60 ? `${sec}s ago` : `${Math.round(sec / 60)}m ago`; + return ( + + {label} + + ); +} diff --git a/src/app/(operator)/FrequencyStrip.tsx b/src/app/(operator)/FrequencyStrip.tsx deleted file mode 100644 index 33f7efec..00000000 --- a/src/app/(operator)/FrequencyStrip.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { garyUi, type DecisionRecord, type MetricsByInstance } from "@/lib/gary-ui/client"; -import Sparkline, { buildDailyBuckets } from "./Sparkline"; - -/** - * FrequencyStrip — top-of-page strip on every operator surface. - * - * Server-renders two cards (gates-per-day sparkline and recommended-match - * ratio) plus a threshold-crossing alert when applicable. Both fetches fail - * silently — empty placeholder rather than crashing the layout — because - * the strip is chrome on every operator route. - */ -export default async function FrequencyStrip() { - let metrics: MetricsByInstance | null = null; - let decisions: DecisionRecord[] = []; - try { - metrics = await garyUi.metrics(); - } catch { - metrics = {}; - } - try { - decisions = await garyUi.decisions(); - } catch { - decisions = []; - } - - const aggregated = aggregateMetrics(metrics); - const daily = buildDailyBuckets(aggregated.gatesByDay, 30); - const total30 = daily.reduce((a, d) => a + d.count, 0); - const last7 = daily.slice(-7).reduce((a, d) => a + d.count, 0); - const prev7 = daily.slice(-14, -7).reduce((a, d) => a + d.count, 0); - const trendPct = prev7 > 0 ? ((last7 - prev7) / prev7) * 100 : null; - - const evaluable = decisions.filter( - (d) => d.recommended_match === true || d.recommended_match === false - ); - const matched = evaluable.filter((d) => d.recommended_match === true).length; - const matchPct = evaluable.length === 0 ? null : Math.round((matched / evaluable.length) * 100); - - const recentCrossings = aggregated.crossings.filter((c) => { - const t = new Date(c.crossed_at).getTime(); - return Number.isFinite(t) && Date.now() - t < 24 * 3600 * 1000; - }); - - return ( -
- {recentCrossings.length > 0 && ( -
- - Gate frequency above threshold — Gary is asking too often. - - - Review patterns → - -
- )} - -
-
-
-
- Gates per day · 30d -
-
- {total30 === 0 ? ( - no data yet - ) : ( - <> - {total30} total - {trendPct !== null && ( - <> - {" · "} - 0 - ? "text-amber-600 dark:text-amber-400" - : "text-text-muted" - } - > - {trendPct < 0 ? "↓" : trendPct > 0 ? "↑" : "·"}{" "} - {Math.abs(Math.round(trendPct))}% vs prior 7d - - - )} - - )} -
-
-
- -
-
- -
-
- Match rate -
-
- = 75 - ? "text-emerald-600 dark:text-emerald-400" - : matchPct < 50 - ? "text-amber-600 dark:text-amber-400" - : "text-text-main" - }`} - > - {matchPct === null ? "—" : `${matchPct}%`} - - - {matchPct === null - ? "no decisions yet" - : `${matched} of ${evaluable.length} aligned`} - -
-
-
-
- ); -} - -interface AggregatedMetrics { - gatesByDay: Record; - autoByDay: Record; - crossings: Array<{ instance_id: string; shape: string; gate_count: number; threshold: number; crossed_at: string }>; -} - -function aggregateMetrics(metrics: MetricsByInstance | null): AggregatedMetrics { - const gatesByDay: Record = {}; - const autoByDay: Record = {}; - const crossings: AggregatedMetrics["crossings"] = []; - if (!metrics) return { gatesByDay, autoByDay, crossings }; - for (const k of Object.keys(metrics)) { - const m = metrics[k]; - if (!m) continue; - for (const [d, c] of Object.entries(m.gates_by_day ?? {})) { - gatesByDay[d] = (gatesByDay[d] ?? 0) + Number(c); - } - for (const [d, c] of Object.entries(m.auto_approved_by_day ?? {})) { - autoByDay[d] = (autoByDay[d] ?? 0) + Number(c); - } - if (Array.isArray(m.threshold_crossings)) crossings.push(...m.threshold_crossings); - } - return { gatesByDay, autoByDay, crossings }; -} diff --git a/src/app/(operator)/OperatorNav.tsx b/src/app/(operator)/OperatorNav.tsx deleted file mode 100644 index ec352101..00000000 --- a/src/app/(operator)/OperatorNav.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { cn } from "@/shared/utils/cn"; - -const TABS: Array<{ href: string; label: string }> = [ - { href: "/approvals", label: "Approvals" }, - { href: "/calibration", label: "Calibration" }, - { href: "/activity", label: "Activity" }, -]; - -export default function OperatorNav() { - const pathname = usePathname() ?? ""; - - return ( - - ); -} diff --git a/src/app/(operator)/activity/page.tsx b/src/app/(operator)/activity/page.tsx index ff8841de..3d8b7dba 100644 --- a/src/app/(operator)/activity/page.tsx +++ b/src/app/(operator)/activity/page.tsx @@ -26,15 +26,7 @@ export default async function ActivityPage() { }); return ( -
-
-

Activity

-

- Decisions you've made and the work Gary did autonomously. Mark a decision as wrong to - feed the calibration loop. -

-
- +
{err && (
gary-ui unreachable: {err} @@ -42,6 +34,6 @@ export default async function ActivityPage() { )} -
+ ); } diff --git a/src/app/(operator)/approvals/page.tsx b/src/app/(operator)/approvals/page.tsx index b9efea56..dcdc920b 100644 --- a/src/app/(operator)/approvals/page.tsx +++ b/src/app/(operator)/approvals/page.tsx @@ -38,14 +38,7 @@ export default async function ApprovalsPage() { } return ( -
-
-

Approvals

-

- Unified queue: Sam-gates and pending sends from the running gary-ui backend. -

-
- +
{error && (
gary-ui unreachable: {error} @@ -53,6 +46,6 @@ export default async function ApprovalsPage() { )} {!error && } -
+ ); } diff --git a/src/app/(operator)/calibration/page.tsx b/src/app/(operator)/calibration/page.tsx index 2aef6acd..5cf1964c 100644 --- a/src/app/(operator)/calibration/page.tsx +++ b/src/app/(operator)/calibration/page.tsx @@ -82,17 +82,10 @@ export default async function CalibrationPage({ searchParams }: PageProps) { : null; return ( -
-
-
-

Calibration

-

- How often you go with Gary's recommendation, and the trend over time. Mark decisions - in Activity to seed the loop. -

-
+
+
-
+ {err && (
@@ -200,7 +193,7 @@ export default async function CalibrationPage({ searchParams }: PageProps) { climbs and the gates-per-day curve flattens.

-
+ ); } diff --git a/src/app/(operator)/layout.tsx b/src/app/(operator)/layout.tsx index 0c5e8703..ba69b466 100644 --- a/src/app/(operator)/layout.tsx +++ b/src/app/(operator)/layout.tsx @@ -1,31 +1,22 @@ -import { Suspense } from "react"; import { notFound } from "next/navigation"; -import { DashboardLayout } from "@/shared/components"; -import OperatorNav from "./OperatorNav"; -import FrequencyStrip from "./FrequencyStrip"; +import CockpitHeader from "./CockpitHeader"; +/** + * The operator route group does NOT wrap in Graze's DashboardLayout. + * The cockpit deserves its own chrome — slim sticky header with tabs + + * status pill, full-width content below. The Graze admin sidebar (Home / + * Endpoints / API Manager / Providers / etc.) is irrelevant to running + * the operator's day and was actively stealing attention from the + * actual decision surface. + */ export default function OperatorRootLayout({ children }: { children: React.ReactNode }) { if (process.env.OPERATOR_BUILD !== "true") { notFound(); } return ( - -
- - }> - - -
- {children} -
- ); -} - -function FrequencyStripFallback() { - return ( -
-
-
+
+ +
{children}
); } diff --git a/src/app/api/operator/status/route.ts b/src/app/api/operator/status/route.ts new file mode 100644 index 00000000..d65027ba --- /dev/null +++ b/src/app/api/operator/status/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { garyUi } from "@/lib/gary-ui/client"; + +export const dynamic = "force-dynamic"; + +/** + * Aggregated status pill for the cockpit header — one round-trip per + * 5-second poll instead of three. Failures collapse to zeros + ok=false + * so the header dot can tint amber without breaking the page. + */ +export async function GET() { + if (process.env.OPERATOR_BUILD !== "true") { + return NextResponse.json({ error: "not found" }, { status: 404 }); + } + + let gateCount = 0; + let sendCount = 0; + let matchPct: number | null = null; + let ok = true; + + try { + const [gates, sends, decisions] = await Promise.all([ + garyUi.gates(), + garyUi.sends(), + garyUi.decisions(), + ]); + gateCount = gates.length; + sendCount = sends.length; + const evaluable = decisions.filter( + (d) => d.recommended_match === true || d.recommended_match === false + ); + if (evaluable.length > 0) { + const matched = evaluable.filter((d) => d.recommended_match === true).length; + matchPct = Math.round((matched / evaluable.length) * 100); + } + } catch { + ok = false; + } + + return NextResponse.json({ gateCount, sendCount, matchPct, ok }); +}