Skip to content
SENS Platform Docs

web-console

apps/web-console is the permanent React administration UI. It owns presentation, form state, bounded list pagination, and a typed HTTP client. It does not decide whether an operation is authorized; platform-api repeats all authentication, tenant, organization, and role checks.

Operational tables for tenants, users, memberships, devices, device types, and Flows use forward cursor responses with a browser-held cursor history for previous-page navigation. They offer page sizes of 25, 50, and 100, and reset to the first page when scope or filters change. Tenant, Device Type, Device, User, and Membership pages display the API’s exact filtered totalCount; other tables do not infer totals that their APIs do not provide. Tenant and Device Type search, status, and page size, Device filters and page size, and User and Membership search, status, role, and page size are URL state, while the current page’s cursor chain is session-bound browser-history state. A detail return restores it, a reload starts on page 1, and a successful mutation reloads the current page or its predecessor when the page became empty.

The Flow selector searches 50 results at a time and exposes an explicit continuation action. The selected Flow remains URL state and is restored through the singleton API when it lies beyond the first selector page. Flow-version history appends 25 older entries only after an explicit action, preserves loaded entries on failure, and never displays an invented exact total.

Device-Type selectors in Device forms, inventory filters, Flow creation, manual Flow tests, and template instantiation search the server and expose an explicit continuation action. Organization selectors use the scoped hierarchy search and show visible ancestor paths; inaccessible branches are never offered.

The counted administration UIs and their Platform API responses are one compatibility unit. Roll back both images to their previous compatible pair, or forward-fix the unmatched image when a paired rollback is unavailable.

React Router runs in declarative browser mode. Canonical routes are ordinary paths such as /tenants/17/devices/{deviceId}; the central route module owns path generation, canonical positive-number validation, aggregate UUID validation, route metadata, and known legacy-hash mapping. Components do not assemble routes independently. Known old hash links are replaced once in browser history. Unknown, syntactically invalid, invisible, or unauthorized resources render the same neutral not-available state.

Tenant, Device, Device-Type, and Raw-message details are dedicated routes. Overview, Dashboards, Organizations, Flows, Users, Memberships, Audit, and Admin remain tenant-owned routes. /tenants and /raw-ingestion are global routes without an active tenant. A separate Administration group exposes the temporary IOTA import only to platform administrators. Navigation hides administration and raw-data views when the current identity lacks the corresponding role. That is a usability feature, not a security boundary.

  1. The browser loads /runtime-config.json with no-store.
  2. The client validates service, environment, version, and API base URL.
  3. It requests API diagnostics and attempts a refresh of an existing session.
  4. With no valid session it displays the login screen.
  5. Login returns an access token to the client and sets the rotating refresh token as a host-only HttpOnly cookie on the API origin.
  6. The access token remains in memory. It is not written to local storage.
  7. A temporary-password account is restricted to the password-change screen.

The tenant selector in the sidebar header loads only tenants visible to the current identity. It requests 50 rows per page, searches on the server, and loads each continuation only after the user selects Load more tenants. Failed continuations retain the current options and remain retryable; repeated or concurrent requests for one cursor never append an option twice. A search change invalidates earlier search and continuation responses so that late responses cannot replace or extend the current result. The selector exposes a keyboard-operable combobox and listbox; users can move from the search field to an option with the arrow keys and select it with Enter. The selected tenant comes from the canonical URL and is loaded through GET /v1/tenants/{tenantNumber}. The number is passed to Tenant collections and creates; detail and subresource calls use flat aggregate API routes. The internal Tenant UUID remains available only for local permission evaluation. The first visible Tenant is only the root-route default, not the authority for an already addressed route. Switching tenants always opens /tenants/{tenantNumber}/overview. The breadcrumb uses the same route metadata and links every completed hierarchy level. The sidebar footer renders profile details from GET /v1/auth/me and provides the session logout action; it does not infer or cache additional identity data.

Membership User and Organization selectors request 50 rows per server-search page and load continuations only after an explicit action. Current options survive a failed continuation and the action remains retryable. Duplicate or concurrent cursor requests cannot append duplicates, and a query change invalidates late responses. Organization targets come from the hierarchy search contract and are restricted to active, actor-manageable units. Membership table labels are resolved only for the current page, never from a silently truncated collection response. The labels are embedded in the authoritative Membership page contract, so a 100-row table still performs one list request rather than User and Organization detail requests for every row.

