Single-page application that consumes the Databricks Ops Console
backend API. Origin is Azure Storage static website ($web container)
behind Azure Front Door (which owns TLS, the custom domain, and the
security-headers rule set). Deployed via Azure DevOps.
The backend API this SPA talks to lives in a separate repo: Databricks Ops Console Backend. It's a Node/Express service deployed to Azure Container Apps.
| Layer | Technology |
|---|---|
| UI | Vanilla HTML / CSS / JavaScript (no build step) |
| Charts | Chart.js 4.4 (bundled locally) |
| Origin | Azure Storage static website ($web container) |
| Edge | Azure Front Door (TLS, custom domain, CSP + sec headers, edge cache) |
| Access | Public via AFD dbxops.asami.ai; storage firewall accepts the AFD profile (Resource Access Rules) only — direct blob URLs return 403 |
| CI/CD | Azure DevOps → az storage blob upload-batch → az afd endpoint purge |
No bundler, no transpiler, no node_modules in production. The files in
src/ are uploaded as-is. This keeps deploys fast and debuggable — every
line the browser runs is a line in this repo.
.
├── src/ ← everything uploaded to $web
│ ├── index.html SPA shell. Pre-paint dark-theme script + script tags load /config.js first.
│ ├── 404.html Dark-themed fallback for any unknown path.
│ ├── config.js Runtime config. Sets window.__API_BASE__.
│ ├── chart.min.js Chart.js 4.4 (bundled, no CDN dependency)
│ ├── assets/ Static images + favicons
│ │ ├── favicon.ico / favicon.png
│ │ ├── databricks-emblem.png Sidebar logo
│ │ └── aws.png / azure.png Cloud provider logos (rendered in js/core/api.js)
│ ├── css/
│ │ ├── tokens.css Design tokens (light + dark mode)
│ │ ├── layout.css Sidebar, topbar, page frame
│ │ ├── components.css Cards, buttons, tables, skeletons
│ │ └── auth.css Login / signup page-specific styles
│ ├── login/ Static sign-in / sign-up SPA (separate page)
│ │ └── index.html
│ ├── data/ Pricing-calculator static dataset
│ │ ├── databricks-pricing.json Generated; consumed by pages/pricing.js
│ │ ├── build-pricing.mjs Rebuilds the JSON from upstream feeds
│ │ ├── verify-pricing.mjs Cross-source audit (~5,500 checks)
│ │ ├── edge-cases.test.mjs Playwright UI regression (116 checks)
│ │ └── README.md Pricing-data architecture + sourcing runbook
│ └── js/
│ ├── core/
│ │ ├── api.js App state, data fetchers, account switcher
│ │ ├── auth.js /api/auth/me bootstrap + 401 redirect
│ │ ├── sidebar.js Navigation, theme toggle, bootstrap init
│ │ └── utils.js Formatters, chart helpers, SVG icons
│ └── pages/ One file per page — keeps concerns isolated
│ ├── home.js Account-level dashboard (workspaces / DBU / regions)
│ ├── workspaces.js Per-workspace resources + cost
│ ├── cost.js Cost Explorer (monthly trend + breakdown)
│ ├── finops.js FinOps findings + recommendations
│ ├── anomalies.js Alerts (acknowledge / resolve)
│ ├── budgets.js Budget CRUD + consumption forecast
│ ├── pricing.js Pricing Calculator (no backend; reads /data)
│ ├── secrets.js Secrets Manager
│ ├── access.js Access Control (users + roles)
│ └── settings.js Account settings + integrations
│
├── azure-pipelines.yml Single-stage deploy to $web (Front Door fronts the bucket)
├── .gitignore
└── README.md
Each page's render logic lives in its own module so adding a new page or
editing one doesn't force a rebase on unrelated work. Pages share state
through a small set of globals defined in js/core/api.js
(allWorkspaces, costData, historyData, explorerData, activeAccountId).
Keeps non-shipping files (README, pipeline, .gitignore) out of $web.
az storage blob upload-batch recurses the source folder verbatim, so
src/index.html → $web/index.html, src/js/core/api.js → $web/js/core/api.js.
src/config.js is loaded before any other JavaScript. It sets
window.__API_BASE__ which every fetch in js/core/api.js and the page
modules prepends to its URL:
// src/config.js
window.__API_BASE__ = 'https://dbx-ops-dashboard.<hash>.eastus.azurecontainerapps.io';To point at a different backend (local dev, staging, alt region), edit this one file and redeploy. No bundle rebuild, no env var injection.
One static bundle has to work against many environments (prod / local / future AWS mirror). Keeping the backend URL in a dedicated, no-cache file means:
- Ops can flip the backend URL without touching app code.
- The pipeline sets
Cache-Control: no-cacheonly onconfig.js,index.htmland404.html— every other asset stays cacheable. - The SPA JS files never reference a hard-coded hostname, so they're byte-identical across environments.
Chart.js 4.4 renders every graph in the app (spend trend, SKU doughnut, anomaly sparklines, resource distribution, etc.). We commit a local copy instead of pulling it from a CDN because:
- The app runs behind an office-IP allowlist; a blocked CDN would break the dashboard.
- No third-party tracking or availability dependency.
- Version is pinned forever — a CDN version can be pulled at any time.
- Loaded with
deferso it never blocks first paint.
The backend listens on port 3000 by default (the same port the SPA's static server uses, below), so start the backend on a different port for local dev:
- Run the backend with
PORT=3001 npm run dev(or any free port). - Edit
src/config.jstohttp://localhost:3001. - Serve this folder over a local static server:
npx http-server ./src -p 3000 -c-1
- Browse to
http://localhost:3000.
Don't commit a local-dev config.js — just git checkout src/config.js
when you're done or use .git/info/exclude to ignore it locally.
| Page | ensureWorkspaces |
ensureCost |
ensureHistory |
ensureExplorer |
Own fetch |
|---|---|---|---|---|---|
home.js |
✓ | ✓ | ✓ | ||
workspaces.js |
✓ | ✓ | ✓ | /api/workspace/:fqdn/resources |
|
cost.js |
✓ | ✓ | ✓ | ✓ | |
finops.js |
✓ | ✓ | /api/finops/recommendations + /api/findings |
||
anomalies.js |
✓ | ✓ | ✓ | /api/alerts (list / acknowledge / resolve) |
|
budgets.js |
✓ | ✓ | /api/budgets CRUD with consumption forecast |
||
pricing.js |
None — reads /data/databricks-pricing.json (static) |
||||
secrets.js |
/api/secrets/* |
||||
access.js |
/api/users / /api/admin/* (admin-only) |
||||
settings.js |
/api/accounts CRUD |
The ensure* helpers are single-flight: concurrent callers share the same
in-flight promise, and once a result is cached in memory it's reused for
the rest of the page's lifetime.
main branch pushes trigger azure-pipelines.yml:
az storage blob upload-batchuploads./srcto$web.index.html,404.htmlandconfig.jsgetCache-Control: no-cache.az afd endpoint purgeinvalidates the Azure Front Door edge cache so the next request hits the freshly-uploaded blobs instead of a stale AFD-cached copy.- Live behind Azure Front Door at
https://dbxops.asami.ai. The$webstorage account haspublic_network_access_enabled = false; only AFD's profile ID (via theprivate_link_accessResource Access Rules entry) and AAD-authenticated Azure Services (bypass = ["AzureServices"]— covers the pipeline'saz storage blob upload-batchstep) can reach it. Direct blob URLs return 403.
- Storage account (the
frontendstatic-site account) exists inAVA-EUS-DBX-OPS-RG. Static-website feature enabled, index document =index.html. Error document is alsoindex.htmlso the SPA's hash router handles any unknown path (and not the static404.html— which is shipped as a real asset but only served if a request explicitly asks for/404.html). - Storage firewall restricts inbound traffic to Azure Front Door's origin only — direct browser access to the blob URL is blocked.
- Azure Front Door profile + endpoint + origin group point at the
storage account's
$webhost. AFD owns the public TLS termination, custom domain, and the CSP / security-headers rule set. - The service principal behind the Azure DevOps service connection has Storage Blob Data Contributor on the storage account and CDN Profile Contributor on the AFD profile (for the purge step).
Blob storage keeps no revision history of its own. To roll back a bad deploy, re-run the previous pipeline (Azure DevOps → Pipelines → the frontend pipeline → select a prior successful run → Rerun all jobs).
- All inbound requests terminate at Azure Front Door. AFD owns TLS,
the custom domain, and a managed rule set that adds the Content-
Security-Policy + other security headers as real HTTP response headers
(the
<meta>CSP fallback was removed — see commitsec(spa): remove redundant meta CSP). - The storage account behind AFD has
public_network_access_enabled = false. AFD reaches it via aprivate_link_accessrule (Resource Access Rules pattern, the Standard-tier equivalent of Private Link). The firewall accepts only the AFD profile's ARM ID + tenant ID; direct blob URLs return 403.bypass = ["AzureServices"]permits the deploy pipeline's AAD-authenticatedaz storage blob upload-batchstep. - The backend API sits behind its own access-control rules at the Container App ingress (Entra-authenticated sessions, CSRF, CORS allowlist).
- No secrets of any kind ship in the static bundle. The backend URL is public knowledge; access to it is gated by the backend's own controls.
config.jsis configuration, not a secret — safe to version-control.
- Create
src/js/pages/<name>.jsexporting arender<Name>()function ontowindow(same pattern as existing pages). - Add a
<script src="/js/pages/<name>.js"></script>include insrc/index.html. - Add a sidebar entry in
src/index.htmland a case injs/core/sidebar.jsnavigate()that callsrender<Name>(). - If the page needs server data, prefer the existing
ensureWorkspaces/ensureCost/etc. helpers instead of adding a new global fetch path.