This folder contains the Service Management Portal for the Linux Broker for AVD Access solution. It is the administrator UI for managing Linux host VMs, scaling rules, and broker activity. For the full solution context, see the repository README.
The portal is a React 18 + TypeScript single-page app built with Vite and styled with Tailwind CSS v4 using a custom glassmorphism design system. Flask remains, but as a backend-for-frontend (BFF): it owns authentication, calls the Broker API on the operator's behalf, and serves the built bundle.
Browser (React SPA)
| session cookie + X-CSRFToken
v
Flask BFF --- /login /getAToken /logout ---> Entra ID
| Bearer token from the server-side session
v
Broker API
Why the BFF stays:
- The MSAL confidential client flow is unchanged. The access token lives in the Flask session
and never reaches the browser, so there is no token in
localStorageto steal and no Entra app registration changes were needed for the rewrite. Flask-WTFCSRF protection still guards every state-changing request.- Flask serves the SPA shell for every non-API path, so a bookmarked deep link or a hard refresh still resolves and React Router renders the right page.
The session holds the operator's access token, so it stays on the server, and the browser carries
only an opaque id in the session cookie, which is Secure, HttpOnly and SameSite=Lax.
session_store.py picks where it lives:
- Local runs and tests keep sessions on disk, under
flask_session/in the working directory, which Git ignores. - Deployments keep them in Redis, so that every instance of the portal finds the session a
sign-in created on another: Azure Managed Redis, or Azure Cache for Redis in Azure Government,
reached through a private endpoint. The portal signs in to Redis as the web app's system-assigned
managed identity, through
redis-entraid, so the Redis store works only where the portal has one. See Portal And API Scale-Out.
Only /login, /getAToken, /logout and /api/ui/* load the session; SESSION_ROUTES in
session_store.py lists the routes outside /api/ui. The SPA shell, the static assets and
/health never touch the store, so they keep working while it is down. Writing to the session on
any other route raises an error naming the route, so a new route that needs a session fails in the
tests rather than losing its data.
When the store can't be reached, a request that needs the session gets 503 with
Retry-After: 30: JSON with an error on /api/ui/*, plain text on the sign-in routes. The
portal logs the cause through linuxbroker.frontend, which Application Insights collects. When a
request only loses the refresh of the session's expiry, its response stands, and the portal logs a
warning.
| Path | Purpose |
|---|---|
app.py |
Creates the Flask app, serves the SPA shell, exposes /api/ui/session and /api/ui/dashboard, and defines the JSON and SPA error handlers. |
config.py |
Reads cloud, Entra ID, and Broker API settings from environment variables. |
session_store.py |
Server-side sessions: the disk or Redis store, the cookie policy, which routes load the session, and the 503 when the store can't be reached. |
function_authentication.py |
@login_required. Returns 401 JSON for /api/ui/* and redirects page requests to /login. |
function_api.py |
Authenticated Broker API helpers, request timeouts, JSON decoding, dashboard VM summary retrieval, history filter parsing, and paged history calls. |
function_bff.py |
Shared JSON plumbing: the @broker_endpoint error decorator, request-body helpers, and the paged history envelope. |
route_authentication.py |
Sign in, token callback, and sign out. Browser redirects, not JSON. |
route_vm_management.py |
VM JSON endpoints, including the host actions (start, stop, restart, drain, return to service, power sync). |
route_scaling_management.py |
Scaling rule and scaling history JSON endpoints. |
route_host_settings.py |
Linux host settings, settings history and fleet health JSON endpoints. |
route_audit.py |
Audit log JSON and CSV export endpoints. |
static/dist/ |
Vite build output. Generated, not committed. |
static/favicon.ico, static/images/ |
The only hand-maintained static assets. |
web/ |
The React application. |
tests/ |
pytest suite covering the JSON contract. |
Inside web/:
| Path | Purpose |
|---|---|
src/styles/theme.css |
The whole design system: tokens, glass surfaces, badges, controls, tables, and the accessibility fallbacks. |
src/lib/ |
api.ts (fetch wrapper, CSRF, 401 handling), queryClient.ts, format.ts, theme.ts, vmLifecycle.ts, settingsDiff.ts. |
src/types/broker.ts |
Every shape the BFF returns. |
src/hooks/ |
useSession, useBroker (all TanStack Query hooks), useHistoryQuery, useAutoRefresh, useConfirm, useHostActions. |
src/components/Icon.tsx |
The 37 hand-authored inline SVG icons. |
src/components/ui/ |
Design system primitives. |
src/components/layout/ |
App shell, nav, breadcrumbs, theme toggle. |
src/components/data/ |
DataTable, Pagination, HistoryFilters, HistoryView. |
src/pages/ |
One file per screen, grouped by feature. |
src/test/ |
Vitest setup and the shared provider-aware render. |
Every JSON endpoint lives under /api/ui. Anything else is either a server-side auth redirect or
a path that serves the SPA shell.
| Method | Path | Notes |
|---|---|---|
| GET | /api/ui/session |
Bootstrap: authenticated, user, version, csrfToken, roles, permissions, legacyAccess, permissionsUnavailable. Not behind @login_required, because the signed-out landing page needs a 200. |
| GET | /api/ui/dashboard |
{stats, recentActivity, fleetHealth, apiError}. fleetHealth is the broker's fleet health summary, or null when the broker predates heartbeats. |
| GET | /api/ui/vms |
With any of page, per_page, q, status, sort or dir, one page of the host list with status counts. When the broker API predates paging it answers a bare list, which the BFF filters, sorts and pages itself, adding legacy: true. Without those parameters, the bare list. |
| GET | /api/ui/vms/import/candidates |
Tagged Linux host VMs the broker does not know yet; allows the broker 90 seconds. |
| POST | /api/ui/vms/import |
Body {hostnames: string[]}, 1 to 100 valid hostnames. |
| GET | /api/ui/vms/<vmid> |
|
| POST | /api/ui/vms |
Returns 201. |
| POST | /api/ui/vms/<vmid>/update-attributes |
|
| POST | /api/ui/vms/<vmid>/delete |
|
| POST | /api/ui/vms/<hostname>/release |
Keyed by hostname, matching the broker. |
| POST | /api/ui/vms/<vmid>/return |
Keyed by VMID, matching the broker. |
| POST | /api/ui/vms/<vmid>/cleanup |
Retries cleanup for a pending previous assignment. |
| POST | /api/ui/vms/<vmid>/maintenance |
Body {enabled: boolean}. |
| POST | /api/ui/vms/<vmid>/start |
|
| POST | /api/ui/vms/<vmid>/stop |
Body {mode?: "PowerOff" | "Deallocate", confirm?: hostname}. The broker requires confirm, and the Admin role, for a host in use. |
| POST | /api/ui/vms/<vmid>/restart |
Body {confirm?: hostname}, as for stop. |
| POST | /api/ui/vms/<vmid>/drain |
|
| POST | /api/ui/vms/<vmid>/undrain |
Return to service. |
| POST | /api/ui/vms/sync |
Corrects recorded power states from Azure; allows the broker 60 seconds. |
| POST | /api/ui/vms/checkout |
Strips password and LeaseId before returning the broker response. |
| GET | /api/ui/vms/history |
Paged. Filters in the query string. |
| GET | /api/ui/scaling/rules |
|
| GET | /api/ui/scaling/rules/<ruleid> |
|
| POST | /api/ui/scaling/rules |
Returns 201. |
| POST | /api/ui/scaling/rules/<ruleid>/update |
|
| POST | /api/ui/scaling/rules/<ruleid>/delete |
|
| GET | /api/ui/scaling/log |
Paged. |
| GET | /api/ui/scaling/rules/history |
Paged. |
| GET | /api/ui/scaling/policy |
The time zone, the default rule, the windows, what applies now and next, the start on demand settings, and the broker script versions the AVD hosts run. {Available: false} when the broker predates it, so the Scaling section offers the scaling rules instead. |
| POST | /api/ui/scaling/policy |
Body with any of timezone, startondemandenabled and maxpendingstarts, applied together. |
| GET | /api/ui/scaling/timezones |
|
| POST | /api/ui/scaling/schedules |
Adds a window. |
| POST | /api/ui/scaling/schedules/<scheduleid>/update |
|
| POST | /api/ui/scaling/schedules/<scheduleid>/delete |
|
| GET, POST | /api/ui/scaling/preview |
What the next scaling run would do; a POST previews proposed values. |
| GET | /api/ui/metrics/utilization |
?hours=24 or 168. {Available: false} when the broker predates it, so the dashboard hides the charts. |
| GET | /api/ui/metrics/attention |
{Available: false} when the broker predates it. |
| GET | /api/ui/sessions |
?state= one session state. |
| GET | /api/ui/users |
?q= any part of the name. |
| GET | /api/ui/users/<username> |
|
| POST | /api/ui/sessions/<hostname>/<username>/signout |
Body {returnHost?: boolean}; allows the broker 120 seconds. |
| POST | /api/ui/sessions/<hostname>/<username>/message |
Body {message}, at most 500 characters. |
| POST | /api/ui/sessions/broadcast |
Body {message, hostnames?}; without hostnames, every host in use. |
| POST | /api/ui/users/<username>/reset-profile |
|
| POST | /api/ui/users/<username>/reset-profile/cancel |
|
| GET | /api/ui/maintenance/runs |
{Available: false} when the broker predates maintenance runs. |
| GET | /api/ui/maintenance/runs/<run_id> |
|
| POST | /api/ui/maintenance/runs |
Starts a run. |
| POST | /api/ui/maintenance/runs/<run_id>/<action> |
pause, resume or cancel. |
| GET | /api/ui/hosts/settings |
{settings, hosts}. |
| POST | /api/ui/hosts/settings |
|
| POST | /api/ui/hosts/settings/apply |
Returns a message and tone the client shows verbatim; uses APPLY_TIMEOUT_SECONDS. |
| GET | /api/ui/hosts/settings/history |
Every saved settings version, newest first. |
| GET | /api/ui/hosts/health |
Fleet health, passed through; ?hostname= for one host. |
| GET | /api/ui/audit |
Paged. Filters from, to, actor, action, targetType, target, outcome in the query string. |
| GET | /api/ui/audit/export.csv |
Every entry matching the filters, up to 10,000, as CSV. Cells that a spreadsheet would run as a formula are prefixed with '. |
Server-rendered routes that are not JSON: /login, /getAToken, /logout, /health,
/favicon.ico.
/api/ui/session mirrors the Broker API /api/me result after sign-in and lazily refreshes it
when the Flask session lacks cached permissions. Anonymous sessions always return roles: [],
permissions: {read:false, operate:false, admin:false}, legacyAccess:false, and
permissionsUnavailable:false. Authenticated sessions return:
roles: Broker API app roles (Reader,Operator,FullAccess) seen in the token.permissions: effective booleans forread,operate, andadmin. The API remains the security boundary; the SPA only hides or explains actions, and broker403responses pass through the BFF unchanged.legacyAccess: true when the API granted access via the legacy delegated scope toggle.permissionsUnavailable: true when/mecould not be fetched; the shell shows a retry panel.
Gating convention: read can view pages, operate can run VM lifecycle and host actions and
Apply Now, and admin can create/delete/edit broker records, save settings, and stop or restart a
host a user is signed in to. Authenticated users without read see the No access page.
@broker_endpoint in function_bff.py turns broker failures into a predictable envelope:
{ "error": "Unable to retrieve VM data. Please try again later." }| Situation | Status |
|---|---|
Client sent something unusable (BadRequest) |
400, naming the field |
| No usable token in the session | 401 |
Broker answered 4xx |
The same status, with the broker's own message, which names the rejected value |
Broker answered 5xx, timed out, or returned junk |
502 |
| Missing or stale CSRF token | 400 |
Unknown /api/ui/* path |
404 JSON |
| Unknown page path | 200 SPA shell; React renders the not-found state |
The client redirects to /login on a 401 and shows the error string as a toast otherwise.
fetch cannot follow a 302 to Entra ID, which is exactly why the API answers 401 instead of
redirecting.
Every string in that envelope is one the portal composes: a literal, a field name, or the broker's
own message. Exceptions are never stringified into a response body, because that is
indistinguishable from leaking a stack trace to static analysis. Raise BadRequest with the exact
text the operator should see, and keep the underlying exception in logger.error instead.
Routes mirror the URLs the Jinja portal served, so existing bookmarks and runbook links still
resolve: /, /profile, /vms, /vms/add, /vms/checkout, /vms/history, /vms/:vmid,
/vms/:vmid/update, /scaling/rules, /scaling/rules/create, /scaling/rules/history,
/scaling/rules/:ruleid, /scaling/rules/:ruleid/update, /scaling/log, /settings/hosts.
Phase 2 added /vms/health (fleet health, filterable with ?show=), /vms/import (Admin),
/vms/maintenance, /vms/maintenance/new (Admin; ?hosts=a,b preselects hosts), /vms/maintenance/:runid,
/sessions, /users/:username, /scaling (the scaling policy), /scaling/schedules/new and
/scaling/schedules/:scheduleid (Admin), and /audit. /vms/checkout is now Test brokering, reached
from the host list's Tools menu.
The navigation is Overview · Hosts · Sessions · Scaling · Settings · Audit. Hosts and Scaling
show their pages as tabs (SectionTabs). Keyboard shortcuts (/ to search, g then a letter
to change section, ? for help) and compact table rows are set in Profile and kept in
localStorage (lb-shortcuts, lb-density); shortcuts never fire while typing and can be
turned off.
Host actions (Start, Stop, Stop and deallocate, Restart, Drain, Return to service) share one
definition in useHostActions: the host list offers them, with release, return, retry cleanup, edit
and delete, in each row's Actions menu (useHostRowActions), and the host's page lists them in its
Actions card. Every confirmation names the host and its current user. Stopping or restarting a host a user is signed in to also asks for the
hostname to be typed (ConfirmDialog's requireText), which the broker requires too.
Everything lives in web/src/styles/theme.css. Tailwind v4 is configured in CSS; there is no
tailwind.config.js.
- Theme-dependent values are runtime custom properties on
:rootand[data-theme="dark"], exposed to Tailwind through@theme inlineso one utility works in both themes. - The theme is switched by
data-themeon<html>, set before first paint by the inline script inweb/index.htmland toggled at runtime bylib/theme.ts, which persists tolocalStorageunderlb-theme.
| Class | Use |
|---|---|
.lb-glass |
The primary translucent surface: blur, border, shadow, and the specular top hairline. |
.lb-glass-strong / .lb-glass-soft |
More or less opaque variants of the same surface. |
.lb-inset |
A flat inner panel. Use inside .lb-glass; never nest a second blur, which compounds into mush behind text. |
.lb-interactive |
Hover lift. Only for a card that is itself a link or button. |
.lb-badge + .lb-tone-* |
Status pill. Tones: ok, accent, info, warn, danger, neutral. |
.lb-field |
Inputs, selects, and textareas. |
.lb-btn |
Button base; variants are applied by the Button component. |
.lb-table |
Data table with a sticky header. |
Use the semantic Tailwind colours (text-ink, text-muted, text-subtle, text-brand,
border-hairline) rather than hard-coding palette values, so both themes come from one rule.
Glassmorphism is easy to make unreadable. These are requirements, not preferences:
- Text sits on a surface opaque enough for at least 4.5:1 contrast. Blur is decoration; it is never the only thing separating text from the backdrop.
prefers-reduced-transparencyandprefers-reduced-motionfall back to solid surfaces with no blur, no aurora backdrop, and no motion. There is a matching@supports not (backdrop-filter: ...)fallback for browsers without the property.- Status is never conveyed by colour alone (WCAG 1.4.1). Every badge pairs colour with an
icon and text.
Badge.test.tsxenforces this. - The skip link and the 3px
:focus-visiblering stay. - Toasts render in an
aria-live="polite"region. Error toasts do not auto-dismiss.
| Component | Notes |
|---|---|
Icon |
37 hand-authored inline SVGs, typed by name. Decorative by default; pass title when the icon is the only content of a control. |
Button, ButtonLink, ButtonAnchor |
ButtonLink is a router link. ButtonAnchor is a plain <a>, required for /login and /logout, which are full navigations to Flask. |
Badge, VmStatusBadge, PowerBadge, NetworkBadge, ActionBadge |
Blank values render an em dash instead of collapsing the cell. |
DataTable |
Client-side filter and sort over the rows currently on screen. Supply value for any column whose cell renders a badge or link, or it cannot be searched or sorted. |
Pagination |
Windowed: first, current ±2, last. paginationWindow is exported and unit tested. |
HistoryFilters |
Date/limit bar shared by the three history views. Ignore switches make inputs read-only, not disabled, so typed values survive and reappear when unticked. |
HistoryView |
Filter bar + table + pager. The three history pages differ only by their columns. |
ConfirmDialog / useConfirm |
Focus trap, Escape to cancel, restores focus on close. Names the specific resource. |
ToastProvider / useToast |
Replaces Flask flash messages. |
ActionMenu |
A menu button: arrow keys, Home and End move between items, Escape and Tab close it, and focus returns to the trigger. Renders nothing when there are no items. |
MessageDialog / useBroadcastDialog |
Composes a message for one session or many, with the character limit the broker enforces. |
RelativeTime |
"5 min ago" in a <time> element with the absolute UTC time as its tooltip, on one shared 30-second clock. Broker times are read as UTC whatever their format. |
TimeSeriesChart |
Inline SVG line chart for the dashboard: a dash pattern per line, markers, a pointer readout and a table view. No chart library. |
SectionTabs |
The tabs under the navigation for the Hosts and Scaling sections; the Import tab is Admin-only. |
StatusChips, ColumnChooser, SortHeader, BulkResults |
The host list's status filter with counts, its optional columns (kept in localStorage under lb-host-columns), server-sorted headers with aria-sort, and each host's outcome after a bulk action. |
LifecycleExplainer |
Available → Checked out → Released, and what release, return, cleanup, drain and maintenance each do. |
The Jinja portal stored history filters in the Flask session and used POST-redirect-GET. Filters
are now query parameters, read by useHistoryQuery and by the BFF from request.args:
startdate, enddate, limit, ignore_dates, ignore_limit, page, per_page.
This makes a filtered view bookmarkable and shareable, stops two browser tabs from overwriting
each other's criteria, and keeps the session small. Dates are entered as YYYY-MM-DD and
converted to the MM/DD/YYYY the stored procedures expect; the ignore flags omit the filter
rather than sending the legacy "null" sentinel. page and per_page are clamped identically on
both sides (per_page caps at 200), so the client and the BFF never disagree.
The host list keeps its view in the URL the same way: q, status, sort, dir, page and
per_page, leaving out the defaults so shared links stay short. The search is sent once typing
pauses for 300 ms. The request to the BFF always carries every field, so it always answers a page.
CSRFProtect is enabled globally. The client reads the token from /api/ui/session and sends it
as an X-CSRFToken header on every POST; Flask-WTF accepts that header out of the box.
lib/api.ts attaches it automatically, so any request made through apiPost is covered. A POST
without it returns 400. This is the easiest way to break a new endpoint.
The portal must not make external asset requests at runtime. This is a hard requirement because the solution supports Azure Government, sovereign, and air-gapped clouds where public CDNs are unreachable.
- Everything is bundled by Vite and served from
static/dist/. - Fonts are the system stack only. Do not add a webfont.
- Icons are inline SVG. Do not add an icon font or an external sprite.
- Do not add a CDN
<link>or<script>tag toweb/index.html.
test_the_shell_references_no_external_assets guards the served shell.
Build time is different from runtime. The Node stage of the Dockerfile resolves packages from the npm registry, so a fully disconnected build host needs an internal npm mirror. See deploy/DEPLOYMENT.md.
Two processes: Flask on :5000 for auth and the BFF, and Vite on :5173 for the SPA with
hot reload. Vite proxies /api/ui, /login, /getAToken, /logout, and /health to Flask, so
the sign-in flow behaves exactly as it does in production.
cd .\front_end
py -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt -r requirements-dev.txt
$env:FLASK_KEY = "dev-secret"
$env:CLIENT_ID = "<frontend-app-client-id>"
$env:TENANT_ID = "<tenant-id>"
$env:MICROSOFT_PROVIDER_AUTHENTICATION_SECRET = "<client-secret>"
$env:API_CLIENT_ID = "<broker-api-client-id>"
$env:API_URL = "https://<broker-api-host>/api"
$env:AZURE_CLOUD_NAME = "AzurePublic"
python .\app.pyIn a second terminal:
cd .\front_end\web
npm ci
npm run devThen open http://localhost:5173.
APPLY_TIMEOUT_SECONDS controls the BFF timeout for /api/ui/hosts/settings/apply and defaults to 110 seconds.
These settings control the sessions. A local run needs none of them:
| Variable | Default | Purpose |
|---|---|---|
SESSION_BACKEND |
filesystem |
filesystem keeps sessions under flask_session/. redis keeps them in Redis and needs REDIS_HOST. |
SESSION_LIFETIME_HOURS |
12 |
How long an idle session lasts, from 1 to 720. Each request that loads the session starts the period again. |
SESSION_COOKIE_SECURE |
true |
Marks the session cookie Secure. Edge, Chrome and Firefox accept it from http://localhost, so set false only to reach the portal over plain HTTP by another name. |
REDIS_HOST |
The Redis host name. | |
REDIS_PORT |
10000 |
10000 for Azure Managed Redis, 6380 for Azure Cache for Redis. |
REDIS_ENTRA_RESOURCE |
https://redis.azure.com |
The Entra resource the managed identity gets tokens for. The deployment sets acca5fbb-b7e4-4009-81f1-37e38fd66d78, the application ID of Azure Cache for Redis, in Azure Government. |
The portal checks them at startup and stops with a message naming the setting that is wrong.
The app reads environment variables directly; it does not load .env files by itself. For Azure
US Government set AZURE_CLOUD_NAME=AzureUSGovernment. For custom or sovereign clouds without a
built-in profile set AZURE_CLOUD_NAME=AzureCustom and provide AZURE_AUTHORITY_HOST.
To run Flask alone against a production-style bundle, build first. Flask returns a plain-text explanation instead of a blank page if the bundle is missing:
cd .\front_end\web
npm run build # writes ../static/dist
cd ..
python .\app.py # now serves the SPA on :5000The container image uses the same app with Gunicorn:
gunicorn --bind 0.0.0.0:8000 app:appDockerfile is multi-stage. A node:22-alpine stage runs npm ci && npm run build, and only
static/dist/ is copied into the Python image, so the runtime image carries no Node toolchain and
the web/ sources are removed. Nothing generated is committed; package-lock.json is, so npm ci
is reproducible.
requirements.txt pins every Python package. redis-entraid 1.2.1 requires PyJWT 2.13, so pyjwt
can move past 2.13 only with a redis-entraid release that allows it.
Two suites, both run in .github/workflows/front-end-tests.yml.
# BFF contract
cd .\front_end
.\.venv\Scripts\Activate.ps1
pytest
# Client
cd .\front_end\web
npm run typecheck
npm testtests/conftest.py fakes the Broker API with FakeBrokerApi and exposes three helpers worth
knowing: csrf_token(client) fetches a token the way the client does, post(client, path, json)
sends a POST with that header attached, and the spa_bundle fixture supplies a stand-in shell so
the SPA-serving tests do not depend on whether anyone has run npm run build. The Python suite
therefore needs no Node toolchain.
tests/test_session_store.py covers the session settings, the cookie policy, which routes load the
session, the Redis client, a sign-in on one instance honoured by another, and the 503 when the
store can't be reached. It fakes the managed identity and the Redis server, so nothing leaves the
machine.
Behaviour is tested on whichever side now owns it. Broadly: date conversion, ignore-filter semantics, pagination parameters, the legacy bare-list fallback, dashboard summary preference and degradation, CSRF, and the error-status mapping are pytest; VM lifecycle rules, pagination windowing, table sort and filter, the filter round-trip through the URL, badge accessibility, confirm-dialog behaviour, theme persistence, the error page, the chart, the schedule timeline, relative times, the host list's view, chips, columns and bulk actions, and the shortcuts are Vitest.
web/src/App.test.tsx is the integration layer: it mounts the real App with fetch stubbed and
walks every authenticated route, so a page that throws on mount, a missing provider, or a hook used
incorrectly fails there rather than in a browser. Add a row to its route table whenever you add a
page.
- Add the JSON endpoint to the matching
route_*.pymodule, underAPI_PREFIX. - Decorate it with
@login_requiredand@broker_endpoint("…"). Usejson_body()andrequire()forPOSTbodies so a missing field is named rather than becoming an opaque400. - Add the response shape to
web/src/types/broker.ts. - Add a query or mutation hook to
web/src/hooks/useBroker.ts. Mutations should invalidate every query their change affects. - Create the page under
web/src/pages/, and register it in the route table inweb/src/App.tsx. Add a matching row to the route table inweb/src/App.test.tsxso the page is mounted for real by the integration test. - Gate actions with
useCan()and mirror the Broker API role requirement (read,operate, oradmin). Routes the user cannot use should render the reusable permission panel instead of relying on hidden controls. - Use
PageHeaderfor the title and actions,GlassCardfor panels, andDataTablefor tables. - For a destructive action, use
useConfirmso the operator sees the specific resource named. - Report the outcome with
useToast, using theerrorstring from the BFF on failure. - If the page belongs to a nav section, make sure its path is matched by
NAV_ITEMSinweb/src/components/layout/NavBar.tsx, or the active highlight will be wrong. A page of the Hosts or Scaling section also needs its tab inSECTIONSinweb/src/components/layout/SectionTabs.tsx.
Worth knowing if you remember the Jinja portal:
- No-JavaScript support is gone. The old portal degraded to working HTML forms. A SPA cannot.
- Flash messages are toasts. Nothing is stored in the session between requests to render them.
- History filters moved from the session to the URL, as described above.
route_user.pyis gone. The profile page reads from/api/ui/session.- Bootstrap,
app.css,app.js, and every Jinja template were deleted, along with thedata-lb-*attribute contract between them. The equivalents are typed component props now.