The fixed dashboards view selects exactly one Device through a paginated, server-searched picker or the /tenants/{tenantNumber}/devices/{deviceId}/dashboard deep link. It polls only the current snapshot every 60 seconds while the tab is visible. Timeseries history loads on Device or range changes and explicit refresh, remains bounded to 30 days and 5,000 rows, and is never persisted as dashboard configuration.

The collection navigation inventory maps every table, selector, timeline and bounded detail collection to its API contract and verification evidence.

Raw lists retrieve metadata only. A Raw detail route requires both the Raw ID and its receivedAt TimescaleDB key, retrieves payload bytes on entry, and checks tenant ownership again for tenant-owned routes. Authorized platform administrators can queue a confirmed replay with a fresh browser-generated Idempotency-Key and then refresh its execution status explicitly.

Raw, Audit, and Device assignment-history investigations append 25 records only after Load older entries. A fixed upper anchor survives every continuation; Refresh invalidates pending responses and starts with a new anchor. A cursor is reserved synchronously, so repeated or concurrent activation appends one logical page at most once. Failures preserve loaded evidence and expose the same retry action. Loaded counts are deliberately not exact timeline totals.

The typed client validates administrative and telemetry responses with the authoritative runtime schemas from @sens-platform/contracts. Response objects permit additive fields, while known fields, identifiers, statuses, timestamps, and bounded metadata must still satisfy the shared contract. An invalid response is treated as an API contract failure rather than trusted by the UI.

IOTA source URL, username, and password remain local state of the Admin view. They never enter routing, the authentication context, Local Storage, Session Storage, or IndexedDB. The password is cleared after every import attempt.

The Vite build is served by apps/web-console/server.mjs as a non-root Node.js process. It provides:

  • /healthz, /readyz, /version, and /metrics;
  • /runtime-config.json generated from validated environment values;
  • immutable caching for hashed assets and no-cache behavior for the app shell;
  • SPA fallback for client routes;
  • Content Security Policy, frame-ancestors 'none', no-referrer, permissions, and content-type protections.

The CSP permits inline style attributes through style-src-attr 'unsafe-inline' because the locally bundled Radix primitives calculate overlay positions and responsive layout variables in the browser. Stylesheets remain restricted to style-src 'self'. Each HTML response also receives a cryptographically random CSP nonce, which the bundled Radix scroll-lock helper applies to its one dynamically generated style element. Scripts remain restricted to script-src 'self'. These exceptions do not permit inline script execution or third-party style origins.

The server has no database or Secret dependency. Readiness proves that the static service is ready, not that platform-api is ready; the browser displays the API diagnostic state separately.

SENS_WEB_CONSOLE_API_BASE_URL is the only application connection and contains no credentials. The API must allow the exact web-console origin for credentialed login and refresh requests. See Runtime Configuration.

SymptomFirst check
Startup screen never completesBrowser network result for /runtime-config.json, then API /readyz.
Login works with curl but not in the browserExact API CORS allowlist, browser Origin, and cookie response.
A navigation item is missingCurrent role and selected tenant from GET /v1/auth/me.
UI and API report different versionsArgo CD image selections and both /version endpoints.
Deep link returns 404Static server SPA fallback and Ingress rewrite behavior.

Tests live beside the client, runtime configuration, auth screens, routes, permissions, views, and static server. Customer behavior is documented under Platform.

Browser Sentry loads from runtime configuration and sends sanitized exceptions without PII, replay, profiles, attachments, console breadcrumbs, or public source maps. PostHog loads only after explicit consent and authenticated pseudonymous identity. Autocapture, pageviews, page-leave capture, replay, and person profiles are disabled. The browser SDK also disables PostHog feature flags and remote configuration because the adapter uses no remotely managed PostHog features. The browser therefore does not request PostHog’s asset/config host, which keeps third-party origins out of script-src. Logout or consent revocation resets the local analytics identity; low-level product correctness never depends on analytics.