diff --git a/.dockerignore b/.dockerignore index e35d1cb..8d39b40 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,17 +1,37 @@ .git .github .azure -.venv -__pycache__ -*.pyc -*.pyo -*.pyd -*.log -*.db .env local.settings.json -bin -obj artifacts example_code priv-folder +bin +obj +*.pyc +*.pyo +*.pyd +*.log +*.db + +# These patterns are matched against the path relative to the build context, and a +# bare name only matches at the root, so the `**/` prefix is what makes them apply +# to front_end/ and api/ as well. Without it a local virtualenv or node_modules is +# copied into the image. +.venv +**/.venv +__pycache__ +**/__pycache__ +.pytest_cache +**/.pytest_cache + +# Flask server-side session files hold live access tokens. They are runtime state +# and must never be baked into an image. +flask_session +**/flask_session + +# Front end build artifacts. node_modules is restored inside the Node build stage, +# and static/dist is produced there, so neither should come from the host. +node_modules +**/node_modules +front_end/static/dist diff --git a/.github/workflows/front-end-tests.yml b/.github/workflows/front-end-tests.yml index ffd648a..7b00975 100644 --- a/.github/workflows/front-end-tests.yml +++ b/.github/workflows/front-end-tests.yml @@ -13,7 +13,42 @@ on: - '.github/workflows/front-end-tests.yml' jobs: + front-end-web: + name: Portal bundle (React) + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node version + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: front_end/web/package-lock.json + + - name: Install dependencies + working-directory: front_end/web + run: npm ci + + - name: Type check + working-directory: front_end/web + run: npm run typecheck + + - name: Run vitest + working-directory: front_end/web + run: npm test + + # Catches a bundle that type checks but cannot actually be built, which is + # what the container image does during `az acr build`. + - name: Build the production bundle + working-directory: front_end/web + run: npm run build + front-end-test: + name: Portal BFF (Flask) runs-on: ubuntu-latest permissions: contents: read @@ -44,6 +79,7 @@ jobs: run: pytest api-test: + name: Broker API runs-on: ubuntu-latest permissions: contents: read diff --git a/.gitignore b/.gitignore index 357d1de..9dda34d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ priv-* .vscode flask_session/ .pytest_cache/ + +# Front end build artifacts +front_end/web/node_modules/ +front_end/static/dist/ diff --git a/README.md b/README.md index 99d6c14..0fc079c 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ The solution consists of the following components: - **Azure Function for Scaling Tasks**: An Azure Function that runs on a schedule to manage scaling of Linux hosts based on the scaling rules. It updates VM network statuses, turns VMs on or off, and performs health checks on the Linux hosts. -- **Service Management Portal**: A front-end web application that allows administrators to manage VMs, scaling rules, and monitor the system. It provides functionalities such as adding/deleting VMs, checking out VMs, releasing/returning VMs, modifying VM statuses, and viewing logs. +- **Service Management Portal**: A front-end web application that allows administrators to manage VMs, scaling rules, and monitor the system. It provides functionalities such as adding/deleting VMs, checking out VMs, releasing/returning VMs, modifying VM statuses, and viewing logs. It is a React 18 and TypeScript single-page app built with Vite and Tailwind CSS, served by a Flask backend-for-frontend that holds the Entra ID token server-side and calls the Broker API on the administrator's behalf. - **Azure Key Vault**: Stores sensitive information such as SSH keys and database passwords, accessed securely by the Broker API using managed identity. @@ -50,7 +50,7 @@ The architecture ensures secure, efficient, and scalable management of Linux hos - **Broker API**: RESTful API for brokering connections and managing VMs. - **Broker Database**: Azure SQL Database for storing VM and scaling data. - **Azure Function for Scaling Tasks**: Manages scaling of Linux hosts. -- **Service Management Portal**: Front-end application for administrators. +- **Service Management Portal**: React and TypeScript front-end application for administrators, served by a Flask backend-for-frontend. - **Azure Key Vault**: Secure storage for SSH keys and passwords. - **Managed Identities**: Used for secure authentication between components. - **Security Groups**: Controls access permissions for managed identities. @@ -259,7 +259,7 @@ For existing environments that need in-place rollout instead of new-environment The deployment targets Azure commercial by default. Set `azureCloudName` to `AzureUSGovernment` or `AzureCustom` to deploy elsewhere; commercial and Government resolve their endpoints automatically, while custom and sovereign clouds require their own authority, Graph, STS, and App Service FQDNs. Air-gapped environments should also set `scriptSourceRoot` to a reachable mirror of this repository, because the Linux hosts download their agent scripts from it during bootstrap. -The Service Management Portal serves all of its front-end assets (Bootstrap, stylesheet, scripts and icons) from its own container under `front_end/static/`. It makes no requests to a public CDN, so the portal renders correctly in Government, sovereign and air-gapped environments where outbound internet access is blocked. +The Service Management Portal serves all of its front-end assets from its own container under `front_end/static/dist/`. The bundle is compiled during the container build, uses the system font stack, and draws its icons as inline SVG, so it makes no requests to a public CDN and renders correctly in Government, sovereign and air-gapped environments where outbound internet access is blocked. Note that building the portal image does require access to the npm registry, so a disconnected build host needs an internal npm mirror. See [front_end/README.md](front_end/README.md). The deployment defaults the App Service plan to Premium v3 `P2mv3`, which provides the minimum supported baseline of 4 vCPUs and 32 GB memory for the frontend, API, and task apps. diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md index f86cf6f..59649e4 100644 --- a/deploy/DEPLOYMENT.md +++ b/deploy/DEPLOYMENT.md @@ -23,6 +23,7 @@ Two details matter here: - The supported path is `azd up` from the `deploy/` directory, not a separate manual mix of Bicep plus ad hoc scripts. - Container images are built remotely with `az acr build`, so local Docker is not required. +- The `frontend` image is multi-stage and compiles the React portal in a Node stage, so the build host needs to reach the npm registry. See [Front end build requirements](#front-end-build-requirements). For upgrade scenarios, keep one more distinction clear: @@ -363,6 +364,14 @@ That means `postprovision` does all of the following: - Adds AVD and Linux VM managed identities to the corresponding Entra groups. - Registers Linux hosts into `dbo.VirtualMachines` through `dbo.RegisterLinuxHostVm`. +### Front End Build Requirements + +The Service Management Portal is a React and TypeScript single-page app. [front_end/Dockerfile](../front_end/Dockerfile) is multi-stage: a `node:22-alpine` stage runs `npm ci` and `npm run build`, and only the compiled bundle is copied into the Python runtime image. + +That means the machine performing the build, which is the ACR build agent when using `az acr build`, needs to pull the `node:22-alpine` base image and resolve packages from the npm registry. Nothing is fetched at runtime: the compiled bundle, the fonts, and the icons all ship inside the image, so the portal still renders in Government, sovereign and air-gapped environments. + +If the build environment cannot reach `registry.npmjs.org`, point npm at an internal mirror before building, for example by adding an `.npmrc` with a `registry=` entry alongside [front_end/web/package.json](../front_end/web/package.json). `package-lock.json` is committed, so `npm ci` installs an exact, reviewable dependency set. + ## Migration For Existing Deployments Use the migration flow when you already have a deployed customer environment and want to roll forward the current application, SQL, and Linux-host release-agent changes without treating that as part of the normal `azd up` lifecycle. diff --git a/front_end/Dockerfile b/front_end/Dockerfile index b79d162..294c09d 100644 --- a/front_end/Dockerfile +++ b/front_end/Dockerfile @@ -1,3 +1,20 @@ +# ---------------------------------------------------------------- web build +# The React portal is compiled here so the runtime image needs no Node toolchain +# and no network access. Everything it serves is bundled locally, which is what +# keeps the portal working in Azure Government, sovereign and air-gapped clouds. +FROM node:22-alpine AS web + +WORKDIR /web + +# Copied first so the dependency layer is only rebuilt when the manifests change. +COPY front_end/web/package.json front_end/web/package-lock.json ./ +RUN npm ci + +COPY front_end/web/ ./ +# Vite writes to ../static/dist, which resolves to /static/dist from /web. +RUN npm run build + +# -------------------------------------------------------------------- runtime FROM python:3.13-slim ENV PYTHONDONTWRITEBYTECODE=1 @@ -15,6 +32,11 @@ RUN pip install --no-cache-dir -r requirements.txt COPY front_end/ ./ +# The sources are not needed at runtime; only the compiled bundle is. +RUN rm -rf ./web + +COPY --from=web /static/dist ./static/dist + EXPOSE 8000 CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"] diff --git a/front_end/README.md b/front_end/README.md index 16cce54..b2867bc 100644 --- a/front_end/README.md +++ b/front_end/README.md @@ -1,218 +1,230 @@ # Service Management Portal Front End -This folder contains the Flask and Jinja **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 by calling the Broker API. For the full solution context, see the repository [README](../README.md). +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](../README.md). -## Directory Layout - -The front end is a small Flask app with server-rendered Jinja templates and local static assets. - -| Path | Purpose | -| --- | --- | -| `app.py` | Creates the Flask app, enables global CSRF protection, registers route modules, and defines shared error handlers. | -| `config.py` | Reads cloud, Entra ID, and Broker API settings from environment variables. | -| `function_authentication.py` | Provides the `@login_required` decorator used by authenticated pages. | -| `function_api.py` | Centralises authenticated Broker API helpers, request timeouts, JSON decoding, dashboard VM summary retrieval, and paged history calls. | -| `route_authentication.py` | Implements sign in, token callback, and sign out. | -| `route_user.py` | Implements the profile page. | -| `route_vm_management.py` | Registers VM management routes with `register_route_vm_management(app)`. | -| `route_scaling_management.py` | Registers scaling and scaling-history routes with `register_route_scaling_management(app)`. | -| `templates\` | Shared layout, macro library, dashboard, error page, and feature templates. | -| `templates\vm\` | VM list, detail, form, checkout, and history templates. | -| `templates\scaling\` | Scaling rule, activity log, and rule history templates. | -| `static\css\app.css` | Portal design tokens and component classes layered on Bootstrap. | -| `static\js\app.js` | Progressive-enhancement behaviours for tables, confirmations, themes, forms, and filters. | -| `static\bootstrap\` | Vendored Bootstrap 5.3.8 CSS and JavaScript. | - -Routes are registered from `app.py` by calling `register_route_*(app)` functions. Add new VM pages to the VM route module and new scaling pages to the scaling route module unless the page is genuinely cross-cutting. - -Current Broker API data flow: - -- The dashboard calls `GET /api/vms/summary` for aggregate counters. If that endpoint returns `404` or `405`, the portal falls back to `GET /api/vms` and counts client-side so rolling deployments keep working. -- VM history, scaling activity, and scaling rule history request one server-side page at a time with `page` and `per_page`. The Flask session stores only filter criteria, not full result sets, so result size is bounded and two browser tabs do not overwrite each other's data. -- The portal omits unset filters instead of sending the legacy `"null"` sentinel. - -## Shared Macro Library - -Import the shared macro library in every content template: - -```jinja -{% extends "base.html" %} -{% import "_macros.html" as ui %} -``` - -The macros in `templates\_macros.html` keep status rendering, CSRF tokens, table controls, and destructive actions consistent. - -| Macro | Signature | Usage | -| --- | --- | --- | -| `icon` | `icon(name, size=16, cls='')` | `{{ ui.icon('server', 20, 'text-body-secondary') }}` | -| `vm_status_badge` | `vm_status_badge(value)` | `{{ ui.vm_status_badge(vm.VmStatus) }}` | -| `power_badge` | `power_badge(value)` | `{{ ui.power_badge(vm.PowerState) }}` | -| `network_badge` | `network_badge(value)` | `{{ ui.network_badge(vm.NetworkStatus) }}` | -| `action_badge` | `action_badge(value)` | `{{ ui.action_badge(entry['ActionTaken']) }}` | -| `value_or_dash` | `value_or_dash(value)` | `{{ ui.value_or_dash(vm.Username) }}` | -| `page_header` | `page_header(title, subtitle='', icon_name='')` | Use with `{% call %}` when the header has action buttons. | -| `csrf_field` | `csrf_field()` | `{{ ui.csrf_field() }}` inside every POST form. | -| `empty_state` | `empty_state(title, message='', icon_name='list')` | `{{ ui.empty_state('No rules found', 'Create a rule first.', 'sliders') }}` | -| `pagination` | `pagination(endpoint, page, total_pages, per_page, window=2)` | `{{ ui.pagination('vm_history', page, total_pages, per_page) }}` | -| `per_page_select` | `per_page_select(endpoint, per_page, options=[10, 25, 50, 100])` | `{{ ui.per_page_select('vm_history', per_page) }}` | -| `table_toolbar` | `table_toolbar(target, placeholder='Search…', total=0, noun='rows')` | `{{ ui.table_toolbar('vms-table', 'Search VMs…', vms|length, 'VMs') }}` | -| `th_sort` | `th_sort(label, type='text', cls='')` | `{{ ui.th_sort('Created', 'date') }}` | -| `confirm_action` | `confirm_action(action_url, label, resource, variant='danger', icon_name='trash', title='', body='', size='sm', block=false, outline=true)` | Creates a POST form with CSRF and the shared confirmation modal. | - -`page_header()` is a caller macro. Use `{% call %}` to pass header actions: - -```jinja -{% call ui.page_header('Scaling Rules', 'Manage automatic VM capacity thresholds.', 'sliders') %} - - {{ ui.icon('plus', 14) }}Add rule - -{% endcall %} -``` +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. -Use `confirm_action()` for destructive or state-changing table actions: +## Architecture -```jinja -{{ ui.confirm_action( - url_for('delete_vm', vmid=vm.VMID), - 'Delete', - vm.Hostname, - title='Delete ' ~ vm.Hostname, - body='Permanently delete ' ~ vm.Hostname ~ ' from the broker? This cannot be undone.') }} ``` - -Available `icon()` names are: - -```text -activity, alert-triangle, arrow-down, arrow-return, arrow-up, -box-arrow-right, check-circle, chevron-down, chevron-expand, -chevron-left, chevron-right, chevron-up, clock, dash-circle, eye, -funnel, gauge, home, info-circle, list, moon, pencil, person, plus, -power, refresh, search, server, shield, sliders, sun, trash, wifi, -wifi-off, wrench, x, x-circle +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 ``` -Icons are hand-authored inline SVG. Do not add an icon font dependency; the inline SVGs keep the portal self-contained for disconnected, sovereign, and air-gapped environments. +Why the BFF stays: -Status must not be conveyed by colour alone. The badge macros always pair colour with an icon and text; follow the same pattern for any new status. +- 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. -## `app.js` Data Hooks +## Directory Layout -`static\js\app.js` is progressive enhancement. The pages still render without JavaScript, but these hooks add client-side filtering, sorting, confirmation, form state, auto-refresh, and theme controls. Treat these names as API contracts between templates and JavaScript. +| 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. | +| `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. | +| `route_scaling_management.py` | Scaling rule and scaling history JSON endpoints. | +| `route_host_settings.py` | Linux host settings JSON 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/`: -| Hook | Where it is used | Behaviour | +| 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`. | +| `src/types/broker.ts` | Every shape the BFF returns. | +| `src/hooks/` | `useSession`, `useBroker` (all TanStack Query hooks), `useHistoryQuery`, `useAutoRefresh`, `useConfirm`. | +| `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 | | --- | --- | --- | -| `data-lb-filter-target` | Search input generated by `ui.table_toolbar()` | Value is the target table `id`; typing filters the table body rows by text content. | -| `data-lb-filter-noun` | Search input generated by `ui.table_toolbar()` | Optional noun for the counter text; defaults to `rows`. | -| `data-lb-count-total` | Counter element generated by `ui.table_toolbar()` | Total row count used to show `N rows` or `shown of total rows`. | -| `data-lb-sort` with `th.lb-sortable` | Header generated by `ui.th_sort()` | Makes the column clickable and keyboard-sortable. Supported types are `text`, `number`, and `date`. | -| `data-lb-value` | Table cells | Overrides a cell's sort value, useful when the visible cell contains badge markup. | -| `data-lb-no-filter` | Table rows | Excludes rows, such as "no results" rows, from filtering and sorting. | -| `.lb-confirm-form` | Form generated by `ui.confirm_action()` | Intercepts submit and opens the shared Bootstrap confirmation modal. | -| `data-lb-confirm-title` | Confirm form and modal title element | Modal title text for the pending action. | -| `data-lb-confirm-body` | Confirm form and modal body element | Modal body text; also used by the native `confirm()` fallback. | -| `data-lb-confirm-label` | Confirm form | Confirm button text. | -| `data-lb-confirm-variant` | Confirm form | Bootstrap button variant for the confirm button. | -| `data-lb-confirm-ok` | Shared confirm modal button | Button that submits the pending form after confirmation. | -| `data-lb-no-guard` | Form | Opts a form out of the submit spinner and the double-submit guard. | -| `data-lb-disables` | Checkbox | Comma-separated input IDs to mark as ignored while the checkbox is checked. Inputs that support it are set `readonly` rather than `disabled`, so their values are still submitted and can be replayed into the form after the redirect. | -| `data-lb-autorefresh` | Checkbox or switch | Enables periodic `window.location.reload()`; the value is the interval in seconds. | -| `data-lb-autorefresh-status` | Label near auto-refresh switch | Receives `Off` or countdown text such as `in 30s`. | -| `data-lb-theme-toggle` | Theme toggle button | Toggles the Bootstrap theme and persists the choice. | - -Example sortable/filterable table: - -```jinja -{{ ui.table_toolbar('vms-table', 'Search hostname, IP, status or user…', vms|length, 'VMs') }} -
| {{ vm.Hostname }} | -{{ ui.vm_status_badge(vm.VmStatus) }} | -