Skip to content

Latest commit

 

History

History
476 lines (389 loc) · 28.5 KB

File metadata and controls

476 lines (389 loc) · 28.5 KB

Service Management Portal Front End

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.

Architecture

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 localStorage to steal and no Entra app registration changes were needed for the rewrite.
  • Flask-WTF CSRF 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.

Sessions

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.

Directory Layout

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.

BFF Endpoints

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.

Session permissions

/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 for read, operate, and admin. The API remains the security boundary; the SPA only hides or explains actions, and broker 403 responses pass through the BFF unchanged.
  • legacyAccess: true when the API granted access via the legacy delegated scope toggle.
  • permissionsUnavailable: true when /me could 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.

Error contract

@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.

Client Routing

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.

Design System

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 :root and [data-theme="dark"], exposed to Tailwind through @theme inline so one utility works in both themes.
  • The theme is switched by data-theme on <html>, set before first paint by the inline script in web/index.html and toggled at runtime by lib/theme.ts, which persists to localStorage under lb-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.

Accessibility rules that must not be broken

Glassmorphism is easy to make unreadable. These are requirements, not preferences:

  1. 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.
  2. prefers-reduced-transparency and prefers-reduced-motion fall 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.
  3. Status is never conveyed by colour alone (WCAG 1.4.1). Every badge pairs colour with an icon and text. Badge.test.tsx enforces this.
  4. The skip link and the 3px :focus-visible ring stay.
  5. Toasts render in an aria-live="polite" region. Error toasts do not auto-dismiss.

Components

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.

Filters Live in the URL

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.

CSRF

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.

No CDN Policy

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 to web/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.

Running Locally

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.py

In a second terminal:

cd .\front_end\web
npm ci
npm run dev

Then 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 :5000

The container image uses the same app with Gunicorn:

gunicorn --bind 0.0.0.0:8000 app:app

Building the Container Image

Dockerfile 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.

Testing

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 test

tests/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.

How to Add a Page

  1. Add the JSON endpoint to the matching route_*.py module, under API_PREFIX.
  2. Decorate it with @login_required and @broker_endpoint("…"). Use json_body() and require() for POST bodies so a missing field is named rather than becoming an opaque 400.
  3. Add the response shape to web/src/types/broker.ts.
  4. Add a query or mutation hook to web/src/hooks/useBroker.ts. Mutations should invalidate every query their change affects.
  5. Create the page under web/src/pages/, and register it in the route table in web/src/App.tsx. Add a matching row to the route table in web/src/App.test.tsx so the page is mounted for real by the integration test.
  6. Gate actions with useCan() and mirror the Broker API role requirement (read, operate, or admin). Routes the user cannot use should render the reusable permission panel instead of relying on hidden controls.
  7. Use PageHeader for the title and actions, GlassCard for panels, and DataTable for tables.
  8. For a destructive action, use useConfirm so the operator sees the specific resource named.
  9. Report the outcome with useToast, using the error string from the BFF on failure.
  10. If the page belongs to a nav section, make sure its path is matched by NAV_ITEMS in web/src/components/layout/NavBar.tsx, or the active highlight will be wrong. A page of the Hosts or Scaling section also needs its tab in SECTIONS in web/src/components/layout/SectionTabs.tsx.

What the Rewrite Changed

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.py is gone. The profile page reads from /api/ui/session.
  • Bootstrap, app.css, app.js, and every Jinja template were deleted, along with the data-lb-* attribute contract between them. The equivalents are typed component props now.