From 7ed7995f0917dca2ab58d9e425f07b77563d77bc Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 3 Sep 2026 14:08:24 -0400 Subject: [PATCH 1/2] Rewrite the Service Management Portal as a React SPA Replace the Flask/Jinja/Bootstrap portal with a React 18 + TypeScript single-page app built with Vite and Tailwind CSS v4, using a custom glassmorphism design system. Flask stays, but as a backend-for-frontend. The MSAL confidential client flow is unchanged: the access token lives in the Flask session and never reaches the browser, so no Entra app registration changes were needed. The route modules now return JSON under /api/ui/*, and Flask serves the built bundle for every non-API path so bookmarked deep links and hard refreshes still resolve. Flask-WTF CSRF protection is preserved; the client reads the token from /api/ui/session and returns it as an X-CSRFToken header. All 14 pages are ported. The 37 hand-authored inline SVG icons carry over, so the portal still has no icon-font dependency. Fonts are the system stack and every asset is bundled locally, which keeps the no-CDN requirement for Azure Government, sovereign and air-gapped clouds intact at runtime. Deleted: 20 Jinja templates, the vendored Bootstrap 5.3 distribution, static/css/app.css, static/js/app.js, and route_user.py, whose only job was rendering the profile page from session claims. Behaviour changes worth knowing: - History filters move from the Flask session into the URL. A filtered view is now bookmarkable and shareable, two browser tabs no longer clobber each other's criteria, and three near-identical POST-redirect-GET blocks are gone. The client and the BFF clamp page and per_page identically. - Flash messages become toasts in an aria-live region. - No-JavaScript support is lost. The old portal degraded to working HTML forms; a single-page app cannot. Fixes found while testing the rewrite: - NavLink overrode the explicit aria-current, so Scaling Management was not highlighted on /scaling/log. This is the same bug the scaling_endpoints list in base.html existed to prevent, so the nav now does its own section matching with a plain Link. - The confirm dialog's focus trap filtered candidates on offsetParent, which collapsed the list to a single element and stopped Tab wrapping. - .dockerignore patterns only matched at the build context root, so a nested node_modules or .venv would have been copied into the image. flask_session is now excluded too, because those files hold live access tokens. - /api/ui/session reported authenticated: true with an all-null user when session['user'] was not a mapping. Build and test: - 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 image. Nothing generated is committed; package-lock.json is, so npm ci is reproducible. Note that building the image now requires npm registry access, which is documented in deploy/DEPLOYMENT.md. - The pytest suite is rewritten against the JSON contract, and a Vitest and React Testing Library suite covers the client. Both run in CI. Everything the previous suite protected is still covered, on whichever side now owns it. App.test.tsx mounts the real app against a stubbed BFF and walks every authenticated route. pytest: 134 passed. Vitest: 103 passed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .dockerignore | 38 +- .github/workflows/front-end-tests.yml | 36 + .gitignore | 4 + README.md | 6 +- deploy/DEPLOYMENT.md | 9 + front_end/Dockerfile | 22 + front_end/README.md | 454 +- front_end/app.py | 196 +- front_end/function_api.py | 59 +- front_end/function_authentication.py | 27 +- front_end/function_bff.py | 128 + front_end/route_host_settings.py | 190 +- front_end/route_scaling_management.py | 319 +- front_end/route_user.py | 20 - front_end/route_vm_management.py | 311 +- .../static/bootstrap/css/bootstrap-grid.css | 4085 ------ .../bootstrap/css/bootstrap-grid.css.map | 1 - .../bootstrap/css/bootstrap-grid.min.css | 6 - .../bootstrap/css/bootstrap-grid.min.css.map | 1 - .../bootstrap/css/bootstrap-grid.rtl.css | 4084 ------ .../bootstrap/css/bootstrap-grid.rtl.css.map | 1 - .../bootstrap/css/bootstrap-grid.rtl.min.css | 6 - .../css/bootstrap-grid.rtl.min.css.map | 1 - .../static/bootstrap/css/bootstrap-reboot.css | 601 - .../bootstrap/css/bootstrap-reboot.css.map | 1 - .../bootstrap/css/bootstrap-reboot.min.css | 6 - .../css/bootstrap-reboot.min.css.map | 1 - .../bootstrap/css/bootstrap-reboot.rtl.css | 598 - .../css/bootstrap-reboot.rtl.css.map | 1 - .../css/bootstrap-reboot.rtl.min.css | 6 - .../css/bootstrap-reboot.rtl.min.css.map | 1 - .../bootstrap/css/bootstrap-utilities.css | 5406 ------- .../bootstrap/css/bootstrap-utilities.css.map | 1 - .../bootstrap/css/bootstrap-utilities.min.css | 6 - .../css/bootstrap-utilities.min.css.map | 1 - .../bootstrap/css/bootstrap-utilities.rtl.css | 5397 ------- .../css/bootstrap-utilities.rtl.css.map | 1 - .../css/bootstrap-utilities.rtl.min.css | 6 - .../css/bootstrap-utilities.rtl.min.css.map | 1 - front_end/static/bootstrap/css/bootstrap.css | 12048 ---------------- .../static/bootstrap/css/bootstrap.css.map | 1 - .../static/bootstrap/css/bootstrap.min.css | 6 - .../bootstrap/css/bootstrap.min.css.map | 1 - .../static/bootstrap/css/bootstrap.rtl.css | 12021 --------------- .../bootstrap/css/bootstrap.rtl.css.map | 1 - .../bootstrap/css/bootstrap.rtl.min.css | 6 - .../bootstrap/css/bootstrap.rtl.min.css.map | 1 - .../static/bootstrap/js/bootstrap.bundle.js | 6312 -------- .../bootstrap/js/bootstrap.bundle.js.map | 1 - .../bootstrap/js/bootstrap.bundle.min.js | 7 - .../bootstrap/js/bootstrap.bundle.min.js.map | 1 - .../static/bootstrap/js/bootstrap.esm.js | 4447 ------ .../static/bootstrap/js/bootstrap.esm.js.map | 1 - .../static/bootstrap/js/bootstrap.esm.min.js | 7 - .../bootstrap/js/bootstrap.esm.min.js.map | 1 - front_end/static/bootstrap/js/bootstrap.js | 4494 ------ .../static/bootstrap/js/bootstrap.js.map | 1 - .../static/bootstrap/js/bootstrap.min.js | 7 - .../static/bootstrap/js/bootstrap.min.js.map | 1 - front_end/static/css/app.css | 517 - front_end/static/js/app.js | 417 - front_end/templates/_macros.html | 263 - front_end/templates/base.html | 184 - front_end/templates/error.html | 35 - front_end/templates/index.html | 244 - front_end/templates/profile.html | 38 - front_end/templates/scaling/create_rule.html | 52 - .../scaling/scaling_activity_log.html | 94 - .../scaling/scaling_rules_history.html | 94 - front_end/templates/scaling/update_rule.html | 52 - .../templates/scaling/view_all_rules.html | 62 - .../templates/scaling/view_rule_details.html | 37 - .../templates/settings/host_settings.html | 223 - front_end/templates/vm/add_vm.html | 108 - front_end/templates/vm/checkout_vm.html | 67 - .../templates/vm/update_vm_attributes.html | 69 - front_end/templates/vm/view_all_vms.html | 126 - front_end/templates/vm/view_vm_details.html | 108 - front_end/templates/vm/vm_history.html | 137 - front_end/tests/conftest.py | 39 +- front_end/tests/test_bff_api.py | 526 + front_end/tests/test_function_api.py | 95 +- front_end/tests/test_host_settings.py | 243 +- front_end/tests/test_ui_regressions.py | 286 - front_end/web/index.html | 35 + front_end/web/package-lock.json | 4039 ++++++ front_end/web/package.json | 36 + front_end/web/src/App.test.tsx | 306 + front_end/web/src/App.tsx | 101 + front_end/web/src/components/Icon.tsx | 221 + .../src/components/data/DataTable.test.tsx | 127 + .../web/src/components/data/DataTable.tsx | 240 + .../components/data/HistoryFilters.test.tsx | 142 + .../src/components/data/HistoryFilters.tsx | 88 + .../web/src/components/data/HistoryView.tsx | 94 + .../src/components/data/Pagination.test.tsx | 61 + .../web/src/components/data/Pagination.tsx | 152 + .../src/components/layout/AppShell.test.tsx | 72 + .../web/src/components/layout/AppShell.tsx | 34 + .../web/src/components/layout/Breadcrumbs.tsx | 52 + .../web/src/components/layout/NavBar.tsx | 146 + .../components/layout/ThemeToggle.test.tsx | 39 + .../web/src/components/layout/ThemeToggle.tsx | 35 + .../web/src/components/ui/Badge.test.tsx | 56 + front_end/web/src/components/ui/Badge.tsx | 91 + front_end/web/src/components/ui/Button.tsx | 105 + .../src/components/ui/ConfirmDialog.test.tsx | 80 + .../web/src/components/ui/ConfirmDialog.tsx | 158 + front_end/web/src/components/ui/Feedback.tsx | 120 + front_end/web/src/components/ui/Field.tsx | 224 + front_end/web/src/components/ui/GlassCard.tsx | 55 + front_end/web/src/components/ui/StatCard.tsx | 60 + front_end/web/src/components/ui/Toast.tsx | 127 + front_end/web/src/hooks/useAutoRefresh.ts | 46 + front_end/web/src/hooks/useBroker.ts | 230 + front_end/web/src/hooks/useConfirm.tsx | 60 + front_end/web/src/hooks/useHistoryQuery.ts | 117 + front_end/web/src/hooks/useSession.tsx | 28 + front_end/web/src/lib/api.ts | 142 + front_end/web/src/lib/format.ts | 69 + front_end/web/src/lib/queryClient.ts | 41 + front_end/web/src/lib/theme.ts | 50 + front_end/web/src/lib/vmLifecycle.test.ts | 50 + front_end/web/src/lib/vmLifecycle.ts | 26 + front_end/web/src/main.tsx | 27 + front_end/web/src/pages/Dashboard.tsx | 349 + front_end/web/src/pages/ErrorPage.test.tsx | 38 + front_end/web/src/pages/ErrorPage.tsx | 37 + front_end/web/src/pages/NotFound.tsx | 11 + front_end/web/src/pages/Profile.tsx | 45 + front_end/web/src/pages/SignIn.tsx | 22 + .../web/src/pages/scaling/ActivityLog.tsx | 128 + .../web/src/pages/scaling/CreateRule.tsx | 50 + .../web/src/pages/scaling/RuleDetails.tsx | 76 + front_end/web/src/pages/scaling/RuleForm.tsx | 113 + .../web/src/pages/scaling/RuleHistory.tsx | 122 + front_end/web/src/pages/scaling/RuleList.tsx | 189 + .../web/src/pages/scaling/UpdateRule.tsx | 87 + .../web/src/pages/settings/HostSettings.tsx | 411 + front_end/web/src/pages/vm/AddVm.tsx | 165 + front_end/web/src/pages/vm/CheckoutVm.tsx | 97 + .../web/src/pages/vm/UpdateVmAttributes.tsx | 129 + front_end/web/src/pages/vm/VmDetails.tsx | 186 + front_end/web/src/pages/vm/VmHistory.tsx | 139 + front_end/web/src/pages/vm/VmList.tsx | 243 + front_end/web/src/styles/theme.css | 495 + front_end/web/src/test/render.tsx | 49 + front_end/web/src/test/setup.ts | 27 + front_end/web/src/types/broker.ts | 180 + front_end/web/src/vite-env.d.ts | 1 + front_end/web/tsconfig.app.json | 28 + front_end/web/tsconfig.json | 4 + front_end/web/tsconfig.node.json | 21 + front_end/web/vite.config.ts | 36 + 154 files changed, 13197 insertions(+), 63782 deletions(-) create mode 100644 front_end/function_bff.py delete mode 100644 front_end/route_user.py delete mode 100644 front_end/static/bootstrap/css/bootstrap-grid.css delete mode 100644 front_end/static/bootstrap/css/bootstrap-grid.css.map delete mode 100644 front_end/static/bootstrap/css/bootstrap-grid.min.css delete mode 100644 front_end/static/bootstrap/css/bootstrap-grid.min.css.map delete mode 100644 front_end/static/bootstrap/css/bootstrap-grid.rtl.css delete mode 100644 front_end/static/bootstrap/css/bootstrap-grid.rtl.css.map delete mode 100644 front_end/static/bootstrap/css/bootstrap-grid.rtl.min.css delete mode 100644 front_end/static/bootstrap/css/bootstrap-grid.rtl.min.css.map delete mode 100644 front_end/static/bootstrap/css/bootstrap-reboot.css delete mode 100644 front_end/static/bootstrap/css/bootstrap-reboot.css.map delete mode 100644 front_end/static/bootstrap/css/bootstrap-reboot.min.css delete mode 100644 front_end/static/bootstrap/css/bootstrap-reboot.min.css.map delete mode 100644 front_end/static/bootstrap/css/bootstrap-reboot.rtl.css delete mode 100644 front_end/static/bootstrap/css/bootstrap-reboot.rtl.css.map delete mode 100644 front_end/static/bootstrap/css/bootstrap-reboot.rtl.min.css delete mode 100644 front_end/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map delete mode 100644 front_end/static/bootstrap/css/bootstrap-utilities.css delete mode 100644 front_end/static/bootstrap/css/bootstrap-utilities.css.map delete mode 100644 front_end/static/bootstrap/css/bootstrap-utilities.min.css delete mode 100644 front_end/static/bootstrap/css/bootstrap-utilities.min.css.map delete mode 100644 front_end/static/bootstrap/css/bootstrap-utilities.rtl.css delete mode 100644 front_end/static/bootstrap/css/bootstrap-utilities.rtl.css.map delete mode 100644 front_end/static/bootstrap/css/bootstrap-utilities.rtl.min.css delete mode 100644 front_end/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map delete mode 100644 front_end/static/bootstrap/css/bootstrap.css delete mode 100644 front_end/static/bootstrap/css/bootstrap.css.map delete mode 100644 front_end/static/bootstrap/css/bootstrap.min.css delete mode 100644 front_end/static/bootstrap/css/bootstrap.min.css.map delete mode 100644 front_end/static/bootstrap/css/bootstrap.rtl.css delete mode 100644 front_end/static/bootstrap/css/bootstrap.rtl.css.map delete mode 100644 front_end/static/bootstrap/css/bootstrap.rtl.min.css delete mode 100644 front_end/static/bootstrap/css/bootstrap.rtl.min.css.map delete mode 100644 front_end/static/bootstrap/js/bootstrap.bundle.js delete mode 100644 front_end/static/bootstrap/js/bootstrap.bundle.js.map delete mode 100644 front_end/static/bootstrap/js/bootstrap.bundle.min.js delete mode 100644 front_end/static/bootstrap/js/bootstrap.bundle.min.js.map delete mode 100644 front_end/static/bootstrap/js/bootstrap.esm.js delete mode 100644 front_end/static/bootstrap/js/bootstrap.esm.js.map delete mode 100644 front_end/static/bootstrap/js/bootstrap.esm.min.js delete mode 100644 front_end/static/bootstrap/js/bootstrap.esm.min.js.map delete mode 100644 front_end/static/bootstrap/js/bootstrap.js delete mode 100644 front_end/static/bootstrap/js/bootstrap.js.map delete mode 100644 front_end/static/bootstrap/js/bootstrap.min.js delete mode 100644 front_end/static/bootstrap/js/bootstrap.min.js.map delete mode 100644 front_end/static/css/app.css delete mode 100644 front_end/static/js/app.js delete mode 100644 front_end/templates/_macros.html delete mode 100644 front_end/templates/base.html delete mode 100644 front_end/templates/error.html delete mode 100644 front_end/templates/index.html delete mode 100644 front_end/templates/profile.html delete mode 100644 front_end/templates/scaling/create_rule.html delete mode 100644 front_end/templates/scaling/scaling_activity_log.html delete mode 100644 front_end/templates/scaling/scaling_rules_history.html delete mode 100644 front_end/templates/scaling/update_rule.html delete mode 100644 front_end/templates/scaling/view_all_rules.html delete mode 100644 front_end/templates/scaling/view_rule_details.html delete mode 100644 front_end/templates/settings/host_settings.html delete mode 100644 front_end/templates/vm/add_vm.html delete mode 100644 front_end/templates/vm/checkout_vm.html delete mode 100644 front_end/templates/vm/update_vm_attributes.html delete mode 100644 front_end/templates/vm/view_all_vms.html delete mode 100644 front_end/templates/vm/view_vm_details.html delete mode 100644 front_end/templates/vm/vm_history.html create mode 100644 front_end/tests/test_bff_api.py delete mode 100644 front_end/tests/test_ui_regressions.py create mode 100644 front_end/web/index.html create mode 100644 front_end/web/package-lock.json create mode 100644 front_end/web/package.json create mode 100644 front_end/web/src/App.test.tsx create mode 100644 front_end/web/src/App.tsx create mode 100644 front_end/web/src/components/Icon.tsx create mode 100644 front_end/web/src/components/data/DataTable.test.tsx create mode 100644 front_end/web/src/components/data/DataTable.tsx create mode 100644 front_end/web/src/components/data/HistoryFilters.test.tsx create mode 100644 front_end/web/src/components/data/HistoryFilters.tsx create mode 100644 front_end/web/src/components/data/HistoryView.tsx create mode 100644 front_end/web/src/components/data/Pagination.test.tsx create mode 100644 front_end/web/src/components/data/Pagination.tsx create mode 100644 front_end/web/src/components/layout/AppShell.test.tsx create mode 100644 front_end/web/src/components/layout/AppShell.tsx create mode 100644 front_end/web/src/components/layout/Breadcrumbs.tsx create mode 100644 front_end/web/src/components/layout/NavBar.tsx create mode 100644 front_end/web/src/components/layout/ThemeToggle.test.tsx create mode 100644 front_end/web/src/components/layout/ThemeToggle.tsx create mode 100644 front_end/web/src/components/ui/Badge.test.tsx create mode 100644 front_end/web/src/components/ui/Badge.tsx create mode 100644 front_end/web/src/components/ui/Button.tsx create mode 100644 front_end/web/src/components/ui/ConfirmDialog.test.tsx create mode 100644 front_end/web/src/components/ui/ConfirmDialog.tsx create mode 100644 front_end/web/src/components/ui/Feedback.tsx create mode 100644 front_end/web/src/components/ui/Field.tsx create mode 100644 front_end/web/src/components/ui/GlassCard.tsx create mode 100644 front_end/web/src/components/ui/StatCard.tsx create mode 100644 front_end/web/src/components/ui/Toast.tsx create mode 100644 front_end/web/src/hooks/useAutoRefresh.ts create mode 100644 front_end/web/src/hooks/useBroker.ts create mode 100644 front_end/web/src/hooks/useConfirm.tsx create mode 100644 front_end/web/src/hooks/useHistoryQuery.ts create mode 100644 front_end/web/src/hooks/useSession.tsx create mode 100644 front_end/web/src/lib/api.ts create mode 100644 front_end/web/src/lib/format.ts create mode 100644 front_end/web/src/lib/queryClient.ts create mode 100644 front_end/web/src/lib/theme.ts create mode 100644 front_end/web/src/lib/vmLifecycle.test.ts create mode 100644 front_end/web/src/lib/vmLifecycle.ts create mode 100644 front_end/web/src/main.tsx create mode 100644 front_end/web/src/pages/Dashboard.tsx create mode 100644 front_end/web/src/pages/ErrorPage.test.tsx create mode 100644 front_end/web/src/pages/ErrorPage.tsx create mode 100644 front_end/web/src/pages/NotFound.tsx create mode 100644 front_end/web/src/pages/Profile.tsx create mode 100644 front_end/web/src/pages/SignIn.tsx create mode 100644 front_end/web/src/pages/scaling/ActivityLog.tsx create mode 100644 front_end/web/src/pages/scaling/CreateRule.tsx create mode 100644 front_end/web/src/pages/scaling/RuleDetails.tsx create mode 100644 front_end/web/src/pages/scaling/RuleForm.tsx create mode 100644 front_end/web/src/pages/scaling/RuleHistory.tsx create mode 100644 front_end/web/src/pages/scaling/RuleList.tsx create mode 100644 front_end/web/src/pages/scaling/UpdateRule.tsx create mode 100644 front_end/web/src/pages/settings/HostSettings.tsx create mode 100644 front_end/web/src/pages/vm/AddVm.tsx create mode 100644 front_end/web/src/pages/vm/CheckoutVm.tsx create mode 100644 front_end/web/src/pages/vm/UpdateVmAttributes.tsx create mode 100644 front_end/web/src/pages/vm/VmDetails.tsx create mode 100644 front_end/web/src/pages/vm/VmHistory.tsx create mode 100644 front_end/web/src/pages/vm/VmList.tsx create mode 100644 front_end/web/src/styles/theme.css create mode 100644 front_end/web/src/test/render.tsx create mode 100644 front_end/web/src/test/setup.ts create mode 100644 front_end/web/src/types/broker.ts create mode 100644 front_end/web/src/vite-env.d.ts create mode 100644 front_end/web/tsconfig.app.json create mode 100644 front_end/web/tsconfig.json create mode 100644 front_end/web/tsconfig.node.json create mode 100644 front_end/web/vite.config.ts 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..b918e0c 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') }} -
- - - - {{ ui.th_sort('Hostname') }} - {{ ui.th_sort('Status') }} - - - - {% for vm in vms %} - - - - - {% endfor %} - -
{{ vm.Hostname }}{{ ui.vm_status_badge(vm.VmStatus) }}
-
+| GET | `/api/ui/session` | Bootstrap: `authenticated`, `user`, `version`, `csrfToken`. Not behind `@login_required`, because the signed-out landing page needs a `200`. | +| GET | `/api/ui/dashboard` | `{stats, recentActivity, apiError}`. | +| GET | `/api/ui/vms` | | +| GET | `/api/ui/vms/` | | +| POST | `/api/ui/vms` | Returns `201`. | +| POST | `/api/ui/vms//update-attributes` | | +| POST | `/api/ui/vms//delete` | | +| POST | `/api/ui/vms//release` | Keyed by **hostname**, matching the broker. | +| POST | `/api/ui/vms//return` | Keyed by **VMID**, matching the broker. | +| POST | `/api/ui/vms/checkout` | | +| GET | `/api/ui/vms/history` | Paged. Filters in the query string. | +| GET | `/api/ui/scaling/rules` | | +| GET | `/api/ui/scaling/rules/` | | +| POST | `/api/ui/scaling/rules` | Returns `201`. | +| POST | `/api/ui/scaling/rules//update` | | +| POST | `/api/ui/scaling/rules//delete` | | +| GET | `/api/ui/scaling/log` | Paged. | +| GET | `/api/ui/scaling/rules/history` | Paged. | +| 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. | + +Server-rendered routes that are **not** JSON: `/login`, `/getAToken`, `/logout`, `/health`, +`/favicon.ico`. + +### Error contract + +`@broker_endpoint` in `function_bff.py` turns broker failures into a predictable envelope: + +```json +{ "error": "Unable to retrieve VM data. Please try again later." } ``` -## Theming - -Dark mode uses Bootstrap 5.3's native `data-bs-theme` attribute. `base.html` runs an inline script before first paint to set the stored or preferred theme and avoid a light/dark flash. The runtime toggle in `app.js` persists the user's choice in `localStorage` under `lb-theme`. +| 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 | -Express colours through Bootstrap CSS variables such as `--bs-body-bg`, `--bs-body-color`, `--bs-border-color`, and `--bs-secondary-color` so both themes work from one stylesheet. Portal-specific tokens live in `:root` in `static\css\app.css` and can be overridden under `[data-bs-theme="dark"]`. +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. -## No CDN Policy +## Client Routing -The portal must not make external asset requests. Bootstrap 5.3.8 is vendored under `static\bootstrap\` and loaded by `base.html`; the portal stylesheet, JavaScript, favicon, and icons are also served locally. This is a hard requirement because the solution supports Azure Government, sovereign, and air-gapped clouds where public CDNs are unreachable. +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`. -Do not add CDN `` or ` - - {% block head_extra %}{% endblock %} - - - -Skip to main content - -{% set vm_endpoints = ['view_all_vms', 'view_vm_details', 'add_vm', 'update_vm_attributes', - 'checkout_vm', 'vm_history'] %} -{% set scaling_endpoints = ['view_all_rules', 'view_rule_details', 'create_rule', 'update_rule', - 'scaling_activity_log', 'scaling_rules_history'] %} -{% set host_settings_endpoints = ['host_settings', 'apply_host_settings'] %} - - - -
-
- - {# Announced to assistive technology as results arrive. #} -
- {% with messages = get_flashed_messages(with_categories=True) %} - {% if messages %} - {% for category, message in messages %} - {%- set variant = {'message': 'info', 'error': 'danger'}.get(category, category) or 'info' -%} - {%- set flash_icon = {'success': 'check-circle', 'danger': 'alert-triangle', - 'warning': 'alert-triangle'}.get(variant, 'info-circle') -%} - - {% endfor %} - {% endif %} - {% endwith %} -
- - {% block content %}{% endblock %} -
-
- -
-
- Linux Broker Management Portal - v{{ config.VERSION }} -
-
- -{# Shared confirmation dialog; populated per action by app.js. #} - - - - -{% block scripts %}{% endblock %} - - diff --git a/front_end/templates/error.html b/front_end/templates/error.html deleted file mode 100644 index 0e09d46..0000000 --- a/front_end/templates/error.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "base.html" %} -{% import "_macros.html" as ui %} - -{% block title %}{{ title | default('Error') }} - Linux Broker Management Portal{% endblock %} - -{% block content %} -
- {{ ui.icon('alert-triangle', 48, 'text-body-secondary opacity-50') }} - - {% if code %} -

{{ code }}

- {% endif %} - -

{{ title | default('Something went wrong') }}

- -

- {{ message | default('An unexpected error occurred. Please try again.') }} -

- - -
-{% endblock %} diff --git a/front_end/templates/index.html b/front_end/templates/index.html deleted file mode 100644 index 9d7b15f..0000000 --- a/front_end/templates/index.html +++ /dev/null @@ -1,244 +0,0 @@ -{% extends "base.html" %} -{% import "_macros.html" as ui %} - -{% block title %}Dashboard - Linux Broker Management Portal{% endblock %} - -{% block content %} - -{% if not authenticated %} - - {# ---------------------------------------------------------- signed out #} -
- {{ ui.icon('server', 44, 'text-body-secondary opacity-50') }} -

Linux Broker Management Portal

-

- Broker Linux hosts to Azure Virtual Desktop sessions, and manage the pool's - autoscaling rules. Sign in with your organizational account to continue. -

- - {{ ui.icon('box-arrow-right', 18) }}Sign in - -
- -{% else %} - -
-
-

- {{ ui.icon('gauge', 22, 'text-body-secondary') }} Pool overview -

-

- Current state of the brokered Linux host pool. -

-
- -
- - {% if api_error or stats is none %} - - - - {% else %} - - {# ------------------------------------------------------- stat cards #} - - -
- - {# ---------------------------------------------------- composition #} -
-
-
- - {{ ui.icon('activity', 16, 'text-body-secondary') }} Pool composition - - {{ stats.total }} VMs -
-
- {% if stats.total %} - - -
- Checked out ({{ stats.checked_out }}) - Available ({{ stats.available }}) - Released ({{ stats.released }}) - Maintenance ({{ stats.maintenance }}) - {% if stats.other %} - Other ({{ stats.other }}) - {% endif %} -
- -
-
-
Powered on
-
{{ stats.powered_on }} / {{ stats.total }}
-
-
-
Unreachable
-
{{ stats.unreachable }}
-
-
-
Ready for checkout
-
{{ stats.ready }}
-
-
-
Utilization
-
{{ stats.utilization }}%
-
-
- {% else %} - {{ ui.empty_state('No virtual machines registered', - 'Add a VM to start brokering sessions.', 'server') }} - {% endif %} -
-
-
- - {# ------------------------------------------------ recent activity #} -
-
-
- - {{ ui.icon('clock', 16, 'text-body-secondary') }} Recent scaling activity - - View full log -
- - {% if recent_activity %} -
- - - - - - - - - - - {% for entry in recent_activity %} - - - - - - - {% endfor %} - -
WhenActionOutcomeTotal VMs
{{ ui.value_or_dash(entry.get('CheckTimestamp')) }}{{ ui.action_badge(entry.get('ActionTaken')) }}{{ ui.value_or_dash(entry.get('Outcome')) }}{{ ui.value_or_dash(entry.get('NewTotalVMs')) }}
-
- {% else %} - {{ ui.empty_state('No recent scaling activity', - 'Runs will appear here once the scaling task has executed.', 'clock') }} - {% endif %} -
-
-
- - {# ------------------------------------------------------ quick links #} -
-
-
-

- {{ ui.icon('server', 16, 'text-body-secondary') }} VM management -

-

- View, add, update and release the Linux hosts in the pool. -

- -
-
-
-
-

- {{ ui.icon('sliders', 16, 'text-body-secondary') }} Scaling management -

-

- Tune the autoscaling thresholds and review scaling decisions. -

- -
-
-
- - {% endif %} - -{% endif %} - -{% endblock %} diff --git a/front_end/templates/profile.html b/front_end/templates/profile.html deleted file mode 100644 index 7148dcf..0000000 --- a/front_end/templates/profile.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends "base.html" %} -{% import "_macros.html" as ui %} - -{% block title %}Profile - Linux Broker Management Portal{% endblock %} - -{% block content %} - -
-
-

- {{ ui.icon('person', 22, 'text-body-secondary') }} Your profile -

-

Details from your signed-in Microsoft Entra ID account.

-
- -
- -
-
-
Name
-
{{ ui.value_or_dash(user.get('name')) }}
- -
Email
-
{{ ui.value_or_dash(user.get('preferred_username')) }}
- -
Object ID
-
{{ ui.value_or_dash(user.get('oid')) }}
- -
Tenant ID
-
{{ ui.value_or_dash(user.get('tid')) }}
-
-
- -{% endblock %} diff --git a/front_end/templates/scaling/create_rule.html b/front_end/templates/scaling/create_rule.html deleted file mode 100644 index 12611da..0000000 --- a/front_end/templates/scaling/create_rule.html +++ /dev/null @@ -1,52 +0,0 @@ -{% extends "base.html" %} -{% import "_macros.html" as ui %} - -{% block title %}Create Scaling Rule{% endblock %} - -{% block content %} -{% call ui.page_header('Create Scaling Rule', 'Define when Linux Broker should power VMs on or off.', 'plus') %} - {{ ui.icon('chevron-left', 14) }}Back to rules -{% endcall %} - -
-
- {{ ui.csrf_field() }} -
-
- - -
Keep at least this many VMs powered on for baseline capacity.
-
-
- - -
Do not power on more than this many VMs for this pool.
-
-
- - -
Power on more VMs when checked-out VMs rise above this percentage.
-
-
- - -
Number of VMs to power on each time the scale-up threshold is met.
-
-
- - -
Power off VMs when checked-out VMs fall below this percentage.
-
-
- - -
Number of idle VMs to power off each time the scale-down threshold is met.
-
-
-
- - Cancel -
-
-
-{% endblock %} diff --git a/front_end/templates/scaling/scaling_activity_log.html b/front_end/templates/scaling/scaling_activity_log.html deleted file mode 100644 index 7b7cc84..0000000 --- a/front_end/templates/scaling/scaling_activity_log.html +++ /dev/null @@ -1,94 +0,0 @@ -{% extends "base.html" %} -{% import "_macros.html" as ui %} - -{% block title %}Scaling Activity Log{% endblock %} - -{% block content %} -{% call ui.page_header('Scaling Activity Log', 'Review capacity checks and actions taken by the scaler.', 'activity') %} - {{ ui.icon('chevron-left', 14) }}Back to rules -{% endcall %} - -
- {{ ui.csrf_field() }} -
-
- - -
Include activity on or after this date.
-
-
- - -
Include activity on or before this date.
-
-
- - -
Maximum records to retrieve.
-
-
-
-
- - -
-
- - -
- -
-
-
-
- -
- {% if log %} - {{ ui.table_toolbar('activity-log-table', 'Search activity log…', total=log|length, noun='records') }} -
{{ ui.icon('chevron-right', 14) }} Scroll horizontally to view notes and remaining columns.
-
- - - - {{ ui.th_sort('Activity ID', 'number') }} - {{ ui.th_sort('Check Timestamp', 'date') }} - {{ ui.th_sort('Current Running VMs', 'number') }} - {{ ui.th_sort('Current In Use VMs', 'number') }} - {{ ui.th_sort('Action Taken') }} - {{ ui.th_sort('VMs Powered On', 'number') }} - {{ ui.th_sort('VMs Powered Off', 'number') }} - {{ ui.th_sort('New Total VMs', 'number') }} - {{ ui.th_sort('Outcome') }} - {{ ui.th_sort('Notes') }} - - - - {% for entry in log %} - - - - - - - - - - - - - {% endfor %} - - - - -
{{ entry['ActivityID'] }}{{ entry['CheckTimestamp'] }}{{ entry['CurrentRunningVMs'] }}{{ entry['CurrentInUseVMs'] }}{{ ui.action_badge(entry['ActionTaken']) }}{{ ui.value_or_dash(entry['VMsPoweredOn']) }}{{ ui.value_or_dash(entry['VMsPoweredOff']) }}{{ ui.value_or_dash(entry['NewTotalVMs']) }}{{ ui.value_or_dash(entry['Outcome']) }}{{ ui.value_or_dash(entry['Notes']) }}
{{ ui.empty_state('No matching activity', 'Try a different search term.', 'search') }}
-
- - {% else %} - {{ ui.empty_state('No activity log records found', 'Adjust the filters and run a search to view scaler activity.', 'activity') }} - {% endif %} -
-{% endblock %} diff --git a/front_end/templates/scaling/scaling_rules_history.html b/front_end/templates/scaling/scaling_rules_history.html deleted file mode 100644 index 61b66a7..0000000 --- a/front_end/templates/scaling/scaling_rules_history.html +++ /dev/null @@ -1,94 +0,0 @@ -{% extends "base.html" %} -{% import "_macros.html" as ui %} - -{% block title %}Scaling Rules History{% endblock %} - -{% block content %} -{% call ui.page_header('Scaling Rules History', 'Audit changes and validity windows for scaling rule versions.', 'clock') %} - {{ ui.icon('chevron-left', 14) }}Back to rules -{% endcall %} - -
- {{ ui.csrf_field() }} -
-
- - -
Include rule versions active on or after this date.
-
-
- - -
Include rule versions active on or before this date.
-
-
- - -
Maximum records to retrieve.
-
-
-
-
- - -
-
- - -
- -
-
-
-
- -
- {% if history %} - {{ ui.table_toolbar('rules-history-table', 'Search rule history…', total=history|length, noun='records') }} -
{{ ui.icon('chevron-right', 14) }} Scroll horizontally to view the full rule history.
-
- - - - {{ ui.th_sort('Rule ID', 'number') }} - {{ ui.th_sort('Min VMs', 'number') }} - {{ ui.th_sort('Max VMs', 'number') }} - {{ ui.th_sort('Scale Up Ratio', 'number') }} - {{ ui.th_sort('Scale Up Increment', 'number') }} - {{ ui.th_sort('Scale Down Ratio', 'number') }} - {{ ui.th_sort('Scale Down Increment', 'number') }} - {{ ui.th_sort('Last Checked', 'date') }} - {{ ui.th_sort('Sys Start Time', 'date') }} - {{ ui.th_sort('Sys End Time', 'date') }} - - - - {% for entry in history %} - - - - - - - - - - - - - {% endfor %} - - - - -
{{ entry['RuleID'] }}{{ entry['MinVMs'] }}{{ entry['MaxVMs'] }}{{ entry['ScaleUpRatio'] }}{{ entry['ScaleUpIncrement'] }}{{ entry['ScaleDownRatio'] }}{{ entry['ScaleDownIncrement'] }}{{ ui.value_or_dash(entry['LastChecked']) }}{{ ui.value_or_dash(entry['SysStartTime']) }}{{ ui.value_or_dash(entry['SysEndTime']) }}
{{ ui.empty_state('No matching history', 'Try a different search term.', 'search') }}
-
- - {% else %} - {{ ui.empty_state('No history records found', 'Adjust the filters and run a search to view rule history.', 'clock') }} - {% endif %} -
-{% endblock %} diff --git a/front_end/templates/scaling/update_rule.html b/front_end/templates/scaling/update_rule.html deleted file mode 100644 index 6d57f99..0000000 --- a/front_end/templates/scaling/update_rule.html +++ /dev/null @@ -1,52 +0,0 @@ -{% extends "base.html" %} -{% import "_macros.html" as ui %} - -{% block title %}Update Scaling Rule{% endblock %} - -{% block content %} -{% call ui.page_header('Update Scaling Rule #' ~ rule.RuleID, 'Adjust capacity boundaries and scaling thresholds.', 'pencil') %} - {{ ui.icon('chevron-left', 14) }}Back to details -{% endcall %} - -
-
- {{ ui.csrf_field() }} -
-
- - -
Keep at least this many VMs powered on for baseline capacity.
-
-
- - -
Do not power on more than this many VMs for this pool.
-
-
- - -
Power on more VMs when checked-out VMs rise above this percentage.
-
-
- - -
Number of VMs to power on each time the scale-up threshold is met.
-
-
- - -
Power off VMs when checked-out VMs fall below this percentage.
-
-
- - -
Number of idle VMs to power off each time the scale-down threshold is met.
-
-
-
- - Cancel -
-
-
-{% endblock %} diff --git a/front_end/templates/scaling/view_all_rules.html b/front_end/templates/scaling/view_all_rules.html deleted file mode 100644 index a0497c1..0000000 --- a/front_end/templates/scaling/view_all_rules.html +++ /dev/null @@ -1,62 +0,0 @@ -{% extends "base.html" %} -{% import "_macros.html" as ui %} - -{% block title %}Scaling Rules{% endblock %} - -{% block content %} -{% call ui.page_header('Scaling Rules', 'Manage automatic VM capacity thresholds and review scaling history.', 'sliders') %} - {{ ui.icon('plus', 14) }}Add rule - {{ ui.icon('activity', 14) }}Activity log - {{ ui.icon('clock', 14) }}Rule history -{% endcall %} - -
- {% if rules|length == 0 %} - {{ ui.empty_state('No scaling rules found', 'Create a rule to define how Linux Broker powers VMs on and off.', 'sliders') }} - - {% else %} - {{ ui.table_toolbar('rules-table', 'Search scaling rules…', total=rules|length, noun='rules') }} -
- - - - {{ ui.th_sort('Rule ID', 'number') }} - {{ ui.th_sort('Min VMs', 'number') }} - {{ ui.th_sort('Max VMs', 'number') }} - {{ ui.th_sort('Scale Up Ratio (%)', 'number') }} - {{ ui.th_sort('Scale Up Increment', 'number') }} - {{ ui.th_sort('Scale Down Ratio (%)', 'number') }} - {{ ui.th_sort('Scale Down Increment', 'number') }} - - - - - {% for rule in rules %} - - - - - - - - - - - {% endfor %} - - - - -
Actions
{{ rule['RuleID'] }}{{ rule['MinVMs'] }}{{ rule['MaxVMs'] }}{{ rule['ScaleUpRatio'] }}{{ rule['ScaleUpIncrement'] }}{{ rule['ScaleDownRatio'] }}{{ rule['ScaleDownIncrement'] }} -
- {{ ui.icon('eye', 14) }}Details - {{ ui.icon('pencil', 14) }}Edit - {{ ui.confirm_action(url_for('delete_rule', ruleid=rule['RuleID']), 'Delete', 'rule #' ~ rule['RuleID'], title='Delete scaling rule?', body='Delete scaling rule #' ~ rule['RuleID'] ~ '? This cannot be undone.') }} -
-
{{ ui.empty_state('No matching rules', 'Try a different search term.', 'search') }}
-
- {% endif %} -
-{% endblock %} diff --git a/front_end/templates/scaling/view_rule_details.html b/front_end/templates/scaling/view_rule_details.html deleted file mode 100644 index a88a828..0000000 --- a/front_end/templates/scaling/view_rule_details.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "base.html" %} -{% import "_macros.html" as ui %} - -{% block title %}Scaling Rule Details{% endblock %} - -{% block content %} -{% call ui.page_header('Scaling Rule #' ~ rule.RuleID, 'Review thresholds and capacity changes for this rule.', 'sliders') %} - {{ ui.icon('chevron-left', 14) }}Back to rules - {{ ui.icon('pencil', 14) }}Update rule - {{ ui.confirm_action(url_for('delete_rule', ruleid=rule.RuleID), 'Delete', 'rule #' ~ rule.RuleID, title='Delete scaling rule?', body='Delete scaling rule #' ~ rule.RuleID ~ '? This cannot be undone.') }} -{% endcall %} - -
-
-
Rule ID
-
{{ rule.RuleID }}
- -
Minimum VMs
-
{{ rule.MinVMs }}
- -
Maximum VMs
-
{{ rule.MaxVMs }}
- -
Scale Up Ratio
-
{{ rule.ScaleUpRatio }}%
- -
Scale Up Increment
-
{{ rule.ScaleUpIncrement }}
- -
Scale Down Ratio
-
{{ rule.ScaleDownRatio }}%
- -
Scale Down Increment
-
{{ rule.ScaleDownIncrement }}
-
-
-{% endblock %} diff --git a/front_end/templates/settings/host_settings.html b/front_end/templates/settings/host_settings.html deleted file mode 100644 index 5ed98c1..0000000 --- a/front_end/templates/settings/host_settings.html +++ /dev/null @@ -1,223 +0,0 @@ -{% extends "base.html" %} -{% import "_macros.html" as ui %} - -{% block title %}Linux Host Settings{% endblock %} - -{% block content %} -{% call ui.page_header('Linux Host Settings', 'Session lifecycle, idle handling and screen lock policy for every Linux host.', 'wrench') %} - Settings version {{ settings.SettingsVersion }} -{% endcall %} - - - -
-
- {{ ui.csrf_field() }} - -

Session lifecycle

-
-
- - -
- How long a disconnected user can reconnect and resume before their account is removed - and the VM returns to the pool. Between 60 and 86400. Default 1200 (20 minutes). -
-
-
- - -
- How often each host re-checks session state. Lower values detect disconnects sooner - but poll the broker more often. Between 30 and 900. Default 60. -
-
-
- - -
- Minimum gap between logind-triggered reconciliations. Between 1 and 300. -
-
-
- - -
- Pause after a logind signal before reconciling. Between 0 and 60. -
-
-
- -
- -

Idle sessions

-
-
- - -
- Disconnect a connected user after this much inactivity. The session stays alive, so - the reconnect grace period above still applies and the user can resume. - Enter 0 to disable, otherwise at least 300. - {% if settings.IdleTimeoutSeconds == 0 %} - Idle enforcement is currently disabled. - {% endif %} -
-
-
- - -
- How long before the idle timeout the user is warned on screen. Must be less than the - idle timeout. Between 0 and 900, where 0 means no warning. -
-
-
- -
- -

Screen lock

- -
-
-
- - -
-
- Disables the Super+L shortcut and the Lock entry in the system menu, so a user cannot - lock the session manually. -
-
-
-
- - -
-
Off by default, for the reason above.
-
-
- - -
- Inactivity before the screen blanks. 0 means never blank. Between 0 and 86400. -
-
-
- - -
- Grace period between the screen blanking and locking. 0 locks immediately. Between 0 and 86400. -
-
-
-
- - -
-
Applies dconf locks so the values above cannot be overridden inside a session.
-
-
- -
- - Cancel -
-
-
- -
-

Host status

-
- {{ ui.csrf_field() }} - -
-
- -{% if hosts %} -
-
- - - - - - - - - - - - - {% for host in hosts %} - - - - - - - - - {% endfor %} - -
HostnamePowerNetworkApplied versionAppliedActions
{{ host.Hostname }}{{ ui.power_badge(host.PowerState) }}{{ ui.network_badge(host.NetworkStatus) }} - {% if host.SettingsVersion == settings.SettingsVersion %} - {{ host.SettingsVersion }} - {% elif host.SettingsVersion %} - {{ host.SettingsVersion }} (pending {{ settings.SettingsVersion }}) - {% else %} - not reported - {% endif %} - {{ ui.value_or_dash(host.SettingsAppliedDate) }} -
- {{ ui.csrf_field() }} - - -
-
-
-
-

- A host showing an older version has not reconciled yet. It converges on its own within one - reconcile interval of coming back online, so no action is required. -

-{% else %} -{{ ui.empty_state('No Linux hosts registered', 'Hosts appear here once they are registered with the broker.', 'server') }} -{% endif %} -{% endblock %} diff --git a/front_end/templates/vm/add_vm.html b/front_end/templates/vm/add_vm.html deleted file mode 100644 index 238af7d..0000000 --- a/front_end/templates/vm/add_vm.html +++ /dev/null @@ -1,108 +0,0 @@ -{% extends "base.html" %} -{% import "_macros.html" as ui %} - -{% block title %}Add VM - Linux Broker Management Portal{% endblock %} - -{% block content %} - - - -
-
-

- {{ ui.icon('plus', 22, 'text-body-secondary') }} Add virtual machine -

-

Register an existing Linux host with the broker.

-
-
- -
-
- {{ ui.csrf_field() }} - -
-
- - -
Enter the host's name.
-
- -
- - -
IPv4 address the broker uses to reach the host.
-
Enter a valid IPv4 address, for example 10.0.0.4.
-
- -
- - -
- -
- - -
- -
- - -
Only Available hosts are offered for checkout.
-
- -
- - -
Only set this if the host is already assigned to someone.
-
- -
- - -
Session host this VM is currently brokered to.
-
- -
- - -
-
- -
- - Cancel -
-
-
- -{% endblock %} diff --git a/front_end/templates/vm/checkout_vm.html b/front_end/templates/vm/checkout_vm.html deleted file mode 100644 index e60e3ad..0000000 --- a/front_end/templates/vm/checkout_vm.html +++ /dev/null @@ -1,67 +0,0 @@ -{% extends "base.html" %} -{% import "_macros.html" as ui %} - -{% block title %}Checkout VM - Linux Broker Management Portal{% endblock %} - -{% block content %} - - - -
-
-

- {{ ui.icon('person', 22, 'text-body-secondary') }} Checkout a virtual machine -

-

- The broker assigns the next available Linux host to the user below. -

-
-
- -
-
- {{ ui.csrf_field() }} - -
- - -
- Pre-filled with your signed-in account. Change it to broker a host on behalf of another user. -
-
Enter the username to assign the host to.
-
- -
- - -
The Azure Virtual Desktop session host initiating the connection.
-
Enter the AVD session host.
-
- - - -
- - Cancel -
-
-
- -{% endblock %} diff --git a/front_end/templates/vm/update_vm_attributes.html b/front_end/templates/vm/update_vm_attributes.html deleted file mode 100644 index e798eea..0000000 --- a/front_end/templates/vm/update_vm_attributes.html +++ /dev/null @@ -1,69 +0,0 @@ -{% extends "base.html" %} -{% import "_macros.html" as ui %} - -{% block title %}Update {{ vm.Hostname }} - Linux Broker Management Portal{% endblock %} - -{% block content %} - - - -
-
-

- {{ ui.icon('pencil', 22, 'text-body-secondary') }} Update attributes -

-

{{ vm.Hostname }} · VMID {{ vm.VMID }}

-
-
- -
-
- {{ ui.csrf_field() }} - -
- - -
Reflects whether the underlying Azure VM is running.
-
- -
- - -
Unreachable hosts are skipped when brokering a session.
-
- -
- - -
- Set Maintenance to take the host out of rotation without deleting it. -
-
- -
- - Cancel -
-
-
- -{% endblock %} diff --git a/front_end/templates/vm/view_all_vms.html b/front_end/templates/vm/view_all_vms.html deleted file mode 100644 index cc9bd09..0000000 --- a/front_end/templates/vm/view_all_vms.html +++ /dev/null @@ -1,126 +0,0 @@ -{% extends "base.html" %} -{% import "_macros.html" as ui %} - -{% block title %}Virtual Machines - Linux Broker Management Portal{% endblock %} - -{% block content %} - -
-
-

- {{ ui.icon('server', 22, 'text-body-secondary') }} Virtual machines -

-

Linux hosts registered with the broker.

-
- -
- -{% if vms|length == 0 %} - -
- {{ ui.empty_state('No virtual machines found', - 'Register a Linux host to start brokering AVD sessions.', 'server') }} - -
- -{% else %} - -
- {{ ui.table_toolbar('vms-table', 'Search hostname, IP, status or user…', vms|length, 'VMs') }} - -
- - - - {{ ui.th_sort('VMID', 'number') }} - {{ ui.th_sort('Hostname') }} - {{ ui.th_sort('IP address') }} - {{ ui.th_sort('Power') }} - {{ ui.th_sort('Network') }} - {{ ui.th_sort('Status') }} - {{ ui.th_sort('Assigned to') }} - - - - - {% for vm in vms %} - - - - - - - - - - - {% endfor %} - -
Actions
{{ vm.VMID }} - {{ vm.Hostname }} - {{ ui.value_or_dash(vm.IPAddress) }}{{ ui.power_badge(vm.PowerState) }}{{ ui.network_badge(vm.NetworkStatus) }}{{ ui.vm_status_badge(vm.VmStatus) }}{{ ui.value_or_dash(vm.Username) }} -
- - {{ ui.icon('eye', 14) }}Details - - - {{ ui.icon('pencil', 14) }}Edit - - - {# - Only lifecycle-valid actions are offered. ReleaseVm moves a - CheckedOut host to Released; ReturnVm moves CheckedOut or - Released back to Available. Offering them on an already - Available host was misleading. - #} - {% if vm.VmStatus == 'CheckedOut' %} - {{ ui.confirm_action( - url_for('release_vm', hostname=vm.Hostname), - 'Release', vm.Hostname, 'warning', 'box-arrow-right', - title='Release ' ~ vm.Hostname, - body='Release ' ~ vm.Hostname ~ '? The session owner will be signed out and the host marked as released.') }} - {% endif %} - - {% if vm.VmStatus in ['CheckedOut', 'Released'] %} - {{ ui.confirm_action( - url_for('return_vm', vmid=vm.VMID), - 'Return', vm.Hostname, 'primary', 'arrow-return', - title='Return ' ~ vm.Hostname, - body='Return ' ~ vm.Hostname ~ ' to the pool? It will become available for checkout again.') }} - {% endif %} - - {{ ui.confirm_action( - url_for('delete_vm', vmid=vm.VMID), - 'Delete', vm.Hostname, 'danger', 'trash', - title='Delete ' ~ vm.Hostname, - body='Permanently delete ' ~ vm.Hostname ~ ' (VMID ' ~ vm.VMID ~ ') from the broker? This cannot be undone.') }} -
-
-
- -
- {{ ui.icon('search', 32, 'text-body-secondary opacity-50') }} -

No matching virtual machines

-

Try a different search term.

-
-
- -{% endif %} - -{% endblock %} diff --git a/front_end/templates/vm/view_vm_details.html b/front_end/templates/vm/view_vm_details.html deleted file mode 100644 index ba2da82..0000000 --- a/front_end/templates/vm/view_vm_details.html +++ /dev/null @@ -1,108 +0,0 @@ -{% extends "base.html" %} -{% import "_macros.html" as ui %} - -{% block title %}{{ vm.Hostname }} - Linux Broker Management Portal{% endblock %} - -{% block content %} - - - -
-
-

- {{ ui.icon('server', 22, 'text-body-secondary') }} {{ vm.Hostname }} -

-

VMID {{ vm.VMID }}

-
- -
- -
-
-
-

Details

-
-
Power state
-
{{ ui.power_badge(vm.PowerState) }}
- -
Network status
-
{{ ui.network_badge(vm.NetworkStatus) }}
- -
VM status
-
{{ ui.vm_status_badge(vm.VmStatus) }}
- -
IP address
-
{{ ui.value_or_dash(vm.IPAddress) }}
- -
Assigned to
-
{{ ui.value_or_dash(vm.Username) }}
- -
AVD host
-
{{ ui.value_or_dash(vm.AvdHost) }}
- -
Last updated
-
{{ ui.value_or_dash(vm.LastUpdateDate) }}
- -
Description
-
{{ ui.value_or_dash(vm.Description) }}
-
-
-
- -
-
-

Actions

- -
- {% if vm.VmStatus == 'CheckedOut' %} - {{ ui.confirm_action( - url_for('release_vm', hostname=vm.Hostname), - 'Release', vm.Hostname, 'warning', 'box-arrow-right', - title='Release ' ~ vm.Hostname, - body='Release ' ~ vm.Hostname ~ '? The session owner will be signed out and the host marked as released.', - size='md', block=true) }} - {% endif %} - - {% if vm.VmStatus in ['CheckedOut', 'Released'] %} - {{ ui.confirm_action( - url_for('return_vm', vmid=vm.VMID), - 'Return', vm.Hostname, 'primary', 'arrow-return', - title='Return ' ~ vm.Hostname, - body='Return ' ~ vm.Hostname ~ ' to the pool? It will become available for checkout again.', - size='md', block=true) }} - {% endif %} - - {% if vm.VmStatus == 'Available' %} -

- {{ ui.icon('info-circle', 14) }} - This host is available. Release and return apply only to hosts that are - currently checked out or released. -

- {% endif %} - -
- - {{ ui.confirm_action( - url_for('delete_vm', vmid=vm.VMID), - 'Delete', vm.Hostname, 'danger', 'trash', - title='Delete ' ~ vm.Hostname, - body='Permanently delete ' ~ vm.Hostname ~ ' (VMID ' ~ vm.VMID ~ ') from the broker? This cannot be undone.', - size='md', block=true, outline=false) }} -
-
-
-
- -{% endblock %} diff --git a/front_end/templates/vm/vm_history.html b/front_end/templates/vm/vm_history.html deleted file mode 100644 index 3e55adc..0000000 --- a/front_end/templates/vm/vm_history.html +++ /dev/null @@ -1,137 +0,0 @@ -{% extends "base.html" %} -{% import "_macros.html" as ui %} - -{% block title %}VM History - Linux Broker Management Portal{% endblock %} - -{% block content %} - - - -
-
-

- {{ ui.icon('clock', 22, 'text-body-secondary') }} Virtual machine history -

-

Point-in-time record of every VM state change.

-
- -
- -{# Filter values are read from `filters`, which the route restores from the - session. Reading request.form here would always be empty because the POST - handler redirects. #} -{% set f = filters | default({}) %} - -
- {{ ui.csrf_field() }} -
-
- - -
- -
- - -
- -
- - -
- -
-
- - -
-
- - -
-
- -
- -
-
-
- -
- {{ ui.table_toolbar('history-table', 'Search this page…', vm_history|length, 'records') }} - - {% if vm_history %} -
- - - - {{ ui.th_sort('VMID', 'number') }} - {{ ui.th_sort('Hostname') }} - {{ ui.th_sort('IP address') }} - {{ ui.th_sort('Power') }} - {{ ui.th_sort('Network') }} - {{ ui.th_sort('Status') }} - - {{ ui.th_sort('Created', 'date') }} - {{ ui.th_sort('Last updated', 'date') }} - {{ ui.th_sort('Valid from', 'date') }} - {{ ui.th_sort('Valid to', 'date') }} - - - - {% for vm in vm_history %} - - - - - - - - - - - - - - {% endfor %} - -
Description
{{ vm.VMID }}{{ ui.value_or_dash(vm.Hostname) }}{{ ui.value_or_dash(vm.IPAddress) }}{{ ui.power_badge(vm.PowerState) }}{{ ui.network_badge(vm.NetworkStatus) }}{{ ui.vm_status_badge(vm.VmStatus) }} - {{ ui.value_or_dash(vm.Description) }} - {{ ui.value_or_dash(vm.CreateDate) }}{{ ui.value_or_dash(vm.LastUpdateDate) }}{{ ui.value_or_dash(vm.SysStartTime) }}{{ ui.value_or_dash(vm.SysEndTime) }}
-
- -
- {{ ui.icon('search', 32, 'text-body-secondary opacity-50') }} -

No matching records on this page

-

Try a different search term or page.

-
- - - {% else %} - {{ ui.empty_state('No history records', - 'Adjust the filter above and apply it to search the VM history.', 'clock') }} - {% endif %} -
- -{% endblock %} diff --git a/front_end/tests/conftest.py b/front_end/tests/conftest.py index 40030d0..16d036c 100644 --- a/front_end/tests/conftest.py +++ b/front_end/tests/conftest.py @@ -57,6 +57,13 @@ "ScreenIdleDelaySeconds": 0, "ScreenLockDelaySeconds": 0, "ScreenLockSettingsLocked": True, "SettingsVersion": 3} +# Every JSON endpoint the React portal calls. +API = "/api/ui" + +# The three history endpoints behave identically apart from the broker path they +# read from, so they are parametrised together throughout the suite. +HISTORY_PATHS = [f"{API}/vms/history", f"{API}/scaling/log", f"{API}/scaling/rules/history"] + class FakeResponse: def __init__(self, payload, status_code=200): @@ -99,7 +106,7 @@ def __init__(self): "Released": 1, "PoweredOn": 3, "PoweredOff": 1, "Unreachable": 1, "Ready": 1, } - # Set to 404/405 to simulate an API that predates /vms/summary. + # Set to 404/405/500 to simulate an API that predates /vms/summary. self.summary_status = None # Set True to simulate an API that predates pagination and answers with a @@ -212,22 +219,24 @@ def sign_in(client): sess["token_expiry"] = expiry -def csrf_token(html): - match = re.search(r'name="csrf_token"[^>]*value="([^"]+)"', html) - assert match, "expected CSRF token in rendered form" - return match.group(1) - +def csrf_token(client): + """Fetch a CSRF token the way the React client does. -def assert_form_value(html, name, value): - assert re.search(rf']*name="{re.escape(name)}"[^>]*value="{re.escape(value)}"', html) + The portal reads it from the session bootstrap and returns it on every + state-changing request as an X-CSRFToken header. + """ + response = client.get(f"{API}/session") + assert response.status_code == 200 + token = response.get_json()["csrfToken"] + assert token + return token -def assert_checkbox_checked(html, name): - assert re.search(rf']*name="{re.escape(name)}"[^>]*checked', html) +def post(client, path, json=None): + """POST with a valid CSRF header, as the portal does.""" + return client.post(path, json=json if json is not None else {}, + headers={"X-CSRFToken": csrf_token(client)}) -def row_for_host(html, hostname): - rows = re.findall(r".*?", html, flags=re.S) - row = next((candidate for candidate in rows if hostname in candidate), "") - assert row, f"expected row for {hostname}" - return row +def vm_by_hostname(hostname): + return next(vm for vm in VMS if vm["Hostname"] == hostname) diff --git a/front_end/tests/test_bff_api.py b/front_end/tests/test_bff_api.py new file mode 100644 index 0000000..3ab8fda --- /dev/null +++ b/front_end/tests/test_bff_api.py @@ -0,0 +1,526 @@ +"""Contract tests for the JSON endpoints the React portal calls. + +These replace the old assertions against rendered Jinja HTML. Everything the +previous suite protected on the server side is still covered here; the parts that +moved into the client are covered by the Vitest suite in front_end/web. +""" + +import pytest + +from conftest import API, HISTORY_PATHS, VMS, csrf_token, post + + +# ============================================================== SPA shell + + +def test_unknown_page_path_serves_the_spa_shell(signed_in_client): + """A deep link or a hard refresh has to reach React, not a 404 from Flask.""" + for path in ["/", "/vms", "/vms/1/update", "/scaling/rules/history", "/settings/hosts"]: + response = signed_in_client.get(path) + assert response.status_code == 200, path + assert response.headers["Content-Type"].startswith("text/html"), path + + +def test_the_shell_is_never_cached(signed_in_client): + """The shell names hashed asset files, so caching it would leave browsers + asking for assets a deploy has already replaced.""" + assert signed_in_client.get("/").headers["Cache-Control"] == "no-store" + + +def test_unknown_api_path_returns_json_not_the_shell(signed_in_client): + """The catch-all must not swallow API paths; the client expects JSON there.""" + response = signed_in_client.get(f"{API}/does-not-exist") + assert response.status_code == 404 + assert response.get_json()["error"] + + +def test_the_shell_references_no_external_assets(signed_in_client): + """The portal must render in sovereign and air-gapped clouds, so nothing may + be fetched from a public CDN.""" + html = signed_in_client.get("/").get_data(as_text=True) + for pattern in ("cdn.jsdelivr.net", "unpkg.com", "cdnjs.", "fonts.googleapis.com"): + assert pattern not in html + + +def test_health_reports_the_version(client): + payload = client.get("/health").get_json() + assert payload["status"] == "healthy" + assert payload["version"] + + +# ========================================================= authentication + + +ANONYMOUS_API_PATHS = [ + f"{API}/dashboard", f"{API}/vms", f"{API}/vms/1", f"{API}/vms/history", + f"{API}/scaling/rules", f"{API}/scaling/rules/1", f"{API}/scaling/log", + f"{API}/scaling/rules/history", f"{API}/hosts/settings", +] + + +@pytest.mark.parametrize("path", ANONYMOUS_API_PATHS) +def test_api_returns_401_json_when_signed_out(client, path): + """`fetch` cannot follow a 302 to Entra ID, so an expired session has to come + back as a 401 the client can act on.""" + response = client.get(path) + assert response.status_code == 401 + assert response.headers["Content-Type"].startswith("application/json") + assert response.get_json()["error"] + + +def test_page_requests_still_redirect_to_login_when_signed_out(client): + """Only the API speaks JSON; a browser navigation keeps the redirect.""" + response = client.get("/logout") + assert response.status_code in {302, 303} + + +def test_session_endpoint_reports_anonymous_without_bouncing(client): + """The signed-out landing page needs a successful response, not a 401.""" + payload = client.get(f"{API}/session").get_json() + assert payload["authenticated"] is False + assert payload["user"] is None + assert payload["csrfToken"] + + +def test_session_endpoint_reports_the_signed_in_user(signed_in_client): + payload = signed_in_client.get(f"{API}/session").get_json() + assert payload["authenticated"] is True + assert payload["user"]["name"] == "Test Operator" + assert payload["user"]["username"] == "op@contoso.com" + assert payload["version"] + + +def test_session_endpoint_survives_a_malformed_session(signed_in_client): + """base.html used to guard against this; a non-mapping user must not turn a + handled state into a 500.""" + with signed_in_client.session_transaction() as session: + session["user"] = "not-a-mapping" + + response = signed_in_client.get(f"{API}/session") + assert response.status_code == 200 + assert response.get_json()["user"] is None + + +# ==================================================================== CSRF + + +def test_csrf_rejects_a_missing_token(signed_in_client): + response = signed_in_client.post(f"{API}/vms/1/delete", json={}) + assert response.status_code == 400 + assert "session expired" in response.get_json()["error"].lower() + + +def test_csrf_accepts_the_header_token(signed_in_client): + response = post(signed_in_client, f"{API}/vms/1/delete") + assert response.status_code == 200 + + +def test_scaling_rule_delete_is_csrf_protected(signed_in_client): + response = signed_in_client.post(f"{API}/scaling/rules/1/delete", json={}) + assert response.status_code == 400 + + +def test_host_settings_apply_is_csrf_protected(signed_in_client): + response = signed_in_client.post(f"{API}/hosts/settings/apply", json={}) + assert response.status_code == 400 + + +# =============================================================== dashboard + + +def test_dashboard_uses_the_summary_endpoint(signed_in_client, broker_api): + """The dashboard must not pull the whole VM list just to count it.""" + broker_api.vm_summary = { + "TotalVMs": 9, "Available": 4, "CheckedOut": 3, "Maintenance": 1, + "Released": 1, "PoweredOn": 7, "PoweredOff": 2, "Unreachable": 2, + "Ready": 3, + } + payload = signed_in_client.get(f"{API}/dashboard").get_json() + + assert payload["apiError"] is False + assert payload["stats"]["total"] == 9 + assert payload["stats"]["checked_out"] == 3 + # 3 of 9 checked out. + assert payload["stats"]["utilization"] == 33 + + +def test_dashboard_falls_back_when_api_predates_the_summary_endpoint(signed_in_client, broker_api): + """During a rolling deploy the portal can be newer than the API. + + An older API does not 404 on /api/vms/summary -- Werkzeug matches it against the + older /api/vms/ rule, which fails converting 'summary' to an int and + returns 500. The fallback must handle that, not just a clean 404. + """ + broker_api.summary_status = 500 + payload = signed_in_client.get(f"{API}/dashboard").get_json() + + assert payload["apiError"] is False + # Counted client-side from the four seeded VMs. + assert payload["stats"]["total"] == 4 + assert payload["stats"]["ready"] == 1 + + +def test_dashboard_reports_an_outage_when_both_paths_fail(signed_in_client, broker_api): + """The fallback must not mask a genuine broker outage.""" + broker_api.summary_status = 500 + broker_api.raise_get_paths.add("/vms") + + payload = signed_in_client.get(f"{API}/dashboard").get_json() + assert payload["apiError"] is True + assert payload["stats"] is None + + +def test_dashboard_handles_a_non_list_activity_log(signed_in_client, broker_api): + broker_api.scaling_log_payload = {"message": "no results"} + payload = signed_in_client.get(f"{API}/dashboard").get_json() + + assert payload["stats"] is not None + assert payload["recentActivity"] == [] + + +def test_dashboard_survives_a_failing_activity_log(signed_in_client, broker_api): + """A secondary panel must never take the dashboard down.""" + broker_api.raise_post_paths.add("/scaling/log") + payload = signed_in_client.get(f"{API}/dashboard").get_json() + + assert payload["stats"] is not None + assert payload["recentActivity"] == [] + + +def test_dashboard_caps_recent_activity(signed_in_client): + payload = signed_in_client.get(f"{API}/dashboard").get_json() + assert len(payload["recentActivity"]) <= 5 + + +# ================================================================= history + + +@pytest.mark.parametrize("path", HISTORY_PATHS) +def test_filters_are_sent_to_the_api_in_the_expected_format(signed_in_client, broker_api, path): + """The operator enters YYYY-MM-DD; the stored procedures expect MM/DD/YYYY.""" + response = signed_in_client.get( + f"{path}?startdate=2026-01-15&enddate=2026-02-20&limit=37" + ) + assert response.status_code == 200 + + sent = broker_api.posts[-1]["json"] + assert sent["startdate"] == "01/15/2026" + assert sent["enddate"] == "02/20/2026" + assert sent["limit"] == 37 # a real int, not the string "37" + + +@pytest.mark.parametrize("path", HISTORY_PATHS) +def test_ignore_flags_omit_the_filters(signed_in_client, broker_api, path): + """The ignore flags mean "omit the filter" rather than sending the + stringly-typed "null" sentinel the API had to special-case.""" + response = signed_in_client.get( + f"{path}?startdate=2026-03-01&enddate=2026-03-31&limit=42" + "&ignore_dates=1&ignore_limit=1" + ) + assert response.status_code == 200 + + sent = broker_api.posts[-1]["json"] + assert "startdate" not in sent + assert "enddate" not in sent + assert "limit" not in sent + + +@pytest.mark.parametrize("path", HISTORY_PATHS) +def test_ignore_flags_without_values_do_not_break(signed_in_client, broker_api, path): + response = signed_in_client.get(f"{path}?ignore_dates=1&ignore_limit=1") + assert response.status_code == 200 + + sent = broker_api.posts[-1]["json"] + assert sent == {} + + +@pytest.mark.parametrize("path", HISTORY_PATHS) +def test_an_unparseable_date_is_rejected_with_a_useful_message(signed_in_client, path): + """Reported against the request that carried it, rather than surfacing later + as an opaque query failure.""" + response = signed_in_client.get(f"{path}?startdate=15-01-2026") + assert response.status_code == 400 + assert "YYYY-MM-DD" in response.get_json()["error"] + + +@pytest.mark.parametrize("path", HISTORY_PATHS) +def test_an_unparseable_date_is_accepted_when_dates_are_ignored(signed_in_client, path): + response = signed_in_client.get(f"{path}?startdate=15-01-2026&ignore_dates=1") + assert response.status_code == 200 + + +@pytest.mark.parametrize("path", HISTORY_PATHS) +def test_history_uses_server_side_pagination(signed_in_client, broker_api, path): + """Pages come from the API rather than a whole result set cached in the session.""" + response = signed_in_client.get(f"{path}?page=3&per_page=10") + assert response.status_code == 200 + + params = broker_api.posts[-1]["params"] + assert params["page"] == 3 + assert params["per_page"] == 10 + + payload = response.get_json() + assert payload["page"] == 3 + assert payload["perPage"] == 10 + assert payload["total"] == 120 + assert payload["totalPages"] == 12 + assert len(payload["items"]) == 10 + + +@pytest.mark.parametrize("path", HISTORY_PATHS) +def test_filters_are_not_stored_in_the_session(signed_in_client, path): + """Filters live in the URL now, so two tabs cannot clobber each other and the + session cannot grow without bound.""" + signed_in_client.get(f"{path}?startdate=2026-01-15&limit=37") + + with signed_in_client.session_transaction() as session: + for key in session.keys(): + assert "history" not in key + assert "filters" not in key + assert "scaling_activity_log" not in key + + +@pytest.mark.parametrize("path", HISTORY_PATHS) +def test_history_falls_back_when_api_predates_pagination(signed_in_client, broker_api, path): + """During a rolling deploy the API may still answer with a bare list.""" + broker_api.legacy_history = True + response = signed_in_client.get(f"{path}?page=1&per_page=10") + + assert response.status_code == 200 + payload = response.get_json() + assert payload["total"] == 120 + assert payload["totalPages"] == 12 + assert len(payload["items"]) == 10 + + +@pytest.mark.parametrize("path", HISTORY_PATHS) +@pytest.mark.parametrize("query", ["page=abc", "page=0", "page=999999", "per_page=-3", + "per_page=abc", "per_page=100000"]) +def test_hostile_pagination_query_strings_do_not_500(signed_in_client, path, query): + response = signed_in_client.get(f"{path}?{query}") + assert response.status_code < 500 + + +@pytest.mark.parametrize("path", HISTORY_PATHS) +def test_per_page_is_capped(signed_in_client, broker_api, path): + signed_in_client.get(f"{path}?per_page=100000") + assert broker_api.posts[-1]["params"]["per_page"] == 200 + + +@pytest.mark.parametrize("path", HISTORY_PATHS) +def test_history_reports_a_broker_outage(signed_in_client, broker_api, path): + broker_api.raise_post_paths.add(path.replace(API, "")) + response = signed_in_client.get(path) + assert response.status_code == 502 + assert "Unable to retrieve" in response.get_json()["error"] + + +# ===================================================================== VMs + + +def test_vms_are_returned_as_json(signed_in_client): + payload = signed_in_client.get(f"{API}/vms").get_json() + assert [vm["Hostname"] for vm in payload] == [vm["Hostname"] for vm in VMS] + + +def test_vm_details_are_returned_as_json(signed_in_client): + payload = signed_in_client.get(f"{API}/vms/2").get_json() + assert payload["Hostname"] == "linux-host-02" + + +def test_vm_list_reports_a_broker_outage(signed_in_client, broker_api): + broker_api.raise_get_paths.add("/vms") + response = signed_in_client.get(f"{API}/vms") + assert response.status_code == 502 + assert "Unable to retrieve VM data" in response.get_json()["error"] + + +def test_add_vm_forwards_the_full_payload(signed_in_client, broker_api): + response = post(signed_in_client, f"{API}/vms", { + "hostname": "linux-host-09", + "ipaddress": "10.0.0.9", + "powerstate": "On", + "networkstatus": "Reachable", + "vmstatus": "Available", + }) + assert response.status_code == 201 + + sent = broker_api.posts[-1]["json"] + assert sent["hostname"] == "linux-host-09" + # Optional fields are sent as empty strings rather than omitted, matching what + # the form used to submit. + assert sent["username"] == "" + assert sent["avdhost"] == "" + assert sent["description"] == "" + + +def test_add_vm_names_the_missing_fields(signed_in_client): + """`request.form['x']` used to raise and surface as a bare 400, which said + nothing about which field was wrong.""" + response = post(signed_in_client, f"{API}/vms", {"hostname": "linux-host-09"}) + assert response.status_code == 400 + + error = response.get_json()["error"] + for field in ("ipaddress", "powerstate", "networkstatus", "vmstatus"): + assert field in error + + +def test_add_vm_rejects_a_blank_required_field(signed_in_client): + response = post(signed_in_client, f"{API}/vms", { + "hostname": " ", + "ipaddress": "10.0.0.9", + "powerstate": "On", + "networkstatus": "Reachable", + "vmstatus": "Available", + }) + assert response.status_code == 400 + assert "hostname" in response.get_json()["error"] + + +def test_update_vm_attributes_forwards_the_three_fields(signed_in_client, broker_api): + response = post(signed_in_client, f"{API}/vms/1/update-attributes", { + "powerstate": "Off", + "networkstatus": "Unreachable", + "vmstatus": "Maintenance", + }) + assert response.status_code == 200 + assert broker_api.posts[-1]["json"] == { + "powerstate": "Off", "networkstatus": "Unreachable", "vmstatus": "Maintenance", + } + + +def test_release_uses_the_hostname_and_return_uses_the_vmid(signed_in_client, broker_api): + """The broker's own routes differ, and the portal must not swap them.""" + post(signed_in_client, f"{API}/vms/linux-host-02/release") + assert broker_api.posts[-1]["url"].endswith("/vms/linux-host-02/release") + + post(signed_in_client, f"{API}/vms/2/return") + assert broker_api.posts[-1]["url"].endswith("/vms/2/return") + + +def test_checkout_requires_both_fields(signed_in_client): + response = post(signed_in_client, f"{API}/vms/checkout", {"username": "op@contoso.com"}) + assert response.status_code == 400 + assert "avdhost" in response.get_json()["error"] + + +def test_checkout_returns_the_assigned_vm(signed_in_client): + response = post(signed_in_client, f"{API}/vms/checkout", { + "username": "op@contoso.com", "avdhost": "avd-01", + }) + assert response.status_code == 200 + assert response.get_json()["VMID"] == 1 + + +def test_history_route_is_not_shadowed_by_the_vm_detail_route(signed_in_client): + """/vms/history and /vms/ share a prefix; the static rule must win.""" + payload = signed_in_client.get(f"{API}/vms/history").get_json() + assert "items" in payload + + +# ================================================================= scaling + + +def test_scaling_rules_are_returned_as_json(signed_in_client): + payload = signed_in_client.get(f"{API}/scaling/rules").get_json() + assert payload[0]["RuleID"] == 1 + + +def test_rule_details_are_returned_as_json(signed_in_client): + payload = signed_in_client.get(f"{API}/scaling/rules/1").get_json() + assert payload["MinVMs"] == 2 + + +def test_create_rule_forwards_every_threshold(signed_in_client, broker_api): + body = { + "minvms": "2", "maxvms": "20", "scaleupratio": "80", + "scaleupincrement": "2", "scaledownratio": "30", "scaledownincrement": "1", + } + response = post(signed_in_client, f"{API}/scaling/rules", body) + + assert response.status_code == 201 + assert broker_api.posts[-1]["json"] == body + + +def test_create_rule_names_the_missing_fields(signed_in_client): + response = post(signed_in_client, f"{API}/scaling/rules", {"minvms": "2"}) + assert response.status_code == 400 + + error = response.get_json()["error"] + for field in ("maxvms", "scaleupratio", "scaleupincrement", + "scaledownratio", "scaledownincrement"): + assert field in error + + +def test_update_rule_targets_the_right_rule(signed_in_client, broker_api): + response = post(signed_in_client, f"{API}/scaling/rules/7/update", { + "minvms": "1", "maxvms": "9", "scaleupratio": "70", + "scaleupincrement": "1", "scaledownratio": "20", "scaledownincrement": "1", + }) + assert response.status_code == 200 + assert broker_api.posts[-1]["url"].endswith("/scaling/rules/7/update") + + +def test_rules_history_route_is_not_shadowed_by_the_rule_detail_route(signed_in_client): + payload = signed_in_client.get(f"{API}/scaling/rules/history").get_json() + assert "items" in payload + + +# ============================================================ error mapping + + +def test_a_broker_4xx_keeps_its_status_and_message(signed_in_client, monkeypatch): + """The broker explains exactly what it rejected, which is far more useful to + an admin than a generic failure.""" + from conftest import FakeResponse + + def bad_request(url, **kwargs): + return FakeResponse({"error": "GracePeriodSeconds must be between 60 and 86400."}, + status_code=400) + + import requests + monkeypatch.setattr(requests, "post", bad_request) + + response = post(signed_in_client, f"{API}/hosts/settings", {"graceperiodseconds": "5"}) + assert response.status_code == 400 + assert "GracePeriodSeconds must be between" in response.get_json()["error"] + + +def test_a_broker_5xx_becomes_a_502(signed_in_client, monkeypatch): + """A broker failure is not the operator's bad request.""" + from conftest import FakeResponse + + def server_error(url, **kwargs): + return FakeResponse({"error": "boom"}, status_code=500) + + import requests + monkeypatch.setattr(requests, "post", server_error) + + response = post(signed_in_client, f"{API}/vms/1/delete") + assert response.status_code == 502 + + +def test_an_expired_token_returns_401_json(signed_in_client): + from datetime import datetime, timedelta + + with signed_in_client.session_transaction() as session: + # Matches the app's naive datetime.utcnow().timestamp() convention. + session["token_expiry"] = (datetime.utcnow() - timedelta(minutes=5)).timestamp() + + response = signed_in_client.get(f"{API}/vms") + assert response.status_code == 401 + assert response.get_json()["error"] + + +def test_deleted_templates_are_not_referenced(): + """The Jinja portal is gone; nothing may still try to render it.""" + from pathlib import Path + + front_end = Path(__file__).resolve().parents[1] + assert not (front_end / "templates").exists() + assert not (front_end / "static" / "bootstrap").exists() + + for module in front_end.glob("*.py"): + text = module.read_text(encoding="utf-8") + assert "render_template" not in text, f"{module.name} still renders a template" + assert "flash(" not in text, f"{module.name} still uses flash messages" diff --git a/front_end/tests/test_function_api.py b/front_end/tests/test_function_api.py index a271714..af60e5f 100644 --- a/front_end/tests/test_function_api.py +++ b/front_end/tests/test_function_api.py @@ -1,4 +1,10 @@ -from function_api import summarize_vms +from function_api import ( + build_history_payload, + filters_from_args, + pagination_from_args, + summarize_vms, + validate_history_filters, +) def test_summarize_vms_empty_and_none_are_safe(): @@ -25,3 +31,90 @@ def test_summarize_vms_ready_requires_available_on_reachable(): assert summary["powered_on"] == 3 assert summary["unreachable"] == 1 assert summary["utilization"] == 20 + + +# --------------------------------------------------- history filter helpers + + +def test_filters_from_args_reads_the_query_string(): + filters = filters_from_args({ + "startdate": " 2026-01-15 ", + "enddate": "2026-02-20", + "limit": "37", + "ignore_dates": "1", + "ignore_limit": "true", + }) + + assert filters["startdate"] == "2026-01-15" + assert filters["limit"] == "37" + assert filters["ignore_dates"] is True + assert filters["ignore_limit"] is True + + +def test_filters_from_args_defaults_to_an_unfiltered_view(): + filters = filters_from_args({}) + + assert filters == {"startdate": "", "enddate": "", "limit": "", + "ignore_dates": False, "ignore_limit": False} + + +def test_only_recognised_truthy_values_set_an_ignore_flag(): + for value in ("1", "true", "TRUE", "yes", "on"): + assert filters_from_args({"ignore_dates": value})["ignore_dates"] is True + for value in ("0", "false", "no", "off", ""): + assert filters_from_args({"ignore_dates": value})["ignore_dates"] is False + + +def test_validate_history_filters_accepts_iso_dates(): + assert validate_history_filters({"startdate": "2026-01-15", "enddate": "2026-02-20"}) is None + + +def test_validate_history_filters_names_the_offending_end(): + error = validate_history_filters({"startdate": "15-01-2026"}) + assert "start" in error and "YYYY-MM-DD" in error + + error = validate_history_filters({"enddate": "not-a-date"}) + assert "end" in error + + +def test_validate_history_filters_skips_validation_when_dates_are_ignored(): + assert validate_history_filters({"startdate": "nonsense", "ignore_dates": True}) is None + + +def test_build_history_payload_converts_to_the_stored_procedure_format(): + payload = build_history_payload({"startdate": "2026-01-15", "enddate": "2026-02-20", + "limit": "37"}) + + assert payload["startdate"] == "01/15/2026" + assert payload["enddate"] == "02/20/2026" + assert payload["limit"] == 37 + + +def test_build_history_payload_honours_the_ignore_flags(): + payload = build_history_payload({"startdate": "2026-01-15", "enddate": "2026-02-20", + "limit": "37", "ignore_dates": True, "ignore_limit": True}) + assert payload == {} + + +def test_build_history_payload_drops_an_unparseable_limit_rather_than_failing(): + payload = build_history_payload({"limit": "many"}) + assert payload == {} + + +# ------------------------------------------------------------- pagination + + +def test_pagination_defaults(): + assert pagination_from_args({}) == (1, 10) + + +def test_pagination_clamps_hostile_values(): + assert pagination_from_args({"page": "abc"}) == (1, 10) + assert pagination_from_args({"page": "0"}) == (1, 10) + assert pagination_from_args({"page": "-5"}) == (1, 10) + assert pagination_from_args({"per_page": "-3"}) == (1, 1) + assert pagination_from_args({"per_page": "abc"}) == (1, 10) + + +def test_pagination_caps_per_page_so_one_request_cannot_pull_everything(): + assert pagination_from_args({"per_page": "100000"}) == (1, 200) diff --git a/front_end/tests/test_host_settings.py b/front_end/tests/test_host_settings.py index 020ecb7..f7de104 100644 --- a/front_end/tests/test_host_settings.py +++ b/front_end/tests/test_host_settings.py @@ -1,66 +1,74 @@ -"""Tests for the Linux host settings page.""" +"""Tests for the Linux host settings endpoints.""" -from conftest import HOST_SETTINGS, assert_checkbox_checked, assert_form_value, csrf_token, row_for_host +from conftest import API, HOST_SETTINGS, VMS, csrf_token, post +SETTINGS_PATH = f"{API}/hosts/settings" +APPLY_PATH = f"{API}/hosts/settings/apply" -def get_settings_page(client): - response = client.get("/settings/hosts") +BASE_FORM = { + "graceperiodseconds": "1200", + "reconcileintervalseconds": "60", + "watcherdebounceseconds": "10", + "watchersettleseconds": "2", + "idletimeoutseconds": "0", + "idlewarningseconds": "120", + "screenidledelayseconds": "0", + "screenlockdelayseconds": "0", +} + + +def get_settings(client): + response = client.get(SETTINGS_PATH) assert response.status_code == 200 - return response.get_data(as_text=True) - - -def post_settings(client, overrides=None, follow_redirects=False): - html = get_settings_page(client) - form = { - "csrf_token": csrf_token(html), - "graceperiodseconds": "1200", - "reconcileintervalseconds": "60", - "watcherdebounceseconds": "10", - "watchersettleseconds": "2", - "idletimeoutseconds": "0", - "idlewarningseconds": "120", - "screenidledelayseconds": "0", - "screenlockdelayseconds": "0", - } - form.update(overrides or {}) - return client.post("/settings/hosts", data=form, follow_redirects=follow_redirects) + return response.get_json() + + +def save_settings(client, overrides=None): + body = dict(BASE_FORM) + body.update(overrides or {}) + return post(client, SETTINGS_PATH, body) + + +def update_payload(broker_api): + return next(p["json"] for p in broker_api.posts if p["url"].endswith("/hosts/settings/update")) + + +def apply_payload(broker_api): + return next(p["json"] for p in broker_api.posts if p["url"].endswith("/hosts/settings/apply")) def test_requires_sign_in(client): - response = client.get("/settings/hosts") - assert response.status_code == 302 - assert "/login" in response.headers["Location"] + response = client.get(SETTINGS_PATH) + assert response.status_code == 401 + assert response.get_json()["error"] -def test_page_renders_current_values(signed_in_client): - html = get_settings_page(signed_in_client) +def test_returns_the_current_values(signed_in_client): + payload = get_settings(signed_in_client) - assert "Linux Host Settings" in html - assert f"Settings version {HOST_SETTINGS['SettingsVersion']}" in html - assert_form_value(html, "graceperiodseconds", "1200") - assert_form_value(html, "reconcileintervalseconds", "60") - assert_form_value(html, "idletimeoutseconds", "0") + assert payload["settings"]["SettingsVersion"] == HOST_SETTINGS["SettingsVersion"] + assert payload["settings"]["GracePeriodSeconds"] == 1200 + assert payload["settings"]["ReconcileIntervalSeconds"] == 60 + assert payload["settings"]["IdleTimeoutSeconds"] == 0 -def test_disabled_idle_enforcement_is_called_out(signed_in_client): - html = get_settings_page(signed_in_client) - assert "Idle enforcement is currently disabled" in html +def test_returns_the_hosts_for_the_drift_table(signed_in_client): + payload = get_settings(signed_in_client) + assert [host["Hostname"] for host in payload["hosts"]] == [vm["Hostname"] for vm in VMS] def test_screen_lock_defaults_reflect_the_disabled_lock_screen(signed_in_client): - html = get_settings_page(signed_in_client) + # The shipped posture removes the lock screen, because a locked greeter inside an + # xrdp session can strand the host lease. + settings = get_settings(signed_in_client)["settings"] - # The shipped posture removes the lock screen, because a locked greeter inside an xrdp - # session can strand the host lease. - assert_checkbox_checked(html, "disablelockscreen") - assert_checkbox_checked(html, "screenlocksettingslocked") - assert 'name="screenlockenabled"' in html - assert 'name="screenlockenabled" checked' not in html + assert settings["DisableLockScreen"] is True + assert settings["ScreenLockSettingsLocked"] is True + assert settings["ScreenLockEnabled"] is False -def test_drift_table_distinguishes_host_states(signed_in_client, broker_api): +def test_drift_data_distinguishes_host_states(signed_in_client, broker_api): broker_api.host_settings = dict(HOST_SETTINGS, SettingsVersion=4) - from conftest import VMS VMS[0]["SettingsVersion"] = 4 VMS[0]["SettingsAppliedDate"] = "2026-08-19 20:00:00" VMS[1]["SettingsVersion"] = 3 @@ -69,11 +77,14 @@ def test_drift_table_distinguishes_host_states(signed_in_client, broker_api): VMS[2]["SettingsAppliedDate"] = None try: - html = get_settings_page(signed_in_client) - - assert "text-bg-success" in row_for_host(html, "linux-host-01") - assert "pending 4" in row_for_host(html, "linux-host-02") - assert "not reported" in row_for_host(html, "linux-host-03") + payload = get_settings(signed_in_client) + hosts = {host["Hostname"]: host for host in payload["hosts"]} + + # Current, behind, and never reported: the three states the drift table renders. + assert payload["settings"]["SettingsVersion"] == 4 + assert hosts["linux-host-01"]["SettingsVersion"] == 4 + assert hosts["linux-host-02"]["SettingsVersion"] == 3 + assert hosts["linux-host-03"]["SettingsVersion"] is None finally: for vm in VMS: vm.pop("SettingsVersion", None) @@ -81,82 +92,109 @@ def test_drift_table_distinguishes_host_states(signed_in_client, broker_api): def test_save_sends_integers_to_the_api(signed_in_client, broker_api): - response = post_settings(signed_in_client, {"graceperiodseconds": "900", "idletimeoutseconds": "1800"}) - assert response.status_code == 302 + response = save_settings(signed_in_client, + {"graceperiodseconds": "900", "idletimeoutseconds": "1800"}) + assert response.status_code == 200 - payload = next(p["json"] for p in broker_api.posts if p["url"].endswith("/hosts/settings/update")) + payload = update_payload(broker_api) assert payload["GracePeriodSeconds"] == 900 assert payload["IdleTimeoutSeconds"] == 1800 assert payload["updatedBy"] == "op@contoso.com" -def test_unchecked_boxes_are_sent_as_false(signed_in_client, broker_api): - # Checkboxes are absent from the form when unchecked, so they must be sent explicitly - # rather than omitted, which the API reads as "leave unchanged". - post_settings(signed_in_client) +def test_save_returns_a_message_the_client_can_show(signed_in_client): + payload = save_settings(signed_in_client).get_json() + + assert payload["tone"] == "success" + assert "Apply Now" in payload["message"] + assert payload["settings"]["SettingsVersion"] == HOST_SETTINGS["SettingsVersion"] - payload = next(p["json"] for p in broker_api.posts if p["url"].endswith("/hosts/settings/update")) + +def test_omitted_booleans_are_sent_as_false(signed_in_client, broker_api): + # A boolean the client leaves out must be sent explicitly rather than omitted, + # which the API reads as "leave unchanged". + save_settings(signed_in_client) + + payload = update_payload(broker_api) assert payload["ScreenLockEnabled"] is False assert payload["DisableLockScreen"] is False assert payload["ScreenLockSettingsLocked"] is False -def test_checked_boxes_are_sent_as_true(signed_in_client, broker_api): - post_settings(signed_in_client, {"disablelockscreen": "on", "screenlocksettingslocked": "on"}) +def test_true_booleans_are_forwarded(signed_in_client, broker_api): + save_settings(signed_in_client, + {"disablelockscreen": True, "screenlocksettingslocked": True}) - payload = next(p["json"] for p in broker_api.posts if p["url"].endswith("/hosts/settings/update")) + payload = update_payload(broker_api) assert payload["DisableLockScreen"] is True assert payload["ScreenLockSettingsLocked"] is True assert payload["ScreenLockEnabled"] is False +def test_blank_integers_are_left_unchanged_rather_than_zeroed(signed_in_client, broker_api): + save_settings(signed_in_client, {"graceperiodseconds": ""}) + + payload = update_payload(broker_api) + assert "GracePeriodSeconds" not in payload + + def test_non_numeric_value_never_reaches_the_api(signed_in_client, broker_api): - response = post_settings(signed_in_client, {"graceperiodseconds": "not-a-number"}) - assert response.status_code == 302 + response = save_settings(signed_in_client, {"graceperiodseconds": "not-a-number"}) + + assert response.status_code == 400 + assert "GracePeriodSeconds must be a whole number" in response.get_json()["error"] assert not any(p["url"].endswith("/hosts/settings/update") for p in broker_api.posts) def test_save_requires_a_csrf_token(signed_in_client): - response = signed_in_client.post("/settings/hosts", data={"graceperiodseconds": "900"}) + response = signed_in_client.post(SETTINGS_PATH, json={"graceperiodseconds": "900"}) assert response.status_code == 400 def test_broker_failure_on_save_is_reported(signed_in_client, broker_api): broker_api.raise_post_paths.add("/hosts/settings/update") - response = post_settings(signed_in_client, follow_redirects=True) - assert "Unable to save host settings" in response.get_data(as_text=True) + response = save_settings(signed_in_client) + + assert response.status_code == 502 + assert "Unable to save host settings" in response.get_json()["error"] -def test_broker_failure_on_load_redirects_home(signed_in_client, broker_api): +def test_broker_failure_on_load_is_reported(signed_in_client, broker_api): broker_api.raise_get_paths.add("/hosts/settings") - response = signed_in_client.get("/settings/hosts") - assert response.status_code == 302 + response = signed_in_client.get(SETTINGS_PATH) + assert response.status_code == 502 + assert "Unable to retrieve host settings" in response.get_json()["error"] -def test_drift_table_failure_does_not_hide_the_form(signed_in_client, broker_api): + +def test_drift_table_failure_does_not_hide_the_settings(signed_in_client, broker_api): # The settings form must still be usable when the VM list cannot be loaded. broker_api.raise_get_paths.add("/vms") - html = get_settings_page(signed_in_client) - assert "Linux Host Settings" in html - assert "No Linux hosts registered" in html + payload = get_settings(signed_in_client) + assert payload["settings"]["SettingsVersion"] == HOST_SETTINGS["SettingsVersion"] + assert payload["hosts"] == [] -def test_apply_to_all_hosts_sends_no_hostname_filter(signed_in_client, broker_api): - html = get_settings_page(signed_in_client) - response = signed_in_client.post("/settings/hosts/apply", data={"csrf_token": csrf_token(html)}) - assert response.status_code == 302 - payload = next(p["json"] for p in broker_api.posts if p["url"].endswith("/hosts/settings/apply")) - assert payload == {} +def test_apply_to_all_hosts_sends_no_hostname_filter(signed_in_client, broker_api): + response = post(signed_in_client, APPLY_PATH, {}) + assert response.status_code == 200 + assert apply_payload(broker_api) == {} def test_apply_to_single_host_targets_that_host(signed_in_client, broker_api): - html = get_settings_page(signed_in_client) - signed_in_client.post("/settings/hosts/apply", - data={"csrf_token": csrf_token(html), "hostname": "linux-host-02"}) + post(signed_in_client, APPLY_PATH, {"hostname": "linux-host-02"}) + assert apply_payload(broker_api) == {"hostnames": ["linux-host-02"]} - payload = next(p["json"] for p in broker_api.posts if p["url"].endswith("/hosts/settings/apply")) - assert payload == {"hostnames": ["linux-host-02"]} + +def test_full_apply_reports_success(signed_in_client): + payload = post(signed_in_client, APPLY_PATH, {}).get_json() + + assert payload["tone"] == "success" + assert payload["succeededCount"] == 2 + assert payload["targetCount"] == 2 + assert payload["unreachable"] == [] + assert "2 of 2" in payload["message"] def test_partial_apply_names_the_hosts_that_failed(signed_in_client, broker_api): @@ -165,22 +203,37 @@ def test_partial_apply_names_the_hosts_that_failed(signed_in_client, broker_api) "Results": [{"Hostname": "linux-host-01", "Applied": True, "Message": "Applied."}, {"Hostname": "linux-host-02", "Applied": False, "Message": "unreachable"}], } - html = get_settings_page(signed_in_client) - response = signed_in_client.post("/settings/hosts/apply", - data={"csrf_token": csrf_token(html)}, - follow_redirects=True) + payload = post(signed_in_client, APPLY_PATH, {}).get_json() + + assert payload["tone"] == "warning" + assert payload["unreachable"] == ["linux-host-02"] + assert "linux-host-02" in payload["message"] + assert "converge" in payload["message"] + - body = response.get_data(as_text=True) - assert "linux-host-02" in body - assert "Unreachable" in body +def test_apply_with_no_reachable_hosts_reassures_rather_than_alarms(signed_in_client, broker_api): + broker_api.apply_result = {"SettingsVersion": 3, "TargetCount": 0, + "SucceededCount": 0, "Results": []} + payload = post(signed_in_client, APPLY_PATH, {}).get_json() + + assert payload["tone"] == "info" + assert "converge on their own" in payload["message"] def test_apply_requires_a_csrf_token(signed_in_client): - response = signed_in_client.post("/settings/hosts/apply", data={}) + response = signed_in_client.post(APPLY_PATH, json={}) assert response.status_code == 400 -def test_nav_links_to_host_settings(signed_in_client): - html = get_settings_page(signed_in_client) - assert "/settings/hosts" in html - assert "Host Settings" in html +def test_apply_reports_a_broker_failure(signed_in_client, broker_api): + broker_api.raise_post_paths.add("/hosts/settings/apply") + response = post(signed_in_client, APPLY_PATH, {}) + + assert response.status_code == 502 + assert "Unable to push host settings" in response.get_json()["error"] + + +def test_csrf_token_is_available_from_the_session_endpoint(signed_in_client): + """The client has no form to read a hidden field from, so the token has to + come from the bootstrap payload.""" + assert csrf_token(signed_in_client) diff --git a/front_end/tests/test_ui_regressions.py b/front_end/tests/test_ui_regressions.py deleted file mode 100644 index a1ddf64..0000000 --- a/front_end/tests/test_ui_regressions.py +++ /dev/null @@ -1,286 +0,0 @@ -import re -from pathlib import Path - -import pytest - -from conftest import VMS, assert_checkbox_checked, assert_form_value, csrf_token, row_for_host - - -AUTHENTICATED_GET_ROUTES = [ - "/", "/profile", "/vms", "/vms/1", "/vms/add", "/vms/1/update", "/vms/checkout", - "/vms/history", "/scaling/rules", "/scaling/rules/1", "/scaling/rules/create", - "/scaling/rules/1/update", "/scaling/log", "/scaling/rules/history", -] - - -@pytest.mark.parametrize("path", AUTHENTICATED_GET_ROUTES) -def test_authenticated_pages_render_without_bootstrap4_or_cdn(signed_in_client, path): - response = signed_in_client.get(path) - assert response.status_code == 200 - html = response.get_data(as_text=True) - assert "cdn.jsdelivr.net" not in html - assert not re.search(r"https?://[^\"'\s>]*(?:cdn|jsdelivr|unpkg|cdnjs)[^\"'\s>]*", html, re.I) - for legacy_class in ("form-row", "form-group", "form-inline", "thead-dark", "jumbotron", "mr-2"): - assert legacy_class not in html - - -def test_error_template_exists_and_renders_without_arguments(app): - """route_user.profile falls back to `render_template('error.html')` with no - arguments, so the zero-argument path is the one that must not break.""" - with app.test_request_context("/profile"): - from flask import render_template - bare = render_template("error.html") - detailed = render_template("error.html", code=500, title="Test error", message="friendly") - - assert "Something went wrong" in bare - assert "500" not in bare - assert "Test error" in detailed - assert "500" in detailed - assert "friendly" in detailed - - -def test_profile_falls_back_to_error_page_when_rendering_fails(signed_in_client): - """Exercises the real except branch in route_user.profile. Before the fix - this raised TemplateNotFound because error.html did not exist.""" - with signed_in_client.session_transaction() as session: - session["user"] = "not-a-mapping" # makes profile.html raise - - response = signed_in_client.get("/profile") - assert response.status_code == 200 - assert "Something went wrong" in response.get_data(as_text=True) - - -def test_deleted_templates_are_not_referenced(): - repo = Path(__file__).resolve().parents[2] - deleted_templates = { - "vm/delete_vm.html", - "vm/release_vm.html", - "vm/return_vm.html", - "scaling/delete_rule.html", - } - searchable = list((repo / "front_end").glob("*.py")) + list((repo / "front_end" / "templates").glob("**/*.html")) - for file_path in searchable: - text = file_path.read_text(encoding="utf-8") - for template in deleted_templates: - assert template not in text, f"{template} is still referenced by {file_path}" - - -@pytest.mark.parametrize("path", ["/vms/history", "/scaling/log", "/scaling/rules/history"]) -def test_filter_values_survive_post_redirect_get(signed_in_client, path): - html = signed_in_client.get(path).get_data(as_text=True) - token = csrf_token(html) - response = signed_in_client.post(path, data={ - "csrf_token": token, - "startdate": "2026-01-15", - "enddate": "2026-02-20", - "limit": "37", - }, follow_redirects=True) - assert response.status_code == 200 - body = response.get_data(as_text=True) - assert_form_value(body, "startdate", "2026-01-15") - assert_form_value(body, "enddate", "2026-02-20") - assert_form_value(body, "limit", "37") - - -@pytest.mark.parametrize("path", ["/vms/history", "/scaling/log", "/scaling/rules/history"]) -def test_ignore_filter_checkboxes_survive_post_redirect_get(signed_in_client, broker_api, path): - """The ignore checkboxes mark the dates/limit as ignored for the API call, - but the operator's typed values must still come back so unticking restores - them. app.js therefore sets those inputs readOnly rather than disabled -- - disabled controls are omitted from submission and the values were lost.""" - html = signed_in_client.get(path).get_data(as_text=True) - token = csrf_token(html) - response = signed_in_client.post(path, data={ - "csrf_token": token, - "startdate": "2026-03-01", - "enddate": "2026-03-31", - "limit": "42", - "ignore_dates": "on", - "ignore_limit": "on", - }, follow_redirects=True) - assert response.status_code == 200 - body = response.get_data(as_text=True) - assert_form_value(body, "startdate", "2026-03-01") - assert_form_value(body, "enddate", "2026-03-31") - assert_form_value(body, "limit", "42") - assert_checkbox_checked(body, "ignore_dates") - assert_checkbox_checked(body, "ignore_limit") - # The ignore flags now mean "omit the filter" rather than sending the - # stringly-typed "null" sentinel that the API had to special-case. - sent = broker_api.posts[-1]["json"] - assert "startdate" not in sent - assert "enddate" not in sent - assert "limit" not in sent - - -@pytest.mark.parametrize("path", ["/vms/history", "/scaling/log", "/scaling/rules/history"]) -def test_ignore_flags_without_values_do_not_break(signed_in_client, broker_api, path): - """Defensive: a client that omits the ignored fields entirely (no JavaScript, - or a control left disabled) must still work rather than 500.""" - html = signed_in_client.get(path).get_data(as_text=True) - response = signed_in_client.post(path, data={ - "csrf_token": csrf_token(html), - "ignore_dates": "on", - "ignore_limit": "on", - }, follow_redirects=True) - assert response.status_code == 200 - body = response.get_data(as_text=True) - assert_checkbox_checked(body, "ignore_dates") - assert_checkbox_checked(body, "ignore_limit") - sent = broker_api.posts[-1]["json"] - assert "startdate" not in sent - assert "enddate" not in sent - assert "limit" not in sent - - -@pytest.mark.parametrize("path", ["/vms/history", "/scaling/log", "/scaling/rules/history"]) -def test_filters_are_sent_to_the_api_in_the_expected_format(signed_in_client, broker_api, path): - """The operator types YYYY-MM-DD; the stored procedures expect MM/DD/YYYY.""" - html = signed_in_client.get(path).get_data(as_text=True) - signed_in_client.post(path, data={ - "csrf_token": csrf_token(html), - "startdate": "2026-01-15", - "enddate": "2026-02-20", - "limit": "37", - }, follow_redirects=True) - - sent = broker_api.posts[-1]["json"] - assert sent["startdate"] == "01/15/2026" - assert sent["enddate"] == "02/20/2026" - assert sent["limit"] == 37 # a real int, not the string "37" - - -@pytest.mark.parametrize("path", ["/vms/history", "/scaling/log", "/scaling/rules/history"]) -def test_history_uses_server_side_pagination(signed_in_client, broker_api, path): - """Pages come from the API rather than a whole result set cached in the session.""" - response = signed_in_client.get(f"{path}?page=3&per_page=10") - assert response.status_code == 200 - - params = broker_api.posts[-1]["params"] - assert params["page"] == 3 - assert params["per_page"] == 10 - - with signed_in_client.session_transaction() as session: - # The old implementation stashed every row in the session. - assert "vm_history" not in session - assert "scaling_activity_log" not in session - assert "scaling_rules_history" not in session - - -@pytest.mark.parametrize("path", ["/vms/history", "/scaling/log", "/scaling/rules/history"]) -def test_history_falls_back_when_api_predates_pagination(signed_in_client, broker_api, path): - """During a rolling deploy the API may still answer with a bare list.""" - broker_api.legacy_history = True - response = signed_in_client.get(f"{path}?page=1&per_page=10") - assert response.status_code == 200 - assert "Unable to retrieve" not in response.get_data(as_text=True) - - -@pytest.mark.parametrize("path", ["/vms/history", "/scaling/log", "/scaling/rules/history"]) -def test_pagination_is_windowed_for_many_pages(signed_in_client, broker_api, path): - broker_api.history_total = 120 - response = signed_in_client.get(f"{path}?page=6&per_page=10") - assert response.status_code == 200 - html = response.get_data(as_text=True) - numeric_links = set(re.findall(r"page=(\d+)&per_page=10", html)) - # Pinned exactly: window=2 around page 6, plus first and last. - assert numeric_links == {"1", "4", "5", "6", "7", "8", "12"} - - -@pytest.mark.parametrize("path", ["/vms/history", "/scaling/log", "/scaling/rules/history"]) -@pytest.mark.parametrize("query", ["page=abc", "page=0", "page=999999", "per_page=-3"]) -def test_hostile_pagination_query_strings_do_not_500(signed_in_client, path, query): - response = signed_in_client.get(f"{path}?{query}", follow_redirects=True) - assert response.status_code < 500 - - -def test_csrf_rejects_missing_token_and_accepts_valid_token(signed_in_client): - missing = signed_in_client.post("/vms/1/delete", data={}) - assert missing.status_code == 400 - assert "Session expired" in missing.get_data(as_text=True) - - html = signed_in_client.get("/vms").get_data(as_text=True) - valid = signed_in_client.post("/vms/1/delete", data={"csrf_token": csrf_token(html)}) - assert valid.status_code in {302, 303} - - -def test_scaling_rule_delete_is_csrf_protected(signed_in_client): - response = signed_in_client.post("/scaling/rules/1/delete", data={}) - assert response.status_code == 400 - assert "Session expired" in response.get_data(as_text=True) - - -def test_vm_row_actions_follow_lifecycle_rules(signed_in_client): - html = signed_in_client.get("/vms").get_data(as_text=True) - expectations = { - "linux-host-01": {"release": False, "return": False}, - "linux-host-02": {"release": True, "return": True}, - "linux-host-03": {"release": False, "return": False}, - "linux-host-04": {"release": False, "return": True}, - } - vm_ids = {vm["Hostname"]: vm["VMID"] for vm in VMS} - for host, expected in expectations.items(): - row = row_for_host(html, host) - assert (f'action="/vms/{host}/release"' in row) is expected["release"] - assert (f'action="/vms/{vm_ids[host]}/return"' in row) is expected["return"] - - -def test_dashboard_handles_non_list_activity_log(signed_in_client, broker_api): - broker_api.scaling_log_payload = {"message": "no results"} - response = signed_in_client.get("/") - assert response.status_code == 200 - html = response.get_data(as_text=True) - assert "Pool overview" in html - assert "No recent scaling activity" in html - - -def test_dashboard_degrades_when_vm_api_is_unavailable(signed_in_client, broker_api): - broker_api.raise_get_paths.add("/vms/summary") - broker_api.raise_get_paths.add("/vms") - response = signed_in_client.get("/") - assert response.status_code == 200 - assert "Pool data unavailable" in response.get_data(as_text=True) - - -def test_dashboard_uses_the_summary_endpoint(signed_in_client, broker_api): - """The dashboard must not pull the whole VM list just to count it.""" - broker_api.vm_summary = { - "TotalVMs": 9, "Available": 4, "CheckedOut": 3, "Maintenance": 1, - "Released": 1, "PoweredOn": 7, "PoweredOff": 2, "Unreachable": 2, - "Ready": 3, - } - response = signed_in_client.get("/") - assert response.status_code == 200 - - html = response.get_data(as_text=True) - assert ">9<" in html.replace(" ", "").replace("\n", "") # total - assert "Pool overview" in html - # 3 of 9 checked out - assert "33% of the pool in use" in html - - -def test_dashboard_falls_back_when_api_predates_the_summary_endpoint(signed_in_client, broker_api): - """During a rolling deploy the portal can be newer than the API. - - An older API does not 404 on /api/vms/summary -- Werkzeug matches it against the - older /api/vms/ rule, which fails converting 'summary' to an int and - returns 500. The fallback must handle that, not just a clean 404. - """ - broker_api.summary_status = 500 - response = signed_in_client.get("/") - assert response.status_code == 200 - - html = response.get_data(as_text=True) - assert "Pool data unavailable" not in html - assert "Pool overview" in html - # Counted client-side from the four seeded VMs. - assert ">4<" in html.replace(" ", "").replace("\n", "") - - -def test_dashboard_still_reports_an_outage_when_both_paths_fail(signed_in_client, broker_api): - """The fallback must not mask a genuine broker outage.""" - broker_api.summary_status = 500 - broker_api.raise_get_paths.add("/vms") - response = signed_in_client.get("/") - assert response.status_code == 200 - assert "Pool data unavailable" in response.get_data(as_text=True) diff --git a/front_end/web/index.html b/front_end/web/index.html new file mode 100644 index 0000000..5b4f4fe --- /dev/null +++ b/front_end/web/index.html @@ -0,0 +1,35 @@ + + + + + + + + Linux Broker Management Portal + + + + + + + + +
+ + + diff --git a/front_end/web/package-lock.json b/front_end/web/package-lock.json new file mode 100644 index 0000000..df6911d --- /dev/null +++ b/front_end/web/package-lock.json @@ -0,0 +1,4039 @@ +{ + "name": "linux-broker-portal", + "version": "0.114.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "linux-broker-portal", + "version": "0.114.0", + "dependencies": { + "@tanstack/react-query": "^5.62.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^7.1.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^25.0.1", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.2", + "vite": "^6.0.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha1-tbcaJaTRavokglkt36YvzMYLx9E=", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha1-zEL1uFxZP3nx+k8l0rmzIeYdF5Q=", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha1-QQ/IoXtw5ZgBPfJXwkRrfzOD8Rk=", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha1-8vu/6ofESiFZDsUVt3iywm2IZuc=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha1-bwI38PNtLlHAVwpjb67Z0tDv5ik=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha1-gMELFySAgpaLV6hXuRZAlx8gcPc=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha1-SwuIeIVCJkMzngkCIUikxOuqSXk=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha1-eh3vcEMCQBxH9k+oVYnpdK4hcEI=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha1-8EqW+9hHMkGxB5JD9bPwOjAQq3s=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha1-7yUEilGOgo1zk/rFiC3dc5Idc5Y=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha1-sGJ0elmXuhOGNyATKLv/d5YFdK4=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha1-wKB2bxoTYX2KF0B9erj51IYiXqQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha1-fwhx2Zgk0jE31g+G/PYTD9WhtR8=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha1-vYcITO0MeW7Ea9pJLeboPSnon8I=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha1-zzFb6UAhOzVOtKvMC9Aevj9zvCo=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha1-Rav951SJl+NDdsPmn+tHXP+0pgc=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha1-llNxai8Qxne5j7xj1L+wAMMCzxc=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha1-wkQkUnhYIgYk/Vmlseq0+kE8gDo=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha1-XPJaNomQa1ji8KLys3R4nmYnsV8=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha1-EgIkUMRaTabY2Ch7GKT/Ldsj92g=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha1-TZ1ABPZFzdME3pWMclFieE7KxwA=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha1-QREBTNxxoPldlHGQdZC6oLimsoo=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha1-Einu8x2FFW1w+j9M2Fk3bQ6vaGM=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha1-EGxUyAjKv9GrTGAthQXuWEwplu8=", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha1-hHP2Pi/NbkWYON1BJAHVlI8iTGU=", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha1-Tjhq86md02xG/vATz+TBw0Hu1vA=", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha1-V1U3Cpopq67FUVtDyLPyz5wuMHY=", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha1-Mz/tq8P9Go5dAQABNzHPGeaoxdM=", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha1-gPy+NhMOWLdnBRHoiLjoiiWe12w=", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha1-MAcSEB9/UPHSYnoWLm4JsQm2dno=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha1-iqSWX40KeYLcIXNL9mATI6Ztp1I=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha1-h9+ycWEgK9yVjvSLthsJx1j67hY=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha1-eRl4mOwf90XSHAceHHzDyALwwf0=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha1-FGQAqFYhM/RcTS6tzzfd0JcYB54=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha1-HF+bpyBuFY/SskxZ+i0si7R8oP4=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha1-6mMfSja+qsS5J5+g/MbKKerusrM=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha1-RSzWayCTLQi9xTqLYcDjC69DSLk=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha1-4QZrzlg5TxsRQd7shVel8KIvWXc=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha1-sk+KzEW89UGSx/LzvhtT5lUer+A=", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha1-+c//p/yDIlcfvEyLMmjK8VvYGtA=", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha1-V1oUvXRkT/q4ka3H1+YNJ1KW8s0=", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha1-dbmccKlfvV93OddpK+/mBgFZGGk=", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha1-LjJZRAMhpE553fdTXDJQV9qHXNY=", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha1-F2dsq7/lko2lsqDW311YzQjbJmM=", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha1-BYN3VoXKggZtBMNQfwlSTTzXowY=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha1-8ExAScsuJS/paxb+2Q9wdGsT9KQ=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha1-d9oNCg2CbXySHuo9QCklSLJYoHY=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha1-Ypb1hnrt7yioGyKrIAnHhqlS3M0=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha1-+NIzAzYOJ7Fs8GWyO7/0PBQUJnk=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha1-SeC3aHRKOSS+DX/ZfdbOmykj2I0=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha1-pu19Z3jWflKMgfsWWyP0kRubE9Y=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha1-msFMN44bZTrxfQjn0840yu9YcyM=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha1-kYlC3LuzXMFPyjmvuRteaj0Scmc=", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha1-m9rYF2vngRrRSNH4dyNZBB9GxsU=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha1-Y0Khn0Q0dRjJPkOxrGnes8Rlah8=", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha1-N1xHbRlylHhRuh4Vro8SMEdEWqE=", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha1-eg7mAfYPmaIMfHxf8MgDiMEYm9Y=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha1-aRKwDSxjHA0Vzhp6tXzWV/Ko+Lo=", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha1-2xXWeByTHzolGj2sOVAcmKYIL9A=", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha1-5X1DBpZgeGYgOAlPs465FG3Drqk=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha1-R9K/TO9tRwsi9YMbQg+JZOC/dV8=", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.0.tgz", + "integrity": "sha1-qF5HVWWKarDsSLQf28mTdQeXVeE=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.0.tgz", + "integrity": "sha1-s4bOSj5uoTxYToDtoti2rjcAal0=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.0.tgz", + "integrity": "sha1-fabX/3oh043KFcKFB6ASDlCKQtA=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.0.tgz", + "integrity": "sha1-AJhsggPv6tpD9EgB9KbH5kblYOQ=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.0.tgz", + "integrity": "sha1-JdKpSAJL1VHtStH6qOTiW4mtp7s=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.0.tgz", + "integrity": "sha1-p0SNuKTQcJf1tyfiNZYyogQaUUs=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.0.tgz", + "integrity": "sha1-hQzyH8jqfOsi2XgQSeoGa7LK0NE=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.0.tgz", + "integrity": "sha1-SCdbaTrTmbkyH8wRiEQ2w+7Lc1w=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.0.tgz", + "integrity": "sha1-JhTvhgG3g9KhqYWvuY6WOGkItqE=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.0.tgz", + "integrity": "sha1-oblQUdCLsUFAvSNZaUBbT8a+RgM=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.0.tgz", + "integrity": "sha1-AhTBQN3vbmu4qndpXp/F3OI75Z8=", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.0.tgz", + "integrity": "sha1-16hkmc4zKm5sA4OmXEMmi/rhzXo=", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.0.tgz", + "integrity": "sha1-H4pdVHuTkRaZK5W2rdi/WM/hAEQ=", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.0.tgz", + "integrity": "sha1-HaOqv/nCj5BP7aL6XeLfPIy7DWM=", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.0.tgz", + "integrity": "sha1-nXL0t4/IIHueaZL6lwnFgRhkiH0=", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.0.tgz", + "integrity": "sha1-jEbGgIloMqS/87b4/MKWCMEZi6w=", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.0.tgz", + "integrity": "sha1-rt1t5CX9f5hXGaifRQXSeRFfwyI=", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.0.tgz", + "integrity": "sha1-OVvdHAVzqd9q3qEx/YlG1cK6Lho=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.0.tgz", + "integrity": "sha1-M1DBUdQDUIPM0iQ0jAvcw4lyX3M=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.0.tgz", + "integrity": "sha1-W0dzgbfQEsNfCqXkx+lBe1PlXic=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.0.tgz", + "integrity": "sha1-XfV0/ZZFERM8cA8ORSX7UIZqS84=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.0.tgz", + "integrity": "sha1-5H6zzfEqexs4AIMDHZAyVGnQIvM=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.0.tgz", + "integrity": "sha1-6juF15uE6l0HJ35au09IhC/kGDc=", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.0.tgz", + "integrity": "sha1-GeYTx1I38JvddElVVJ54Xvdng2o=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.0.tgz", + "integrity": "sha1-LP0XQbZpP3jevd4aXU+qBbt1o0k=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha1-OP8EMJ/wNuo1iae62QacROydOIM=", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha1-YmYQnQJc/LBPjkxYlUpvYwpBW38=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha1-hI+TA0FV2veJIYUCjfsYFDv8fQc=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha1-XHeeMsHDYb63UTb9jixif8uaWyg=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha1-uikv9S+jJkE59/54dHGc4KRmaHU=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha1-B+WJSHuaY2I1pK3kjO/B+e7uxX8=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha1-DIq8Io2eGeAGXdtwfrpS4hhVs/8=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha1-UGfcev0V0rl63HepEXfcS+yNG4s=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha1-Ka5fJ5vizmTbNocRmFtq2ORWLlc=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha1-fWk7QPaYdXRLSZSBNZsif9OsOjw=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha1-alXWZPR8X/mtP0a/Bf4H1BC4z9g=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha1-QIqbxiDmjxqvDf6jPae9O2X54u8=", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha1-PVgrABgPpGgOC2yQvmn6X0ogkJI=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha1-zkxmP+87T95nYRpME5GGb4wKp5s=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha1-H0A3uty15sa7V009igFyyD8muX8=", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.102.8", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tanstack/query-core/-/query-core-5.102.8.tgz", + "integrity": "sha1-xVwxtPmRJAVIBc6OyBN8hBCPMlI=", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.102.8", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tanstack/react-query/-/react-query-5.102.8.tgz", + "integrity": "sha1-vcXCRIUljmri5+ysCkz1jFmTeDk=", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.102.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha1-1ET4qInppG6aO087iOD8s++2z5U=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha1-dhOgThRt0pdtJN3wGXMNV6idVsI=", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha1-mT6SXMHXPyxmLn113VpURSWaj9g=", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha1-ZyiDt6y453X8BJLZ6dJeBuiXhtA=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.6", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@testing-library/user-event/-/user-event-14.6.6.tgz", + "integrity": "sha1-7NVhT+Lbt2oXRKm1qW4wW4WRzqw=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha1-GjHD03iFDSd42rtjdNA23LpLpwg=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha1-PfFfJ7qFMZyqB7oI0HIYibs5wBc=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha1-tYGSlMUReZV6+uw0FEL5NB5BCKk=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha1-VnJRNwHBshmbxtrWNqnXSRWGdm8=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha1-B9cT1szg0mXJhJ2wy+YtP2Hzb3Q=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha1-jpzZ4cNYH6azQaWu1ViOsoW+C0o=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha1-M0MRlx06BxIefrkbaEpgXn7qnL0=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha1-zz8Oh2177hWpOrkluCv1cKOQSiQ=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha1-5uWobWAr6spxzlFj+t9fldcJMcc=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/react/-/react-18.3.31.tgz", + "integrity": "sha1-teleKP/M6rjZgvM/LrB24XZTwqQ=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha1-uJ3fLNg7T+r8xOLqQa/fuVoNGU8=", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha1-ZHr057t1rTrdV452KtmEuQ9KJLk=", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha1-cKNBWDg9AIw79dgC4mQzF/Cd9tg=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha1-MxvpRMt4PGQt1CvXQ0EayiTqBGY=", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha1-KntZP44Afp2O9+c0OqMOxz/eryk=", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha1-wMCAIoGJ8fps2kD1m+CddGsKylE=", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha1-o6fhlQzpnsTPAjleIN3KQDtsgY4=", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha1-yn++5EAZUjykUDldmiKEzp7OHzE=", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha1-MCyBJiEaxN/qh7O1CFwJjW0i6J4=", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha1-48121MVI7oldPD/Y3B9sW5Ay56g=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha1-B0SWkK1Fd30ZJKwquy/IiV26g2s=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha1-ZQxWnkGtkLUbPX315e7Rx1ScED4=", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha1-9kGhlrM1aQsQcL8AtudZP+wZC/c=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.19", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz", + "integrity": "sha1-RxGrrEi4jMtWtYF+hvGzqaB2QnY=", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha1-o8ec63AChSfl2n2vyIfzIAtRaMA=", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cac/-/cac-6.7.14.tgz", + "integrity": "sha1-gE4eb1Bu42PLDjzLsJytXdmHCVk=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha1-S1QowiK+mF15w9gmV0edvgtZstY=", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha1-SXC0d96jJ4N03pvEOqj105/DzaI=", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chai/-/chai-5.3.3.tgz", + "integrity": "sha1-3T2pVeJwkWpL0/Yl9LkZmWrafgY=", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha1-JCc2ERe3DMqNyJaA6tMrFXAZyvU=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha1-w9RaizT9cwYxoRCoolIGgrMdWn8=", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha1-S1YPZJ/E6RjdCrdc9JYei8iC2Co=", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha1-O7m9/II2nbnC9pyTycPOsxDIizw=", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha1-6hgAcCTjFn9PEFMV8+wtmCv0jtk=", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha1-MCHRtDUvvzthSq7tC8DVc5q+C8I=", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha1-7EjA8+mT5QZIyG2lWeJhCZXPmJo=", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha1-L3aQa84YJEKf/stpIPRaCzDwDd4=", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/debug/-/debug-4.4.3.tgz", + "integrity": "sha1-xq5DLZvZZiWC/OCHCbA4xY6ePWo=", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha1-5kmkPjq5U6chkv9Zg4ZeUJ837Zo=", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha1-S3VtjXcKklcwCCXVKiws/5nDo0E=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha1-JkQhTxmX057Q7g7OcjNUkKesZ74=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha1-aJxdzcGQDvVYOky59te0c3QgdK0=", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha1-WnQp5gZus2ZNkR4z+w5F3o6whFM=", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha1-165mfh3INIL4tw/Q9u78UNow9Yo=", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.415", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/electron-to-chromium/-/electron-to-chromium-1.5.415.tgz", + "integrity": "sha1-7dc1a/tHUqEsgpP444oAd4sTu7Q=", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha1-tNrTJVt1RfB7pVNRiYaOn4X0dXM=", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/entities/-/entities-6.0.1.tgz", + "integrity": "sha1-wow0pDN5yn9h0HQTCy9fcCCjBpQ=", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha1-mD6y+aZyTpMD9hrd8BHHLgngsPo=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha1-BfdaJdq5jk+x3NXhRywFRtUFfI8=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha1-kVlgFWGICoXyc0VgqQmbLDHlNyo=", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha1-otCzcyBXJN+lJdI7DD4bHKWCyZs=", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha1-8x274MGDsAptJutjJcgQwP0YvU0=", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha1-l6HQQfSrAML84vg40rmWmi0ql6U=", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha1-ARo/aYVroYnf+n3I/M6Z0qh5A+U=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha1-Z8PlSexAKkh7T8GT0ZU6UkdSNA0=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha1-JO338MxppE0AhWe6RZSrlvPDo9Y=", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha1-7Sq5Z6MxreYvGNB32uGSaE1Q01A=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha1-KOhk4beG2+u2jbH0UvljUnhmWCc=", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha1-ysZAd4XQNnWipeGlMFxpezR9kNY=", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha1-LALYZNl/PqbIgwxGTL0Rq26rehw=", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha1-MqbudsPX9S1GsrGuXZP+qFgKJeA=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha1-dD8OO2lkqTpUke0b/6rgVNf5jQE=", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha1-FQs/J0OGnvPoUewMSdFbHRTQDuE=", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha1-ifVrghe9vIgCvSmd9tfxCB1+UaE=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha1-QYPk6L8Iu24Fu7L30uDI9xLKQOM=", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha1-/JxqeDoISVHQuXH+EBjegTcHozg=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha1-LNxC1AvvLltO6rfAGnPFTOerWrw=", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha1-jGLYy5C+sqrV0KW2dYGtmFTD8AM=", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha1-aW31KafP2CRGNp3FGT5ZCjc1tEg=", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha1-mosfJGhmwChQlIZYX2K48sGMJw4=", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha1-2o3+rH2hMLBcK6S1nJts1mYRprk=", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha1-pS+AvzjaGVLrXGgXkHGYcaGnJQE=", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha1-Yk+PRJfWGbLZdoUx1Y9BIoVNclE=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha1-Fx7W8Z46xVQ5Tt94yqBXhKRb67U=", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha1-l0Io8vTKK8IYhaF5e0X+po6VDGQ=", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha1-GSA/tZmR35jjoocFDUZHzerzJJk=", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha1-U27GhcKI/IpXc6Zfgti0S63Mc+8=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha1-dNM1ojT2ftGZB/2t+sfM+dQJgl0=", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json5/-/json5-2.2.3.tgz", + "integrity": "sha1-eM1vGhm9wStz21rQxh79ZsHikoM=", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha1-uFqulkhtyxv0mnyFcSISc/Tx5Kk=", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha1-8DOIURbf79nG9UeHUj41FLYeGWg=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha1-ULcYcbAcgZlYS2SeKSVH+up6+bU=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha1-NfPpczLRMLnKGB4RtWje1q68bV4=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha1-l3enZHK2Ttb/lDQq1kx7r9eUpXU=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha1-E65lLhq3O5E117faFy9mbEEK1T0=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha1-QXhYeVqUWS9oASOhsfnaig4e8zU=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha1-a+NmkugQtxgECAL9gJYjz/5zITM=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha1-C3gDr06yHP043Tn+Kru1PH3QkfY=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha1-iNyLqGXd3bGsXvBLDxYYBEGMFjs=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha1-TzC6P6XpJfW3n5RejMDRdsOxqzg=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha1-FBqlYFZFBkkokCu0rwRfp9n0Igo=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha1-ce5R+nvkyuwaY4OffmgtgTLTDK8=", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha1-AJXPVtxbepp8CP9bGoeW7IrRfnY=", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha1-HaJ+ZxAnGUdpXa9oSOhH8B2EuSA=", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha1-watQ93iHtxJiEgG6n9Tjpu0JmUE=", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha1-VnY+wJoPqAkd8nh5/ZTRkHjADZE=", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha1-oN10voHiqlwvJ+Zc4oNgXuTit/k=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha1-u6vNwChZ9JhzAchW4zh85exDv3A=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha1-OBqHG2KnNEUGYK497uRIE/cNlZo=", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha1-pj9oFnOzBXH76LwlaGrnRu76mGk=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ms/-/ms-2.1.3.tgz", + "integrity": "sha1-V0yBOM4dK1hh8LRFedut1gxmFbI=", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha1-9mot4Rmf/eD88hyKXxMQaxwIGRM=", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha1-Ca8X1WR6qfIh7FzyvsuVtoqYGv4=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha1-+JJwQ9TJtRar3r6ASjLI0flITR8=", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha1-1+Ik+nI5nHoXUJn0X8KtAksF7AU=", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha1-PsvsVUIWhbcKnahyss/z4cvtFxY=", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha1-iFXFooma8HLWrAXRHkYEWtDcYF0=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha1-PTIa8+q5ObCDyPkpodEs2oHCa2s=", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha1-YxM2ADTMs2s9xh7L3/eBIfkP4h8=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha1-bnUTV4DH4Q3zQzvyJmxVLTXIxiA=", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha1-IYGHn96lGnpYUfs52SD6pj8B2I4=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha1-AnQi4vrsCyXhVJw+G9gwm5EztuU=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react/-/react-18.3.1.tgz", + "integrity": "sha1-SauJIAnFOTNiW9FrJTP8dUyrKJE=", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha1-wiZdeVEbV9R5s90/36UVNklMXLQ=", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha1-5pHUqOnHiTZWVVOas3J2Kw77VPA=", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha1-t+V5w2V/I9BOzL5K0uWKjtUeflM=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha1-p2xGzp5e2s1PUSidWnH3EwXbkVI=", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha1-AL8dDOi/F6bYMZSarNpyphIOSSY=", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/redent/-/redent-3.0.0.tgz", + "integrity": "sha1-5Ve3mYMWu1PJ8fVvpiY1LGljBZ8=", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.63.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rollup/-/rollup-4.63.0.tgz", + "integrity": "sha1-VXmKmDSlWVdUSpcazA+DZcomW4c=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.0", + "@rollup/rollup-android-arm64": "4.63.0", + "@rollup/rollup-darwin-arm64": "4.63.0", + "@rollup/rollup-darwin-x64": "4.63.0", + "@rollup/rollup-freebsd-arm64": "4.63.0", + "@rollup/rollup-freebsd-x64": "4.63.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.0", + "@rollup/rollup-linux-arm-musleabihf": "4.63.0", + "@rollup/rollup-linux-arm64-gnu": "4.63.0", + "@rollup/rollup-linux-arm64-musl": "4.63.0", + "@rollup/rollup-linux-loong64-gnu": "4.63.0", + "@rollup/rollup-linux-loong64-musl": "4.63.0", + "@rollup/rollup-linux-ppc64-gnu": "4.63.0", + "@rollup/rollup-linux-ppc64-musl": "4.63.0", + "@rollup/rollup-linux-riscv64-gnu": "4.63.0", + "@rollup/rollup-linux-riscv64-musl": "4.63.0", + "@rollup/rollup-linux-s390x-gnu": "4.63.0", + "@rollup/rollup-linux-x64-gnu": "4.63.0", + "@rollup/rollup-linux-x64-musl": "4.63.0", + "@rollup/rollup-openbsd-x64": "4.63.0", + "@rollup/rollup-openharmony-arm64": "4.63.0", + "@rollup/rollup-win32-arm64-msvc": "4.63.0", + "@rollup/rollup-win32-ia32-msvc": "4.63.0", + "@rollup/rollup-win32-x64-gnu": "4.63.0", + "@rollup/rollup-win32-x64-msvc": "4.63.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha1-xzRRpIS4bdfPseCyiY30twMYPks=", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha1-RPoWGwGHuVSd2Eu5GAL5vYOFzWo=", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha1-/ltKR2jfTxSiAbG6amXB89mYjMU=", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha1-QUumSjsoKJLpRM8hCOzAeNEVzcM=", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/semver/-/semver-6.3.1.tgz", + "integrity": "sha1-VW0u+GiRRuRtzqS/3QlfNDTf/LQ=", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha1-zNCGc6muXS5E6iot4lCJ5nx+32g=", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha1-MudscLeXJOO7Vny51UPrhYzPrzA=", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha1-HOVlD93YerwJnto33P8CTCZnrkY=", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha1-Gsig2Ug4SNFpXkGLbQMaPDzmjjs=", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha1-2BCyfjoHMEeyteQANIgfXqb5yDs=", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha1-wy4c7pQLazQyx3G8LFS8znPNMAE=", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha1-IiskPdLUnAvNDeiQatvYQXcZYDI=", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha1-LsQ5ZGWENSlvZ2GzThBnHC2VJ/Q=", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha1-QwY30ki6d+B4iDlR+5qg7tfGP6I=", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha1-wAaGFhHCE8GHeJOrWyPaoWviu1U=", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha1-XafJmSxGA4IhJnmFqyhCGoh58WA=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha1-EDyfi6bXI3pHq23R3P93JRhjQms=", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha1-lBeU5leoXklld5lcbu9m9T9Cs9I=", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha1-ViqabJ6ys7Ej05cZ+a9btE/NdjE=", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha1-BZ8tBCvTdWf7wBfT1Ca90qJhJZE=", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha1-lQmyFiQ2MV6A4+7g/M5EdNJEQpQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha1-13oAL7U6iKoUKbQZwckkkuDIH3g=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha1-CH4FVbMblyXuSMp+d+3FYRXNgvc=", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha1-qT5u2dUFy1TFQs5D/rFMc5EyZdg=", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha1-Ztd0tKHZ4S3HUIlyWvOsdewxvtc=", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha1-lq6GfN24/bZKScwwWajUKLzyOMo=", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha1-W09Z4VMQqxeiFvXWz1PuR27eZw8=", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha1-pxwo3SL1BUgdvEaJCHsY2TPpCv0=", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vite/-/vite-6.4.3.tgz", + "integrity": "sha1-haFk23znBvKndoEu+is0DxchhY4=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha1-82dtlMSvHnaJjBYsknKLymX3uwc=", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha1-GUS27QE6Jf0mpz0Y4a+SwQpXr2w=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha1-+SW6JoVRWFlNkHMTzt0UdsWWf2w=", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha1-JWtOGIK+feu/AdBfCqIDl3jqCAo=", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha1-0PTvdpkF1CbhaI8+NDgambYLduU=", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha1-vBv5SphdxQOI1UqSWKxAXDyi/Ao=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha1-TuAtXXJRVdrgBPaulcc+fvXZVmM=", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha1-o/aalxB/SUs83Dvd3Yg6fWXOvwQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ws/-/ws-8.21.3.tgz", + "integrity": "sha1-ZgtPrdtqPldchuB4EmkZlh9N5Pw=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha1-gr6blX96/az5YeWYDxvyJ8C/dnM=", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha1-Bg/hvLf5x2/ioX24apvDq4lCEMs=", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha1-27fa+b/YusmrRev2ArjLrQ1dCP0=", + "dev": true, + "license": "ISC" + } + } +} diff --git a/front_end/web/package.json b/front_end/web/package.json new file mode 100644 index 0000000..7b37526 --- /dev/null +++ b/front_end/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "linux-broker-portal", + "private": true, + "version": "0.114.0", + "type": "module", + "description": "Service Management Portal for the Linux Broker for AVD Access solution.", + "scripts": { + "dev": "vite", + "build": "tsc --build --force && vite build", + "preview": "vite preview", + "typecheck": "tsc --build --force", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@tanstack/react-query": "^5.62.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^7.1.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^25.0.1", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.2", + "vite": "^6.0.0", + "vitest": "^3.0.0" + } +} diff --git a/front_end/web/src/App.test.tsx b/front_end/web/src/App.test.tsx new file mode 100644 index 0000000..44f2c0e --- /dev/null +++ b/front_end/web/src/App.test.tsx @@ -0,0 +1,306 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import { App } from './App'; +import { ToastProvider } from './components/ui/Toast'; +import { setCsrfToken } from './lib/api'; + +/* + * Integration cover for the whole client: session bootstrap, the app shell, the + * query layer, and the pages mounting against real (stubbed) BFF responses. + * + * The unit tests check components in isolation and TypeScript checks the types, + * but neither catches a page that throws on mount, a missing provider, or a hook + * used incorrectly. This does. + */ + +const SESSION = { + authenticated: true, + version: '0.114', + csrfToken: 'test-csrf-token', + user: { + name: 'Test Operator', + username: 'op@contoso.com', + objectId: '0000-1111', + tenantId: '2222-3333', + }, +}; + +const DASHBOARD = { + stats: { + total: 9, available: 4, checked_out: 3, maintenance: 1, released: 1, other: 0, + unreachable: 2, powered_on: 7, powered_off: 2, ready: 3, attention: 3, + utilization: 33, + pct: { available: 44.44, checked_out: 33.33, released: 11.11, maintenance: 11.11, other: 0 }, + }, + recentActivity: [ + { + ActivityID: 1, CheckTimestamp: '2026-08-19 10:00:00', CurrentRunningVMs: 5, + CurrentInUseVMs: 4, ActionTaken: 'Scale Up', VMsPoweredOn: 2, VMsPoweredOff: 0, + NewTotalVMs: 7, Outcome: 'Scaled up by 2 VMs', Notes: 'Utilization above threshold', + }, + ], + apiError: false, +}; + +const VMS = [ + { + VMID: 1, Hostname: 'linux-host-01', IPAddress: '10.0.0.4', PowerState: 'On', + NetworkStatus: 'Reachable', VmStatus: 'Available', Username: null, AvdHost: null, + Description: 'Pool host', LastUpdateDate: '2026-08-01 10:00:00', + CreateDate: '2026-07-01 10:00:00', SysStartTime: '2026-08-01 10:00:00', SysEndTime: null, + }, + { + VMID: 2, Hostname: 'linux-host-02', IPAddress: '10.0.0.5', PowerState: 'On', + NetworkStatus: 'Reachable', VmStatus: 'CheckedOut', Username: 'alice@contoso.com', + AvdHost: 'avd-01', Description: '', LastUpdateDate: '2026-08-02 11:00:00', + CreateDate: '2026-07-01 10:00:00', SysStartTime: '2026-08-02 11:00:00', SysEndTime: null, + }, +]; + +const RULES = [ + { + RuleID: 1, MinVMs: 2, MaxVMs: 20, ScaleUpRatio: 80, ScaleUpIncrement: 2, + ScaleDownRatio: 30, ScaleDownIncrement: 1, + }, +]; + +const HOST_SETTINGS = { + settings: { + GracePeriodSeconds: 1200, ReconcileIntervalSeconds: 60, WatcherDebounceSeconds: 10, + WatcherSettleSeconds: 2, IdleTimeoutSeconds: 0, IdleWarningSeconds: 120, + ScreenLockEnabled: false, DisableLockScreen: true, ScreenIdleDelaySeconds: 0, + ScreenLockDelaySeconds: 0, ScreenLockSettingsLocked: true, SettingsVersion: 3, + }, + hosts: VMS, +}; + +const EMPTY_PAGE = { items: [], page: 1, perPage: 10, total: 0, totalPages: 0 }; + +function jsonResponse(body: unknown, status = 200) { + return { + ok: status < 400, + status, + json: async () => body, + } as Response; +} + +let session: typeof SESSION | { authenticated: false; version: string; csrfToken: string; user: null } = + SESSION; +const requests: string[] = []; + +function stubFetch() { + return vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + requests.push(url); + + if (url.startsWith('/api/ui/session')) return jsonResponse(session); + if (url.startsWith('/api/ui/dashboard')) return jsonResponse(DASHBOARD); + if (url.startsWith('/api/ui/vms/history')) return jsonResponse(EMPTY_PAGE); + if (url.startsWith('/api/ui/vms/')) return jsonResponse(VMS[0]); + if (url.startsWith('/api/ui/vms')) return jsonResponse(VMS); + if (url.startsWith('/api/ui/scaling/rules/history')) return jsonResponse(EMPTY_PAGE); + if (url.startsWith('/api/ui/scaling/log')) return jsonResponse(EMPTY_PAGE); + if (url.startsWith('/api/ui/scaling/rules')) return jsonResponse(RULES); + if (url.startsWith('/api/ui/hosts/settings')) return jsonResponse(HOST_SETTINGS); + + return jsonResponse({ error: 'Unexpected request' }, 404); + }); +} + +function renderApp(route: string) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + return render( + + + + + + + , + ); +} + +beforeEach(() => { + session = SESSION; + requests.length = 0; + setCsrfToken(null); + vi.stubGlobal('fetch', stubFetch()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('App', () => { + it('shows a starting state before the session resolves', () => { + renderApp('/'); + expect(screen.getByText('Starting the portal')).toBeInTheDocument(); + }); + + it('renders the dashboard for a signed-in operator', async () => { + renderApp('/'); + + // The header renders immediately and the counters arrive with the query, so + // the data assertions have to wait rather than read the loading state. + expect(await screen.findByText('33% of the pool in use')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Pool overview', level: 1 })).toBeInTheDocument(); + expect(screen.getByText('On, reachable and unassigned')).toBeInTheDocument(); + expect(screen.getByText('2 unreachable \u00b7 1 maintenance')).toBeInTheDocument(); + expect(screen.getByText('Pool composition')).toBeInTheDocument(); + // The activity panel rendered too, with its action badge. + expect(screen.getByText('Scale up')).toBeInTheDocument(); + }); + + it('caches the CSRF token from the session bootstrap', async () => { + renderApp('/'); + await screen.findByText('33% of the pool in use'); + + const { getCsrfToken } = await import('./lib/api'); + expect(getCsrfToken()).toBe('test-csrf-token'); + }); + + it('sends the session cookie on every BFF call', async () => { + renderApp('/'); + await screen.findByText('33% of the pool in use'); + + const call = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(call[1]).toMatchObject({ credentials: 'same-origin' }); + }); + + it('shows the sign-in landing page when signed out', async () => { + session = { authenticated: false, version: '0.114', csrfToken: 'anon-token', user: null }; + renderApp('/'); + + // The footer carries the same wording, so match the page heading specifically. + expect( + await screen.findByRole('heading', { name: 'Linux Broker Management Portal', level: 1 }), + ).toBeInTheDocument(); + expect(screen.getAllByRole('link', { name: /Sign in/ })[0]).toHaveAttribute('href', '/login'); + // No data is fetched for an anonymous visitor beyond the bootstrap itself. + expect(requests.every((url) => url.startsWith('/api/ui/session'))).toBe(true); + }); + + it('redirects a signed-out visitor away from a deep link', async () => { + session = { authenticated: false, version: '0.114', csrfToken: 'anon-token', user: null }; + renderApp('/vms'); + + // Rendering a page frame that cannot load any data would be worse than the + // landing page, so everything collapses to it while signed out. + expect( + await screen.findByRole('heading', { name: 'Linux Broker Management Portal', level: 1 }), + ).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Virtual machines' })).not.toBeInTheDocument(); + }); + + it('reports a backend it cannot reach, and can retry', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonResponse({ error: 'boom' }, 500)), + ); + + renderApp('/'); + // The session query retries once before giving up, so this needs longer than + // the default assertion timeout. + expect( + await screen.findByText('The portal could not start', undefined, { timeout: 5000 }), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); + }); + + // Every authenticated page, mounted for real. A page that throws on mount, uses + // a hook incorrectly, or misses a provider fails here. + it.each([ + ['/', 'Pool overview'], + ['/profile', 'Profile'], + ['/vms', 'Virtual machines'], + ['/vms/add', 'Add virtual machine'], + ['/vms/checkout', 'Checkout a virtual machine'], + ['/vms/history', 'Virtual machine history'], + ['/vms/1', 'linux-host-01'], + ['/vms/1/update', 'Update linux-host-01'], + ['/scaling/rules', 'Scaling rules'], + ['/scaling/rules/create', 'Create scaling rule'], + ['/scaling/rules/history', 'Scaling rule history'], + ['/scaling/log', 'Scaling activity log'], + ['/settings/hosts', 'Linux host settings'], + ])('mounts %s', async (route, heading) => { + renderApp(route); + expect(await screen.findByRole('heading', { name: heading, level: 1 })).toBeInTheDocument(); + }); + + it('renders the not-found state for an unknown client route', async () => { + renderApp('/nope'); + expect(await screen.findByText('Page not found')).toBeInTheDocument(); + }); + + it('lists VMs with lifecycle-appropriate row actions', async () => { + renderApp('/vms'); + // Wait for the rows, not just the page header, which renders while loading. + await screen.findByRole('button', { name: 'Delete linux-host-01' }); + + // linux-host-01 is Available, so neither release nor return applies. + expect(screen.queryByRole('button', { name: 'Release linux-host-01' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Return linux-host-01' })).not.toBeInTheDocument(); + + // linux-host-02 is CheckedOut, so both do. + expect(screen.getByRole('button', { name: 'Release linux-host-02' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Return linux-host-02' })).toBeInTheDocument(); + }); + + it('confirms before sending a destructive action', async () => { + renderApp('/vms'); + const deleteButton = await screen.findByRole('button', { name: 'Delete linux-host-01' }); + + await userEvent.click(deleteButton); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText(/Permanently delete linux-host-01/)).toBeInTheDocument(); + // Nothing has been sent yet. + expect(requests.some((url) => url.includes('/delete'))).toBe(false); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(requests.some((url) => url.includes('/delete'))).toBe(false); + }); + + it('sends the CSRF header on a confirmed mutation', async () => { + renderApp('/vms'); + await userEvent.click(await screen.findByRole('button', { name: 'Delete linux-host-01' })); + + const dialog = await screen.findByRole('dialog'); + await userEvent.click(within(dialog).getByRole('button', { name: 'Delete' })); + + await waitFor(() => { + const call = (globalThis.fetch as ReturnType).mock.calls.find( + ([url]) => String(url) === '/api/ui/vms/1/delete', + ); + expect(call).toBeDefined(); + expect(call?.[1]?.headers).toMatchObject({ 'X-CSRFToken': 'test-csrf-token' }); + }); + }); + + it('surfaces a BFF error message to the operator', async () => { + renderApp('/vms'); + const deleteButton = await screen.findByRole('button', { name: 'Delete linux-host-01' }); + + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonResponse({ error: 'Unable to delete VM. Please try again later.' }, 502)), + ); + + await userEvent.click(deleteButton); + const dialog = await screen.findByRole('dialog'); + await userEvent.click(within(dialog).getByRole('button', { name: 'Delete' })); + + expect( + await screen.findByText('Unable to delete VM. Please try again later.'), + ).toBeInTheDocument(); + }); +}); diff --git a/front_end/web/src/App.tsx b/front_end/web/src/App.tsx new file mode 100644 index 0000000..662d79d --- /dev/null +++ b/front_end/web/src/App.tsx @@ -0,0 +1,101 @@ +import { Navigate, Route, Routes } from 'react-router-dom'; + +import { AppShell } from './components/layout/AppShell'; +import { ErrorPanel, LoadingPanel } from './components/ui/Feedback'; +import { SessionContext, useSessionQuery } from './hooks/useSession'; +import { errorMessage } from './lib/api'; +import type { SessionInfo } from './types/broker'; + +import { Dashboard } from './pages/Dashboard'; +import { NotFound } from './pages/NotFound'; +import { Profile } from './pages/Profile'; +import { SignIn } from './pages/SignIn'; +import { AddVm } from './pages/vm/AddVm'; +import { CheckoutVm } from './pages/vm/CheckoutVm'; +import { UpdateVmAttributes } from './pages/vm/UpdateVmAttributes'; +import { VmDetails } from './pages/vm/VmDetails'; +import { VmHistory } from './pages/vm/VmHistory'; +import { VmList } from './pages/vm/VmList'; +import { ActivityLog } from './pages/scaling/ActivityLog'; +import { CreateRule } from './pages/scaling/CreateRule'; +import { RuleDetails } from './pages/scaling/RuleDetails'; +import { RuleHistory } from './pages/scaling/RuleHistory'; +import { RuleList } from './pages/scaling/RuleList'; +import { UpdateRule } from './pages/scaling/UpdateRule'; +import { HostSettingsPage } from './pages/settings/HostSettings'; + +/** + * Routes deliberately mirror the URLs the Jinja portal served, so existing + * bookmarks and links in runbooks keep resolving after the rewrite. + */ +function AuthenticatedRoutes() { + return ( + + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + } /> + + ); +} + +export function App() { + const { data: session, isPending, error, refetch } = useSessionQuery(); + + if (isPending) { + return ( +
+ +
+ ); + } + + if (error || !session) { + return ( +
+ void refetch()}> + Try again + + } + /> +
+ ); + } + + return ( + + + {session.authenticated ? ( + + ) : ( + // Everything collapses to the landing page while signed out, rather than + // rendering a page frame that cannot load any data. + + } /> + } /> + + )} + + + ); +} diff --git a/front_end/web/src/components/Icon.tsx b/front_end/web/src/components/Icon.tsx new file mode 100644 index 0000000..fd8d727 --- /dev/null +++ b/front_end/web/src/components/Icon.tsx @@ -0,0 +1,221 @@ +import type { ReactNode, SVGProps } from 'react'; + +/* + * Icons are hand-authored inline SVG so the portal has no icon-font or sprite + * dependency and renders identically in disconnected and sovereign clouds. + * These are the same 37 glyphs the Jinja `ui.icon()` macro used to provide. + */ +const ICONS = { + 'check-circle': ( + <> + + + + ), + 'x-circle': ( + <> + + + + ), + 'dash-circle': ( + <> + + + + ), + 'info-circle': ( + <> + + + + + ), + 'alert-triangle': ( + <> + + + + + ), + power: ( + <> + + + + ), + person: ( + <> + + + + ), + wifi: ( + <> + + + + + ), + 'wifi-off': ( + <> + + + + + + ), + wrench: ( + + ), + 'arrow-up': ( + <> + + + + ), + 'arrow-down': ( + <> + + + + ), + 'arrow-return': ( + <> + + + + ), + 'box-arrow-right': ( + <> + + + + + ), + clock: ( + <> + + + + ), + search: ( + <> + + + + ), + plus: , + pencil: , + trash: ( + <> + + + + + ), + eye: ( + <> + + + + ), + sun: ( + <> + + + + ), + moon: , + gauge: ( + <> + + + + ), + sliders: ( + <> + + + + + ), + server: ( + <> + + + + + ), + 'chevron-up': , + 'chevron-down': , + 'chevron-left': , + 'chevron-right': , + 'chevron-expand': ( + <> + + + + ), + funnel: , + refresh: ( + <> + + + + ), + x: , + list: ( + <> + + + + ), + activity: , + shield: , + home: ( + <> + + + + ), +} satisfies Record; + +export type IconName = keyof typeof ICONS; + +export const ICON_NAMES = Object.keys(ICONS) as IconName[]; + +const FALLBACK = ; + +export interface IconProps extends Omit, 'name'> { + name: IconName; + size?: number; + /** Set when the icon is the only content of its control and carries meaning. */ + title?: string; +} + +export function Icon({ name, size = 16, title, className, ...rest }: IconProps) { + const decorative = title === undefined; + + return ( + + {title === undefined ? null : {title}} + {ICONS[name] ?? FALLBACK} + + ); +} diff --git a/front_end/web/src/components/data/DataTable.test.tsx b/front_end/web/src/components/data/DataTable.test.tsx new file mode 100644 index 0000000..409d5fc --- /dev/null +++ b/front_end/web/src/components/data/DataTable.test.tsx @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { DataTable } from './DataTable'; +import type { Column } from './DataTable'; + +interface Row { + id: number; + hostname: string; + vms: number; + seen: string; + status: string; +} + +const ROWS: Row[] = [ + { id: 1, hostname: 'linux-host-03', vms: 10, seen: '2026-08-03 09:00:00', status: 'Maintenance' }, + { id: 2, hostname: 'linux-host-01', vms: 2, seen: '2026-08-01 10:00:00', status: 'Available' }, + { id: 3, hostname: 'linux-host-02', vms: 30, seen: '2026-08-02 11:00:00', status: 'CheckedOut' }, +]; + +const COLUMNS: Array> = [ + { + key: 'hostname', + header: 'Hostname', + sort: 'text', + value: (row) => row.hostname, + render: (row) => row.hostname, + }, + { key: 'vms', header: 'VMs', sort: 'number', value: (row) => row.vms, render: (row) => row.vms }, + { + key: 'seen', + header: 'Last seen', + sort: 'date', + value: (row) => row.seen, + render: (row) => row.seen, + }, + { + key: 'status', + header: 'Status', + // The cell renders a badge, so the sort and search value has to be supplied. + value: (row) => row.status, + render: (row) => {row.status}, + }, +]; + +function hostnames() { + const body = screen.getAllByRole('rowgroup')[1]; + return within(body) + .getAllByRole('row') + .map((row) => within(row).getAllByRole('cell')[0].textContent); +} + +function renderTable() { + return render( + row.id} + searchable + searchPlaceholder="Search hosts…" + noun="hosts" + />, + ); +} + +describe('DataTable', () => { + it('renders rows in the given order until a column is sorted', () => { + renderTable(); + expect(hostnames()).toEqual(['linux-host-03', 'linux-host-01', 'linux-host-02']); + }); + + it('sorts text ascending then descending, and reports it to assistive technology', async () => { + renderTable(); + const header = screen.getByRole('columnheader', { name: /Hostname/ }); + + await userEvent.click(within(header).getByRole('button')); + expect(hostnames()).toEqual(['linux-host-01', 'linux-host-02', 'linux-host-03']); + expect(header).toHaveAttribute('aria-sort', 'ascending'); + + await userEvent.click(within(header).getByRole('button')); + expect(hostnames()).toEqual(['linux-host-03', 'linux-host-02', 'linux-host-01']); + expect(header).toHaveAttribute('aria-sort', 'descending'); + }); + + it('sorts numbers numerically rather than lexically', async () => { + renderTable(); + await userEvent.click( + within(screen.getByRole('columnheader', { name: /VMs/ })).getByRole('button'), + ); + // A lexical sort would put 10 before 2. + expect(hostnames()).toEqual(['linux-host-01', 'linux-host-03', 'linux-host-02']); + }); + + it('sorts broker timestamps chronologically', async () => { + renderTable(); + await userEvent.click( + within(screen.getByRole('columnheader', { name: /Last seen/ })).getByRole('button'), + ); + expect(hostnames()).toEqual(['linux-host-01', 'linux-host-02', 'linux-host-03']); + }); + + it('filters on the supplied value, so badge cells are still searchable', async () => { + renderTable(); + await userEvent.type(screen.getByRole('searchbox'), 'CheckedOut'); + expect(hostnames()).toEqual(['linux-host-02']); + expect(screen.getByText('1 shown of 3 hosts')).toBeInTheDocument(); + }); + + it('shows the plain total when nothing is filtered out', () => { + renderTable(); + expect(screen.getByText('3 hosts')).toBeInTheDocument(); + }); + + it('explains an empty result rather than showing a blank table', async () => { + renderTable(); + await userEvent.type(screen.getByRole('searchbox'), 'no-such-host'); + expect(screen.getByText('No results.')).toBeInTheDocument(); + }); + + it('leaves unsortable columns without a sort control', () => { + renderTable(); + const header = screen.getByRole('columnheader', { name: 'Status' }); + expect(within(header).queryByRole('button')).not.toBeInTheDocument(); + expect(header).not.toHaveAttribute('aria-sort'); + }); +}); diff --git a/front_end/web/src/components/data/DataTable.tsx b/front_end/web/src/components/data/DataTable.tsx new file mode 100644 index 0000000..9aa432f --- /dev/null +++ b/front_end/web/src/components/data/DataTable.tsx @@ -0,0 +1,240 @@ +import { useId, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; + +import { classNames } from '../../lib/format'; +import { Icon } from '../Icon'; +import { GlassCard } from '../ui/GlassCard'; + +export type SortType = 'text' | 'number' | 'date'; + +export interface Column { + /** Stable key, also used as the React key for the cell. */ + key: string; + header: ReactNode; + render: (row: T) => ReactNode; + /** Omit to make the column unsortable. */ + sort?: SortType; + /** + * Value used for sorting and for the search box. Needed whenever the cell + * renders a badge or a link rather than plain text. + */ + value?: (row: T) => string | number | null | undefined; + className?: string; + headerClassName?: string; +} + +type SortState = { key: string; direction: 'asc' | 'desc' } | null; + +function cellText(column: Column, row: T): string { + if (column.value) { + const value = column.value(row); + return value === null || value === undefined ? '' : String(value); + } + const rendered = column.render(row); + return typeof rendered === 'string' || typeof rendered === 'number' ? String(rendered) : ''; +} + +function compare(column: Column, a: T, b: T): number { + const left = cellText(column, a); + const right = cellText(column, b); + + if (column.sort === 'number') { + const nl = Number.parseFloat(left); + const nr = Number.parseFloat(right); + const safeLeft = Number.isNaN(nl) ? Number.NEGATIVE_INFINITY : nl; + const safeRight = Number.isNaN(nr) ? Number.NEGATIVE_INFINITY : nr; + return safeLeft - safeRight; + } + + if (column.sort === 'date') { + // Broker timestamps are 'YYYY-MM-DD HH:MM:SS'; the space needs replacing for + // Date.parse to accept them consistently across engines. + const dl = Date.parse(left.replace(' ', 'T')); + const dr = Date.parse(right.replace(' ', 'T')); + const safeLeft = Number.isNaN(dl) ? Number.NEGATIVE_INFINITY : dl; + const safeRight = Number.isNaN(dr) ? Number.NEGATIVE_INFINITY : dr; + return safeLeft - safeRight; + } + + return left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' }); +} + +export interface DataTableProps { + columns: Array>; + rows: T[]; + rowKey: (row: T) => string | number; + /** Shows the search box and the "shown of total" counter. */ + searchable?: boolean; + searchPlaceholder?: string; + /** Plural noun for the counter, for example "VMs". */ + noun?: string; + emptyMessage?: string; + caption?: string; +} + +/** + * Sortable, filterable table. + * + * Replaces the `data-lb-sort` / `data-lb-value` / `data-lb-filter-target` hooks + * that app.js used to wire up against the Jinja markup. Filtering and sorting are + * client-side and apply to the rows currently on screen, which for the paged + * views means the current page, exactly as before. + */ +export function DataTable({ + columns, + rows, + rowKey, + searchable = false, + searchPlaceholder = 'Search…', + noun = 'rows', + emptyMessage = 'No results.', + caption, +}: DataTableProps) { + const [query, setQuery] = useState(''); + const [sort, setSort] = useState(null); + const searchId = useId(); + const countId = useId(); + + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) { + return rows; + } + return rows.filter((row) => + columns.some((column) => cellText(column, row).toLowerCase().includes(needle)), + ); + }, [columns, rows, query]); + + const sorted = useMemo(() => { + if (!sort) { + return filtered; + } + const column = columns.find((candidate) => candidate.key === sort.key); + if (!column?.sort) { + return filtered; + } + const factor = sort.direction === 'asc' ? 1 : -1; + return [...filtered].sort((a, b) => compare(column, a, b) * factor); + }, [columns, filtered, sort]); + + function toggleSort(column: Column) { + if (!column.sort) { + return; + } + setSort((current) => + current?.key === column.key + ? { key: column.key, direction: current.direction === 'asc' ? 'desc' : 'asc' } + : { key: column.key, direction: 'asc' }, + ); + } + + const counter = + filtered.length === rows.length + ? `${rows.length} ${noun}` + : `${filtered.length} shown of ${rows.length} ${noun}`; + + return ( +
+ {searchable ? ( +
+
+ + + setQuery(event.target.value)} + /> +
+ + {counter} + +
+ ) : null} + + +
+ + {caption ? : null} + + + {columns.map((column) => { + const active = sort?.key === column.key; + const ariaSort = !column.sort + ? undefined + : active + ? sort.direction === 'asc' + ? 'ascending' + : 'descending' + : 'none'; + + return ( + + ); + })} + + + + {sorted.length === 0 ? ( + + + + ) : ( + sorted.map((row) => ( + + {columns.map((column) => ( + + ))} + + )) + )} + +
{caption}
+ {column.sort ? ( + + ) : ( + column.header + )} +
+ {emptyMessage} +
+ {column.render(row)} +
+
+
+
+ ); +} diff --git a/front_end/web/src/components/data/HistoryFilters.test.tsx b/front_end/web/src/components/data/HistoryFilters.test.tsx new file mode 100644 index 0000000..d3eb2ce --- /dev/null +++ b/front_end/web/src/components/data/HistoryFilters.test.tsx @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; + +import { HistoryFilters } from './HistoryFilters'; +import { useHistoryQuery } from '../../hooks/useHistoryQuery'; + +/** + * Exercises the filter bar wired to the URL exactly as the history pages wire it. + * + * Ports the Jinja round-trip assertions: the operator's typed values must survive + * being applied, and the ignore switches must mark a filter as omitted without + * discarding what was typed. The Jinja version needed `readOnly` rather than + * `disabled` for this, because disabled controls are dropped from a form + * submission; the same requirement applies here. + */ +function Harness() { + const query = useHistoryQuery(); + + return ( + <> + + {query.search} + {query.page} + + ); +} + +function renderHarness(route = '/vms/history') { + return render( + + + , + ); +} + +const startDate = () => screen.getByLabelText('Start date') as HTMLInputElement; +const endDate = () => screen.getByLabelText('End date') as HTMLInputElement; +const limit = () => screen.getByLabelText('Limit') as HTMLInputElement; +const search = () => screen.getByTestId('search').textContent ?? ''; + +describe('history filters', () => { + it('round-trips typed values through the URL', async () => { + renderHarness(); + + await userEvent.type(startDate(), '2026-01-15'); + await userEvent.type(endDate(), '2026-02-20'); + await userEvent.type(limit(), '37'); + await userEvent.click(screen.getByRole('button', { name: /Apply filter/ })); + + expect(startDate().value).toBe('2026-01-15'); + expect(endDate().value).toBe('2026-02-20'); + expect(limit().value).toBe('37'); + + expect(search()).toContain('startdate=2026-01-15'); + expect(search()).toContain('enddate=2026-02-20'); + expect(search()).toContain('limit=37'); + }); + + it('reads its initial state from the URL, so a filtered view can be shared', () => { + renderHarness('/vms/history?startdate=2026-03-01&enddate=2026-03-31&limit=42&page=4'); + + expect(startDate().value).toBe('2026-03-01'); + expect(endDate().value).toBe('2026-03-31'); + expect(limit().value).toBe('42'); + expect(screen.getByTestId('page').textContent).toBe('4'); + }); + + it('keeps the typed values when the ignore switches are on', async () => { + renderHarness('/vms/history?startdate=2026-03-01&enddate=2026-03-31&limit=42'); + + await userEvent.click(screen.getByLabelText('All dates')); + await userEvent.click(screen.getByLabelText('No limit')); + await userEvent.click(screen.getByRole('button', { name: /Apply filter/ })); + + // The values are still there and still submitted, so unticking restores them. + expect(startDate().value).toBe('2026-03-01'); + expect(endDate().value).toBe('2026-03-31'); + expect(limit().value).toBe('42'); + expect(screen.getByLabelText('All dates')).toBeChecked(); + expect(screen.getByLabelText('No limit')).toBeChecked(); + }); + + it('makes ignored inputs read-only rather than disabled', async () => { + renderHarness('/vms/history?ignore_dates=1&ignore_limit=1'); + + // A disabled control would be dropped from submission and the value lost. + expect(startDate()).toHaveAttribute('readonly'); + expect(startDate()).not.toBeDisabled(); + expect(limit()).toHaveAttribute('readonly'); + expect(limit()).not.toBeDisabled(); + }); + + it('omits ignored filters from the request', async () => { + renderHarness('/vms/history?startdate=2026-03-01&enddate=2026-03-31&limit=42'); + + await userEvent.click(screen.getByLabelText('All dates')); + await userEvent.click(screen.getByLabelText('No limit')); + await userEvent.click(screen.getByRole('button', { name: /Apply filter/ })); + + const query = search(); + expect(query).toContain('ignore_dates=1'); + expect(query).toContain('ignore_limit=1'); + expect(query).not.toContain('startdate='); + expect(query).not.toContain('enddate='); + expect(query).not.toContain('limit=42'); + }); + + it('returns to the first page when the filter changes', async () => { + renderHarness('/vms/history?page=7'); + expect(screen.getByTestId('page').textContent).toBe('7'); + + await userEvent.type(limit(), '5'); + await userEvent.click(screen.getByRole('button', { name: /Apply filter/ })); + + expect(screen.getByTestId('page').textContent).toBe('1'); + }); + + it('always sends page and per_page', () => { + renderHarness(); + expect(search()).toContain('page=1'); + expect(search()).toContain('per_page=10'); + }); + + it('clamps a hostile per_page rather than passing it through', () => { + renderHarness('/vms/history?per_page=-3'); + // Clamped to the same bounds the BFF applies, so the two never disagree. + expect(search()).toContain('per_page=1'); + expect(search()).not.toContain('per_page=-3'); + }); + + it('caps an excessive per_page at the server limit', () => { + renderHarness('/vms/history?per_page=100000'); + expect(search()).toContain('per_page=200'); + }); + + it('clamps a hostile page rather than passing it through', () => { + renderHarness('/vms/history?page=abc'); + expect(search()).toContain('page=1'); + }); +}); diff --git a/front_end/web/src/components/data/HistoryFilters.tsx b/front_end/web/src/components/data/HistoryFilters.tsx new file mode 100644 index 0000000..d82316f --- /dev/null +++ b/front_end/web/src/components/data/HistoryFilters.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react'; + +import type { HistoryFilterValues } from '../../types/broker'; +import { Button } from '../ui/Button'; +import { Checkbox, TextField } from '../ui/Field'; +import { GlassCard } from '../ui/GlassCard'; + +export interface HistoryFiltersProps { + value: HistoryFilterValues; + onApply: (filters: HistoryFilterValues) => void; +} + +/** + * The date/limit filter bar shared by VM history, the activity log and rule history. + * + * The ignore switches mark a filter as omitted rather than clearing it, so the + * operator's typed dates survive and reappear when the switch is turned back off. + * The inputs are therefore made read-only, not cleared or disabled. + */ +export function HistoryFilters({ value, onApply }: HistoryFiltersProps) { + const [draft, setDraft] = useState(value); + + // Re-sync when the URL changes underneath us, for example on a back navigation. + useEffect(() => { + setDraft(value); + }, [value]); + + function set(key: K, next: HistoryFilterValues[K]) { + setDraft((current) => ({ ...current, [key]: next })); + } + + return ( + +
{ + event.preventDefault(); + onApply(draft); + }} + className="grid grid-cols-1 items-end gap-4 sm:grid-cols-2 lg:grid-cols-5" + > + set('startdate', event.target.value)} + /> + set('enddate', event.target.value)} + /> + set('limit', event.target.value)} + /> + +
+ set('ignore_dates', checked)} + /> + set('ignore_limit', checked)} + /> +
+ + + +
+ ); +} diff --git a/front_end/web/src/components/data/HistoryView.tsx b/front_end/web/src/components/data/HistoryView.tsx new file mode 100644 index 0000000..3b83704 --- /dev/null +++ b/front_end/web/src/components/data/HistoryView.tsx @@ -0,0 +1,94 @@ +import type { ReactNode } from 'react'; + +import type { Paged } from '../../types/broker'; +import type { HistoryQuery } from '../../hooks/useHistoryQuery'; +import { EmptyState, ErrorPanel, LoadingPanel, Spinner } from '../ui/Feedback'; +import { DataTable } from './DataTable'; +import type { Column } from './DataTable'; +import { HistoryFilters } from './HistoryFilters'; +import { Pagination, PerPageSelect } from './Pagination'; + +export interface HistoryViewProps { + query: HistoryQuery; + data: Paged | undefined; + isPending: boolean; + isFetching: boolean; + error: unknown; + errorText: string; + columns: Array>; + rowKey: (row: T) => string | number; + emptyTitle: string; + emptyMessage: string; + emptyIcon?: 'clock' | 'list' | 'activity'; + noun: string; + caption: string; + children?: ReactNode; +} + +/** + * Filter bar, table and pager for the three history views. + * + * VM history, the scaling activity log and rule history differ only by their + * columns, so the surrounding plumbing lives here rather than being repeated. + */ +export function HistoryView({ + query, + data, + isPending, + isFetching, + error, + errorText, + columns, + rowKey, + emptyTitle, + emptyMessage, + emptyIcon = 'clock', + noun, + caption, +}: HistoryViewProps) { + const rows = data?.items ?? []; + + return ( + <> + + + {isPending ? : null} + + {error ? : null} + + {!isPending && !error && rows.length === 0 ? ( + + ) : null} + + {rows.length > 0 ? ( + <> + + +
+
+ + + {data ? `${data.total} ${noun} total` : null} + + {isFetching ? : null} +
+ +
+ + ) : null} + + ); +} diff --git a/front_end/web/src/components/data/Pagination.test.tsx b/front_end/web/src/components/data/Pagination.test.tsx new file mode 100644 index 0000000..5908f12 --- /dev/null +++ b/front_end/web/src/components/data/Pagination.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { Pagination, paginationWindow } from './Pagination'; + +describe('paginationWindow', () => { + it('keeps first, last and a window of two around the current page', () => { + // Pinned exactly, matching the Jinja macro this replaced: rendering every page + // number produced hundreds of links once the "No limit" filter was used. + const items = paginationWindow(6, 12, 2).filter((item) => item !== 'gap'); + expect(items).toEqual([1, 4, 5, 6, 7, 8, 12]); + }); + + it('inserts a gap marker only where pages were skipped', () => { + expect(paginationWindow(6, 12, 2)).toEqual([1, 'gap', 4, 5, 6, 7, 8, 'gap', 12]); + }); + + it('does not insert a gap when the window already reaches the ends', () => { + expect(paginationWindow(3, 5, 2)).toEqual([1, 2, 3, 4, 5]); + }); + + it('returns nothing when there are no pages', () => { + expect(paginationWindow(1, 0)).toEqual([]); + }); +}); + +describe('Pagination', () => { + it('renders nothing for a single page', () => { + const { container } = render( + {}} />, + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('marks the current page for assistive technology', () => { + render( {}} />); + expect(screen.getByRole('button', { name: 'Page 3' })).toHaveAttribute('aria-current', 'page'); + }); + + it('disables the step buttons at each end', () => { + const { rerender } = render( + {}} />, + ); + expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled(); + + rerender( {}} />); + expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled(); + }); + + it('reports the requested page', async () => { + const onPageChange = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Page 5' })); + expect(onPageChange).toHaveBeenCalledWith(5); + + await userEvent.click(screen.getByRole('button', { name: 'Next page' })); + expect(onPageChange).toHaveBeenCalledWith(4); + }); +}); diff --git a/front_end/web/src/components/data/Pagination.tsx b/front_end/web/src/components/data/Pagination.tsx new file mode 100644 index 0000000..bbdbd5b --- /dev/null +++ b/front_end/web/src/components/data/Pagination.tsx @@ -0,0 +1,152 @@ +import { classNames } from '../../lib/format'; +import { Icon } from '../Icon'; + +export type PageItem = number | 'gap'; + +/** + * Windowed page list: first, current +/- `window`, last, with gaps collapsed. + * + * Rendering every page number produced hundreds of links once the "No limit" + * filter was used, so the window is deliberately small and pinned by a test. + */ +export function paginationWindow(page: number, totalPages: number, window = 2): PageItem[] { + if (!totalPages || totalPages < 1) { + return []; + } + + const pages: number[] = []; + for (let candidate = 1; candidate <= totalPages; candidate += 1) { + if ( + candidate === 1 || + candidate === totalPages || + (candidate >= page - window && candidate <= page + window) + ) { + pages.push(candidate); + } + } + + const items: PageItem[] = []; + let previous = 0; + for (const candidate of pages) { + if (previous && candidate > previous + 1) { + items.push('gap'); + } + items.push(candidate); + previous = candidate; + } + + return items; +} + +export interface PaginationProps { + page: number; + totalPages: number; + onPageChange: (page: number) => void; + window?: number; +} + +export function Pagination({ page, totalPages, onPageChange, window = 2 }: PaginationProps) { + if (!totalPages || totalPages <= 1) { + return null; + } + + const items = paginationWindow(page, totalPages, window); + const atStart = page <= 1; + const atEnd = page >= totalPages; + + const stepClass = + 'lb-btn border-[var(--lb-hairline)] bg-[var(--lb-glass-bg-strong)] px-2 py-1.5 text-xs disabled:opacity-40'; + + return ( + + ); +} + +export const PER_PAGE_OPTIONS = [10, 25, 50, 100]; + +export interface PerPageSelectProps { + perPage: number; + onPerPageChange: (perPage: number) => void; + options?: number[]; +} + +export function PerPageSelect({ + perPage, + onPerPageChange, + options = PER_PAGE_OPTIONS, +}: PerPageSelectProps) { + // Include the current value when it is not a preset, so a hand-edited + // ?per_page= does not leave the control showing nothing. + const choices = options.includes(perPage) ? options : [...options, perPage].sort((a, b) => a - b); + + return ( +
+ + +
+ ); +} diff --git a/front_end/web/src/components/layout/AppShell.test.tsx b/front_end/web/src/components/layout/AppShell.test.tsx new file mode 100644 index 0000000..9f1e27b --- /dev/null +++ b/front_end/web/src/components/layout/AppShell.test.tsx @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { screen } from '@testing-library/react'; + +import { AppShell } from './AppShell'; +import { renderWithProviders, TEST_SESSION } from '../../test/render'; + +const ANONYMOUS = { ...TEST_SESSION, authenticated: false, user: null }; + +function renderShell(route: string, session = TEST_SESSION) { + return renderWithProviders(page body, { route, session }); +} + +describe('AppShell', () => { + it('exposes a skip link before the navigation', () => { + renderShell('/'); + const skip = screen.getByRole('link', { name: 'Skip to main content' }); + expect(skip).toHaveAttribute('href', '#main-content'); + }); + + it('renders the page inside a focusable main landmark', () => { + renderShell('/'); + const main = screen.getByRole('main'); + expect(main).toHaveAttribute('id', 'main-content'); + expect(main).toHaveTextContent('page body'); + }); + + it('shows the portal version in the footer', () => { + renderShell('/'); + expect(screen.getByText(`v${TEST_SESSION.version}`)).toBeInTheDocument(); + }); + + it('offers the management sections and the signed-in account when authenticated', () => { + renderShell('/'); + for (const label of ['Dashboard', 'VM Management', 'Scaling Management', 'Host Settings']) { + expect(screen.getByRole('link', { name: label })).toBeInTheDocument(); + } + expect(screen.getByRole('link', { name: /Test Operator/ })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Sign out/ })).toHaveAttribute('href', '/logout'); + }); + + it('offers only sign in when signed out', () => { + renderShell('/', ANONYMOUS); + expect(screen.queryByRole('link', { name: 'VM Management' })).not.toBeInTheDocument(); + // A full navigation to Flask, which starts the MSAL redirect. + expect(screen.getByRole('link', { name: /Sign in/ })).toHaveAttribute('href', '/login'); + }); + + it.each([ + ['/', 'Dashboard'], + ['/vms', 'VM Management'], + ['/vms/12/update', 'VM Management'], + ['/scaling/rules', 'Scaling Management'], + ['/scaling/log', 'Scaling Management'], + ['/scaling/rules/history', 'Scaling Management'], + ['/settings/hosts', 'Host Settings'], + ])('marks %s as the current page under %s', (route, label) => { + renderShell(route); + expect(screen.getByRole('link', { name: label })).toHaveAttribute('aria-current', 'page'); + }); + + it('does not mark the dashboard current on a sub-page', () => { + // '/' is a prefix of every path, so it needs an exact match rather than the + // prefix match the other sections use. + renderShell('/vms'); + expect(screen.getByRole('link', { name: 'Dashboard' })).not.toHaveAttribute('aria-current'); + }); + + it('falls back to a generic profile label when the account has no name', () => { + renderShell('/', { ...TEST_SESSION, user: { ...TEST_SESSION.user!, name: null } }); + expect(screen.getByRole('link', { name: /Profile/ })).toBeInTheDocument(); + }); +}); diff --git a/front_end/web/src/components/layout/AppShell.tsx b/front_end/web/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..e8fbc32 --- /dev/null +++ b/front_end/web/src/components/layout/AppShell.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react'; +import { useLocation } from 'react-router-dom'; + +import { useSession } from '../../hooks/useSession'; +import { NavBar } from './NavBar'; + +export function AppShell({ children }: { children: ReactNode }) { + const { pathname } = useLocation(); + const session = useSession(); + + return ( +
+ + Skip to main content + + + + +
+
{children}
+
+ +
+
+ Linux Broker Management Portal + v{session.version} +
+
+
+ ); +} diff --git a/front_end/web/src/components/layout/Breadcrumbs.tsx b/front_end/web/src/components/layout/Breadcrumbs.tsx new file mode 100644 index 0000000..0aa6ab2 --- /dev/null +++ b/front_end/web/src/components/layout/Breadcrumbs.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; + +import { Icon } from '../Icon'; + +export interface Crumb { + label: string; + to?: string; +} + +export function Breadcrumbs({ items }: { items: Crumb[] }) { + return ( + + ); +} + +export interface DetailListProps { + items: Array<{ label: string; value: ReactNode }>; +} + +export function DetailList({ items }: DetailListProps) { + return ( +
+ {items.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ ); +} diff --git a/front_end/web/src/components/layout/NavBar.tsx b/front_end/web/src/components/layout/NavBar.tsx new file mode 100644 index 0000000..1934e6b --- /dev/null +++ b/front_end/web/src/components/layout/NavBar.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; + +import { classNames } from '../../lib/format'; +import { useSession } from '../../hooks/useSession'; +import { Icon } from '../Icon'; +import type { IconName } from '../Icon'; +import { ThemeToggle } from './ThemeToggle'; + +interface NavItem { + to: string; + label: string; + icon: IconName; + /** Additional path prefixes that belong to this section. */ + match?: string[]; +} + +const NAV_ITEMS: NavItem[] = [ + { to: '/', label: 'Dashboard', icon: 'gauge' }, + { to: '/vms', label: 'VM Management', icon: 'server' }, + { to: '/scaling/rules', label: 'Scaling Management', icon: 'sliders', match: ['/scaling'] }, + { to: '/settings/hosts', label: 'Host Settings', icon: 'wrench' }, +]; + +/** + * Whether a nav item owns the current path. + * + * A section covers more than the page it links to: Scaling Management points at + * /scaling/rules but also owns /scaling/log, which is why `match` exists. This is + * deliberately not React Router's `NavLink`, whose matching is limited to the `to` + * path and which overrides any `aria-current` passed to it. + */ +function isActive(item: NavItem, pathname: string) { + if (item.to === '/') { + // Every path starts with '/', so the dashboard needs an exact match. + return pathname === '/'; + } + const prefixes = item.match ?? [item.to]; + return prefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)); +} + +const LINK_BASE = + 'flex items-center gap-1.5 rounded-[var(--radius-glass-sm)] px-3 py-2 text-sm font-medium no-underline transition-colors'; + +export function NavBar({ pathname }: { pathname: string }) { + const session = useSession(); + const [open, setOpen] = useState(false); + + const linkClass = (active: boolean) => + classNames(LINK_BASE, active ? 'bg-white/20 text-white' : 'text-white/85 hover:bg-white/12 hover:text-white'); + + return ( + + ); +} diff --git a/front_end/web/src/components/layout/ThemeToggle.test.tsx b/front_end/web/src/components/layout/ThemeToggle.test.tsx new file mode 100644 index 0000000..5cdd9fb --- /dev/null +++ b/front_end/web/src/components/layout/ThemeToggle.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ThemeToggle } from './ThemeToggle'; + +describe('ThemeToggle', () => { + it('starts from the attribute the pre-paint script set', () => { + document.documentElement.setAttribute('data-theme', 'dark'); + render(); + expect(screen.getByRole('button', { name: 'Switch to light theme' })).toBeInTheDocument(); + }); + + it('falls back to the stored preference when no attribute is present', () => { + window.localStorage.setItem('lb-theme', 'dark'); + render(); + expect(screen.getByRole('button', { name: 'Switch to light theme' })).toBeInTheDocument(); + }); + + it('switches the document theme and persists the choice', async () => { + document.documentElement.setAttribute('data-theme', 'light'); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Switch to dark theme' })); + + expect(document.documentElement).toHaveAttribute('data-theme', 'dark'); + expect(window.localStorage.getItem('lb-theme')).toBe('dark'); + }); + + it('toggles back again', async () => { + document.documentElement.setAttribute('data-theme', 'dark'); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Switch to light theme' })); + + expect(document.documentElement).toHaveAttribute('data-theme', 'light'); + expect(window.localStorage.getItem('lb-theme')).toBe('light'); + }); +}); diff --git a/front_end/web/src/components/layout/ThemeToggle.tsx b/front_end/web/src/components/layout/ThemeToggle.tsx new file mode 100644 index 0000000..56802bd --- /dev/null +++ b/front_end/web/src/components/layout/ThemeToggle.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react'; + +import { applyTheme, nextTheme, resolveInitialTheme } from '../../lib/theme'; +import type { Theme } from '../../lib/theme'; +import { Icon } from '../Icon'; + +export function ThemeToggle() { + const [theme, setTheme] = useState('light'); + + // Read on mount rather than during render: the attribute is set by the inline + // script in index.html, which does not exist during a server-side render or a test. + useEffect(() => { + setTheme(resolveInitialTheme()); + }, []); + + function toggle() { + const next = nextTheme(theme); + setTheme(next); + applyTheme(next); + } + + const goingDark = theme === 'light'; + + return ( + + ); +} diff --git a/front_end/web/src/components/ui/Badge.test.tsx b/front_end/web/src/components/ui/Badge.test.tsx new file mode 100644 index 0000000..ccca4a8 --- /dev/null +++ b/front_end/web/src/components/ui/Badge.test.tsx @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +import { ActionBadge, NetworkBadge, PowerBadge, VmStatusBadge } from './Badge'; + +/* + * Status must never be conveyed by colour alone (WCAG 1.4.1). Every badge pairs a + * colour with an icon and a text label, and these tests hold that line. + */ +describe('status badges', () => { + it.each([ + ['Available', 'Available'], + ['CheckedOut', 'Checked out'], + ['Maintenance', 'Maintenance'], + ['Released', 'Released'], + ])('renders VM status %s with the label %s', (value, label) => { + const { container } = render(); + expect(screen.getByText(label)).toBeInTheDocument(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('renders an unknown VM status verbatim rather than hiding it', () => { + render(); + expect(screen.getByText('Rebuilding')).toBeInTheDocument(); + }); + + it.each([null, undefined, '', ' '])('renders a dash for %p', (value) => { + const { container } = render(); + expect(container.textContent).toBe('\u2014'); + expect(container.querySelector('svg')).not.toBeInTheDocument(); + }); + + it('pairs power state with an icon', () => { + const { container } = render(); + expect(screen.getByText('On')).toBeInTheDocument(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('distinguishes reachable from unreachable by icon and text', () => { + const reachable = render(); + expect(screen.getByText('Reachable')).toBeInTheDocument(); + reachable.unmount(); + + render(); + expect(screen.getByText('Unreachable')).toBeInTheDocument(); + }); + + it.each([ + ['Scale Up', 'Scale up'], + ['Scale Down', 'Scale down'], + ['No Action', 'No action'], + ])('renders the scaling action %s as %s', (value, label) => { + render(); + expect(screen.getByText(label)).toBeInTheDocument(); + }); +}); diff --git a/front_end/web/src/components/ui/Badge.tsx b/front_end/web/src/components/ui/Badge.tsx new file mode 100644 index 0000000..7ff7765 --- /dev/null +++ b/front_end/web/src/components/ui/Badge.tsx @@ -0,0 +1,91 @@ +import { classNames, DASH, isBlank } from '../../lib/format'; +import { Icon } from '../Icon'; +import type { IconName } from '../Icon'; + +export type Tone = 'ok' | 'accent' | 'info' | 'warn' | 'danger' | 'neutral'; + +export interface BadgeProps { + tone?: Tone; + icon: IconName; + children: React.ReactNode; + className?: string; +} + +/** + * Status pill. + * + * Every badge pairs colour with an icon and text, so status is never conveyed by + * colour alone (WCAG 1.4.1). Keep that pattern for any new status. + */ +export function Badge({ tone = 'neutral', icon, children, className }: BadgeProps) { + return ( + + + {children} + + ); +} + +/** Placeholder for an absent value, so an empty cell does not collapse its row. */ +export function EmptyValue() { + return {DASH}; +} + +interface StatusDescriptor { + tone: Tone; + icon: IconName; + label: string; +} + +function renderStatus( + value: string | null | undefined, + map: Record, +) { + if (isBlank(value)) { + return ; + } + + const key = String(value); + const descriptor = map[key] ?? { tone: 'neutral' as Tone, icon: 'dash-circle' as IconName, label: key }; + + return ( + + {descriptor.label} + + ); +} + +const VM_STATUS: Record = { + Available: { tone: 'ok', icon: 'check-circle', label: 'Available' }, + CheckedOut: { tone: 'accent', icon: 'person', label: 'Checked out' }, + Maintenance: { tone: 'warn', icon: 'wrench', label: 'Maintenance' }, + Released: { tone: 'info', icon: 'arrow-return', label: 'Released' }, +}; + +const POWER_STATE: Record = { + On: { tone: 'ok', icon: 'power', label: 'On' }, + Off: { tone: 'neutral', icon: 'power', label: 'Off' }, +}; + +const NETWORK_STATUS: Record = { + Reachable: { tone: 'ok', icon: 'wifi', label: 'Reachable' }, + Unreachable: { tone: 'danger', icon: 'wifi-off', label: 'Unreachable' }, +}; + +const SCALING_ACTION: Record = { + 'Scale Up': { tone: 'ok', icon: 'arrow-up', label: 'Scale up' }, + 'Scale Down': { tone: 'warn', icon: 'arrow-down', label: 'Scale down' }, + 'No Action': { tone: 'neutral', icon: 'dash-circle', label: 'No action' }, +}; + +export const VmStatusBadge = ({ value }: { value: string | null | undefined }) => + renderStatus(value, VM_STATUS); + +export const PowerBadge = ({ value }: { value: string | null | undefined }) => + renderStatus(value, POWER_STATE); + +export const NetworkBadge = ({ value }: { value: string | null | undefined }) => + renderStatus(value, NETWORK_STATUS); + +export const ActionBadge = ({ value }: { value: string | null | undefined }) => + renderStatus(value, SCALING_ACTION); diff --git a/front_end/web/src/components/ui/Button.tsx b/front_end/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..73408d7 --- /dev/null +++ b/front_end/web/src/components/ui/Button.tsx @@ -0,0 +1,105 @@ +import { forwardRef } from 'react'; +import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react'; +import { Link } from 'react-router-dom'; +import type { LinkProps } from 'react-router-dom'; + +import { classNames } from '../../lib/format'; +import { Icon } from '../Icon'; +import type { IconName } from '../Icon'; + +export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'warning'; +export type ButtonSize = 'sm' | 'md'; + +const VARIANT_CLASS: Record = { + primary: + 'bg-[var(--lb-brand)] text-[var(--lb-on-brand)] border-transparent hover:bg-[var(--lb-brand-strong)]', + secondary: + 'bg-[var(--lb-glass-bg-strong)] text-ink border-[var(--lb-hairline)] hover:border-[var(--lb-brand)] hover:text-[var(--lb-brand)]', + ghost: 'bg-transparent text-muted border-transparent hover:bg-[var(--lb-hover)] hover:text-ink', + danger: + 'bg-[var(--lb-danger-bg)] text-[var(--lb-danger-fg)] border-[var(--lb-danger-bd)] hover:brightness-110', + warning: + 'bg-[var(--lb-warn-bg)] text-[var(--lb-warn-fg)] border-[var(--lb-warn-bd)] hover:brightness-110', +}; + +const SIZE_CLASS: Record = { + sm: 'text-xs px-2.5 py-1.5', + md: 'text-sm px-3.5 py-2', +}; + +function buttonClass(variant: ButtonVariant, size: ButtonSize, className?: string) { + return classNames('lb-btn', VARIANT_CLASS[variant], SIZE_CLASS[size], className); +} + +function iconSize(size: ButtonSize) { + return size === 'sm' ? 14 : 15; +} + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + icon?: IconName; +} + +export const Button = forwardRef(function Button( + { variant = 'secondary', size = 'md', icon, className, children, type = 'button', ...rest }, + ref, +) { + return ( + + ); +}); + +export interface ButtonLinkProps extends LinkProps { + variant?: ButtonVariant; + size?: ButtonSize; + icon?: IconName; +} + +/** A router link styled as a button, for navigation actions such as "Add VM". */ +export function ButtonLink({ + variant = 'secondary', + size = 'md', + icon, + className, + children, + ...rest +}: ButtonLinkProps) { + return ( + + {icon ? : null} + {children} + + ); +} + +export interface ButtonAnchorProps extends AnchorHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + icon?: IconName; +} + +/** + * A plain anchor styled as a button, for the server-rendered auth routes. + * + * Sign in and sign out are full browser navigations to Flask, which then redirects + * to Entra ID, so they must not be intercepted by React Router. + */ +export function ButtonAnchor({ + variant = 'secondary', + size = 'md', + icon, + className, + children, + ...rest +}: ButtonAnchorProps) { + return ( + + {icon ? : null} + {children} + + ); +} diff --git a/front_end/web/src/components/ui/ConfirmDialog.test.tsx b/front_end/web/src/components/ui/ConfirmDialog.test.tsx new file mode 100644 index 0000000..c00ed09 --- /dev/null +++ b/front_end/web/src/components/ui/ConfirmDialog.test.tsx @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ConfirmDialog } from './ConfirmDialog'; + +function setup(overrides: Partial> = {}) { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + + render( + , + ); + + return { onConfirm, onCancel }; +} + +describe('ConfirmDialog', () => { + it('renders nothing while closed, so no action can be taken by accident', () => { + const { onConfirm } = setup({ open: false }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('names the specific resource in an accessible dialog', () => { + setup(); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(screen.getByText('Delete linux-host-01')).toBeInTheDocument(); + expect(screen.getByText(/Permanently delete linux-host-01/)).toBeInTheDocument(); + }); + + it('only runs the action once the operator confirms', async () => { + const { onConfirm, onCancel } = setup(); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onConfirm).not.toHaveBeenCalled(); + expect(onCancel).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByRole('button', { name: 'Delete' })); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('focuses the confirm button so the keyboard path is immediate', () => { + setup(); + expect(screen.getByRole('button', { name: 'Delete' })).toHaveFocus(); + }); + + it('cancels on Escape', async () => { + const { onCancel, onConfirm } = setup(); + await userEvent.keyboard('{Escape}'); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('keeps Tab inside the dialog', async () => { + setup(); + const close = screen.getByRole('button', { name: 'Close' }); + const confirm = screen.getByRole('button', { name: 'Delete' }); + + // Confirm is the last control, so Tab must wrap back to the first one. + confirm.focus(); + await userEvent.tab(); + expect(close).toHaveFocus(); + }); + + it('disables both actions while the request is in flight', () => { + setup({ busy: true }); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Working…' })).toBeDisabled(); + }); +}); diff --git a/front_end/web/src/components/ui/ConfirmDialog.tsx b/front_end/web/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..e0cc596 --- /dev/null +++ b/front_end/web/src/components/ui/ConfirmDialog.tsx @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; + +import { Icon } from '../Icon'; +import { Button } from './Button'; +import type { ButtonVariant } from './Button'; + +const FOCUSABLE = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +export interface ConfirmDialogProps { + open: boolean; + title: string; + body: string; + confirmLabel?: string; + variant?: ButtonVariant; + busy?: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +/** + * Confirmation dialog for destructive and state-changing actions. + * + * Hand-built rather than using `` so the focus trap, the Escape handling + * and the restore-focus-on-close behaviour are explicit and testable, and so the + * backdrop can carry the same glass treatment as the rest of the portal. + */ +export function ConfirmDialog({ + open, + title, + body, + confirmLabel = 'Confirm', + variant = 'danger', + busy = false, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + const panelRef = useRef(null); + const confirmRef = useRef(null); + const previouslyFocused = useRef(null); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + event.stopPropagation(); + onCancel(); + return; + } + + if (event.key !== 'Tab' || !panelRef.current) { + return; + } + + // No visibility filtering: everything focusable inside the panel is visible + // while the dialog is open, and an `offsetParent` check would silently + // collapse the list to one element in environments without layout. + const focusable = Array.from(panelRef.current.querySelectorAll(FOCUSABLE)).filter( + (element) => !element.hasAttribute('disabled'), + ); + + if (focusable.length < 2) { + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + // Wrap at both ends so focus can never escape the dialog while it is open. + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }, + [onCancel], + ); + + useEffect(() => { + if (!open) { + return; + } + + previouslyFocused.current = document.activeElement as HTMLElement | null; + confirmRef.current?.focus(); + + const { overflow } = document.body.style; + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = overflow; + // Send focus back where it came from, so keyboard users do not land at the + // top of the document after confirming a row action. + previouslyFocused.current?.focus?.(); + }; + }, [open]); + + if (!open) { + return null; + } + + return createPortal( +
{ + if (event.target === event.currentTarget) { + onCancel(); + } + }} + onKeyDown={handleKeyDown} + > +
+
+

+ {title} +

+ +
+ +

+ {body} +

+ +
+ + +
+
+
, + document.body, + ); +} diff --git a/front_end/web/src/components/ui/Feedback.tsx b/front_end/web/src/components/ui/Feedback.tsx new file mode 100644 index 0000000..7b08012 --- /dev/null +++ b/front_end/web/src/components/ui/Feedback.tsx @@ -0,0 +1,120 @@ +import type { ReactNode } from 'react'; + +import { classNames } from '../../lib/format'; +import { Icon } from '../Icon'; +import type { IconName } from '../Icon'; +import { GlassCard } from './GlassCard'; + +export interface PageHeaderProps { + title: string; + subtitle?: string; + icon?: IconName; + actions?: ReactNode; +} + +export function PageHeader({ title, subtitle, icon, actions }: PageHeaderProps) { + return ( +
+
+

+ {icon ? : null} + {title} +

+ {subtitle ?

{subtitle}

: null} +
+ {actions ?
{actions}
: null} +
+ ); +} + +export interface EmptyStateProps { + title: string; + message?: string; + icon?: IconName; + action?: ReactNode; +} + +export function EmptyState({ title, message, icon = 'list', action }: EmptyStateProps) { + return ( + + +

{title}

+ {message ?

{message}

: null} + {action ?
{action}
: null} +
+ ); +} + +export type NoticeTone = 'info' | 'success' | 'warning' | 'danger'; + +const NOTICE_TONE: Record = { + info: { cls: 'lb-tone-info', icon: 'info-circle' }, + success: { cls: 'lb-tone-ok', icon: 'check-circle' }, + warning: { cls: 'lb-tone-warn', icon: 'alert-triangle' }, + danger: { cls: 'lb-tone-danger', icon: 'alert-triangle' }, +}; + +export interface NoticeProps { + tone?: NoticeTone; + children: ReactNode; + className?: string; +} + +/** Inline explanatory or warning panel. Always pairs its colour with an icon. */ +export function Notice({ tone = 'info', children, className }: NoticeProps) { + const { cls, icon } = NOTICE_TONE[tone]; + + return ( +
+ +
{children}
+
+ ); +} + +export function Spinner({ label = 'Loading' }: { label?: string }) { + return ( + + + {label} + + ); +} + +export function LoadingPanel({ label = 'Loading' }: { label?: string }) { + return ( + + + + ); +} + +export function ErrorPanel({ + title = 'Something went wrong', + message, + action, +}: { + title?: string; + message: string; + action?: ReactNode; +}) { + return ( + + +

{title}

+

{message}

+ {action ?
{action}
: null} +
+ ); +} diff --git a/front_end/web/src/components/ui/Field.tsx b/front_end/web/src/components/ui/Field.tsx new file mode 100644 index 0000000..17e56f2 --- /dev/null +++ b/front_end/web/src/components/ui/Field.tsx @@ -0,0 +1,224 @@ +import { useId } from 'react'; +import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from 'react'; + +import { classNames } from '../../lib/format'; + +interface FieldShellProps { + label: string; + help?: ReactNode; + error?: string; + htmlFor: string; + className?: string; + children: ReactNode; +} + +function FieldShell({ label, help, error, htmlFor, className, children }: FieldShellProps) { + return ( +
+ + {children} + {error ? ( +

+ {error} +

+ ) : null} + {help ? ( +

+ {help} +

+ ) : null} +
+ ); +} + +export interface TextFieldProps extends Omit, 'id'> { + label: string; + help?: ReactNode; + error?: string; + fieldClassName?: string; +} + +export function TextField({ + label, + help, + error, + className, + fieldClassName, + ...rest +}: TextFieldProps) { + const id = useId(); + const describedBy = [help ? `${id}-help` : null, error ? `${id}-error` : null] + .filter(Boolean) + .join(' '); + + return ( + + + + ); +} + +export interface SelectFieldProps extends Omit, 'id'> { + label: string; + help?: ReactNode; + error?: string; + options: Array<{ value: string; label: string }>; + fieldClassName?: string; +} + +export function SelectField({ + label, + help, + error, + options, + className, + fieldClassName, + ...rest +}: SelectFieldProps) { + const id = useId(); + const describedBy = [help ? `${id}-help` : null, error ? `${id}-error` : null] + .filter(Boolean) + .join(' '); + + return ( + + + + ); +} + +export interface TextAreaFieldProps + extends Omit, 'id'> { + label: string; + help?: ReactNode; + fieldClassName?: string; +} + +export function TextAreaField({ + label, + help, + className, + fieldClassName, + ...rest +}: TextAreaFieldProps) { + const id = useId(); + + return ( + +