Skip to content

Architecture

Rod Christiansen edited this page Oct 8, 2026 · 2 revisions

Architecture

How the dashboard is put together and how a page request turns into fleet data.

Backend-for-frontend, not a client

No browser code calls reportmate-api. Every data path goes:

  1. A client component calls a same-origin path under /api/....
  2. That route handler in app/api/**/route.ts builds upstream headers with getInternalApiHeaders() from lib/api-auth.ts and fetches ${API_BASE_URL}/api/v1/....
  3. The JSON is returned to the browser, in most routes with Cache-Control: no-store.

Two things follow from this. The API base URL and the shared secret stay server-side — they are read from process.env inside route handlers, never from a NEXT_PUBLIC_ variable. And the user's own session is not what authenticates the upstream hop: X-Internal-Secret authenticates the proxy to the API, so the middleware has to be the thing that stops an anonymous browser from reaching a data route. It answers /api/* with a JSON 401 rather than an HTML redirect for exactly that reason.

lib/api-auth.ts picks the upstream credential in a fixed order: API_INTERNAL_SECRET as X-Internal-Secret, else REPORTMATE_PASSPHRASE as X-API-PASSPHRASE, else no auth header at all (which the API rejects).

Request lifecycle

middleware.ts runs on every path except static assets, api/auth, api/healthz, api/health, api/transmission and api/version. In order it:

  1. Resolves device identifiers. On /device/<identifier> it classifies the identifier as a UUID, asset tag, device name, hostname or serial number. Asset tags, device names and hostnames are looked up against ${API_BASE_URL}/api/v1/devices?limit=1000 and redirected (HTTP 307) to the canonical /device/<serial>. Serial numbers and UUIDs pass through untouched.
  2. Bypasses auth for genuine localhost in development, and for the whole app when NEXT_PUBLIC_DEMO_MODE=true.
  3. Checks the session with getToken. A kiosk viewer token is additionally restricted to GET/HEAD on an allow-list of read-only paths; anything else is 403 (API) or redirected to /events (page).
  4. Rejects or redirects — JSON 401 for /api/*, a redirect to /auth/signin?callbackUrl=... for pages.

Directory layout

Path Contents
app/ App Router pages and route handlers
app/api/ The BFF proxy layer — every server route
lib/ auth.ts (NextAuth options), auth-roles.ts, api-auth.ts, device-names.ts
src/components/ UI: tabs/, widgets/, tables/, cards/, navigation/, settings/, ui/
src/lib/data-processing/ Per-module normalizers that turn raw module JSON into view models
src/lib/kiosk/ Kiosk token parsing, session minting, viewer allow-list
src/lib/settings/ Types, defaults and migrations for the server-side settings document
src/lib/installs/ Managed-install status and system-problem helpers
src/lib/system/ Installed-update search and KB coverage for the System report
src/hooks/ Data-loading hooks (useDeviceData, useInstallsData, useProgressiveData, SWR helpers)
src/providers/ React context: SWR, theme-adjacent settings, platform filter, debug mode, demo mode
e2e/ Playwright specs
middleware.ts Auth, kiosk gating, device-identifier resolution

Module data flow

The agents report per-module payloads (applications, hardware, identity, installs, inventory, management, network, peripherals, security, system). Two shapes of page consume them:

  • Fleet reports (/hardware, /system, …) use useDeviceData from src/hooks/useDeviceData.ts, which fetches /api/v1/devices for the inventory context and /api/v1/<module> for the module rows, then joins them client-side.
  • Device detail (/device/<serial>) fetches /api/device/<id>/info for the header and /api/device/<id>/modules/<module> per tab, so a tab's payload is only pulled when that tab is opened.

Normalization lives in src/lib/data-processing/modules/*.ts, one file per module, orchestrated by device-mapper-modular.ts. Some of the file names MODULAR_MIGRATION.md in the repo lists do not exist; the modules/ directory is the source of truth.

Device status

calculateDeviceStatus in src/lib/data-processing/device-status.ts decides the status every page shows, in this order:

  1. Archived — the device is archived.
  2. Storage — the API carries an inventory state from an asset inventory system saying the device is checked in to storage. A stored device is expected to be silent, so it never counts as stale or missing.
  3. Active — last seen under 24 hours ago.
  4. Stale — last seen under 7 days ago.
  5. Missing — anything older, or no last-seen time at all.

The dashboard, the devices list, the device page and every report page use the same rule, and report pages share reportDeviceStatus for their Active, Stale, Missing and Storage pills. Stored devices are left out of the installs error, warning, pending and success drill-downs, and the fleet status donut gives them their own slice.

Real-time events

The dashboard opens a WebSocket to an Azure Web PubSub hub using the json.webpubsub.azure.v1 subprotocol. The token comes from /api/v1/negotiate?device=dashboard — the same-origin proxy, because minting a stream token needs the internal secret. If the negotiate call fails or returns no URL (or takes more than 10 seconds), the connection pill reads polling and the feed updates from the regular refresh alone. That refresh calls /api/dashboard every 30 seconds whether or not the socket is connected: the socket carries events only, and /api/dashboard is the sole source of the device list and of the install statistics behind the Errors and Warnings tiles.

Caching

There is no shared cache tier. Route handlers set dynamic = 'force-dynamic' and revalidate = 0, and pass cache: 'no-store' upstream. /api/v1/installs and /api/v1/installs/data share an in-process cache in app/api/v1/installs/shared.ts, and lib/device-names.ts keeps an in-process serial-to-name map. /api/cache/status and /api/cache/invalidate exist but are descriptive stubs — they report and acknowledge, they do not clear anything.

Clone this wiki locally