-
Notifications
You must be signed in to change notification settings - Fork 0
release: fleet observability Phase 2 — /admin/fleet panel + ingest route (#69) #71
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| import { getTranslations } from "next-intl/server"; | ||
| import { requireAdmin } from "@/lib/rbac"; | ||
| import { controlLocale } from "@/lib/control-locale"; | ||
| import { db } from "@/lib/db"; | ||
| import { loadTenantRegistry, type RegistryTenant } from "@/lib/tenant-registry"; | ||
|
|
||
| // The registry file changes underneath us (rsync on deploy-repo push) — always re-read. | ||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| // Presentation-layer liveness thresholds (the backend stores raw facts; "online"/"stale" is derived | ||
| // here, per the fleet plan). A device heartbeats ~every 60s and the backend rolls up every few min. | ||
| const ONLINE_WINDOW_MS = 5 * 60 * 1000; // no heartbeat in 5 min ⇒ offline | ||
| const STALE_FEED_MS = 5 * 60 * 1000; // feed "running" but no successful poll in 5 min ⇒ wedged | ||
|
|
||
| type Translator = (key: string, values?: Record<string, string | number>) => string; | ||
|
|
||
| type FleetDeviceRow = { | ||
| deviceId: string; | ||
| label: string | null; | ||
| platform: string | null; | ||
| appVersion: string | null; | ||
| feedRunning: boolean; | ||
| lastHeartbeatAt: Date | null; | ||
| lastSuccessfulPollAt: Date | null; | ||
| }; | ||
|
|
||
| type FleetSummaryRow = { missedOrders: number; recentErrors: number }; | ||
|
|
||
| function livenessLabel(device: FleetDeviceRow, now: number, t: Translator): { label: string; online: boolean } { | ||
| const online = device.lastHeartbeatAt != null && now - device.lastHeartbeatAt.getTime() < ONLINE_WINDOW_MS; | ||
| if (online) return { label: t("device.online"), online }; | ||
| if (device.lastHeartbeatAt == null) return { label: t("device.neverSeen"), online }; | ||
| const minutes = Math.max(0, Math.round((now - device.lastHeartbeatAt.getTime()) / 60000)); | ||
| return { label: t("device.offlineSince", { minutes }), online }; | ||
| } | ||
|
|
||
| function feedStatus(device: FleetDeviceRow, now: number, t: Translator): { label: string; ok: boolean } { | ||
| const stale = | ||
| device.feedRunning && | ||
| (device.lastSuccessfulPollAt == null || now - device.lastSuccessfulPollAt.getTime() > STALE_FEED_MS); | ||
| if (!device.feedRunning) return { label: t("device.feedStopped"), ok: false }; | ||
| if (stale) return { label: t("device.feedStale"), ok: false }; | ||
| return { label: t("device.feedRunning"), ok: true }; | ||
| } | ||
|
|
||
| function DeviceRow({ device, now, t }: Readonly<{ device: FleetDeviceRow; now: number; t: Translator }>) { | ||
| const liveness = livenessLabel(device, now, t); | ||
| const feed = feedStatus(device, now, t); | ||
| const feedTone = feed.ok | ||
| ? "text-craft-success-text dark:text-craft-success" | ||
| : "text-craft-error-text dark:text-craft-error"; | ||
|
|
||
| return ( | ||
| <li className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-2 first:border-t-0 first:pt-0"> | ||
| <span> | ||
| <span className="font-label font-bold">{device.label ?? device.deviceId.slice(0, 8)}</span> | ||
| <span className="ml-2 font-label text-sm text-muted-foreground"> | ||
| {device.platform ?? "—"} · v{device.appVersion ?? "—"} | ||
| </span> | ||
| </span> | ||
| <span className="font-label text-sm text-right"> | ||
| <span className={liveness.online ? "text-craft-success-text dark:text-craft-success" : "text-muted-foreground"}> | ||
| {liveness.label} | ||
| </span> | ||
| <span className={`ml-3 ${feedTone}`}>{feed.label}</span> | ||
| </span> | ||
| </li> | ||
| ); | ||
| } | ||
|
|
||
| function TenantCard({ | ||
| tenant, | ||
| devices, | ||
| summary, | ||
| now, | ||
| t, | ||
| }: Readonly<{ | ||
| tenant: RegistryTenant; | ||
| devices: FleetDeviceRow[]; | ||
| summary?: FleetSummaryRow; | ||
| now: number; | ||
| t: Translator; | ||
| }>) { | ||
| return ( | ||
| <li className="hand-drawn-border bg-card p-5"> | ||
| <div className="flex flex-wrap items-baseline justify-between gap-3"> | ||
| <span> | ||
| <span className="font-hand text-2xl font-bold">{tenant.slug}</span> | ||
| <span className="ml-3 font-label text-sm text-muted-foreground"> | ||
| {tenant.name} | ||
| {tenant.city ? ` · ${tenant.city}` : ""} | ||
| </span> | ||
| </span> | ||
| {summary && ( | ||
| <span className="font-label text-sm text-right text-muted-foreground"> | ||
| {t("summary.missed", { count: summary.missedOrders })} ·{" "} | ||
| {t("summary.errors", { count: summary.recentErrors })} | ||
| </span> | ||
| )} | ||
| </div> | ||
|
|
||
| {devices.length === 0 ? ( | ||
| <p className="mt-3 font-label text-sm text-muted-foreground">{t("tenant.noReport")}</p> | ||
| ) : ( | ||
| <ul className="mt-3 grid gap-2"> | ||
| {devices.map((d) => ( | ||
| <DeviceRow key={d.deviceId} device={d} now={now} t={t} /> | ||
| ))} | ||
| </ul> | ||
| )} | ||
| </li> | ||
| ); | ||
| } | ||
|
|
||
| function FleetHeader({ t }: Readonly<{ t: Translator }>) { | ||
| return ( | ||
| <div> | ||
| <h1 className="font-display font-bold text-5xl">{t("title")}</h1> | ||
| <p className="mt-2 font-label text-muted-foreground">{t("intro")}</p> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default async function AdminFleetPage() { | ||
| await requireAdmin(); | ||
| const locale = await controlLocale(); | ||
| const t = await getTranslations({ locale, namespace: "control.admin.fleet" }); | ||
| const registry = await loadTenantRegistry(); | ||
|
|
||
| // Registry unavailable ⇒ bail before the DB reads (nothing to join to). | ||
| if (!registry.ok) { | ||
| return ( | ||
| <div className="grid gap-10"> | ||
| <FleetHeader t={t} /> | ||
| <p className="hand-drawn-border bg-card p-4 font-label text-craft-error-text"> | ||
| {t("unavailable", { error: registry.error })} | ||
| </p> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| const [devices, summaries] = await Promise.all([ | ||
| db.fleetDevice.findMany({ orderBy: [{ tenantSlug: "asc" }, { label: "asc" }] }), | ||
| db.fleetSummary.findMany(), | ||
| ]); | ||
|
|
||
| const devicesBySlug = new Map<string, FleetDeviceRow[]>(); | ||
| for (const d of devices) { | ||
| const list = devicesBySlug.get(d.tenantSlug) ?? []; | ||
| list.push(d); | ||
| devicesBySlug.set(d.tenantSlug, list); | ||
| } | ||
| const summaryBySlug = new Map(summaries.map((s) => [s.tenantSlug, s])); | ||
| const now = Date.now(); | ||
|
|
||
| return ( | ||
| <div className="grid gap-10"> | ||
| <FleetHeader t={t} /> | ||
| <section> | ||
| <h2 className="font-hand text-3xl font-bold"> | ||
| {t("registered", { count: registry.tenants.length })} | ||
| </h2> | ||
| <ul className="mt-4 grid gap-4"> | ||
| {registry.tenants.map((tenant) => ( | ||
| <TenantCard | ||
| key={tenant.slug} | ||
| tenant={tenant} | ||
| devices={devicesBySlug.get(tenant.slug) ?? []} | ||
| summary={summaryBySlug.get(tenant.slug)} | ||
| now={now} | ||
| t={t} | ||
| /> | ||
| ))} | ||
| {registry.tenants.length === 0 && ( | ||
| <li className="font-label text-muted-foreground">{t("empty")}</li> | ||
| )} | ||
| </ul> | ||
| </section> | ||
| </div> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { NextResponse } from "next/server"; | ||
| import { createHash, timingSafeEqual } from "node:crypto"; | ||
| import { fleetPushSchema, ingestFleetPush } from "@/lib/fleet"; | ||
|
|
||
| // Machine-to-machine ingest for the fleet roll-up: each tenant backend's FleetSummaryPushService | ||
| // POSTs a compact per-tenant snapshot here. NOT a (control) surface — no RBAC session; guarded by a | ||
| // shared PRINTER_TELEMETRY_SECRET bearer token. One-directional (backend → sofra): devices/backends | ||
| // hold no sofra credential, sofra holds no backend credential. Non-PII (schema-enforced). | ||
| // | ||
| // TRUST MODEL (follow-up): the bearer is a single shared machine secret and the tenant is taken from | ||
| // the body's tenantSlug, so any secret-holder could write/prune another tenant's rows. Blast radius | ||
| // is bounded — internal admin-only view, non-PII, self-heals on the next legitimate push, one tenant | ||
| // today. When the backend push slice lands, bind the slug to a per-tenant credential. | ||
|
|
||
| // Constant-time bearer check. Both sides are SHA-256'd to a fixed 32 bytes first so timingSafeEqual | ||
| // never sees a length mismatch (which would throw and leak the secret's length via timing). | ||
| function authorized(request: Request): boolean { | ||
| const secret = process.env.PRINTER_TELEMETRY_SECRET; | ||
| if (!secret) return false; | ||
| const digest = (s: string) => createHash("sha256").update(s).digest(); | ||
| const provided = digest(request.headers.get("authorization") ?? ""); | ||
| const expected = digest(`Bearer ${secret}`); | ||
| return timingSafeEqual(provided, expected); | ||
| } | ||
|
|
||
| export async function POST(request: Request) { | ||
| if (!process.env.PRINTER_TELEMETRY_SECRET) { | ||
| return NextResponse.json({ error: "fleet telemetry not configured" }, { status: 503 }); | ||
| } | ||
| if (!authorized(request)) { | ||
| return NextResponse.json({ error: "unauthorized" }, { status: 401 }); | ||
| } | ||
|
|
||
| let body: unknown; | ||
| try { | ||
| body = await request.json(); | ||
| } catch { | ||
| return NextResponse.json({ error: "invalid json" }, { status: 400 }); | ||
| } | ||
|
|
||
| const parsed = fleetPushSchema.safeParse(body); | ||
| if (!parsed.success) { | ||
| // Never echo the payload — just that it was malformed. | ||
| return NextResponse.json({ error: "invalid payload" }, { status: 400 }); | ||
| } | ||
|
|
||
| await ingestFleetPush(parsed.data); | ||
| return NextResponse.json({ ok: true, devices: parsed.data.devices.length }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import { z } from "zod"; | ||
| import { db } from "@/lib/db"; | ||
|
|
||
| // Payload pushed by a tenant backend's FleetSummaryPushService (one-directional, bearer-authed). | ||
| // Non-PII only: device roster + counts, never customer data / API keys / raw orders. | ||
| export const fleetDeviceSchema = z.object({ | ||
| deviceId: z.string().trim().min(1).max(64), | ||
| label: z.string().trim().max(120).nullish(), | ||
| platform: z.string().trim().max(40).nullish(), | ||
| appVersion: z.string().trim().max(40).nullish(), | ||
| feedRunning: z.boolean().default(false), | ||
| lastHeartbeatAt: z.coerce.date().nullish(), | ||
| lastSuccessfulPollAt: z.coerce.date().nullish(), | ||
| apiBaseUrl: z.string().trim().max(300).nullish(), | ||
| kitchenPrinter: z.string().trim().max(200).nullish(), | ||
| cashierPrinter: z.string().trim().max(200).nullish(), | ||
| }); | ||
|
|
||
| export const fleetPushSchema = z.object({ | ||
| tenantSlug: z.string().trim().min(1).max(80), | ||
| reportedAt: z.coerce.date(), | ||
| missedOrders: z.number().int().min(0).max(1_000_000).default(0), | ||
| recentErrors: z.number().int().min(0).max(1_000_000).default(0), | ||
| devices: z.array(fleetDeviceSchema).max(200), | ||
| }); | ||
|
|
||
| export type FleetPush = z.infer<typeof fleetPushSchema>; | ||
| type FleetDeviceInput = z.infer<typeof fleetDeviceSchema>; | ||
|
|
||
| function deviceColumns(d: FleetDeviceInput) { | ||
| return { | ||
| label: d.label ?? null, | ||
| platform: d.platform ?? null, | ||
| appVersion: d.appVersion ?? null, | ||
| feedRunning: d.feedRunning, | ||
| lastHeartbeatAt: d.lastHeartbeatAt ?? null, | ||
| lastSuccessfulPollAt: d.lastSuccessfulPollAt ?? null, | ||
| apiBaseUrl: d.apiBaseUrl ?? null, | ||
| kitchenPrinter: d.kitchenPrinter ?? null, | ||
| cashierPrinter: d.cashierPrinter ?? null, | ||
| }; | ||
| } | ||
|
|
||
| // Upsert the tenant's summary + reconcile its device roster (upsert reported, prune the rest) in | ||
| // one transaction, so a partial failure can't leave a half-updated fleet view. | ||
| export async function ingestFleetPush(push: FleetPush): Promise<void> { | ||
| const slug = push.tenantSlug; | ||
| const now = new Date(); | ||
| // Dedup by deviceId (a repeated id in one push would upsert the same row twice) and sort | ||
| // deterministically, so concurrent same-tenant pushes acquire row locks in the same order and | ||
| // can't deadlock. A later duplicate wins (Map keeps the last value for a key). | ||
| const devices = Array.from(new Map(push.devices.map((d) => [d.deviceId, d])).values()).sort((a, b) => | ||
| a.deviceId.localeCompare(b.deviceId), | ||
| ); | ||
| const reportedIds = devices.map((d) => d.deviceId); | ||
|
|
||
| await db.$transaction(async (tx) => { | ||
| await tx.fleetSummary.upsert({ | ||
| where: { tenantSlug: slug }, | ||
| create: { | ||
| tenantSlug: slug, | ||
| deviceCount: devices.length, | ||
| missedOrders: push.missedOrders, | ||
| recentErrors: push.recentErrors, | ||
| reportedAt: push.reportedAt, | ||
| receivedAt: now, | ||
| }, | ||
| update: { | ||
| deviceCount: devices.length, | ||
| missedOrders: push.missedOrders, | ||
| recentErrors: push.recentErrors, | ||
| reportedAt: push.reportedAt, | ||
| receivedAt: now, | ||
| }, | ||
| }); | ||
|
|
||
| // Prune devices this tenant no longer reports (decommissioned) — scoped to THIS tenant only. | ||
| // An empty roster drops them all; otherwise keep only the reported ids. | ||
| if (reportedIds.length === 0) { | ||
| await tx.fleetDevice.deleteMany({ where: { tenantSlug: slug } }); | ||
| } else { | ||
| await tx.fleetDevice.deleteMany({ where: { tenantSlug: slug, deviceId: { notIn: reportedIds } } }); | ||
| } | ||
|
|
||
| for (const d of devices) { | ||
| const columns = deviceColumns(d); | ||
| await tx.fleetDevice.upsert({ | ||
| where: { tenantSlug_deviceId: { tenantSlug: slug, deviceId: d.deviceId } }, | ||
| create: { tenantSlug: slug, deviceId: d.deviceId, ...columns }, | ||
| update: columns, | ||
| }); | ||
| } | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.