diff --git a/.github/extensions/ftk-local-dashboard/PRODUCT.md b/.github/extensions/ftk-local-dashboard/PRODUCT.md
new file mode 100644
index 000000000..373f1f366
--- /dev/null
+++ b/.github/extensions/ftk-local-dashboard/PRODUCT.md
@@ -0,0 +1,36 @@
+# Product
+
+## Register
+
+product
+
+## Users
+
+FinOps practitioners, cloud engineers, and consultants who need to analyze Azure cost data locally — without deploying Azure resources. They run this inside GitHub Copilot as a canvas panel while they work: exploring the data model, validating large datasets, or doing FinOps analysis in disconnected / on-premises environments. They are data-fluent, comfortable with KQL and Azure concepts, and expect density and precision over decoration. They are in a task when they open this — they want numbers fast.
+
+## Product Purpose
+
+A FinOps hub dashboard that connects to a local Kusto emulator or a remote Azure Data Explorer cluster. It provides cost, allocation, rate optimization, usage, anomaly, AI token, and capacity views. The Capacity workspace keeps quota, billed demand, inventory, physical supply, and pricing commitments as separate quota areas. Success means that a practitioner can load hub data, select a view, and find the quota answer they need in one session.
+
+## Brand Personality
+
+Precise. Grounded. Efficient. The interface should feel like a well-calibrated instrument, not a product pitch. Numbers are the hero; the chrome disappears.
+
+## Anti-references
+
+- Consumer personal finance dashboards (Mint, Copilot Money) — too soft, too colorful
+- SaaS marketing dashboards (hero metric templates, gradient text, glassmorphism cards)
+- Over-designed BI tools with heavy chrome, deep sidebars, and modal-heavy workflows
+- Any interface that prioritizes looking impressive over being immediately useful
+
+## Design Principles
+
+1. **Numbers first** — KPIs and data are the primary visual element. Supporting chrome (headers, tabs, labels) recedes.
+2. **GitHub-native** — Use GitHub design tokens (`--background-color-default`, `--text-color-default`, etc.) so the panel feels like an extension of Copilot, not a foreign app.
+3. **Density is a virtue** — FinOps data is inherently multi-dimensional. Don't sacrifice information density for whitespace.
+4. **State is explicit** — Loading, error, empty, and no-data states are real states, not afterthoughts. Every panel handles all of them.
+5. **Zero ceremony** — No animated intros, no onboarding tours. Open panel → see data.
+
+## Accessibility & Inclusion
+
+WCAG AA minimum. SVG charts include `
` elements for screen-reader context. Interactive controls have ARIA roles and labels. Capacity heatmaps include values and states as text. Users can operate the tabs with a keyboard. The interface respects reduced-motion preferences.
diff --git a/.github/extensions/ftk-local-dashboard/README.md b/.github/extensions/ftk-local-dashboard/README.md
new file mode 100644
index 000000000..81a2f565d
--- /dev/null
+++ b/.github/extensions/ftk-local-dashboard/README.md
@@ -0,0 +1,43 @@
+# FinOps hub dashboard canvas
+
+The FinOps hub dashboard is a repository-scoped GitHub Copilot canvas. It connects to a local Kusto emulator or a remote Azure Data Explorer cluster.
+
+The dashboard includes these views:
+
+- Cost overview
+- Allocation
+- Rate optimization
+- Usage and unit economics
+- Anomalies and forecast
+- AI tokenomics
+- AI and emerging workloads
+- Capacity
+- Read-only KQL query editor
+
+## Capacity
+
+The Capacity workspace shows seven quota areas: App Service, Azure AI, Compute, Azure SQL, Storage, capacity reservations, and Premium SSD v2.
+
+The workspace doesn't combine unlike readings into one score. It keeps these concepts separate:
+
+- Provider quota entitlement
+- Billed demand
+- Observed resource inventory
+- Physical Azure capacity
+- Pricing commitments
+
+Only registered Compute metrics support quota utilization and headroom calculations. Unknown metrics stay visible as descriptive rows. Stale or invalid rows don't receive quota-health calculations.
+
+## Run the canvas
+
+Reload GitHub Copilot extensions after you change the source. The repository-scoped extension must report `sourceScope: "project"` from the `get_build_info` action.
+
+The installed user extension uses `http://127.0.0.1:47821/`. The repository-scoped extension uses `http://127.0.0.1:47822/` so both sources can run during development. Connection preferences remain in the user's Copilot extension artifacts directory and aren't stored in the repository.
+
+## Test the canvas
+
+Run the dependency-free test suite:
+
+```console
+npm run test-dashboard
+```
diff --git a/.github/extensions/ftk-local-dashboard/extension.mjs b/.github/extensions/ftk-local-dashboard/extension.mjs
new file mode 100644
index 000000000..f5f9304ab
--- /dev/null
+++ b/.github/extensions/ftk-local-dashboard/extension.mjs
@@ -0,0 +1,930 @@
+// Extension: ftk-local-dashboard
+// A FinOps dashboard canvas for local and remote FinOps hubs.
+//
+// open() boots a per-instance loopback HTTP server that serves the static
+// dashboard (public/) and JSON endpoints for configuration, shared canvas
+// state, dashboard views, and read-only KQL.
+// The dashboard renderer fetches /api/dashboard, which runs the FinOps query
+// layer (kusto.mjs) against the selected Hub database.
+
+import { createServer } from "node:http";
+import { readFile, mkdir, rename, rm, writeFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import {
+ runQuery,
+ getDashboard,
+ getTokenomics,
+ getAllocation,
+ getRate,
+ getUsage,
+ getAnomaly,
+ getCapacity,
+ getComputeSubscriptionPage,
+ getAi,
+ normalizeConnection,
+ normalizeCapacityClassId,
+ validateFilters,
+ ALLOWED_FILTER_COLUMNS,
+ CAPACITY_CLASS_REGISTRY,
+} from "./kusto.mjs";
+
+const TEST_MODE = process.env.FTK_LOCAL_DASHBOARD_TEST === "1";
+let joinSession, createCanvas, CanvasError;
+if (TEST_MODE) {
+ createCanvas = (definition) => definition;
+ CanvasError = class extends Error {
+ constructor(code, message) {
+ super(message);
+ this.code = code;
+ }
+ };
+} else {
+ ({ joinSession, createCanvas, CanvasError } = await import("@github/copilot-sdk/extension"));
+}
+
+const GETTERS = {
+ overview: getDashboard,
+ tokenomics: getTokenomics,
+ ai: getAi,
+ allocation: getAllocation,
+ rate: getRate,
+ usage: getUsage,
+ anomaly: getAnomaly,
+ capacity: getCapacity,
+};
+
+const PUBLIC_DIR = new URL("./public/", import.meta.url);
+const HARDCODED_CLUSTER = "http://localhost:8082";
+const HARDCODED_DB = "Hub";
+const VALID_PRESETS = ["all", "12m", "6m", "3m"];
+const DASHBOARD_TABS = Object.keys(GETTERS);
+const VALID_TABS = [...DASHBOARD_TABS, "monaco"];
+const QUERY_MAX_LENGTH = 65536;
+const QUERY_ROW_LIMIT = 500;
+const REQUEST_BODY_LIMIT = 128 * 1024;
+const BUILD_ID = "ftk-local-dashboard-capacity-v1";
+const SOURCE_SCOPE = import.meta.url.includes("/.github/extensions/") ? "project" : "user";
+
+// Use stable, scope-specific ports so the project source can run beside an
+// installed user copy without falling back to a changing ephemeral URL.
+const DEFAULT_DASHBOARD_PORT = SOURCE_SCOPE === "project" ? 47822 : 47821;
+const DASHBOARD_PORT = Number(process.env.FTK_LOCAL_DASHBOARD_PORT) || DEFAULT_DASHBOARD_PORT;
+
+// Per-user preference file: the selected hub/database is a user choice, not a
+// repo-wide constant, so it's remembered across sessions instead of hardcoded.
+// See create-canvas skill's
+// "State model" — per-user preference, not per-session/instance.
+const CONFIG_DIR = join(process.env.COPILOT_HOME || join(homedir(), ".copilot"), "extensions", "ftk-local-dashboard", "artifacts");
+const CONFIG_FILE = join(CONFIG_DIR, "config.json");
+
+async function loadPersistedConfig() {
+ try {
+ const raw = await readFile(CONFIG_FILE, "utf8");
+ const parsed = JSON.parse(raw);
+ return {
+ clusterUri: typeof parsed.clusterUri === "string" ? parsed.clusterUri : undefined,
+ database: typeof parsed.database === "string" ? parsed.database : undefined,
+ lastQuery: typeof parsed.lastQuery === "string" ? parsed.lastQuery : undefined,
+ };
+ } catch {
+ return {};
+ }
+}
+
+// Merges `patch` onto whatever is currently on disk instead of overwriting the
+// whole file, so saving the query editor's text can't clobber the persisted
+// clusterUri/database (and vice versa) -- the two are updated independently
+// and on different cadences (query text on every edit; connection on Settings
+// dialog submit). Writes are serialized onto a single chained promise: two
+// concurrent callers (e.g. a Settings-dialog POST landing while the query
+// editor's debounced autosave is also writing) both read-modify-write the
+// same file, and without serialization the second writer's stale read can
+// silently discard the first writer's change. Chaining forces each write to
+// see the previous one's result.
+let configWriteChain = Promise.resolve();
+
+async function savePersistedConfig(patch) {
+ configWriteChain = configWriteChain.catch(() => {}).then(async () => {
+ await mkdir(CONFIG_DIR, { recursive: true });
+ const current = await loadPersistedConfig();
+ const tempFile = `${CONFIG_FILE}.${process.pid}.tmp`;
+ try {
+ await writeFile(tempFile, JSON.stringify({ ...current, ...patch }, null, 2));
+ await rename(tempFile, CONFIG_FILE);
+ } catch (err) {
+ await rm(tempFile, { force: true }).catch(() => {});
+ throw err;
+ }
+ });
+ return configWriteChain;
+}
+
+const persisted = TEST_MODE ? {} : await loadPersistedConfig();
+
+// Resolution order: remembered last choice (highest, once anything has ever
+// been persisted) > explicit `open` input > FTK_LOCAL_CLUSTER_URI/
+// FTK_LOCAL_DATABASE env vars > hardcoded fallback.
+//
+// This used to put `open` input first, on the theory that a later open()
+// call carrying input meant "the Settings dialog reopened this canvas with
+// a new connection." That's wrong: the SDK's actual open() wire type
+// (CanvasProviderOpenRequest, generated/rpc.d.ts) carries no `reason` field,
+// so extension code cannot tell a genuine user-driven reopen apart from the
+// host silently replaying the *original, creation-time* input on one of its
+// frequent restart-driven rehydrates (see DASHBOARD_PORT comment above).
+// Once a real connection has ever been persisted via POST /api/config (the
+// only channel the Settings dialog actually uses -- see public/app.js), it
+// must always win, or every host restart silently reverts the user's
+// deliberate choice back to whatever input the panel first opened with.
+const DEFAULT_CLUSTER = persisted.clusterUri || process.env.FTK_LOCAL_CLUSTER_URI || HARDCODED_CLUSTER;
+const DEFAULT_DB = persisted.database || process.env.FTK_LOCAL_DATABASE || HARDCODED_DB;
+const DEFAULT_QUERY = "Costs\n| take 20";
+
+const STATIC = {
+ "/": ["index.html", "text/html; charset=utf-8"],
+ "/index.html": ["index.html", "text/html; charset=utf-8"],
+ "/app.css": ["app.css", "text/css; charset=utf-8"],
+ "/app.js": ["app.js", "application/javascript; charset=utf-8"],
+};
+
+// This canvas has no legitimate multi-instance use case -- it's one live
+// dashboard onto one Kusto emulator. Per-instance servers (keyed by
+// caller-supplied instanceId) meant any duplicate panel -- whether from the
+// host reopening under a new id, or an agent mistakenly inventing one --
+// spun up its own ephemeral port with independently diverging state
+// (connection settings, in-progress query text). A singleton removes the
+// possibility entirely: every open(), regardless of instanceId, resolves to
+// the same server/port/state, so duplicate panels can never diverge.
+let singleton = null; // { server, url, clusterUri, database, lastQuery, canvasState, openInstances: Set }
+
+async function getOrCreateSingleton(clusterUri, database) {
+ if (!singleton) {
+ const connection = normalizeConnection(clusterUri, database);
+ singleton = {
+ clusterUri: connection.clusterUri,
+ database: connection.database,
+ lastQuery: persisted.lastQuery ?? DEFAULT_QUERY,
+ canvasState: {
+ tab: "overview",
+ preset: "all",
+ filters: {},
+ capacityClass: "home",
+ capacitySelections: {},
+ revision: Date.now(),
+ },
+ openInstances: new Set(),
+ };
+ await startServer(singleton);
+ }
+ return singleton;
+}
+
+/** Parse and validate `?filters=` from a URL search params. */
+function parseFilters(url) {
+ const raw = url.searchParams.get("filters");
+ if (!raw) return {};
+ try {
+ return validateFilters(JSON.parse(raw));
+ } catch (err) {
+ throw new Error(`Invalid filters: ${err.message}`);
+ }
+}
+
+function connectionInfo(entry) {
+ const normalized = normalizeConnection(entry.clusterUri, entry.database);
+ return {
+ clusterUri: normalized.clusterUri,
+ database: normalized.database,
+ mode: normalized.mode,
+ authentication: normalized.authentication,
+ };
+}
+
+export function getBuildInfo() {
+ return { buildId: BUILD_ID, sourceScope: SOURCE_SCOPE };
+}
+
+const CAPACITY_SELECTION_FIELDS = Object.freeze({
+ quotaSelection: new Set(["subAccountId", "location", "resourceName", "unit", "sourceVersion", "resourceId"]),
+ metricSelection: new Set(["resourceName", "unit", "sourceVersion"]),
+ demandSelection: new Set([
+ "meterCategory",
+ "meterSubcategory",
+ "meter",
+ "priceId",
+ "unit",
+ "currency",
+ "resourceId",
+ "capacityReservationId",
+ "capacityReservationStatus",
+ ]),
+});
+
+export function validateCapacitySelections(input = {}) {
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
+ throw new Error("Capacity selections must be an object.");
+ }
+ const normalized = {};
+ for (const [selectionName, selection] of Object.entries(input)) {
+ const allowedFields = CAPACITY_SELECTION_FIELDS[selectionName];
+ if (!allowedFields) throw new Error(`Unsupported capacity selection '${selectionName}'.`);
+ if (!selection || typeof selection !== "object" || Array.isArray(selection)) {
+ throw new Error(`Capacity selection '${selectionName}' must be an object.`);
+ }
+ const clean = {};
+ for (const [field, value] of Object.entries(selection)) {
+ if (!allowedFields.has(field)) throw new Error(`Unsupported ${selectionName} field '${field}'.`);
+ if (typeof value !== "string" || !value.trim() || value.length > 512 || /[\u0000-\u001f\u007f]/.test(value)) {
+ throw new Error(`${selectionName}.${field} must be 1-512 printable characters.`);
+ }
+ clean[field] = value.trim();
+ }
+ normalized[selectionName] = clean;
+ }
+ return normalized;
+}
+
+export function validateCanvasStatePatch(input = {}) {
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("Canvas state must be an object.");
+ const allowed = new Set(["tab", "preset", "filters", "capacityClass", "capacitySelections", "expectedRevision"]);
+ for (const key of Object.keys(input)) {
+ if (!allowed.has(key)) throw new Error(`Unsupported canvas state property '${key}'.`);
+ }
+ const patch = {};
+ if ("tab" in input) {
+ if (!VALID_TABS.includes(input.tab)) throw new Error(`Unknown canvas tab '${input.tab}'.`);
+ patch.tab = input.tab;
+ }
+ if ("preset" in input) {
+ if (!VALID_PRESETS.includes(input.preset)) throw new Error(`Unknown time preset '${input.preset}'.`);
+ patch.preset = input.preset;
+ }
+ if ("filters" in input) patch.filters = validateFilters(input.filters);
+ if ("capacityClass" in input) patch.capacityClass = normalizeCapacityClassId(input.capacityClass);
+ if ("capacitySelections" in input) patch.capacitySelections = validateCapacitySelections(input.capacitySelections);
+ if ("expectedRevision" in input && (!Number.isInteger(input.expectedRevision) || input.expectedRevision < 0)) {
+ throw new Error("expectedRevision must be a non-negative integer.");
+ }
+ return { patch, expectedRevision: input.expectedRevision };
+}
+
+export function updateCanvasState(current, input = {}) {
+ const { patch, expectedRevision } = validateCanvasStatePatch(input);
+ if (expectedRevision !== undefined && expectedRevision !== current.revision) {
+ const err = new Error("Canvas state changed before this update.");
+ err.code = "revision_conflict";
+ err.state = current;
+ throw err;
+ }
+ return { ...current, ...patch, revision: current.revision + 1 };
+}
+
+function queryStructure(kql) {
+ let output = "";
+ let state = "code";
+ for (let i = 0; i < kql.length; i++) {
+ const char = kql[i], next = kql[i + 1];
+ if (state === "line") {
+ if (char === "\n") { state = "code"; output += "\n"; } else output += " ";
+ } else if (state === "block") {
+ if (char === "*" && next === "/") { output += " "; i++; state = "code"; }
+ else output += char === "\n" ? "\n" : " ";
+ } else if (state === "single" || state === "double") {
+ const quote = state === "single" ? "'" : '"';
+ if (char === "\\") { output += " "; i++; }
+ else if (char === quote) { output += " "; state = "code"; }
+ else output += char === "\n" ? "\n" : " ";
+ } else if (char === "/" && next === "/") {
+ output += " "; i++; state = "line";
+ } else if (char === "/" && next === "*") {
+ output += " "; i++; state = "block";
+ } else if (char === "'" || char === '"') {
+ output += " "; state = char === "'" ? "single" : "double";
+ } else {
+ output += char;
+ }
+ }
+ return output;
+}
+
+export function validateReadOnlyQuery(kql) {
+ if (typeof kql !== "string" || !kql.trim()) throw new Error("KQL is required.");
+ if (kql.length > QUERY_MAX_LENGTH) throw new Error(`KQL must be at most ${QUERY_MAX_LENGTH} characters.`);
+ const structure = queryStructure(kql);
+ for (const match of structure.matchAll(/(?:^|[;\n])\s*\.(\w+)/gim)) {
+ if (match[1].toLowerCase() !== "show") throw new Error(`Management command '.${match[1]}' is not allowed.`);
+ }
+ return kql.trim();
+}
+
+async function readJsonBody(req) {
+ let body = "";
+ for await (const chunk of req) {
+ body += chunk;
+ if (Buffer.byteLength(body) > REQUEST_BODY_LIMIT) throw new Error("Request body is too large.");
+ }
+ try {
+ return JSON.parse(body || "{}");
+ } catch {
+ throw new Error("Invalid JSON.");
+ }
+}
+
+export async function changeConnection(entry, input, dependencies = {}) {
+ const query = dependencies.runQueryFn || runQuery;
+ const persist = dependencies.persistConfig || savePersistedConfig;
+ const next = normalizeConnection(input?.clusterUri, input?.database || "Hub");
+ await query(next.clusterUri, next.database, "Costs() | take 0");
+ await persist({ clusterUri: next.clusterUri, database: next.database });
+ entry.clusterUri = next.clusterUri;
+ entry.database = next.database;
+ entry.canvasState = { ...entry.canvasState, revision: entry.canvasState.revision + 1 };
+ return connectionInfo(entry);
+}
+
+export function validateLoopbackRequest(entry, req, path) {
+ if (!entry.url) return { status: 503, error: "Dashboard server is starting." };
+ const expected = new URL(entry.url);
+ const host = String(req.headers.host || "").toLowerCase();
+ if (host !== expected.host.toLowerCase()) {
+ return { status: 403, error: "Request host is not allowed." };
+ }
+ const origin = req.headers.origin;
+ if (origin && origin !== expected.origin) {
+ return { status: 403, error: "Request origin is not allowed." };
+ }
+ if (req.headers["sec-fetch-site"] === "cross-site") {
+ return { status: 403, error: "Cross-site requests are not allowed." };
+ }
+ if (path.startsWith("/api/") && req.method === "POST") {
+ const contentType = String(req.headers["content-type"] || "");
+ if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
+ return { status: 415, error: "POST requests require application/json." };
+ }
+ }
+ return null;
+}
+
+export function validateViewInput(input = {}) {
+ const name = input.name || "overview";
+ const preset = input.preset || "all";
+ if (!DASHBOARD_TABS.includes(name)) throw new Error(`Unknown view '${name}'.`);
+ if (!VALID_PRESETS.includes(preset)) throw new Error(`Unknown time preset '${preset}'.`);
+ const capacityClass = normalizeCapacityClassId(input.capacityClass || "home");
+ const capacitySelections = validateCapacitySelections(input.capacitySelections || {});
+ return { name, preset, filters: validateFilters(input.filters || {}), capacityClass, capacitySelections };
+}
+
+function sendJson(res, status, obj) {
+ const body = JSON.stringify(obj);
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
+ res.end(body);
+}
+
+function logError(context, err) {
+ console.error("[ftk-local-dashboard]", context, err);
+}
+
+function sendQueryError(res, entry, viewName, err) {
+ logError(`Could not query ${viewName} for ${entry.clusterUri}/${entry.database}`, err);
+ sendJson(res, 200, {
+ error: err?.message || "Could not query the FinOps hub. Check the extension logs for details.",
+ clusterUri: entry.clusterUri,
+ database: entry.database,
+ });
+}
+
+async function handleRequest(entry, req, res) {
+ const url = new URL(req.url, "http://127.0.0.1");
+ const path = url.pathname;
+ const policyError = validateLoopbackRequest(entry, req, path);
+ if (policyError) {
+ sendJson(res, policyError.status, { error: policyError.error });
+ return;
+ }
+
+ if (STATIC[path]) {
+ const [file, type] = STATIC[path];
+ try {
+ const buf = await readFile(new URL(file, PUBLIC_DIR));
+ res.writeHead(200, { "Content-Type": type, "Cache-Control": "no-store" });
+ res.end(buf);
+ } catch (err) {
+ logError(`Could not serve asset ${file}`, err);
+ res.writeHead(500, { "Content-Type": "text/plain" });
+ res.end("Asset error");
+ }
+ return;
+ }
+
+ if (path === "/api/config" && req.method === "GET") {
+ sendJson(res, 200, {
+ ...connectionInfo(entry),
+ lastQuery: entry.lastQuery ?? DEFAULT_QUERY,
+ });
+ return;
+ }
+
+ // Probe and persist a new connection before changing the live singleton.
+ if (path === "/api/config" && req.method === "POST") {
+ try {
+ const body = await readJsonBody(req);
+ const connection = await changeConnection(entry, body);
+ sendJson(res, 200, connection);
+ } catch (err) {
+ logError("Could not change FinOps hub connection", err);
+ sendJson(res, 400, { error: err.message || "Could not change connection." });
+ }
+ return;
+ }
+
+ // The query editor tab autosaves its text here (debounced) so that a page
+ // reload -- whether from a manual refresh or the host restarting this
+ // extension process (the URL itself is now fixed and survives restarts,
+ // but the server process, and any unpersisted in-memory state, does not)
+ // -- never silently discards an in-progress, unrun query.
+ if (path === "/api/query-state" && req.method === "POST") {
+ let query;
+ try { ({ query } = await readJsonBody(req)); } catch (err) { sendJson(res, 400, { error: err.message }); return; }
+ if (typeof query !== "string") { sendJson(res, 400, { error: "query must be a string" }); return; }
+ entry.lastQuery = query;
+ try {
+ await savePersistedConfig({ lastQuery: query });
+ sendJson(res, 200, { ok: true });
+ } catch (err) {
+ logError("Could not persist query text", err);
+ sendJson(res, 200, { ok: false });
+ }
+ return;
+ }
+
+ if (path === "/api/session-state" && req.method === "GET") {
+ sendJson(res, 200, entry.canvasState);
+ return;
+ }
+
+ if (path === "/api/session-state" && req.method === "POST") {
+ try {
+ entry.canvasState = updateCanvasState(entry.canvasState, await readJsonBody(req));
+ sendJson(res, 200, entry.canvasState);
+ } catch (err) {
+ if (err.code === "revision_conflict") {
+ sendJson(res, 409, { error: err.code, state: err.state });
+ } else {
+ sendJson(res, 400, { error: err.message || "Invalid canvas state." });
+ }
+ }
+ return;
+ }
+
+ if (path === "/api/config") {
+ res.writeHead(405, { "Content-Type": "text/plain" });
+ res.end("Method not allowed");
+ return;
+ }
+
+ // Database schema for the experimental Monaco KQL tab's autocomplete
+ // (monaco-kusto's worker.setSchemaFromShowSchema expects the parsed
+ // `.show schema as json` object, not generic query rows).
+ if (path === "/api/schema") {
+ try {
+ const rows = await runQuery(entry.clusterUri, entry.database, ".show schema as json");
+ const cell = rows[0] ? Object.values(rows[0])[0] : null;
+ const schema = typeof cell === "string" ? JSON.parse(cell) : cell;
+ sendJson(res, 200, { schema, clusterUri: entry.clusterUri, database: entry.database });
+ } catch (err) {
+ sendQueryError(res, entry, "schema", err);
+ }
+ return;
+ }
+
+ if (path === "/api/dashboard") {
+ try {
+ const preset = url.searchParams.get("preset") || "all";
+ if (!VALID_PRESETS.includes(preset)) throw new Error(`Unknown time preset '${preset}'.`);
+ const filters = parseFilters(url);
+ const payload = await getDashboard(entry.clusterUri, entry.database, preset, filters);
+ sendJson(res, 200, payload);
+ } catch (err) {
+ sendQueryError(res, entry, "overview", err);
+ }
+ return;
+ }
+
+ if (path === "/api/tokenomics") {
+ try {
+ const preset = url.searchParams.get("preset") || "all";
+ if (!VALID_PRESETS.includes(preset)) throw new Error(`Unknown time preset '${preset}'.`);
+ const filters = parseFilters(url);
+ const payload = await getTokenomics(entry.clusterUri, entry.database, preset, filters);
+ sendJson(res, 200, payload);
+ } catch (err) {
+ sendQueryError(res, entry, "tokenomics", err);
+ }
+ return;
+ }
+
+ if (path === "/api/view" && (req.method === "GET" || req.method === "POST")) {
+ try {
+ const input = req.method === "POST"
+ ? await readJsonBody(req)
+ : {
+ name: url.searchParams.get("name") || "overview",
+ preset: url.searchParams.get("preset") || "all",
+ filters: parseFilters(url),
+ };
+ const { name, preset, filters, capacityClass, capacitySelections } = validateViewInput(input);
+ const getter = GETTERS[name];
+ const payload = name === "capacity"
+ ? await getter(entry.clusterUri, entry.database, capacityClass, capacitySelections)
+ : await getter(entry.clusterUri, entry.database, preset, filters);
+ sendJson(res, 200, payload);
+ } catch (err) {
+ sendQueryError(res, entry, "view", err);
+ }
+ return;
+ }
+
+ if (path === "/api/capacity-subscriptions" && req.method === "POST") {
+ try {
+ const payload = await getComputeSubscriptionPage(
+ entry.clusterUri,
+ entry.database,
+ await readJsonBody(req)
+ );
+ sendJson(res, 200, payload);
+ } catch (err) {
+ sendQueryError(res, entry, "capacity subscriptions", err);
+ }
+ return;
+ }
+
+ if (path === "/api/kql" && req.method === "POST") {
+ try {
+ const body = await readJsonBody(req);
+ if ("database" in body) throw new Error("Change databases through connection settings.");
+ const kql = validateReadOnlyQuery(body.kql);
+ entry.lastQuery = kql;
+ void savePersistedConfig({ lastQuery: kql }).catch((err) => logError("Could not persist query text", err));
+ const rows = await runQuery(entry.clusterUri, entry.database, kql);
+ sendJson(res, 200, {
+ rows: rows.slice(0, QUERY_ROW_LIMIT),
+ truncated: rows.length > QUERY_ROW_LIMIT,
+ rowLimit: QUERY_ROW_LIMIT,
+ });
+ } catch (err) {
+ logError("Custom KQL error", err);
+ sendJson(res, 200, { error: err.message || "Query failed" });
+ }
+ return;
+ }
+
+ res.writeHead(404, { "Content-Type": "text/plain" });
+ res.end("Not found");
+}
+
+// Binds to DASHBOARD_PORT so the canvas URL survives host-triggered extension
+// restarts. A restarted process's predecessor has already exited by the time
+// this runs (the OS reclaims a LISTEN socket's port immediately on process
+// exit), so this normally succeeds on the first try; the short retries only
+// guard against the rare case of two processes briefly overlapping during
+// teardown. Falls back to an OS-assigned ephemeral port as a last resort so
+// the dashboard still works (with a non-stable URL) if the fixed port is
+// genuinely held by something else.
+async function bindServer(server) {
+ const maxAttempts = 5;
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ try {
+ await new Promise((resolve, reject) => {
+ const onError = (err) => reject(err);
+ server.once("error", onError);
+ server.listen(DASHBOARD_PORT, "127.0.0.1", () => {
+ server.removeListener("error", onError);
+ resolve();
+ });
+ });
+ return;
+ } catch (err) {
+ if (err.code !== "EADDRINUSE") throw err;
+ if (attempt === maxAttempts) {
+ logError(`Fixed port ${DASHBOARD_PORT} unavailable after ${maxAttempts} attempts, falling back to an ephemeral port`, err);
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ return;
+ }
+ await new Promise((r) => setTimeout(r, 150));
+ }
+ }
+}
+
+async function startServer(entry) {
+ const server = createServer((req, res) => {
+ handleRequest(entry, req, res).catch((err) => {
+ logError("Unhandled dashboard request failure", err);
+ if (res.headersSent) {
+ res.destroy();
+ } else {
+ sendJson(res, 500, { error: "Unexpected dashboard server error" });
+ }
+ });
+ });
+ await bindServer(server);
+ const addr = server.address();
+ const port = typeof addr === "object" && addr ? addr.port : 0;
+ entry.server = server;
+ entry.url = `http://127.0.0.1:${port}/`;
+ return entry;
+}
+
+// Compact headline KPIs for the agent-facing `summary` action.
+function headline(payload) {
+ if (payload.empty) return { empty: true, window: payload.window };
+ const d = payload.data;
+ const s = d.summary?.[0] || {};
+ const list = s.List || 0, eff = s.Effective || 0, contracted = s.Contracted || 0;
+ const tag = Object.fromEntries((d.tagged || []).map((r) => [r._t, r.Cost || 0]));
+ const tagTotal = (tag.Tagged || 0) + (tag.Untagged || 0);
+ const price = Object.fromEntries((d.pricing || []).map((r) => [r.PricingCategory, r.Cost || 0]));
+ const priceTotal = Object.values(price).reduce((a, b) => a + b, 0);
+ return {
+ window: payload.window,
+ effectiveCost: Math.round(eff * 100) / 100,
+ billedCost: Math.round((s.Billed || 0) * 100) / 100,
+ totalSavings: Math.round((list - eff) * 100) / 100,
+ effectiveSavingsRate: list > 0 ? +((list - eff) / list).toFixed(4) : 0,
+ negotiatedSavings: Math.round((list - contracted) * 100) / 100,
+ commitmentSavings: Math.round((contracted - eff) * 100) / 100,
+ untaggedPercent: tagTotal > 0 ? +((tag.Untagged || 0) / tagTotal).toFixed(4) : 0,
+ commitmentCoverage: priceTotal > 0 ? +((price.Committed || 0) / priceTotal).toFixed(4) : 0,
+ resources: s.Resources || 0,
+ services: s.Services || 0,
+ subscriptions: s.Subscriptions || 0,
+ regions: s.Regions || 0,
+ topServices: (d.topServices || []).slice(0, 5).map((r) => ({ name: r.ServiceName, cost: Math.round((r.Cost || 0) * 100) / 100 })),
+ generatedAt: payload.generatedAt,
+ };
+}
+
+// Compact headline token KPIs for the agent-facing `tokenomics` action.
+function tokenHeadline(payload) {
+ if (payload.empty) return { empty: true, window: payload.window };
+ const d = payload.data;
+ const s = d.summary?.[0] || {};
+ const tokens = s.Tokens || 0, eff = s.Effective || 0;
+ const cloud = d.totalCloud?.[0]?.Effective || 0;
+ const dir = Object.fromEntries((d.direction || []).map((r) => [r.Direction, r]));
+ const inTok = dir["Input"]?.Tokens || 0;
+ const cachedTok = dir["Cached input"]?.Tokens || 0;
+ return {
+ window: payload.window,
+ aiTokenCost: Math.round(eff * 100) / 100,
+ totalTokens: tokens,
+ blendedCostPerMillionTokens: tokens > 0 ? +((eff / tokens) * 1e6).toFixed(4) : 0,
+ cachedInputShareOfInputTokens: inTok + cachedTok > 0 ? +(cachedTok / (inTok + cachedTok)).toFixed(4) : 0,
+ aiShareOfCloudCost: cloud > 0 ? +(eff / cloud).toFixed(4) : 0,
+ modelCount: s.Models || 0,
+ directionMix: (d.direction || []).map((r) => ({ direction: r.Direction, tokens: r.Tokens, cost: Math.round((r.Cost || 0) * 100) / 100 })),
+ topModels: (d.models || []).slice(0, 5).map((r) => ({
+ model: r.Model, tokens: r.Tokens, cost: Math.round((r.Cost || 0) * 100) / 100,
+ costPerMillionTokens: +((r.CostPer1K || 0) * 1000).toFixed(4),
+ })),
+ generatedAt: payload.generatedAt,
+ };
+}
+
+const FILTER_SCHEMA = {
+ type: "object",
+ additionalProperties: false,
+ properties: Object.fromEntries(
+ [...ALLOWED_FILTER_COLUMNS].map((name) => [name, {
+ type: "array",
+ maxItems: 8,
+ items: { type: "string", minLength: 1, maxLength: 256 },
+ }])
+ ),
+};
+
+const SELECTION_VALUE_SCHEMA = { type: "string", minLength: 1, maxLength: 512 };
+const CAPACITY_SELECTION_SCHEMA = {
+ type: "object",
+ additionalProperties: false,
+ properties: Object.fromEntries(
+ Object.entries(CAPACITY_SELECTION_FIELDS).map(([name, fields]) => [name, {
+ type: "object",
+ additionalProperties: false,
+ properties: Object.fromEntries([...fields].map((field) => [field, SELECTION_VALUE_SCHEMA])),
+ }])
+ ),
+};
+
+export function createDashboardCanvas(dependencies = {}) {
+ const canvasFactory = dependencies.canvasFactory || createCanvas;
+ const query = dependencies.runQueryFn || runQuery;
+ const getters = dependencies.getters || GETTERS;
+ const persist = dependencies.persistConfig || savePersistedConfig;
+ const getEntry = dependencies.getEntry || (() => singleton);
+ const requireEntry = () => {
+ const entry = getEntry();
+ if (!entry) throw new CanvasError("canvas_not_open", "Open the FinOps hub dashboard first.");
+ return entry;
+ };
+ const queryFailure = (context, err) => {
+ logError(context, err);
+ throw new CanvasError("query_failed", err?.message || "Could not query the FinOps hub.");
+ };
+
+ return canvasFactory({
+ id: "ftk-local-dashboard",
+ displayName: "FinOps hub dashboard",
+ description: "Live FinOps dashboard for local and remote hubs with cost, allocation, rate, usage, anomaly, AI tokenomics, AI and emerging workload, and capacity views.",
+ inputSchema: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ clusterUri: { type: "string", description: `Local loopback or remote Kusto cluster origin. Seeds only the first run before a connection is persisted; defaults to ${HARDCODED_CLUSTER}.` },
+ database: { type: "string", description: "Database name. Default Hub." },
+ },
+ },
+ actions: [
+ {
+ name: "get_build_info",
+ description: "Return the dashboard build identifier and project or user source scope.",
+ inputSchema: { type: "object", additionalProperties: false },
+ handler: async () => getBuildInfo(),
+ },
+ {
+ name: "get_connection",
+ description: "Return the shared FinOps hub connection and authentication mode without credentials.",
+ inputSchema: { type: "object", additionalProperties: false },
+ handler: async () => connectionInfo(requireEntry()),
+ },
+ {
+ name: "set_connection",
+ description: "Probe and switch the shared FinOps hub connection, then persist it for future sessions.",
+ inputSchema: {
+ type: "object",
+ additionalProperties: false,
+ required: ["clusterUri"],
+ properties: {
+ clusterUri: { type: "string" },
+ database: { type: "string", default: "Hub" },
+ },
+ },
+ handler: async (ctx) => {
+ const entry = requireEntry();
+ try {
+ return await changeConnection(entry, ctx.input, { runQueryFn: query, persistConfig: persist });
+ } catch (err) {
+ logError("Could not change FinOps hub connection", err);
+ throw new CanvasError("connection_failed", err.message || "Could not change connection.");
+ }
+ },
+ },
+ {
+ name: "get_canvas_state",
+ description: "Return the visible tab, Capacity class and selectors, time preset, filters, and state revision shared with the open canvas.",
+ inputSchema: { type: "object", additionalProperties: false },
+ handler: async () => ({ ...requireEntry().canvasState }),
+ },
+ {
+ name: "set_canvas_state",
+ description: "Change the visible tab, Capacity class or selectors, time preset, or filters with optional revision conflict detection.",
+ inputSchema: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ tab: { type: "string", enum: VALID_TABS },
+ preset: { type: "string", enum: VALID_PRESETS },
+ filters: FILTER_SCHEMA,
+ capacityClass: { type: "string", enum: ["home", ...Object.keys(CAPACITY_CLASS_REGISTRY)] },
+ capacitySelections: CAPACITY_SELECTION_SCHEMA,
+ expectedRevision: { type: "integer", minimum: 0 },
+ },
+ },
+ handler: async (ctx) => {
+ const entry = requireEntry();
+ try {
+ entry.canvasState = updateCanvasState(entry.canvasState, ctx.input);
+ return { ...entry.canvasState };
+ } catch (err) {
+ if (err.code === "revision_conflict") {
+ throw new CanvasError("revision_conflict", `Canvas state is now at revision ${err.state.revision}.`);
+ }
+ throw new CanvasError("invalid_canvas_state", err.message);
+ }
+ },
+ },
+ {
+ name: "get_view",
+ description: "Run any dashboard view against the shared connection and return its structured payload.",
+ inputSchema: {
+ type: "object",
+ additionalProperties: false,
+ required: ["view"],
+ properties: {
+ view: { type: "string", enum: DASHBOARD_TABS },
+ preset: { type: "string", enum: VALID_PRESETS, default: "all" },
+ filters: FILTER_SCHEMA,
+ capacityClass: { type: "string", enum: ["home", ...Object.keys(CAPACITY_CLASS_REGISTRY)], default: "home" },
+ capacitySelections: CAPACITY_SELECTION_SCHEMA,
+ },
+ },
+ handler: async (ctx) => {
+ const entry = requireEntry();
+ try {
+ const input = validateViewInput({
+ name: ctx.input?.view,
+ preset: ctx.input?.preset,
+ filters: ctx.input?.filters,
+ capacityClass: ctx.input?.capacityClass,
+ capacitySelections: ctx.input?.capacitySelections,
+ });
+ return input.name === "capacity"
+ ? await getters[input.name](entry.clusterUri, entry.database, input.capacityClass, input.capacitySelections)
+ : await getters[input.name](entry.clusterUri, entry.database, input.preset, input.filters);
+ } catch (err) {
+ return queryFailure(`Could not query ${ctx.input?.view}`, err);
+ }
+ },
+ },
+ {
+ name: "run_query",
+ description: "Run bounded read-only KQL against the shared connection and return up to 500 rows.",
+ inputSchema: {
+ type: "object",
+ additionalProperties: false,
+ required: ["kql"],
+ properties: { kql: { type: "string", minLength: 1, maxLength: QUERY_MAX_LENGTH } },
+ },
+ handler: async (ctx) => {
+ const entry = requireEntry();
+ try {
+ const kql = validateReadOnlyQuery(ctx.input?.kql);
+ const rows = await query(entry.clusterUri, entry.database, kql);
+ return { rows: rows.slice(0, QUERY_ROW_LIMIT), truncated: rows.length > QUERY_ROW_LIMIT, rowLimit: QUERY_ROW_LIMIT };
+ } catch (err) {
+ return queryFailure("Could not run custom KQL", err);
+ }
+ },
+ },
+ {
+ name: "summary",
+ description: "Return headline FinOps KPIs for a time window from the shared connection.",
+ inputSchema: {
+ type: "object",
+ additionalProperties: false,
+ properties: { preset: { type: "string", enum: VALID_PRESETS, description: "Time window. Default all." } },
+ },
+ handler: async (ctx) => {
+ const entry = requireEntry();
+ try {
+ return headline(await getters.overview(entry.clusterUri, entry.database, ctx.input?.preset || "all"));
+ } catch (err) {
+ return queryFailure("Could not query summary", err);
+ }
+ },
+ },
+ {
+ name: "tokenomics",
+ description: "Return headline AI token-economics KPIs for a time window from the shared connection.",
+ inputSchema: {
+ type: "object",
+ additionalProperties: false,
+ properties: { preset: { type: "string", enum: VALID_PRESETS, description: "Time window. Default all." } },
+ },
+ handler: async (ctx) => {
+ const entry = requireEntry();
+ try {
+ return tokenHeadline(await getters.tokenomics(entry.clusterUri, entry.database, ctx.input?.preset || "all"));
+ } catch (err) {
+ return queryFailure("Could not query tokenomics", err);
+ }
+ },
+ },
+ ],
+ open: async (ctx) => {
+ try {
+ const clusterUri = persisted.clusterUri || ctx.input?.clusterUri || DEFAULT_CLUSTER;
+ const database = persisted.database || ctx.input?.database || DEFAULT_DB;
+ const entry = await getOrCreateSingleton(clusterUri, database);
+ entry.openInstances.add(ctx.instanceId);
+ const connection = connectionInfo(entry);
+ return {
+ title: "FinOps hub dashboard",
+ url: entry.url,
+ status: `${connection.mode} · ${entry.clusterUri} · ${entry.database}`,
+ };
+ } catch (err) {
+ throw new CanvasError("invalid_connection", err.message || "Could not open the FinOps hub dashboard.");
+ }
+ },
+ onClose: async (ctx) => {
+ if (!singleton) return;
+ singleton.openInstances.delete(ctx.instanceId);
+ },
+ });
+}
+
+if (!TEST_MODE) {
+ await joinSession({ canvases: [createDashboardCanvas()] });
+}
diff --git a/.github/extensions/ftk-local-dashboard/kusto.mjs b/.github/extensions/ftk-local-dashboard/kusto.mjs
new file mode 100644
index 000000000..a339cc871
--- /dev/null
+++ b/.github/extensions/ftk-local-dashboard/kusto.mjs
@@ -0,0 +1,2106 @@
+// KQL query layer for local and remote FinOps hubs.
+//
+// Talks to the Kusto HTTP API (/v1/rest/query) and parses the v1
+// response shape (Tables[0]) into plain row objects. The dashboard queries are
+// grounded in the FinOps Framework domains and the FinOps toolkit query
+// catalog (src/queries/INDEX.md, KPI.md, finops-hub-database-guide.md).
+
+import { randomUUID } from "node:crypto";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
+const execFileAsync = promisify(execFile);
+const DEFAULT_TIMEOUT_MS = 20000;
+const TOKEN_TIMEOUT_MS = 15000;
+const TOKEN_MAX_BUFFER = 1024 * 1024;
+const TOKEN_REFRESH_SKEW_MS = 2 * 60 * 1000;
+export const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
+export const ALLOWED_FILTER_COLUMNS = new Set([
+ "ServiceName",
+ "ServiceCategory",
+ "RegionId",
+ "x_ResourceGroupName",
+ "SubAccountName",
+ "CommitmentDiscountName",
+ "x_SkuMeterSubcategory",
+]);
+export const CAPACITY_LIMITS = Object.freeze({
+ primaryRows: 250,
+ selectorKeys: 500,
+ dailyPoints: 430,
+ heatmapCells: 500,
+ familyCells: 4000,
+});
+export const COMPUTE_SUBSCRIPTION_PAGE_SIZE = 50;
+export const CAPACITY_FRESHNESS_HOURS = 48;
+
+const CAPACITY_SOURCE_TYPES = Object.freeze([
+ "AppServiceUsage",
+ "CognitiveServicesUsage",
+ "ComputeUsage",
+ "SqlSubscriptionUsage",
+ "StorageUsage",
+ "CapacityReservation",
+ "PremiumSSDv2Disk",
+]);
+
+export const CAPACITY_CLASS_REGISTRY = Object.freeze({
+ "app-service": Object.freeze({
+ id: "app-service",
+ sourceType: "AppServiceUsage",
+ sourceVersions: Object.freeze(["1.0-usage"]),
+ providerApiVersion: "2024-11-01",
+ title: "App Service quota",
+ quotaType: "quota",
+ sourceNote: "Provider-reported App Service quota — point-in-time",
+ emptyLabel: "No App Service quota observations were ingested; this does not mean zero usage or unlimited capacity.",
+ demandPredicateId: "app-service-cost",
+ }),
+ "azure-ai": Object.freeze({
+ id: "azure-ai",
+ sourceType: "CognitiveServicesUsage",
+ sourceVersions: Object.freeze(["1.0-usage"]),
+ providerApiVersion: "2023-05-01",
+ title: "Azure AI quota pools",
+ quotaType: "provider-counter",
+ sourceNote: "Provider-reported Azure AI quota — point-in-time",
+ emptyLabel: "No Azure AI quota observations were ingested; check query coverage and provider access.",
+ demandPredicateId: "azure-ai-cost",
+ }),
+ compute: Object.freeze({
+ id: "compute",
+ sourceType: "ComputeUsage",
+ sourceVersions: Object.freeze(["1.0-usage"]),
+ providerApiVersion: "2024-07-01",
+ title: "Compute quota",
+ quotaType: "quota",
+ sourceNote: "Provider-reported compute quota — point-in-time",
+ emptyLabel: "No Compute quota observations were ingested; deployment capacity is unknown.",
+ demandPredicateId: "compute-cost",
+ }),
+ "azure-sql": Object.freeze({
+ id: "azure-sql",
+ sourceType: "SqlSubscriptionUsage",
+ sourceVersions: Object.freeze(["1.0-sql"]),
+ providerApiVersion: "2023-08-01",
+ title: "Azure SQL subscription quota and counters",
+ quotaType: "provider-counter",
+ sourceNote: "Provider-reported SQL quota — point-in-time",
+ emptyLabel: "No Azure SQL subscription-usage observations were ingested; SQL quota posture is unknown.",
+ demandPredicateId: "azure-sql-cost",
+ }),
+ storage: Object.freeze({
+ id: "storage",
+ sourceType: "StorageUsage",
+ sourceVersions: Object.freeze(["1.0-usage"]),
+ providerApiVersion: "2025-06-01",
+ title: "Storage quotas",
+ quotaType: "quota",
+ sourceNote: "Provider-reported storage quota — point-in-time",
+ emptyLabel: "No Storage quota was reported for the selected scope and time. Validate ingestion, permissions, supported regions, and source execution.",
+ demandPredicateId: "storage-cost",
+ }),
+ "capacity-reservations": Object.freeze({
+ id: "capacity-reservations",
+ sourceType: "CapacityReservation",
+ sourceVersions: Object.freeze(["1.0-capacity-reservation"]),
+ providerApiVersion: "2024-03-01",
+ title: "Capacity reservation groups",
+ quotaType: "inventory",
+ sourceNote: "Capacity reservation group observed — inventory only",
+ emptyLabel: "No capacity reservation groups were observed in the latest ingestion window; absence is unverified without a complete snapshot.",
+ demandPredicateId: "capacity-reservation-cost",
+ }),
+ "premium-ssd-v2": Object.freeze({
+ id: "premium-ssd-v2",
+ sourceType: "PremiumSSDv2Disk",
+ sourceVersions: Object.freeze(["1.0-disk"]),
+ providerApiVersion: "2024-03-02",
+ title: "Premium SSD v2 disks",
+ quotaType: "inventory",
+ sourceNote: "Observed Premium SSD v2 provisioned size — GiB inventory; no quota limit",
+ emptyLabel: "No Premium SSD v2 disks were observed in the latest ingestion window; this is not a disk quota or regional availability conclusion.",
+ demandPredicateId: "premium-ssd-cost",
+ }),
+});
+
+const CAPACITY_CLASS_BY_SOURCE = new Map(
+ Object.values(CAPACITY_CLASS_REGISTRY).map((entry) => [entry.sourceType.toLowerCase(), entry])
+);
+
+export const CAPACITY_METRIC_REGISTRY = Object.freeze({
+ "computeusage|cores|count": Object.freeze({
+ metricRole: "total-regional-vcpu",
+ direction: "higher-is-worse",
+ limitMode: "positive-denominator",
+ zeroLimitMode: "no-entitlement",
+ historyMode: "quota-series",
+ heatmapMode: "regional-percent",
+ sourceNote: "Total regional vCPU quota",
+ }),
+ "computeusage|lowprioritycores|count": Object.freeze({
+ metricRole: "low-priority-vcpu",
+ direction: "higher-is-worse",
+ limitMode: "positive-denominator",
+ zeroLimitMode: "no-entitlement",
+ historyMode: "quota-series",
+ heatmapMode: "regional-percent",
+ sourceNote: "Regional low-priority or Spot vCPU quota",
+ }),
+ "computeusage|virtualmachines|count": Object.freeze({
+ metricRole: "virtual-machine-count",
+ direction: "higher-is-worse",
+ limitMode: "positive-denominator",
+ zeroLimitMode: "no-entitlement",
+ historyMode: "quota-series",
+ heatmapMode: "regional-percent",
+ sourceNote: "Regional virtual machine count quota",
+ }),
+});
+
+export const CAPACITY_DEMAND_REGISTRY = Object.freeze({
+ "app-service": Object.freeze({
+ units: Object.freeze(["Hours", "GiB Hours", "GB", "Units/Hour"]),
+ label: "Billed App Service usage — daily {unit}, grouped by meter; not quota usage",
+ }),
+ "azure-ai": Object.freeze({
+ units: Object.freeze(["Units", "Seconds", "Minutes", "Hours"]),
+ label: "Billed Azure AI usage — daily {unit}, meter-specific; not requests, tokens, or quota unless named by the meter",
+ }),
+ compute: Object.freeze({
+ units: Object.freeze(["Hours", "Units/Hour", "GB", "Units/Month", "Units", "GB/Month"]),
+ label: "Billed compute usage — {unit}, meter-specific; not peak cores or capacity availability",
+ }),
+ "azure-sql": Object.freeze({
+ units: Object.freeze(["Units/Day", "Hours", "GB/Month", "Units/Hour", "Units/Month"]),
+ label: "Billed SQL usage — daily {unit}, exact meter; not quota utilization",
+ }),
+ storage: Object.freeze({
+ units: Object.freeze(["Units", "Units/Hour", "GB", "GB/Month", "Units/Month"]),
+ label: "Billed storage demand — daily {unit}, meter-specific; not quota utilization",
+ }),
+ "capacity-reservations": Object.freeze({
+ units: Object.freeze(["Hours"]),
+ label: "Capacity-reservation-linked billed hours — accounting status Used/Unused; not allocated or guaranteed capacity",
+ }),
+ "premium-ssd-v2": Object.freeze({
+ units: Object.freeze([]),
+ label: "Resource-matched disk effective cost ({currency}) — financial context; usage quantity not classified",
+ }),
+});
+
+let cachedToken = null;
+let tokenInFlight = null;
+
+export function normalizeConnection(clusterUri, database = "Hub") {
+ if (typeof clusterUri !== "string" || !clusterUri.trim()) {
+ throw new Error("Cluster URI is required.");
+ }
+
+ let url;
+ try {
+ url = new URL(clusterUri.trim());
+ } catch {
+ throw new Error("Cluster URI must be a valid absolute URL.");
+ }
+ if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
+ throw new Error("Cluster URI must contain only the cluster origin.");
+ }
+
+ const hostname = url.hostname.toLowerCase();
+ const isLoopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
+ let mode;
+ if (isLoopback && url.protocol === "http:") {
+ mode = "local";
+ } else if (
+ url.protocol === "https:" &&
+ !url.port &&
+ hostname.endsWith(".kusto.windows.net") &&
+ hostname.length > ".kusto.windows.net".length
+ ) {
+ mode = "remote";
+ } else {
+ throw new Error("Use loopback HTTP for a local hub or HTTPS for a *.kusto.windows.net cluster.");
+ }
+
+ const normalizedDatabase = typeof database === "string" ? database.trim() : "";
+ if (!normalizedDatabase || normalizedDatabase.length > 256 || /[\u0000-\u001f\u007f]/.test(normalizedDatabase)) {
+ throw new Error("Database must be a non-empty name of at most 256 characters.");
+ }
+
+ return {
+ clusterUri: url.origin,
+ database: normalizedDatabase,
+ mode,
+ authentication: mode === "remote" ? "azure-cli" : "none",
+ };
+}
+
+function tokenExpiryMs(value) {
+ if (typeof value === "number" || /^\d+$/.test(String(value ?? ""))) {
+ const numeric = Number(value);
+ return numeric < 1e12 ? numeric * 1000 : numeric;
+ }
+ const parsed = Date.parse(String(value ?? ""));
+ return Number.isFinite(parsed) ? parsed : NaN;
+}
+
+async function acquireAzureCliToken() {
+ try {
+ const { stdout } = await execFileAsync(
+ "az",
+ ["account", "get-access-token", "--resource", "https://api.kusto.windows.net", "--output", "json"],
+ { timeout: TOKEN_TIMEOUT_MS, maxBuffer: TOKEN_MAX_BUFFER, windowsHide: true }
+ );
+ return JSON.parse(stdout);
+ } catch {
+ throw new Error("Azure CLI could not acquire a Kusto token. Run az login and retry.");
+ }
+}
+
+async function getRemoteToken(tokenProvider = acquireAzureCliToken) {
+ const now = Date.now();
+ if (cachedToken && cachedToken.expiresOnMs - TOKEN_REFRESH_SKEW_MS > now) {
+ return cachedToken.accessToken;
+ }
+ if (!tokenInFlight) {
+ tokenInFlight = Promise.resolve()
+ .then(() => tokenProvider())
+ .then((result) => {
+ const accessToken = result?.accessToken ?? result?.access_token;
+ const expiresOnMs = tokenExpiryMs(result?.expiresOnMs ?? result?.expires_on);
+ if (typeof accessToken !== "string" || !accessToken || !Number.isFinite(expiresOnMs) || expiresOnMs <= Date.now()) {
+ throw new Error("Azure CLI returned an invalid or expired Kusto token.");
+ }
+ cachedToken = { accessToken, expiresOnMs };
+ return accessToken;
+ })
+ .catch(() => {
+ cachedToken = null;
+ throw new Error("Azure CLI could not acquire a Kusto token. Run az login and retry.");
+ })
+ .finally(() => {
+ tokenInFlight = null;
+ });
+ }
+ return tokenInFlight;
+}
+
+export function resetKustoAuthForTests() {
+ cachedToken = null;
+ tokenInFlight = null;
+}
+
+export async function readBoundedBody(response, maxBytes = MAX_RESPONSE_BYTES) {
+ const declaredLength = Number(response.headers.get("content-length"));
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
+ throw new Error(`Kusto response exceeded the ${maxBytes}-byte limit.`);
+ }
+ if (!response.body?.getReader) {
+ const text = await response.text();
+ if (Buffer.byteLength(text) > maxBytes) throw new Error(`Kusto response exceeded the ${maxBytes}-byte limit.`);
+ return text;
+ }
+
+ const reader = response.body.getReader();
+ const chunks = [];
+ let total = 0;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ total += value.byteLength;
+ if (total > maxBytes) {
+ await reader.cancel();
+ throw new Error(`Kusto response exceeded the ${maxBytes}-byte limit.`);
+ }
+ chunks.push(Buffer.from(value));
+ }
+ return Buffer.concat(chunks, total).toString("utf8");
+}
+
+function rowsFromTable(table) {
+ if (!table) return [];
+ const cols = table.Columns.map((c) => c.ColumnName);
+ return table.Rows.map((r) => Object.fromEntries(cols.map((c, i) => [c, r[i]])));
+}
+
+export function parseKustoResponse(json) {
+ if (json?.error || json?.Exceptions || json?.OneApiErrors) {
+ const msg = json?.error?.["@message"] || JSON.stringify(json).slice(0, 300);
+ throw new Error(`Kusto query error: ${msg}`);
+ }
+ const tables = Array.isArray(json?.Tables) ? json.Tables : [];
+ const statusTable = tables.find((table) => {
+ const names = new Set((table.Columns || []).map((column) => column.ColumnName));
+ return names.has("Severity") && names.has("StatusCode") && names.has("StatusDescription");
+ });
+ const failure = rowsFromTable(statusTable).find((row) => Number(row.StatusCode) !== 0 || Number(row.Severity) <= 2);
+ if (failure) {
+ throw new Error(`Kusto query failed: ${failure.StatusDescription || `status ${failure.StatusCode}`}`);
+ }
+ return rowsFromTable(tables[0]);
+}
+
+/**
+ * Run a single KQL query against a FinOps hub and return rows as objects.
+ * Throws on transport error or Kusto error payload.
+ */
+export async function runQuery(clusterUri, database, csl, options = {}) {
+ if (typeof options === "number") options = { timeoutMs: options };
+ const {
+ timeoutMs = DEFAULT_TIMEOUT_MS,
+ fetchImpl = fetch,
+ tokenProvider = acquireAzureCliToken,
+ maxResponseBytes = MAX_RESPONSE_BYTES,
+ } = options;
+ const connection = normalizeConnection(clusterUri, database);
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ let res, text;
+ try {
+ const headers = {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ "x-ms-readonly": "true",
+ "x-ms-client-request-id": `FinOpsToolkit.FtkDashboard;${randomUUID()}`,
+ };
+ if (connection.mode === "remote") {
+ headers.Authorization = `Bearer ${await getRemoteToken(tokenProvider)}`;
+ }
+ res = await fetchImpl(`${connection.clusterUri}/v1/rest/query`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ db: connection.database, csl }),
+ signal: controller.signal,
+ });
+ text = await readBoundedBody(res, maxResponseBytes);
+ } catch (err) {
+ if (err?.name === "AbortError") {
+ throw new Error(`Timed out after ${timeoutMs}ms reaching ${connection.clusterUri}`);
+ }
+ if (/Azure CLI|Kusto token|Kusto response exceeded/.test(err?.message || "")) throw err;
+ throw new Error(`Could not reach Kusto at ${connection.clusterUri}: ${err?.message ?? err}`);
+ } finally {
+ clearTimeout(timer);
+ }
+
+ if (!res.ok) {
+ throw new Error(`Kusto returned HTTP ${res.status}. ${text.slice(0, 300)}`);
+ }
+
+ let json;
+ try {
+ json = JSON.parse(text);
+ } catch {
+ throw new Error("Kusto returned an invalid JSON response.");
+ }
+ return parseKustoResponse(json);
+}
+
+// --- date helpers (work in UTC to match Kusto datetimes) ----------------------
+
+function startOfMonthUTC(d) {
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
+}
+function addMonthsUTC(d, n) {
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1));
+}
+function isoDay(d) {
+ return d.toISOString().slice(0, 10);
+}
+
+/**
+ * Resolve a preset window (all | 12m | 6m | 3m) against the actual data range.
+ * Returns inclusive start and exclusive end ISO-day strings plus the data range.
+ */
+export async function resolveWindow(clusterUri, database, preset) {
+ const range = await runQuery(
+ clusterUri,
+ database,
+ "Costs() | summarize MinDate=min(ChargePeriodStart), MaxDate=max(ChargePeriodStart), Rows=count()"
+ );
+ const row = range[0] ?? {};
+ if (!row.MaxDate) {
+ return { start: null, end: null, dataMin: null, dataMax: null, rows: 0, empty: true };
+ }
+ const dataMin = new Date(row.MinDate);
+ const dataMax = new Date(row.MaxDate);
+ const endExclusive = addMonthsUTC(startOfMonthUTC(dataMax), 1); // include the whole last month
+ const lastMonth = startOfMonthUTC(dataMax);
+
+ let start;
+ switch (preset) {
+ case "3m": start = addMonthsUTC(lastMonth, -2); break;
+ case "6m": start = addMonthsUTC(lastMonth, -5); break;
+ case "12m": start = addMonthsUTC(lastMonth, -11); break;
+ case "all":
+ default: start = startOfMonthUTC(dataMin); break;
+ }
+ if (start < startOfMonthUTC(dataMin)) start = startOfMonthUTC(dataMin);
+
+ return {
+ start: isoDay(start),
+ end: isoDay(endExclusive),
+ dataMin: isoDay(dataMin),
+ dataMax: isoDay(dataMax),
+ rows: row.Rows ?? 0,
+ empty: false,
+ };
+}
+
+/**
+ * Build a KQL `| where` clause from a filters object `{ ColumnName: ["val1","val2"] }`.
+ * Returns an empty string when there are no filters.
+ */
+export function validateFilters(filters = {}) {
+ if (!filters || typeof filters !== "object" || Array.isArray(filters)) throw new Error("Filters must be an object.");
+ const normalized = {};
+ for (const [column, values] of Object.entries(filters)) {
+ if (!ALLOWED_FILTER_COLUMNS.has(column)) throw new Error(`Unsupported filter dimension '${column}'.`);
+ if (!Array.isArray(values) || values.length > 8) throw new Error(`Filter '${column}' must contain at most 8 values.`);
+ const clean = [...new Set(values.map((value) => String(value)))];
+ if (clean.some((value) => !value || value.length > 256)) {
+ throw new Error(`Filter '${column}' values must be 1-256 characters.`);
+ }
+ if (clean.length) normalized[column] = clean;
+ }
+ return normalized;
+}
+
+export function buildFilterWhere(filters) {
+ const clauses = Object.entries(validateFilters(filters))
+ .map(([col, vals]) => {
+ const quoted = vals.map((value) => JSON.stringify(value)).join(", ");
+ return `| where ${col} in (${quoted})`;
+ });
+ return clauses.length > 0 ? "\n" + clauses.join("\n") : "";
+}
+
+function normalizeCapacityValue(value) {
+ return String(value ?? "").trim().toLowerCase();
+}
+
+function finiteNumber(value) {
+ if (value === null || value === undefined || value === "") return null;
+ const number = Number(value);
+ return Number.isFinite(number) ? number : null;
+}
+
+export function normalizeCapacityClassId(value) {
+ const normalized = normalizeCapacityValue(value);
+ if (normalized === "home") return "home";
+ if (CAPACITY_CLASS_REGISTRY[normalized]) return normalized;
+ const sourceMatch = CAPACITY_CLASS_BY_SOURCE.get(normalized);
+ if (sourceMatch) return sourceMatch.id;
+ throw new Error(`Unsupported capacity class '${value}'.`);
+}
+
+export function resolveCapacityMetric(row = {}) {
+ const sourceType = String(row.x_SourceType ?? "").trim();
+ const sourceVersion = String(row.x_SourceVersion ?? "").trim();
+ const resourceName = String(row.ResourceName ?? "").trim();
+ const unit = String(row.unit ?? "").trim();
+ const classContract = CAPACITY_CLASS_BY_SOURCE.get(sourceType.toLowerCase());
+
+ if (!sourceType || !sourceVersion || !classContract) {
+ return {
+ capability: "disabled",
+ reasonCode: classContract ? "invalid-identity" : "unsupported-source",
+ sourceNote: "Required source identity is missing or unsupported",
+ classId: classContract?.id ?? null,
+ };
+ }
+ if (!classContract.sourceVersions.some((version) => version.toLowerCase() === sourceVersion.toLowerCase())) {
+ return {
+ capability: "descriptive-only",
+ reasonCode: "source-version-mismatch",
+ sourceNote: "Source version changed — registry review required",
+ classId: classContract.id,
+ };
+ }
+ if (classContract.quotaType === "inventory") {
+ if (!resourceName || !String(row.ResourceId ?? "").trim()) {
+ return {
+ capability: "disabled",
+ reasonCode: "invalid-identity",
+ sourceNote: "Inventory identity is incomplete",
+ classId: classContract.id,
+ };
+ }
+ return {
+ capability: "enabled",
+ reasonCode: "inventory-source-contract",
+ sourceNote: classContract.sourceNote,
+ classId: classContract.id,
+ quotaType: "inventory",
+ };
+ }
+ if (!resourceName || !unit) {
+ return {
+ capability: "disabled",
+ reasonCode: "invalid-identity",
+ sourceNote: "Quota metric identity is incomplete",
+ classId: classContract.id,
+ };
+ }
+
+ const metricKey = [sourceType, resourceName, unit].map(normalizeCapacityValue).join("|");
+ const metric = CAPACITY_METRIC_REGISTRY[metricKey];
+ if (!metric) {
+ const negativeSqlLimit =
+ classContract.id === "azure-sql" && finiteNumber(row.limit) !== null && finiteNumber(row.limit) < 0;
+ return {
+ capability: "descriptive-only",
+ reasonCode: "unclassified-metric",
+ sourceNote: negativeSqlLimit
+ ? "Negative provider limit — interpretation unverified"
+ : "Registry review required",
+ classId: classContract.id,
+ metricKey,
+ };
+ }
+ return {
+ capability: "enabled",
+ reasonCode: "registered-metric",
+ sourceNote: metric.sourceNote,
+ classId: classContract.id,
+ metricKey,
+ ...metric,
+ };
+}
+
+export function classifyCapacityObservation(row = {}, now = new Date(), freshnessHours = CAPACITY_FRESHNESS_HOURS) {
+ const semantic = resolveCapacityMetric(row);
+ const classContract = semantic.classId ? CAPACITY_CLASS_REGISTRY[semantic.classId] : null;
+ const currentValue = finiteNumber(row.currentValue);
+ const limit = finiteNumber(row.limit);
+ const observedAt = Date.parse(String(row.x_IngestionTime ?? ""));
+ const nowMs = now instanceof Date ? now.getTime() : Date.parse(String(now));
+ if (!Number.isFinite(observedAt) || !Number.isFinite(nowMs)) {
+ return {
+ ...semantic,
+ capability: "disabled",
+ state: "invalid",
+ reasonCode: "invalid-ingestion-time",
+ sourceNote: "Observation time is missing or invalid",
+ ageHours: null,
+ };
+ }
+
+ const ageHours = Math.max(0, (nowMs - observedAt) / 3600000);
+ if (semantic.capability === "disabled") {
+ return { ...semantic, state: "invalid", ageHours };
+ }
+ if (
+ (classContract?.quotaType !== "inventory" && (currentValue === null || currentValue < 0 || limit === null)) ||
+ (semantic.classId === "premium-ssd-v2" && (currentValue === null || currentValue < 0))
+ ) {
+ return {
+ ...semantic,
+ capability: "disabled",
+ state: "invalid",
+ reasonCode: classContract?.quotaType === "inventory" ? "invalid-inventory-value" : "invalid-provider-values",
+ sourceNote: classContract?.quotaType === "inventory"
+ ? "Premium SSD v2 size is missing or invalid"
+ : "Provider values are missing or invalid",
+ ageHours,
+ };
+ }
+ if (ageHours > freshnessHours) {
+ return {
+ ...semantic,
+ capability: "disabled",
+ state: "stale",
+ reasonCode: "stale-observation",
+ sourceNote: `Stale observation — older than ${freshnessHours} hours`,
+ ageHours,
+ };
+ }
+ if (semantic.capability === "descriptive-only") {
+ return { ...semantic, state: "unclassified", ageHours };
+ }
+
+ if (classContract.quotaType === "inventory") {
+ return {
+ ...semantic,
+ state: "inventory",
+ ageHours,
+ currentValue,
+ limit: null,
+ utilizationPercent: null,
+ headroom: null,
+ };
+ }
+
+ if (limit === 0) {
+ return {
+ ...semantic,
+ state: currentValue > 0 ? "invalid" : "no-entitlement",
+ reasonCode: currentValue > 0 ? "conflicting-provider-values" : "no-entitlement",
+ sourceNote: currentValue > 0
+ ? "Conflicting provider values"
+ : "No quota reported or no entitlement",
+ ageHours,
+ currentValue,
+ limit,
+ utilizationPercent: null,
+ headroom: null,
+ };
+ }
+ if (limit < 0) {
+ return {
+ ...semantic,
+ capability: "disabled",
+ state: "invalid",
+ reasonCode: "unexpected-negative-limit",
+ sourceNote: "Negative provider limit — interpretation unverified",
+ ageHours,
+ };
+ }
+
+ const utilizationPercent = 100 * currentValue / limit;
+ const state =
+ utilizationPercent >= 100 ? "exhausted" :
+ utilizationPercent >= 90 ? "action" :
+ utilizationPercent >= 80 ? "watch" :
+ "healthy";
+ return {
+ ...semantic,
+ state,
+ ageHours,
+ currentValue,
+ limit,
+ utilizationPercent,
+ headroom: limit - currentValue,
+ };
+}
+
+export function resolveCapacityHistoryCapability(snapshotCount, options = {}) {
+ const count = Number(snapshotCount);
+ const inventory = options.quotaType === "inventory";
+ if (!Number.isInteger(count) || count < 1) {
+ return { mode: "unavailable", reasonCode: "no-compatible-snapshots", confidence: null };
+ }
+ if (count === 1) {
+ return { mode: "current-only", reasonCode: "collecting-history", confidence: null };
+ }
+ if (inventory) {
+ return { mode: "observed-history", reasonCode: "inventory-runway-disabled", confidence: null };
+ }
+ if (count === 2) {
+ return { mode: "observed-delta", reasonCode: "insufficient-trend-points", confidence: null };
+ }
+ if (count < 7) {
+ return { mode: "provisional-runway", reasonCode: "low-confidence", confidence: "low" };
+ }
+ return { mode: "trend-runway", reasonCode: "compatible-daily-history", confidence: "normal" };
+}
+
+function capacityClass(value) {
+ const classId = normalizeCapacityClassId(value);
+ if (classId === "home") throw new Error("A source class is required for this query.");
+ return CAPACITY_CLASS_REGISTRY[classId];
+}
+
+function kqlString(value, fieldName) {
+ const string = String(value ?? "").trim();
+ if (!string || string.length > 512 || /[\u0000-\u001f\u007f]/.test(string)) {
+ throw new Error(`${fieldName} must be 1-512 printable characters.`);
+ }
+ return JSON.stringify(string);
+}
+
+function validateCapacityFilters(filters = {}) {
+ if (!filters || typeof filters !== "object" || Array.isArray(filters)) {
+ throw new Error("Capacity filters must be an object.");
+ }
+ const normalized = {};
+ for (const column of ["SubAccountId", "location"]) {
+ const values = filters[column];
+ if (values === undefined) continue;
+ if (!Array.isArray(values) || values.length > 8) {
+ throw new Error(`Capacity filter '${column}' must contain at most 8 values.`);
+ }
+ const clean = [...new Set(values.map((value) => String(value).trim()))];
+ if (clean.some((value) => !value || value.length > 256)) {
+ throw new Error(`Capacity filter '${column}' values must be 1-256 characters.`);
+ }
+ if (clean.length) normalized[column] = clean;
+ }
+ const unsupported = Object.keys(filters).filter((key) => !["SubAccountId", "location"].includes(key));
+ if (unsupported.length) throw new Error(`Unsupported capacity filter '${unsupported[0]}'.`);
+ return normalized;
+}
+
+function buildCapacityWhere(filters, target = "quota") {
+ const familyColumns = { SubAccountId: "SubscriptionId", location: "Location" };
+ return Object.entries(validateCapacityFilters(filters))
+ .map(([column, values]) => {
+ const targetColumn = target === "cost" && column === "location"
+ ? "RegionId"
+ : target === "family"
+ ? (familyColumns[column] ?? column)
+ : column;
+ return `| where ${targetColumn} in~ (${values.map((value) => kqlString(value, column)).join(", ")})`;
+ })
+ .join("\n");
+}
+
+function demandPredicate(predicateId) {
+ switch (predicateId) {
+ case "app-service-cost":
+ return `| where ProviderName =~ 'Microsoft' and ChargeCategory =~ 'Usage'
+| where x_ResourceType in~ ('microsoft.web/hostingenvironments','microsoft.web/serverfarms','microsoft.web/sites','microsoft.web/sites/slots')
+| where ConsumedUnit in~ ('Hours','GiB Hours','GB','Units/Hour')`;
+ case "azure-ai-cost":
+ return `| where ProviderName =~ 'Microsoft' and ChargeCategory =~ 'Usage'
+| where x_ResourceType in~ ('microsoft.cognitiveservices/accounts','microsoft.cognitiveservices/accounts/projects')
+| where ConsumedUnit in~ ('Units','Seconds','Minutes','Hours')`;
+ case "compute-cost":
+ return `| where ProviderName =~ 'Microsoft' and ChargeCategory =~ 'Usage'
+| where x_ResourceType in~ ('microsoft.compute/virtualmachines','microsoft.compute/virtualmachinescalesets','microsoft.compute/virtualmachinescalesets/virtualmachines')
+| where ConsumedUnit in~ ('Hours','Units/Hour','GB','Units/Month','Units','GB/Month')`;
+ case "azure-sql-cost":
+ return `| where ProviderName =~ 'Microsoft' and ChargeCategory =~ 'Usage'
+| where x_ResourceType startswith 'microsoft.sql/'
+| where ConsumedUnit in~ ('Units/Day','Hours','GB/Month','Units/Hour','Units/Month')`;
+ case "storage-cost":
+ return `| where ProviderName =~ 'Microsoft' and ChargeCategory =~ 'Usage'
+| where x_ResourceType =~ 'microsoft.storage/storageaccounts'
+| where ConsumedUnit in~ ('Units','Units/Hour','GB','GB/Month','Units/Month')`;
+ case "capacity-reservation-cost":
+ return `| where ProviderName =~ 'Microsoft' and ChargeCategory =~ 'Usage'
+| where isnotempty(CapacityReservationId)
+| where CapacityReservationStatus in~ ('Used','Unused')
+| where ConsumedUnit =~ 'Hours'`;
+ case "premium-ssd-cost":
+ return `| where ProviderName =~ 'Microsoft' and ChargeCategory =~ 'Usage'
+| where x_ResourceType =~ 'microsoft.compute/disks'`;
+ default:
+ throw new Error(`Unsupported capacity demand predicate '${predicateId}'.`);
+ }
+}
+
+function exactWhere(selection, fields) {
+ if (!selection || typeof selection !== "object" || Array.isArray(selection)) {
+ throw new Error("A structured capacity selection is required.");
+ }
+ return fields.map(([selectionName, column]) => {
+ const value = selection[selectionName];
+ return `| where ${column} =~ ${kqlString(value, selectionName)}`;
+ }).join("\n");
+}
+
+export function buildCapacityHomeQuery() {
+ return `Quota()
+| where x_SourceType in~ (${CAPACITY_SOURCE_TYPES.map((value) => JSON.stringify(value)).join(", ")})
+| summarize
+ Observations=count(),
+ Resources=dcount(ResourceId),
+ DistinctDays=dcount(startofday(x_IngestionTime)),
+ LatestObservation=max(x_IngestionTime)
+ by x_SourceType
+| order by x_SourceType asc
+| take 7`;
+}
+
+export function buildCapacitySchemaQuery(source) {
+ if (source === "Quota") {
+ return `Quota() | getschema | project ColumnName, ColumnType | order by ColumnName asc | take 200`;
+ }
+ if (source === "Costs") {
+ return `Costs() | getschema | project ColumnName, ColumnType | order by ColumnName asc | take 500`;
+ }
+ throw new Error(`Unsupported schema source '${source}'.`);
+}
+
+export function buildCapacityCoverageQuery(classId, filters = {}) {
+ const contract = capacityClass(classId);
+ const where = buildCapacityWhere(filters);
+ return `Quota()
+| where x_SourceType =~ ${JSON.stringify(contract.sourceType)}
+${where}
+| summarize
+ Observations=count(),
+ Resources=dcount(ResourceId),
+ DistinctDays=dcount(startofday(x_IngestionTime)),
+ FirstObservation=min(x_IngestionTime),
+ LastObservation=max(x_IngestionTime),
+ Units=make_set(unit, 64)`;
+}
+
+export function buildCapacityDemandCoverageQuery(classId, filters = {}) {
+ const contract = capacityClass(classId);
+ const costWhere = buildCapacityWhere(filters, "cost");
+ return `Costs()
+| where ChargePeriodStart >= startofday(now()-430d)
+${demandPredicate(contract.demandPredicateId)}
+${costWhere}
+| summarize
+ Observations=count(),
+ DistinctDays=dcount(startofday(ChargePeriodStart)),
+ FirstObservation=min(ChargePeriodStart),
+ LastObservation=max(ChargePeriodStart),
+ Units=make_set(ConsumedUnit, 64)`;
+}
+
+export function buildCapacityCurrentQuery(classId, filters = {}) {
+ const contract = capacityClass(classId);
+ const where = buildCapacityWhere(filters);
+ return `Quota()
+| where x_SourceType =~ ${JSON.stringify(contract.sourceType)}
+${where}
+| summarize arg_max(x_IngestionTime, *) by ResourceId
+| project ProviderName, ResourceId, ResourceName, ResourceType, SubAccountId, displayName, location, currentValue, limit, unit, x_SourceType, x_SourceVersion, x_IngestionTime
+| order by SubAccountId asc, location asc, ResourceName asc
+| take ${CAPACITY_LIMITS.primaryRows + 1}`;
+}
+
+export function buildCapacitySelectorQuery(classId, filters = {}) {
+ const contract = capacityClass(classId);
+ const where = buildCapacityWhere(filters);
+ if (contract.id === "compute") {
+ return `Quota()
+| where x_SourceType =~ ${JSON.stringify(contract.sourceType)}
+${where}
+| summarize displayName=take_any(displayName), x_IngestionTime=max(x_IngestionTime)
+ by ResourceName, unit, x_SourceType, x_SourceVersion
+| project ResourceName, displayName, unit, x_SourceType, x_SourceVersion, x_IngestionTime
+| order by ResourceName asc, unit asc, x_SourceVersion asc
+| take ${CAPACITY_LIMITS.selectorKeys + 1}`;
+ }
+ return `Quota()
+| where x_SourceType =~ ${JSON.stringify(contract.sourceType)}
+${where}
+| summarize arg_max(x_IngestionTime, *) by ResourceId
+| project SubAccountId, location, ResourceId, ResourceName, displayName, unit, x_SourceType, x_SourceVersion, x_IngestionTime
+| order by SubAccountId asc, location asc, ResourceName asc
+| take ${CAPACITY_LIMITS.selectorKeys + 1}`;
+}
+
+export function buildCapacityHistoryQuery(classId, selection) {
+ const contract = capacityClass(classId);
+ const identity = contract.quotaType === "inventory"
+ ? exactWhere(selection, [["resourceId", "ResourceId"]])
+ : exactWhere(selection, [
+ ["subAccountId", "SubAccountId"],
+ ["location", "location"],
+ ["resourceName", "ResourceName"],
+ ["unit", "unit"],
+ ["sourceVersion", "x_SourceVersion"],
+ ]);
+ return `Quota()
+| where x_SourceType =~ ${JSON.stringify(contract.sourceType)}
+${identity}
+| extend Day=startofday(x_IngestionTime)
+| summarize arg_max(x_IngestionTime, *) by Day, ResourceId
+| project Day, ResourceId, ResourceName, displayName, SubAccountId, location, currentValue, limit, unit, x_SourceType, x_SourceVersion, x_IngestionTime
+| order by Day asc
+| take ${CAPACITY_LIMITS.dailyPoints + 1}`;
+}
+
+export function buildCapacityHeatmapQuery(classId, selection, filters = {}) {
+ const contract = capacityClass(classId);
+ const where = buildCapacityWhere(filters);
+ if (contract.quotaType === "inventory") {
+ return `Quota()
+| where x_SourceType =~ ${JSON.stringify(contract.sourceType)}
+${where}
+| summarize arg_max(x_IngestionTime, *) by ResourceId
+| summarize
+ ObservedObjects=count(),
+ ObservedGiB=sum(currentValue),
+ x_IngestionTime=max(x_IngestionTime)
+ by SubAccountId, location, x_SourceType, x_SourceVersion
+| order by SubAccountId asc, location asc
+| take ${CAPACITY_LIMITS.heatmapCells + 1}`;
+ }
+ const metric = exactWhere(selection, [
+ ["resourceName", "ResourceName"],
+ ["unit", "unit"],
+ ["sourceVersion", "x_SourceVersion"],
+ ]);
+ return `Quota()
+| where x_SourceType =~ ${JSON.stringify(contract.sourceType)}
+${metric}
+${where}
+| summarize arg_max(x_IngestionTime, *) by ResourceId
+| project SubAccountId, location, ResourceId, ResourceName, currentValue, limit, unit, x_SourceType, x_SourceVersion, x_IngestionTime
+| order by SubAccountId asc, location asc
+| take ${CAPACITY_LIMITS.heatmapCells + 1}`;
+}
+
+// Estate-wide Compute supply and demand at VM family and region grain.
+// Regional core quota is never duplicated across zones; zones stay descriptive.
+export function buildComputeFamilyQuery(filters = {}) {
+ const where = buildCapacityWhere(filters, "family");
+ return `let base = ComputeQuota()
+${where}
+;
+let zonesPresent = base
+| mv-expand PhysicalZone = PhysicalZonesPresent to typeof(string)
+| where isnotempty(PhysicalZone)
+| summarize ZonesPresent=make_set(PhysicalZone) by Location, FamilyKey;
+let zonesRestricted = base
+| mv-expand PhysicalZone = PhysicalZonesRestricted to typeof(string)
+| where isnotempty(PhysicalZone)
+| summarize ZonesRestricted=make_set(PhysicalZone) by Location, FamilyKey;
+base
+| summarize
+ CoresUsed=sum(CoresUsed),
+ CoresTotal=sum(CoresTotal),
+ Subscriptions=dcount(SubscriptionId),
+ RestrictedSubscriptions=countif(RegionRestricted),
+ x_IngestionTime=max(x_IngestionTime)
+ by Family, FamilyKey, Location
+| join kind=leftouter zonesPresent on Location, FamilyKey
+| join kind=leftouter zonesRestricted on Location, FamilyKey
+| project Family, FamilyKey, Location, CoresUsed, CoresTotal, Subscriptions, RestrictedSubscriptions, ZonesPresent, ZonesRestricted, x_IngestionTime
+| order by Location asc, FamilyKey asc
+| take ${CAPACITY_LIMITS.familyCells + 1}`;
+}
+
+function normalizeComputeSubscriptionOptions(options = {}) {
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
+ throw new Error("Compute subscription options must be an object.");
+ }
+ const status = options.status || "in-use";
+ if (!["in-use", "restricted", "no-quota", "all"].includes(status)) {
+ throw new Error(`Unsupported Compute subscription status '${status}'.`);
+ }
+ const cleanText = (value, name) => {
+ const text = String(value ?? "").trim();
+ if (text.length > 128 || /[\u0000-\u001f\u007f]/.test(text)) {
+ throw new Error(`${name} must be at most 128 printable characters.`);
+ }
+ return text;
+ };
+ const inputRegions = options.regions || [];
+ if (!Array.isArray(inputRegions)) {
+ throw new Error("Compute subscription regions must be an array.");
+ }
+ const regions = [...new Set(inputRegions.map((value) => String(value).trim()))];
+ if (regions.length > 64 || regions.some((value) => !value || value.length > 128)) {
+ throw new Error("Compute subscription regions must contain at most 64 non-empty values.");
+ }
+ const page = Number(options.page || 1);
+ const pageSize = Number(options.pageSize || COMPUTE_SUBSCRIPTION_PAGE_SIZE);
+ if (!Number.isInteger(page) || page < 1 || page > 100000) {
+ throw new Error("Compute subscription page must be an integer from 1 to 100000.");
+ }
+ if (!Number.isInteger(pageSize) || pageSize < 10 || pageSize > 100) {
+ throw new Error("Compute subscription page size must be an integer from 10 to 100.");
+ }
+ return {
+ status,
+ familySearch: cleanText(options.familySearch, "familySearch"),
+ subscriptionSearch: cleanText(options.subscriptionSearch, "subscriptionSearch"),
+ regions,
+ page,
+ pageSize,
+ };
+}
+
+export function buildComputeSubscriptionQuery(options = {}) {
+ const normalized = normalizeComputeSubscriptionOptions(options);
+ const statusWhere = normalized.status === "in-use"
+ ? "| where CoresUsed > 0"
+ : normalized.status === "restricted"
+ ? "| where RegionRestricted or ZoneRestrictions > 0"
+ : normalized.status === "no-quota"
+ ? "| where CoresTotal <= 0"
+ : "";
+ // The client search is substring-based so operators can enter partial family names.
+ const familyWhere = normalized.familySearch
+ ? `| where Family contains ${kqlString(normalized.familySearch, "familySearch")} or FamilyKey contains ${kqlString(normalized.familySearch, "familySearch")}`
+ : "";
+ const regionWhere = normalized.regions.length
+ ? `| where Location in~ (${normalized.regions.map((value) => kqlString(value, "region")).join(", ")})`
+ : "";
+ const subscriptionWhere = normalized.subscriptionSearch
+ ? `| where SubscriptionId startswith ${kqlString(normalized.subscriptionSearch, "subscriptionSearch")}`
+ : "";
+ const firstRow = (normalized.page - 1) * normalized.pageSize + 1;
+ const lastRow = firstRow + normalized.pageSize - 1;
+ return `let scoped = ComputeQuota()
+| where isnotempty(SubscriptionId)
+| extend ZoneRestrictions=coalesce(array_length(PhysicalZonesRestricted), 0)
+${statusWhere}
+${familyWhere}
+${regionWhere}
+${subscriptionWhere}
+| summarize
+ CoresUsed=sum(CoresUsed),
+ CoresTotal=sum(CoresTotal),
+ Families=dcount(FamilyKey),
+ Regions=dcount(Location),
+ RestrictedRows=countif(RegionRestricted or ZoneRestrictions > 0),
+ LastIngestion=max(x_IngestionTime)
+ by SubscriptionId;
+let total = toscalar(scoped | count);
+scoped
+| order by CoresUsed desc, SubscriptionId asc
+| serialize RowNumber=row_number()
+| where RowNumber between (${firstRow} .. ${lastRow})
+| extend HeadroomCores=iff(CoresTotal > 0, CoresTotal - CoresUsed, real(null)), TotalSubscriptions=total
+| project SubscriptionId, Families, Regions, CoresUsed, CoresTotal, HeadroomCores, RestrictedRows, LastIngestion, RowNumber, TotalSubscriptions
+| take ${normalized.pageSize}`;
+}
+
+export async function getComputeSubscriptionPage(clusterUri, database, options = {}) {
+ const normalized = normalizeComputeSubscriptionOptions(options);
+ const rows = await runQuery(clusterUri, database, buildComputeSubscriptionQuery(normalized));
+ const totalSubscriptions = Number(rows[0]?.TotalSubscriptions || 0);
+ return {
+ rows: rows.map(({ TotalSubscriptions: _total, RowNumber: _row, ...row }) => row),
+ page: normalized.page,
+ pageSize: normalized.pageSize,
+ totalSubscriptions,
+ totalPages: Math.ceil(totalSubscriptions / normalized.pageSize),
+ };
+}
+
+// Regional quota drives the state. Supply restrictions never fabricate headroom.
+export function computeFamilyCell(row = {}) {
+ const total = Number(row.CoresTotal);
+ const used = Number(row.CoresUsed);
+ const subscriptions = Number(row.Subscriptions || 0);
+ const restricted = Number(row.RestrictedSubscriptions || 0);
+ const regionRestricted = subscriptions > 0 && restricted >= subscriptions;
+ const zonesRestricted = Array.isArray(row.ZonesRestricted) ? row.ZonesRestricted.length : 0;
+ const zonesPresent = Array.isArray(row.ZonesPresent) ? row.ZonesPresent.length : 0;
+ // Every zone restricted is not a partial constraint. Regional placement may still
+ // work, so it stays distinct from a region restriction, but it never reads healthy.
+ const allZonesRestricted = zonesPresent > 0 && zonesRestricted >= zonesPresent;
+ const hasQuota = Number.isFinite(total) && Number.isFinite(used) && total > 0 && used >= 0;
+ // Supply answers "can this land here", demand answers "how close to my limit".
+ // They are independent, so a blocked region never inherits a healthy utilization.
+ const supply =
+ regionRestricted || allZonesRestricted ? "blocked" :
+ !hasQuota ? "none" :
+ zonesRestricted > 0 ? "partial" :
+ "open";
+ if (!hasQuota) {
+ return {
+ utilizationPercent: null,
+ headroomCores: null,
+ regionRestricted,
+ supply,
+ state: regionRestricted ? "restricted" : allZonesRestricted ? "zone-restricted" : "no-entitlement",
+ text: regionRestricted ? "Restricted" : allZonesRestricted ? "Zones restricted" : "No quota",
+ };
+ }
+ const utilizationPercent = 100 * used / total;
+ const state =
+ regionRestricted ? "restricted" :
+ allZonesRestricted ? "zone-restricted" :
+ utilizationPercent >= 100 ? "exhausted" :
+ utilizationPercent >= 90 ? "action" :
+ utilizationPercent >= 80 ? "watch" :
+ "healthy";
+ return {
+ utilizationPercent,
+ headroomCores: total - used,
+ regionRestricted,
+ supply,
+ state,
+ text: `${utilizationPercent.toFixed(1)}%`,
+ };
+}
+
+export function annotateComputeFamilyRows(rows = []) {
+ return rows.map((row) => {
+ const zonesPresent = Array.isArray(row.ZonesPresent) ? row.ZonesPresent : [];
+ const zonesRestricted = Array.isArray(row.ZonesRestricted) ? row.ZonesRestricted : [];
+ return {
+ Family: row.Family,
+ FamilyKey: row.FamilyKey,
+ Location: row.Location,
+ CoresUsed: row.CoresUsed,
+ CoresTotal: row.CoresTotal,
+ Subscriptions: row.Subscriptions,
+ RestrictedSubscriptions: row.RestrictedSubscriptions,
+ ZonesPresentCount: zonesPresent.length,
+ ZonesRestricted: zonesRestricted,
+ x_IngestionTime: row.x_IngestionTime,
+ semantic: computeFamilyCell(row),
+ };
+ });
+}
+
+export function buildCapacityDemandSelectorQuery(classId, filters = {}) {
+ const contract = capacityClass(classId);
+ const costWhere = buildCapacityWhere(filters, "cost");
+ if (contract.id === "premium-ssd-v2") {
+ return `let disks = Quota()
+| where x_SourceType =~ 'PremiumSSDv2Disk'
+${buildCapacityWhere(filters)}
+| summarize arg_max(x_IngestionTime, *) by ResourceId
+| extend JoinResourceId=tolower(ResourceId)
+| project JoinResourceId, InventoryResourceId=ResourceId, DiskName=ResourceName, SubAccountId, location, SizeGiB=currentValue, x_IngestionTime;
+let diskCost = Costs()
+| where ChargePeriodStart >= startofday(now()-430d)
+${demandPredicate(contract.demandPredicateId)}
+${costWhere}
+| extend JoinResourceId=tolower(ResourceId)
+| summarize FirstDay=min(startofday(ChargePeriodStart)), LastDay=max(startofday(ChargePeriodStart)), EffectiveCost=sum(EffectiveCost), Rows=count()
+ by JoinResourceId, ResourceId, x_SkuMeterCategory, x_SkuMeterSubcategory, SkuMeter, SkuPriceId, BillingCurrency;
+disks
+| join kind=leftouter diskCost on JoinResourceId
+| project InventoryResourceId, DiskName, SubAccountId, location, SizeGiB, ResourceId, x_SkuMeterCategory, x_SkuMeterSubcategory, SkuMeter, SkuPriceId, BillingCurrency, FirstDay, LastDay, EffectiveCost, Rows, x_IngestionTime
+| order by InventoryResourceId asc, BillingCurrency asc
+| take ${CAPACITY_LIMITS.selectorKeys + 1}`;
+ }
+
+ const extraDimensions = contract.id === "capacity-reservations"
+ ? ", CapacityReservationId, CapacityReservationStatus"
+ : "";
+ return `Costs()
+| where ChargePeriodStart >= startofday(now()-430d)
+${demandPredicate(contract.demandPredicateId)}
+${costWhere}
+| summarize
+ FirstDay=min(startofday(ChargePeriodStart)),
+ LastDay=max(startofday(ChargePeriodStart)),
+ BilledQuantity=sum(ConsumedQuantity),
+ EffectiveCost=sum(EffectiveCost),
+ Rows=count()
+ by x_SkuMeterCategory, x_SkuMeterSubcategory, SkuMeter, SkuPriceId, ConsumedUnit, BillingCurrency${extraDimensions}
+| order by ConsumedUnit asc, x_SkuMeterSubcategory asc, SkuMeter asc
+| take ${CAPACITY_LIMITS.selectorKeys + 1}`;
+}
+
+export function buildCapacityDemandHistoryQuery(classId, selection, filters = {}) {
+ const contract = capacityClass(classId);
+ const costWhere = buildCapacityWhere(filters, "cost");
+ const commonSelection = exactWhere(selection, [
+ ["meterCategory", "x_SkuMeterCategory"],
+ ["meterSubcategory", "x_SkuMeterSubcategory"],
+ ["meter", "SkuMeter"],
+ ["priceId", "SkuPriceId"],
+ ["currency", "BillingCurrency"],
+ ]);
+ if (contract.id === "premium-ssd-v2") {
+ const disk = exactWhere(selection, [["resourceId", "ResourceId"]]);
+ return `Costs()
+| where ChargePeriodStart >= startofday(now()-430d)
+${demandPredicate(contract.demandPredicateId)}
+${costWhere}
+${disk}
+${commonSelection}
+| summarize EffectiveCost=sum(EffectiveCost), Rows=count()
+ by Day=startofday(ChargePeriodStart), ResourceId, x_SkuMeterCategory, x_SkuMeterSubcategory, SkuMeter, SkuPriceId, BillingCurrency
+| order by Day asc
+| take ${CAPACITY_LIMITS.dailyPoints + 1}`;
+ }
+ const quantitySelection = exactWhere(selection, [["unit", "ConsumedUnit"]]);
+ const reservationSelection = contract.id === "capacity-reservations"
+ ? exactWhere(selection, [
+ ["capacityReservationId", "CapacityReservationId"],
+ ["capacityReservationStatus", "CapacityReservationStatus"],
+ ])
+ : "";
+ return `Costs()
+| where ChargePeriodStart >= startofday(now()-430d)
+${demandPredicate(contract.demandPredicateId)}
+${costWhere}
+${commonSelection}
+${quantitySelection}
+${reservationSelection}
+| summarize BilledQuantity=sum(ConsumedQuantity), EffectiveCost=sum(EffectiveCost), Rows=count()
+ by Day=startofday(ChargePeriodStart), x_SkuMeterCategory, x_SkuMeterSubcategory, SkuMeter, SkuPriceId, ConsumedUnit, BillingCurrency
+| order by Day asc
+| take ${CAPACITY_LIMITS.dailyPoints + 1}`;
+}
+
+export function buildCapacityReservationReconciliationQuery(filters = {}) {
+ const quotaWhere = buildCapacityWhere(filters);
+ const costWhere = buildCapacityWhere(filters, "cost");
+ return `let inventory = Quota()
+| where x_SourceType =~ 'CapacityReservation'
+${quotaWhere}
+| summarize arg_max(x_IngestionTime, *) by ResourceId
+| extend GroupKey=tolower(ResourceId)
+| project GroupKey, GroupResourceId=ResourceId, GroupName=ResourceName, SubAccountId, location, x_SourceVersion, x_IngestionTime;
+let billed = Costs()
+| where ChargePeriodStart >= startofday(now()-430d)
+${demandPredicate("capacity-reservation-cost")}
+${costWhere}
+| extend CapacityReservationGroupId=extract(@"(?i)^(.*)/capacityreservations/[^/]+$", 1, CapacityReservationId)
+| where isnotempty(CapacityReservationGroupId)
+| extend GroupKey=tolower(CapacityReservationGroupId)
+| summarize
+ UsedHours=sumif(ConsumedQuantity, CapacityReservationStatus =~ 'Used'),
+ UnusedHours=sumif(ConsumedQuantity, CapacityReservationStatus =~ 'Unused'),
+ ReservationCount=dcount(CapacityReservationId),
+ LinkedResources=dcount(ResourceId),
+ FirstDay=min(startofday(ChargePeriodStart)),
+ LastDay=max(startofday(ChargePeriodStart))
+ by GroupKey, CostGroupResourceId=CapacityReservationGroupId, BillingCurrency;
+inventory
+| join kind=fullouter billed on GroupKey
+| extend ReconciliationState=case(
+ isnotempty(GroupResourceId) and isnotempty(CostGroupResourceId), 'matched',
+ isnotempty(GroupResourceId), 'inventory-only',
+ 'cost-only')
+| project GroupResourceId=coalesce(GroupResourceId, CostGroupResourceId), GroupName, SubAccountId, location, BillingCurrency, UsedHours, UnusedHours, ReservationCount, LinkedResources, FirstDay, LastDay, x_SourceVersion, x_IngestionTime, ReconciliationState
+| order by GroupResourceId asc, BillingCurrency asc
+| take ${CAPACITY_LIMITS.primaryRows + 1}`;
+}
+
+function boundedCollection(rows, limit, overflowMode = "truncate") {
+ const overflow = rows.length > limit;
+ if (overflow && overflowMode === "disable") {
+ return { status: "disabled", rows: [], limit, totalReturned: rows.length, truncated: false, reasonCode: "refine-filters" };
+ }
+ return {
+ status: overflow ? "bounded" : "ready",
+ rows: rows.slice(0, limit),
+ limit,
+ totalReturned: rows.length,
+ truncated: overflow,
+ reasonCode: overflow ? "result-cap-reached" : null,
+ };
+}
+
+function annotateCapacityRows(rows, now) {
+ return rows.map((row) => ({ ...row, semantic: classifyCapacityObservation(row, now) }));
+}
+
+const QUOTA_REQUIRED_FIELDS = Object.freeze([
+ "ResourceId",
+ "ResourceName",
+ "SubAccountId",
+ "location",
+ "currentValue",
+ "limit",
+ "unit",
+ "x_SourceType",
+ "x_SourceVersion",
+ "x_IngestionTime",
+]);
+
+const COST_REQUIRED_FIELDS = Object.freeze([
+ "ChargePeriodStart",
+ "ProviderName",
+ "ChargeCategory",
+ "ResourceId",
+ "SubAccountId",
+ "RegionId",
+ "x_ResourceType",
+ "x_SkuMeterCategory",
+ "x_SkuMeterSubcategory",
+ "SkuMeter",
+ "SkuPriceId",
+ "EffectiveCost",
+ "BillingCurrency",
+]);
+
+function requiredCostFields(classId) {
+ if (classId === "premium-ssd-v2") return COST_REQUIRED_FIELDS;
+ const fields = [...COST_REQUIRED_FIELDS, "ConsumedQuantity", "ConsumedUnit"];
+ if (classId === "capacity-reservations") {
+ fields.push("CapacityReservationId", "CapacityReservationStatus");
+ }
+ return fields;
+}
+
+function assessSchema(rows, requiredFields, source) {
+ const availableNames = new Set(rows.map((row) => normalizeCapacityValue(row.ColumnName)));
+ const missingFields = requiredFields.filter((field) => !availableNames.has(field.toLowerCase()));
+ return {
+ source,
+ available: missingFields.length === 0,
+ fields: rows.map((row) => ({ name: row.ColumnName, type: row.ColumnType })),
+ requiredFields,
+ missingFields,
+ reasonCode: missingFields.length ? "required-source-field-unavailable" : null,
+ };
+}
+
+function selectorMatches(row, selection, mapping) {
+ return mapping.every(([selectionName, rowName]) =>
+ normalizeCapacityValue(row[rowName]) === normalizeCapacityValue(selection?.[selectionName])
+ );
+}
+
+function validateQuotaSelection(contract, selectors, selection, mode) {
+ if (!selection) return;
+ const mapping = contract.quotaType === "inventory"
+ ? [["resourceId", "ResourceId"]]
+ : mode === "metric"
+ ? [["resourceName", "ResourceName"], ["unit", "unit"], ["sourceVersion", "x_SourceVersion"]]
+ : [
+ ["subAccountId", "SubAccountId"],
+ ["location", "location"],
+ ["resourceName", "ResourceName"],
+ ["unit", "unit"],
+ ["sourceVersion", "x_SourceVersion"],
+ ];
+ if (!selectors.some((row) => selectorMatches(row, selection, mapping))) {
+ throw new Error("The selected quota key is not present in the bounded selector catalog.");
+ }
+}
+
+function validateDemandSelection(classId, selectors, selection) {
+ if (!selection) return;
+ const common = [
+ ["meterCategory", "x_SkuMeterCategory"],
+ ["meterSubcategory", "x_SkuMeterSubcategory"],
+ ["meter", "SkuMeter"],
+ ["priceId", "SkuPriceId"],
+ ["currency", "BillingCurrency"],
+ ];
+ const mapping = classId === "premium-ssd-v2"
+ ? [["resourceId", "InventoryResourceId"], ...common]
+ : classId === "capacity-reservations"
+ ? [
+ ...common,
+ ["unit", "ConsumedUnit"],
+ ["capacityReservationId", "CapacityReservationId"],
+ ["capacityReservationStatus", "CapacityReservationStatus"],
+ ]
+ : [...common, ["unit", "ConsumedUnit"]];
+ if (!selectors.some((row) => selectorMatches(row, selection, mapping))) {
+ throw new Error("The selected demand key is not present in the bounded selector catalog.");
+ }
+}
+
+export async function getCapacity(clusterUri, database, classId = "home", options = {}) {
+ const normalizedClassId = normalizeCapacityClassId(classId);
+ const generatedAt = new Date();
+ const quotaSchemaRows = await runQuery(clusterUri, database, buildCapacitySchemaQuery("Quota"));
+ const quotaSchema = assessSchema(quotaSchemaRows, QUOTA_REQUIRED_FIELDS, "Quota");
+
+ if (normalizedClassId === "home") {
+ const rows = quotaSchema.available
+ ? await runQuery(clusterUri, database, buildCapacityHomeQuery())
+ : [];
+ const bySource = new Map(rows.map((row) => [normalizeCapacityValue(row.x_SourceType), row]));
+ return {
+ classId: "home",
+ classes: Object.values(CAPACITY_CLASS_REGISTRY).map((contract) => ({
+ ...contract,
+ capability: quotaSchema.available
+ ? { mode: "descriptive-only", reasonCode: "class-quota-index", sourceNote: contract.sourceNote }
+ : { mode: "disabled", reasonCode: quotaSchema.reasonCode, sourceNote: "Quota source fields are unavailable" },
+ summary: bySource.get(contract.sourceType.toLowerCase()) ?? {
+ x_SourceType: contract.sourceType,
+ Observations: 0,
+ Resources: 0,
+ DistinctDays: 0,
+ LatestObservation: null,
+ },
+ })),
+ schema: { quota: quotaSchema },
+ generatedAt: generatedAt.toISOString(),
+ };
+ }
+
+ const contract = capacityClass(normalizedClassId);
+ const filters = options.filters ?? {};
+ const costSchemaRows = await runQuery(clusterUri, database, buildCapacitySchemaQuery("Costs"));
+ const costSchema = assessSchema(costSchemaRows, requiredCostFields(normalizedClassId), "Costs");
+ const baseQueries = {};
+ if (quotaSchema.available) {
+ if (normalizedClassId !== "compute") {
+ baseQueries.current = buildCapacityCurrentQuery(normalizedClassId, filters);
+ }
+ baseQueries.selectors = buildCapacitySelectorQuery(normalizedClassId, filters);
+ baseQueries.coverage = buildCapacityCoverageQuery(normalizedClassId, filters);
+ }
+ if (costSchema.available && (normalizedClassId !== "premium-ssd-v2" || quotaSchema.available)) {
+ baseQueries.demandSelectors = buildCapacityDemandSelectorQuery(normalizedClassId, filters);
+ baseQueries.demandCoverage = buildCapacityDemandCoverageQuery(normalizedClassId, filters);
+ }
+
+ const baseEntries = Object.entries(baseQueries);
+ const baseResults = await Promise.all(baseEntries.map(([, query]) => runQuery(clusterUri, database, query)));
+ const data = Object.fromEntries(baseEntries.map(([key], index) => [key, baseResults[index]]));
+ data.current ??= [];
+ data.selectors ??= [];
+ data.demandSelectors ??= [];
+
+ validateQuotaSelection(contract, data.selectors, options.quotaSelection, "series");
+ validateQuotaSelection(contract, data.selectors, options.metricSelection, "metric");
+ validateDemandSelection(normalizedClassId, data.demandSelectors, options.demandSelection);
+
+ const selectedQueries = {};
+ if (quotaSchema.available && options.quotaSelection) {
+ selectedQueries.history = buildCapacityHistoryQuery(normalizedClassId, options.quotaSelection);
+ }
+ if (quotaSchema.available && (contract.quotaType === "inventory" || options.metricSelection)) {
+ selectedQueries.heatmap = buildCapacityHeatmapQuery(normalizedClassId, options.metricSelection, filters);
+ }
+ if (quotaSchema.available && normalizedClassId === "compute") {
+ selectedQueries.familyHeatmap = buildComputeFamilyQuery(filters);
+ }
+ if (costSchema.available && options.demandSelection) {
+ selectedQueries.demandHistory = buildCapacityDemandHistoryQuery(normalizedClassId, options.demandSelection, filters);
+ }
+ if (quotaSchema.available && costSchema.available && normalizedClassId === "capacity-reservations") {
+ selectedQueries.reconciliation = buildCapacityReservationReconciliationQuery(filters);
+ }
+
+ const selectedEntries = Object.entries(selectedQueries);
+ const selectedResults = await Promise.all(selectedEntries.map(([, query]) => runQuery(clusterUri, database, query)));
+ Object.assign(data, Object.fromEntries(selectedEntries.map(([key], index) => [key, selectedResults[index]])));
+
+ const currentRows = annotateCapacityRows(data.current, generatedAt);
+ const heatmapRows = data.heatmap
+ ? (contract.quotaType === "inventory" ? data.heatmap : annotateCapacityRows(data.heatmap, generatedAt))
+ : [];
+ const distinctHistoryDays = new Set((data.history ?? []).map((row) => String(row.Day))).size;
+ const historyBounds = data.history
+ ? boundedCollection(data.history, CAPACITY_LIMITS.dailyPoints, "disable")
+ : null;
+ const table = boundedCollection(currentRows, CAPACITY_LIMITS.primaryRows);
+ const selectorBounds = boundedCollection(data.selectors, CAPACITY_LIMITS.selectorKeys);
+ const demandSelectorBounds = boundedCollection(data.demandSelectors, CAPACITY_LIMITS.selectorKeys);
+ const seriesBounds = data.demandHistory
+ ? boundedCollection(data.demandHistory, CAPACITY_LIMITS.dailyPoints, "disable")
+ : null;
+ const heatmapBounds = data.heatmap
+ ? boundedCollection(heatmapRows, CAPACITY_LIMITS.heatmapCells, "disable")
+ : null;
+ if (heatmapBounds?.status === "disabled") heatmapBounds.status = "heatmap-disabled";
+ const familyBounds = data.familyHeatmap
+ ? boundedCollection(annotateComputeFamilyRows(data.familyHeatmap), CAPACITY_LIMITS.familyCells, "disable")
+ : null;
+ if (familyBounds?.status === "disabled") familyBounds.status = "heatmap-disabled";
+ const coverageRow = data.coverage?.[0] ?? {};
+ const demandCoverageRow = data.demandCoverage?.[0] ?? {};
+ const enabledRows = normalizedClassId === "compute"
+ ? (familyBounds?.rows?.length || 0)
+ : currentRows.filter((row) => row.semantic.capability === "enabled").length;
+ const classCapability = !quotaSchema.available
+ ? { mode: "disabled", reasonCode: quotaSchema.reasonCode, sourceNote: "Quota source fields are unavailable" }
+ : enabledRows > 0 && normalizedClassId === "compute"
+ ? { mode: "enabled", reasonCode: "registered-metrics-present", sourceNote: contract.sourceNote }
+ : { mode: "descriptive-only", reasonCode: "fail-closed-class-view", sourceNote: contract.sourceNote };
+ const firstHistory = data.history?.[0]?.Day ?? null;
+ const lastHistory = data.history?.at(-1)?.Day ?? null;
+ const meterKey = options.demandSelection
+ ? [
+ options.demandSelection.meterCategory,
+ options.demandSelection.meterSubcategory,
+ options.demandSelection.meter,
+ options.demandSelection.priceId,
+ options.demandSelection.unit,
+ options.demandSelection.currency,
+ ].map((value) => String(value ?? "").trim()).join("|")
+ : null;
+
+ return {
+ classId: normalizedClassId,
+ contract,
+ capability: classCapability,
+ coverage: {
+ state: Number(coverageRow.Observations ?? 0) > 0 ? "observed" : "not-reported",
+ reasonCode: Number(coverageRow.Observations ?? 0) > 0 ? null : "collection-outcome-unknown",
+ observations: Number(coverageRow.Observations ?? 0),
+ resources: Number(coverageRow.Resources ?? 0),
+ distinctDays: Number(coverageRow.DistinctDays ?? 0),
+ firstObservation: coverageRow.FirstObservation ?? null,
+ lastObservation: coverageRow.LastObservation ?? null,
+ units: coverageRow.Units ?? [],
+ },
+ schema: { quota: quotaSchema, costs: costSchema },
+ table: { ...table, rowLimit: CAPACITY_LIMITS.primaryRows },
+ current: table,
+ selectors: { ...selectorBounds, items: selectorBounds.rows, itemLimit: CAPACITY_LIMITS.selectorKeys },
+ history: historyBounds
+ ? {
+ ...historyBounds,
+ points: historyBounds.rows,
+ pointLimit: CAPACITY_LIMITS.dailyPoints,
+ ...resolveCapacityHistoryCapability(distinctHistoryDays, contract),
+ distinctDays: distinctHistoryDays,
+ firstDate: firstHistory,
+ lastDate: lastHistory,
+ }
+ : {
+ status: quotaSchema.available ? "no-selection" : "disabled",
+ mode: "unavailable",
+ reasonCode: quotaSchema.available ? "select-exact-series" : quotaSchema.reasonCode,
+ rows: [],
+ points: [],
+ pointLimit: CAPACITY_LIMITS.dailyPoints,
+ distinctDays: 0,
+ firstDate: null,
+ lastDate: null,
+ },
+ heatmap: heatmapBounds ?? {
+ status: quotaSchema.available ? "no-selection" : "heatmap-disabled",
+ rows: [],
+ limit: CAPACITY_LIMITS.heatmapCells,
+ reasonCode: quotaSchema.available ? "select-exact-metric" : quotaSchema.reasonCode,
+ },
+ familyHeatmap: familyBounds ?? {
+ status: normalizedClassId === "compute" ? "unavailable" : "not-applicable",
+ rows: [],
+ limit: CAPACITY_LIMITS.familyCells,
+ reasonCode: normalizedClassId === "compute" ? quotaSchema.reasonCode : "class-has-no-family-grain",
+ },
+ series: seriesBounds
+ ? {
+ ...seriesBounds,
+ points: seriesBounds.rows,
+ pointLimit: CAPACITY_LIMITS.dailyPoints,
+ unit: options.demandSelection?.unit ?? null,
+ meterKey,
+ }
+ : {
+ status: costSchema.available ? "no-selection" : "disabled",
+ rows: [],
+ points: [],
+ pointLimit: CAPACITY_LIMITS.dailyPoints,
+ unit: null,
+ meterKey: null,
+ reasonCode: costSchema.available ? "select-exact-series" : costSchema.reasonCode,
+ },
+ demand: {
+ contract: CAPACITY_DEMAND_REGISTRY[normalizedClassId],
+ capability: costSchema.available
+ ? { mode: "parallel", reasonCode: "billed-demand-available", sourceNote: CAPACITY_DEMAND_REGISTRY[normalizedClassId].label }
+ : { mode: "disabled", reasonCode: costSchema.reasonCode, sourceNote: "Required cost source fields are unavailable" },
+ coverage: {
+ state: Number(demandCoverageRow.Observations ?? 0) > 0 ? "observed" : "not-reported",
+ reasonCode: Number(demandCoverageRow.Observations ?? 0) > 0 ? null : "no-billed-demand-available",
+ observations: Number(demandCoverageRow.Observations ?? 0),
+ distinctDays: Number(demandCoverageRow.DistinctDays ?? 0),
+ firstObservation: demandCoverageRow.FirstObservation ?? null,
+ lastObservation: demandCoverageRow.LastObservation ?? null,
+ units: demandCoverageRow.Units ?? [],
+ },
+ selectors: { ...demandSelectorBounds, items: demandSelectorBounds.rows, itemLimit: CAPACITY_LIMITS.selectorKeys },
+ history: seriesBounds ?? { status: "no-selection", rows: [], reasonCode: "select-exact-series" },
+ },
+ reconciliation: data.reconciliation
+ ? boundedCollection(data.reconciliation, CAPACITY_LIMITS.primaryRows)
+ : null,
+ generatedAt: generatedAt.toISOString(),
+ };
+}
+
+
+
+/**
+ * Run all dashboard queries in parallel for the resolved window and shape the
+ * result into a single payload the renderer consumes.
+ */
+export async function getDashboard(clusterUri, database, preset = "all", filters = {}) {
+ const win = await resolveWindow(clusterUri, database, preset);
+ if (win.empty) {
+ return { window: win, empty: true, generatedAt: new Date().toISOString() };
+ }
+
+ const filterWhere = buildFilterWhere(filters);
+ const period = `| where ChargePeriodStart >= datetime(${win.start}) and ChargePeriodStart < datetime(${win.end})${filterWhere}`;
+
+ const queries = {
+ // KPI totals — Understand Usage & Cost + Quantify Business Value
+ summary: `Costs() ${period} | summarize Billed=sum(BilledCost), Effective=sum(EffectiveCost), List=sum(ListCost), Contracted=sum(ContractedCost), Resources=dcount(ResourceId), Services=dcount(ServiceName), Subscriptions=dcount(SubAccountId), Regions=dcount(RegionId), Rows=count()`,
+ // Allocation KPI — percentage-untagged-costs
+ tagged: `Costs() ${period} | extend _t=iff(isnull(Tags) or array_length(bag_keys(Tags))==0,'Untagged','Tagged') | summarize Cost=sum(EffectiveCost) by _t`,
+ // Rate Optimization — commitment coverage (Committed vs Standard pricing)
+ pricing: `Costs() ${period} | summarize Cost=sum(EffectiveCost) by PricingCategory`,
+ // Reporting & Analytics — monthly-cost-trend (Billed vs Effective)
+ trend: `Costs() ${period} | summarize Billed=sum(BilledCost), Effective=sum(EffectiveCost) by Month=format_datetime(startofmonth(ChargePeriodStart),'yyyy-MM') | order by Month asc`,
+ // Understand Usage & Cost — cost by service category
+ serviceCategory: `Costs() ${period} | summarize Cost=sum(EffectiveCost) by ServiceCategory | where Cost > 0 | order by Cost desc`,
+ // top-services-by-cost
+ topServices: `Costs() ${period} | summarize Cost=sum(EffectiveCost) by ServiceName | top 10 by Cost desc`,
+ // top-resource-groups-by-cost
+ topResourceGroups: `Costs() ${period} | where isnotempty(x_ResourceGroupName) | summarize Cost=sum(EffectiveCost) by x_ResourceGroupName | top 10 by Cost desc`,
+ // cost-by-region-trend (top regions by cost)
+ topRegions: `Costs() ${period} | where isnotempty(RegionId) | summarize Cost=sum(EffectiveCost) by RegionId | top 12 by Cost desc`,
+ // Charge category mix (Usage / Purchase / Adjustment)
+ chargeCategory: `Costs() ${period} | summarize Cost=sum(EffectiveCost) by ChargeCategory | where Cost != 0 | order by Cost desc`,
+ // macc-consumption-vs-commitment — MACC burn rate (graceful: returns CommitmentAmount=0 if no MACC data)
+ macc: `let con = toscalar(Costs() ${period} | where not(ChargeCategory == 'Purchase' and isnotempty(CommitmentDiscountCategory)) | summarize sum(EffectiveCost));
+let com = toscalar(Transactions() | where isnotnull(x_MonetaryCommitment) | summarize sum(x_MonetaryCommitment));
+let com0 = coalesce(todouble(com), 0.0);
+print ConsumptionAmount=con, CommitmentAmount=com0, CommitmentBurnPercent=iff(com0 > 0, con / com0 * 100.0, 0.0)`,
+ };
+
+ const entries = Object.entries(queries);
+ const results = await Promise.all(
+ entries.map(([, csl]) => runQuery(clusterUri, database, csl))
+ );
+ const data = Object.fromEntries(entries.map(([key], i) => [key, results[i]]));
+
+ return {
+ window: win,
+ empty: false,
+ data,
+ generatedAt: new Date().toISOString(),
+ };
+}
+
+// --- tokenomics (AI / Azure OpenAI token economics) ---------------------------
+//
+// Grounded in the FinOps toolkit AI query catalog (ai-token-usage-breakdown,
+// ai-model-cost-comparison, ai-daily-trend) and the FinOps Foundation
+// "Token Consumption Metrics" KPI (Cost per Token = Total Cost / Tokens Used).
+//
+// Token meters are scoped to Azure OpenAI subcategories whose SKU description
+// is denominated in tokens, which excludes non-token AI meters (image/media,
+// Cognitive Search). ConsumedQuantity is the token count per the catalog.
+const AI_SCOPE = `| where x_SkuMeterSubcategory has 'OpenAI' and x_SkuDescription contains 'Token'`;
+
+// Direction: descriptions use abbreviations (Inp / Outp / cached Inp), so the
+// canonical contains "Input"/"Output" test is replaced with term/substring
+// matching that also splits cached input out as its own (cheaper) bucket.
+const DIRECTION = `extend Direction = case(
+ x_SkuDescription has 'Outp' or x_SkuDescription contains 'Output', 'Output',
+ x_SkuDescription contains 'cached', 'Cached input',
+ x_SkuDescription has 'Inp' or x_SkuDescription contains 'Input', 'Input',
+ 'Other')`;
+
+// Collapse verbose SKU descriptions to a clean model family, e.g.
+// "Azure OpenAI - gpt 4.1 cached Inp glbl Tokens - US East 2" -> "GPT 4.1".
+const MODEL_FAMILY = `extend Model = x_SkuDescription
+| extend Model = replace_regex(Model, @'^Azure OpenAI(?: GPT5)?\\s*-\\s*', '')
+| extend Model = replace_regex(Model, @'(?i)[\\s-]+(cached[\\s-]+)?(inp|inpt|outp|out|chat|media)([\\s-].*)?$', '')
+| extend Model = replace_regex(trim(@'[\\s-]+', Model), @'(?i)^gpt', 'GPT')`;
+
+export async function getTokenomics(clusterUri, database, preset = "all", filters = {}) {
+ const win = await resolveWindow(clusterUri, database, preset);
+ if (win.empty) {
+ return { window: win, empty: true, generatedAt: new Date().toISOString() };
+ }
+ const filterWhere = buildFilterWhere(filters);
+ const period = `| where ChargePeriodStart >= datetime(${win.start}) and ChargePeriodStart < datetime(${win.end})${filterWhere}`;
+
+ const queries = {
+ // Token KPI totals — Token Consumption Metrics. Models is collapsed
+ // through the same MODEL_FAMILY normalization as the "models" query
+ // below, so "Models in use" counts distinct model families, not raw
+ // (and often duplicated) billing-SKU description strings.
+ summary: `Costs() ${period} ${AI_SCOPE} | ${MODEL_FAMILY} | summarize Tokens=sum(ConsumedQuantity), Effective=sum(EffectiveCost), List=sum(ListCost), Models=dcount(Model), Resources=dcount(ResourceId), Rows=count()`,
+ // Total cloud effective cost in the window — for AI share-of-spend
+ totalCloud: `Costs() ${period} | summarize Effective=sum(EffectiveCost)`,
+ // ai-token-usage-breakdown — direction mix (input/cached/output)
+ direction: `Costs() ${period} ${AI_SCOPE} | ${DIRECTION} | summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost) by Direction`,
+ // ai-model-cost-comparison — by model family with cost per 1K tokens
+ models: `Costs() ${period} ${AI_SCOPE} | ${MODEL_FAMILY} | summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost), List=sum(ListCost) by Model | extend CostPer1K=iff(Tokens==0, 0.0, Cost/Tokens*1000) | top 12 by Cost desc`,
+ // ai-daily-trend (monthly variant) — token volume + AI cost over time
+ trend: `Costs() ${period} ${AI_SCOPE} | summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost) by Month=format_datetime(startofmonth(ChargePeriodStart),'yyyy-MM') | order by Month asc`,
+ // ai-cost-by-application — AI cost showback by app/team/env/cost-center
+ byApplication: `Costs() ${period} ${AI_SCOPE}
+| extend Application = tostring(Tags['application']), Team = tostring(Tags['team'])
+| extend CostCenter = coalesce(tostring(Tags['cost-center']), tostring(Tags['CostCenter']), '')
+| extend Environment = tostring(Tags['environment'])
+| summarize TokenCount=sum(ConsumedQuantity), EffectiveCost=sum(EffectiveCost)
+ by Application, Team, CostCenter, Environment
+| extend CostPer1KTokens = iff(TokenCount == 0, 0.0, EffectiveCost / TokenCount * 1000)
+| top 12 by EffectiveCost desc`,
+ };
+
+ const entries = Object.entries(queries);
+ const results = await Promise.all(entries.map(([, csl]) => runQuery(clusterUri, database, csl)));
+ const data = Object.fromEntries(entries.map(([key], i) => [key, results[i]]));
+
+ const tokenRows = data.summary?.[0]?.Rows ?? 0;
+ return {
+ window: win,
+ empty: tokenRows === 0,
+ data,
+ generatedAt: new Date().toISOString(),
+ };
+}
+
+// --- shared page runner -------------------------------------------------------
+// Resolves the window, builds a named map of KQL queries from the period clause,
+// runs them in parallel, and returns { window, empty, data, generatedAt }.
+async function runPage(clusterUri, database, preset, buildQueries, filters = {}) {
+ const win = await resolveWindow(clusterUri, database, preset);
+ if (win.empty) return { window: win, empty: true, generatedAt: new Date().toISOString() };
+ const filterWhere = buildFilterWhere(filters);
+ const period = `| where ChargePeriodStart >= datetime(${win.start}) and ChargePeriodStart < datetime(${win.end})${filterWhere}`;
+ const queries = buildQueries(period, win);
+ const entries = Object.entries(queries);
+ const results = await Promise.all(entries.map(([, csl]) => runQuery(clusterUri, database, csl)));
+ const data = Object.fromEntries(entries.map(([key], i) => [key, results[i]]));
+ return { window: win, empty: false, data, generatedAt: new Date().toISOString() };
+}
+
+// --- Allocation page ----------------------------------------------------------
+// FinOps "Allocation" capability. Grounded in catalog queries:
+// percentage-untagged-costs, percentage-unallocated-costs, tagging-policy-compliance,
+// allocation-accuracy-index, cost-by-financial-hierarchy. Tag policy keys are tuned
+// to this estate's taxonomy (CostCenter/env/org); allocation attribution also honours
+// the enriched x_CostCenter / x_CostAllocationRuleName columns.
+const NON_PURCHASE = `| where not(ChargeCategory == 'Purchase' and isnotempty(CommitmentDiscountCategory))`;
+
+export async function getAllocation(clusterUri, database, preset = "all", filters = {}) {
+ return runPage(clusterUri, database, preset, (period) => ({
+ // Single-pass core: total, untagged, attributed (AAI), compliant
+ core: `let req=dynamic(['CostCenter','env','org']);
+let ev=dynamic(['cost-center','team','owner','application','product','CostCenter','org','env','Project']);
+Costs() ${period} ${NON_PURCHASE}
+| extend tk=coalesce(bag_keys(Tags), dynamic([]))
+| extend isUntagged = array_length(tk)==0
+| extend hasAttribution = isnotempty(x_CostAllocationRuleName) or isnotempty(x_CostCenter) or array_length(set_intersect(tk,ev))>0
+| extend isCompliant = array_length(set_intersect(tk,req))==array_length(req)
+| summarize Total=sum(EffectiveCost), Untagged=sumif(EffectiveCost,isUntagged), Attributed=sumif(EffectiveCost,hasAttribution), Compliant=sumif(EffectiveCost,isCompliant), Subs=dcount(SubAccountId)`,
+ // cost-by-financial-hierarchy (tuned to org/Project/env taxonomy)
+ hierarchy: `Costs() ${period}
+| extend Org=tostring(Tags['org']), Project=tostring(Tags['Project']), Env=tostring(Tags['env'])
+| summarize Cost=sum(EffectiveCost) by Org, Project, Env
+| where Cost > 0 | top 12 by Cost desc`,
+ // Tag-key coverage — cost touched by each tag key. Excludes Azure/FTK
+ // auto-injected tags (ftk-*, cm-*, costanalysis-parent, aks-managed-*)
+ // so governance-relevant keys aren't crowded out by system noise.
+ tagKeys: `Costs() ${period}
+| mv-expand k=bag_keys(Tags) to typeof(string)
+| where isnotempty(k) and k !in ('ftk-tool','ftk-version','cm-resource-parent','costanalysis-parent') and not(k startswith 'aks-managed-')
+| summarize Cost=sum(EffectiveCost) by k
+| top 12 by Cost desc`,
+ // Cost by subscription (SubAccountName)
+ bySubscription: `Costs() ${period} | where isnotempty(SubAccountName) | summarize Cost=sum(EffectiveCost) by SubAccountName | top 10 by Cost desc`,
+ }), filters);
+}
+
+// --- Rate optimization page ---------------------------------------------------
+// FinOps "Rate Optimization" capability. Grounded in catalog queries:
+// savings-summary-report, commitment-discount-waste, compute-spend-commitment-coverage,
+// commitment-discount-utilization. (cost-optimization-index/COIN is omitted because it
+// depends on Recommendations(), which is empty in this estate, so it would always read 100.)
+// Commitment utilization is derived as the effective-cost complement of waste, the cleanest
+// single-basis definition for a grand-total KPI.
+export async function getRate(clusterUri, database, preset = "all", filters = {}) {
+ return runPage(clusterUri, database, preset, (period) => ({
+ // savings-summary-report — ESR + negotiated/commitment/total savings
+ savings: `Costs() ${period} ${NON_PURCHASE}
+| extend neg=iff(ListCost0, cores*ConsumedQuantity, toreal(''))
+| extend t=iff(isempty(CommitmentDiscountType),'On Demand',CommitmentDiscountType)
+| summarize CoreHours=sum(ch) by t | where CoreHours > 0 | order by CoreHours desc`,
+ // Per-commitment waste — which reservations/plans are underutilized
+ byCommitment: `Costs() ${period} | where isnotempty(CommitmentDiscountName) ${NON_PURCHASE}
+| summarize Unused=sumif(EffectiveCost,CommitmentDiscountStatus=='Unused'), Total=sum(EffectiveCost) by CommitmentDiscountName
+| where Unused > 0 | top 10 by Unused desc`,
+ // commitment-utilization-score (formal KPI) — per-commitment and grand-total utilization score
+ commitmentUtilScore: `let rows = materialize(Costs() ${period} | where isnotempty(CommitmentDiscountId)
+| extend Potential = case(ChargeCategory == 'Purchase', toreal(0), isnotempty(CommitmentDiscountCategory), toreal(EffectiveCost), toreal(0))
+| extend Amount = iff(CommitmentDiscountStatus == 'Used', Potential, toreal(0)));
+let byCommit = rows | summarize Amount=sum(Amount), Potential=sum(Potential) by CommitmentDiscountName, CommitmentDiscountCategory, CommitmentDiscountType
+| extend Score = iff(Potential > 0, Amount / Potential * 100.0, 0.0);
+union byCommit, (byCommit | summarize Amount=sum(Amount), Potential=sum(Potential)
+| extend CommitmentDiscountName='(Grand Total)', CommitmentDiscountCategory='', CommitmentDiscountType='', Score=iff(Potential>0, Amount/Potential*100.0, 0.0))
+| project CommitmentDiscountName, CommitmentDiscountCategory, CommitmentDiscountType, Amount, Potential, Score | order by Potential desc`,
+ // top-commitment-transactions — largest RI/SP purchases
+ topCommitmentTxns: `Costs() ${period} | where ChargeCategory != 'Usage' and isnotempty(CommitmentDiscountType) and BilledCost > 0
+| summarize BilledCost=sum(BilledCost), EffectiveCost=sum(EffectiveCost)
+ by CommitmentDiscountName, CommitmentDiscountType, CommitmentDiscountCategory
+| top 10 by BilledCost desc`,
+ }), filters);
+}
+
+// --- Usage & unit economics page ----------------------------------------------
+// FinOps "Usage Optimization" + "Unit Economics". Grounded in catalog queries:
+// compute-cost-per-core, cost-per-gb-stored, storage-tier-distribution, top-resource-types-by-cost.
+export async function getUsage(clusterUri, database, preset = "all", filters = {}) {
+ return runPage(clusterUri, database, preset, (period) => ({
+ // compute-cost-per-core
+ compute: `Costs() ${period} ${NON_PURCHASE}
+| extend vm = x_SkuMeterCategory in ('Virtual Machines','Virtual Machine Licenses') and ChargeCategory=='Usage'
+| extend isComputeCommit = x_SkuMeterCategory in ('Virtual Machines','Virtual Machine Licenses')
+| extend cores = iff(vm, toint(coalesce(x_SkuDetails.VCPUs, x_SkuDetails.vCores)), toint(''))
+| extend ch = iff(vm and isnotempty(cores), toreal(cores*ConsumedQuantity), toreal(''))
+| summarize ComputeEff=sumif(EffectiveCost,vm), UnusedCommit=sumif(EffectiveCost, CommitmentDiscountStatus=='Unused' and isnotempty(CommitmentDiscountCategory) and isComputeCommit), CoreHours=sum(ch)`,
+ // cost-per-gb-stored
+ storage: `Costs() ${period} | where ServiceCategory=='Storage' and ChargeCategory=='Usage'
+| extend gb = case(ConsumedUnit endswith 'PB', toreal(ConsumedQuantity)*1048576.0, ConsumedUnit endswith 'TB', toreal(ConsumedQuantity)*1024.0, ConsumedUnit endswith 'MB', toreal(ConsumedQuantity)/1024.0, toreal(ConsumedQuantity))
+| summarize Cost=sum(EffectiveCost), GBMonths=sum(gb)`,
+ // storage-tier-distribution
+ storageTiers: `Costs() ${period} | where ServiceCategory=='Storage' and ChargeCategory=='Usage'
+| extend Tier = case(
+ x_SkuTier in ('Hot','Standard','Premium'), 'Frequent',
+ x_SkuTier in ('Cool','Cold','Archive'), 'Infrequent',
+ x_SkuMeterSubcategory has_any ('Hot','Standard','Premium','Frequent'), 'Frequent',
+ x_SkuMeterSubcategory has_any ('Cool','Cold','Archive'), 'Infrequent',
+ 'Unclassified')
+| summarize Cost=sum(EffectiveCost) by Tier | where Cost > 0 | order by Cost desc`,
+ // top-resource-types-by-cost — Resources is a distinct-resource count
+ // (dcount(ResourceId)), matching the same "Resources" label semantics
+ // used by the Overview summary KPI, not a row count.
+ topResourceTypes: `Costs() ${period} | where isnotempty(ResourceType) | summarize Resources=dcount(ResourceId), Cost=sum(EffectiveCost) by ResourceType | top 10 by Cost desc`,
+ // compute-cost-per-core grouped by VM series — where the expensive cores are
+ perCoreSeries: `Costs() ${period} | where x_SkuMeterCategory in ('Virtual Machines','Virtual Machine Licenses') and ChargeCategory=='Usage'
+| extend cores=toint(coalesce(x_SkuDetails.VCPUs, x_SkuDetails.vCores))
+| extend ch=iff(isnotempty(cores), toreal(cores*ConsumedQuantity), toreal(''))
+| summarize Eff=sum(EffectiveCost), CH=sum(ch) by x_SkuMeterSubcategory
+| where CH > 100 | extend PerCore=Eff/CH | top 10 by Eff desc`,
+ // grand total for share-of-cost on the resource-type table
+ total: `Costs() ${period} | summarize Total=sum(EffectiveCost)`,
+ }), filters);
+}
+
+// --- Anomalies & forecast page ------------------------------------------------
+// FinOps "Anomaly Management" + "Forecasting" + data-freshness (Data Ingestion).
+// Grounded in catalog queries: cost-anomaly-detection, anomaly-detection-rate,
+// anomaly-variance-total, monthly-cost-change-percentage, cost-forecasting-model,
+// data-update-frequency, cost-visibility-delay. Time-series array outputs are
+// flattened with mv-expand so the renderer can chart them.
+export async function getAnomaly(clusterUri, database, preset = "all", filters = {}) {
+ return runPage(clusterUri, database, preset, (period, win) => {
+ // forecast uses full history for accuracy; horizon = 4 months past the last data month
+ const dmax = new Date(win.dataMax);
+ const monthStart = new Date(Date.UTC(dmax.getUTCFullYear(), dmax.getUTCMonth(), 1));
+ const horizon = new Date(Date.UTC(dmax.getUTCFullYear(), dmax.getUTCMonth() + 4, 1));
+ const isoH = horizon.toISOString().slice(0, 10);
+ const fcDays = Math.round((horizon - monthStart) / 86400000);
+ return {
+ // cost-anomaly-detection + anomaly-variance-total (flattened daily series)
+ daily: `let s=datetime(${win.start}); let e=datetime(${win.end});
+Costs() | where ChargePeriodStart>=s and ChargePeriodStart=s
+| summarize Eff=sum(EffectiveCost) by bin(ChargePeriodStart,1d)
+| make-series Actual=sum(Eff) default=0.0 on ChargePeriodStart from s to datetime(${isoH}) step 1d
+| extend Fc=series_decompose_forecast(Actual,${fcDays})
+| mv-expand Day=ChargePeriodStart to typeof(datetime), Actual to typeof(real), Fc to typeof(real)
+| extend M=startofmonth(Day)
+| summarize Actual=sum(toreal(Actual)), Forecast=sum(toreal(Fc)) by M
+| order by M asc | project Month=format_datetime(M,'yyyy-MM'), Actual, Forecast`,
+ // data-update-frequency + cost-visibility-delay
+ freshness: `Costs() ${period} | where isnotnull(x_IngestionTime)
+| summarize LastUpdate=max(x_IngestionTime), Rows=count(), P50=percentile(todouble((x_IngestionTime-ChargePeriodEnd)/1h),50), P90=percentile(todouble((x_IngestionTime-ChargePeriodEnd)/1h),90)`,
+ };
+ }, filters);
+}
+
+// --- AI & emerging workloads page ---------------------------------------------
+// The 2026 FinOps "AI as a Technology Scope" view: the whole AI/ML estate, not
+// just tokens. Tokenomics (above) drills into Azure OpenAI token unit economics;
+// this page covers foundation models, cognitive services, the ML platform, AI
+// Search / retrieval, and GPU-accelerated compute together.
+//
+// GPU detection is a case-insensitive regex over the concatenated service and
+// SKU text, so N-series capacity billed through Virtual Machines or Virtual
+// Machine Scale Sets is attributed to the AI estate even though its
+// ServiceCategory is Compute. The regex replaces the equivalent toupper()
+// form to keep case handling out of comparison position.
+const AI_GPU = `strcat(' ', ServiceName, ' ', x_SkuMeterCategory, ' ', x_SkuMeterSubcategory, ' ', x_SkuInstanceType, ' ', tostring(SkuMeter), ' ') matches regex @'(?i)(^|[^a-z0-9])(nc|nd|nv|ng)[a-z0-9_\\-]*'`;
+
+// The AI/ML estate: the declared FOCUS service category, plus two services that
+// sit outside it but are unambiguously AI workloads, plus GPU compute.
+const AI_ESTATE = `| where ServiceCategory == 'AI and Machine Learning' or ServiceName in ('Azure AI Search', 'Azure Databricks') or (${AI_GPU})`;
+
+// Token meters on this page are scoped by meter category rather than the
+// tokenomics page's SKU-description test, so Foundry-billed models beyond
+// Azure OpenAI (Deepseek, Phi, and later additions) are counted too.
+const AI_TOKENS = `x_SkuMeterCategory == 'Foundry Models' and PricingUnit == 'Units'`;
+
+// Capability taxonomy, ordered most specific first: GPU wins over service name
+// because N-series capacity bills through generic Compute services.
+const AI_CAPABILITY = `extend Capability = case(
+ _gpu, 'GPU / accelerated compute',
+ x_SkuMeterCategory == 'Foundry Models' or x_SkuMeterSubcategory has_any ('OpenAI', 'Deepseek', 'GPT'), 'Foundation models (LLM)',
+ ServiceName == 'Azure AI Search', 'AI Search / retrieval',
+ ServiceName == 'Azure Machine Learning', 'ML platform & compute',
+ ServiceName == 'Azure Databricks', 'ML / analytics platform',
+ ServiceName == 'Azure AI Video Indexer' or x_SkuMeterSubcategory has_any ('Vision', 'Speech', 'Translator', 'Content Understanding', 'Video Indexer', 'Bing', 'Content Safety', 'Phi'), 'Cognitive services',
+ ServiceName == 'Azure AI Bot Service', 'Bot & agents',
+ 'Other AI/ML')`;
+
+// Tag keys that identify an owning application or team. Allocation coverage is
+// reported per dimension rather than as one blended score, so a gap in tagging
+// stays distinguishable from a gap in cost-center enrichment.
+const AI_APP_TAGS = `dynamic(['application', 'app', 'workload', 'product', 'service'])`;
+const AI_OWNER_TAGS = `dynamic(['owner', 'team', 'createdby'])`;
+
+/**
+ * First day of the most recent *complete* month in the data.
+ *
+ * Ingestion usually stops mid-month, so the newest month in the window is
+ * partial. Comparing it against a full prior month reports a collapse that is
+ * an artifact of the ingestion cut-off, not a change in spend, so
+ * month-over-month comparison is anchored to the last closed month instead.
+ */
+export function lastClosedMonthStart(dataMax) {
+ if (!dataMax) return null;
+ const max = new Date(dataMax);
+ if (Number.isNaN(max.getTime())) return null;
+ const monthStart = startOfMonthUTC(max);
+ const lastDayOfMonth = new Date(addMonthsUTC(monthStart, 1).getTime() - 86400000);
+ return isoDay(max) === isoDay(lastDayOfMonth) ? monthStart : addMonthsUTC(monthStart, -1);
+}
+
+export async function getAi(clusterUri, database, preset = "all", filters = {}) {
+ let closedMonth = null;
+ let built = null;
+ const page = await runPage(clusterUri, database, preset, (period, win) => {
+ closedMonth = lastClosedMonthStart(win.dataMax);
+ // Fall back to the window start when the data is too short to contain a
+ // closed month, so the driver comparison stays inside the window.
+ const closedDay = isoDay(closedMonth ?? new Date(win.start));
+ built = {
+ // Single monthly rollup that powers both trend charts and every
+ // month-over-month KPI, so the page costs one scan instead of the
+ // five scalar round-trips the source dashboard used.
+ monthly: `Costs() ${period}
+| extend _gpu = ${AI_GPU}
+| extend _ai = ServiceCategory == 'AI and Machine Learning' or ServiceName in ('Azure AI Search', 'Azure Databricks') or _gpu
+| extend _tok = ${AI_TOKENS}
+| summarize Cloud=sum(EffectiveCost), Estate=sumif(EffectiveCost, _ai), MlGpu=sumif(EffectiveCost, ServiceName == 'Azure Machine Learning' or _gpu),
+ Tokens=sumif(ConsumedQuantity, _tok), TokenCost=sumif(EffectiveCost, _tok)
+ by Month=format_datetime(startofmonth(ChargePeriodStart), 'yyyy-MM')
+| order by Month asc`,
+ // AI/ML estate by capability over time — stacked column source.
+ capabilityTrend: `Costs() ${period} ${AI_ESTATE}
+| extend _gpu = ${AI_GPU}
+| ${AI_CAPABILITY}
+| summarize Cost=sum(EffectiveCost) by Month=format_datetime(startofmonth(ChargePeriodStart), 'yyyy-MM'), Capability
+| order by Month asc`,
+ // Estate composition: cost, distinct services, and share per capability.
+ capability: `Costs() ${period} ${AI_ESTATE}
+| extend _gpu = ${AI_GPU}
+| ${AI_CAPABILITY}
+| summarize Cost=sum(EffectiveCost), Services=dcount(ServiceName) by Capability
+| where Cost > 0
+| order by Cost desc`,
+ // Estate spend by billing service.
+ byService: `Costs() ${period} ${AI_ESTATE}
+| summarize Cost=sum(EffectiveCost), Meters=dcount(x_SkuMeterSubcategory) by Service=ServiceName, Category=ServiceCategory
+| where Cost > 0
+| top 15 by Cost desc`,
+ // AI Search / retrieval meters.
+ search: `Costs() ${period}
+| where ServiceName == 'Azure AI Search'
+| summarize Cost=sum(EffectiveCost), Quantity=sum(ConsumedQuantity) by Meter=tostring(SkuMeter), Unit=PricingUnit
+| where Cost > 0
+| top 10 by Cost desc`,
+ // ML platform and GPU compute components.
+ mlGpu: `Costs() ${period}
+| extend _gpu = ${AI_GPU}
+| where ServiceName == 'Azure Machine Learning' or _gpu
+| summarize Cost=sum(EffectiveCost), Quantity=sum(PricingQuantity) by Component=x_SkuMeterSubcategory, Unit=PricingUnit
+| where Cost > 0
+| top 12 by Cost desc`,
+ // ML compute unit economics — $/VM-hour and $/1K core-hours by series.
+ mlUnit: `Costs() ${period}
+| where ServiceName == 'Azure Machine Learning' and x_SkuMeterCategory == 'Virtual Machines'
+| extend CoreHours = todouble(coalesce(x_SkuCoreCount, 0)) * PricingQuantity
+| summarize Cost=sum(EffectiveCost), VmHours=sum(PricingQuantity), CoreHours=sum(CoreHours) by Series=x_SkuMeterSubcategory
+| where VmHours > 0
+| extend PerVmHour=Cost/VmHours, Per1KCoreHours=iff(CoreHours > 0, Cost/CoreHours*1000.0, real(null))
+| top 10 by Cost desc`,
+ // Foundation model benchmark — cost per 1M tokens by model family.
+ // 'embed' is a genuine substring test: the token appears fused inside
+ // meter names such as 'text-embedding-3-large'.
+ modelBench: `Costs() ${period}
+| where ${AI_TOKENS}
+| extend Family = iff(SkuMeter contains 'embed', 'Embeddings', x_SkuMeterSubcategory)
+| summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost) by Family
+| where Tokens > 0
+| extend Cpmt=Cost/Tokens*1000000.0
+| order by Cost desc`,
+ // Token direction mix by meter name. Meter names fuse the direction
+ // into abbreviations ("Inpt", "Outp", "cchd", "cd inp"), so the tests
+ // anchor to a word boundary rather than a bare substring: a plain
+ // `contains 'out'` would also classify a future "Throughput" meter as
+ // output. Verified against the live meter catalog with zero rows
+ // falling through to 'Other'.
+ direction: `Costs() ${period}
+| where ${AI_TOKENS}
+| extend Direction = case(
+ SkuMeter contains 'embed', 'Embedding',
+ SkuMeter matches regex @'(?i)\\bcd\\s+wr', 'Cached write',
+ SkuMeter has_any ('cchd', 'cached') or SkuMeter matches regex @'(?i)\\bcd\\s+inp', 'Cached input',
+ SkuMeter matches regex @'(?i)\\b(out|opt)', 'Output',
+ SkuMeter matches regex @'(?i)\\binp', 'Input',
+ 'Other')
+| summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost) by Direction
+| where Tokens > 0
+| extend Cpmt=Cost/Tokens*1000000.0
+| order by Tokens desc`,
+ // Cognitive and specialized AI services, excluding token meters.
+ cognitive: `Costs() ${period}
+| where ServiceName in ('Azure AI Services', 'Azure AI Video Indexer') and x_SkuMeterCategory != 'Foundry Models'
+| summarize Cost=sum(EffectiveCost), Units=sum(ConsumedQuantity) by Service=x_SkuMeterSubcategory
+| where Cost > 0
+| top 12 by Cost desc`,
+ // Allocation coverage per dimension, plus the estate total that every
+ // coverage percentage is measured against.
+ allocation: `Costs() ${period} ${AI_ESTATE}
+| extend tk = coalesce(bag_keys(Tags), dynamic([]))
+| summarize Total=sum(EffectiveCost),
+ App=sumif(EffectiveCost, array_length(set_intersect(tk, ${AI_APP_TAGS})) > 0),
+ Owner=sumif(EffectiveCost, array_length(set_intersect(tk, ${AI_OWNER_TAGS})) > 0),
+ CostCenter=sumif(EffectiveCost, isnotempty(x_CostCenter)),
+ ResourceGroup=sumif(EffectiveCost, isnotempty(x_ResourceGroupName))`,
+ // Estate spend by owning team or cost center.
+ // Tag values are free text, so the same owner arrives in several
+ // casings ("ACM9000" / "acm9000"). Splitting them into separate rows
+ // understates the real owner and reads as broken data on screen, so
+ // group case-insensitively and keep the most expensive casing as the
+ // display label. tolower() is the grouping key here, not a comparison —
+ // KQL's comparison operators are already case-insensitive.
+ byOwner: `Costs() ${period} ${AI_ESTATE}
+| extend Owner = coalesce(tostring(Tags['owner']), tostring(Tags['team']), x_CostCenter, x_ResourceGroupName, '(unassigned)')
+| summarize Cost=sum(EffectiveCost) by Owner
+| where Cost > 0
+| summarize Cost=sum(Cost), Variants=dcount(Owner), (TopCost, Owner)=arg_max(Cost, Owner) by OwnerKey=tolower(Owner)
+| project Owner, Cost, Variants
+| top 12 by Cost desc`,
+ // Commitment posture across the estate.
+ posture: `Costs() ${period} ${AI_ESTATE}
+| summarize Total=sum(EffectiveCost), Committed=sumif(EffectiveCost, isnotempty(CommitmentDiscountCategory))`,
+ // AI-scoped rate recommendations and commitment transactions. Both
+ // render as descriptive counts; an empty result means no AI-scoped
+ // records were ingested, not that no opportunity exists.
+ recommendations: `Recommendations()
+| where ResourceType has_any ('MachineLearning', 'CognitiveServices', 'Search/search', 'Databricks', 'BotService', 'VideoIndexer')
+ or x_RecommendationDescription has_any ('AI', 'OpenAI', 'GPU', 'machine learning', 'cognitive')
+| summarize Count=count()`,
+ transactions: `Transactions()
+| where ChargeDescription has_any ('NC', 'ND', 'NV', 'NG', 'GPU', 'Machine Learning', 'Cognitive', 'OpenAI', 'Databricks', 'AI Search')
+| summarize Count=count()`,
+ // Top movers: the last closed month against the month before it, so a
+ // partial ingestion month can't read as a collapse in spend. A single
+ // conditional aggregation replaces the source dashboard's self-join.
+ drivers: `let _last = datetime(${closedDay});
+let _prev = datetime_add('month', -1, _last);
+Costs() ${period} ${AI_ESTATE}
+| where ChargePeriodStart >= _prev and ChargePeriodStart < datetime_add('month', 1, _last)
+| summarize Cost=sumif(EffectiveCost, ChargePeriodStart >= _last), Prev=sumif(EffectiveCost, ChargePeriodStart < _last)
+ by Service=ServiceName, Meter=x_SkuMeterSubcategory
+| where Cost > 0 or Prev > 0
+| extend Change=Cost-Prev
+| top 12 by Cost desc`,
+ };
+ return built;
+ }, filters);
+ if (!page.empty) {
+ page.lastClosedMonth = closedMonth ? isoDay(closedMonth).slice(0, 7) : null;
+ // Ship the queries that actually ran, so the panel "KQL" dialog shows
+ // executed text rather than a hand-maintained copy that can drift.
+ page.kql = built;
+ }
+ return page;
+}
diff --git a/.github/extensions/ftk-local-dashboard/public/app.css b/.github/extensions/ftk-local-dashboard/public/app.css
new file mode 100644
index 000000000..42bab0060
--- /dev/null
+++ b/.github/extensions/ftk-local-dashboard/public/app.css
@@ -0,0 +1,1330 @@
+:root {
+ --accent: #3b82f6;
+ --pos: #10b981;
+ --neg: #ef4444;
+ --warn: #f59e0b;
+ --grid: var(--border-color-default, rgba(128, 128, 128, 0.22));
+ --muted: var(--text-color-muted, #6b7280);
+ --card-bg: var(--background-color-default, #ffffff);
+ --radius: 12px;
+ --gap: 16px;
+ --border-muted: var(--border-color-muted, rgba(128, 128, 128, 0.3));
+ /* Mixing toward the host text colour keeps alert text at AA in light and dark themes. */
+ --neg-ink: color-mix(in srgb, var(--neg) 70%, var(--text-color-default, #1f2328));
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ padding: 0 20px 28px;
+ background: var(--background-color-default, #f6f8fa);
+ color: var(--text-color-default, #1f2328);
+ font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
+ font-size: var(--text-body-medium, 14px);
+ line-height: var(--leading-body-medium, 20px);
+ -webkit-font-smoothing: antialiased;
+}
+
+.muted { color: var(--muted); }
+
+/* ---------- header ---------- */
+.app-header {
+ position: sticky;
+ top: 0;
+ z-index: 5;
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 18px 0 14px;
+ margin-bottom: 6px;
+ background: linear-gradient(var(--background-color-default, #f6f8fa) 78%, transparent);
+ flex-wrap: wrap;
+}
+.title-block h1 {
+ margin: 0;
+ font-size: var(--text-title-large, 24px);
+ font-weight: var(--font-weight-semibold, 600);
+ letter-spacing: -0.01em;
+}
+.title-block .sub {
+ margin: 4px 0 0;
+ font-size: 12.5px;
+ color: var(--muted);
+}
+.title-block .sub code {
+ font-family: var(--font-mono, ui-monospace, monospace);
+ font-size: 11.5px;
+ padding: 1px 5px;
+ border-radius: 5px;
+ background: color-mix(in srgb, var(--muted) 14%, transparent);
+}
+
+.controls { display: flex; align-items: center; gap: 10px; }
+.seg {
+ display: inline-flex;
+ border: 1px solid var(--grid);
+ border-radius: 9px;
+ overflow: hidden;
+}
+.seg button {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: var(--text-color-default, #1f2328);
+ padding: 10px 13px;
+ min-height: 44px;
+ font-size: 12.5px;
+ font-weight: 600;
+ cursor: pointer;
+ border-left: 1px solid var(--grid);
+}
+.seg button:first-child { border-left: 0; }
+.seg button.active { background: var(--accent); color: #fff; }
+.seg button:not(.active):hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
+#preset[hidden] { display: none; }
+
+.btn {
+ appearance: none;
+ border: 1px solid var(--grid);
+ background: var(--card-bg);
+ color: var(--text-color-default, #1f2328);
+ padding: 10px 13px;
+ min-height: 44px;
+ border-radius: 9px;
+ font-size: 12.5px;
+ font-weight: 600;
+ cursor: pointer;
+}
+.btn:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
+.btn:active { transform: translateY(1px); }
+
+/* ---------- tabs ---------- */
+.tabs {
+ display: flex;
+ gap: 4px;
+ margin: 2px 0 18px;
+ border-bottom: 1px solid var(--grid);
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+}
+.tabs::-webkit-scrollbar { display: none; }
+.tabs button {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ font-size: 13.5px;
+ font-weight: 600;
+ padding: 12px 14px 14px;
+ min-height: 44px;
+ white-space: nowrap;
+ flex-shrink: 0;
+ cursor: pointer;
+ position: relative;
+ border-radius: 8px 8px 0 0;
+}
+.tabs button:hover { color: var(--text-color-default, #1f2328); background: color-mix(in srgb, var(--accent) 8%, transparent); }
+.tabs button.active { color: var(--text-color-default, #1f2328); }
+.tabs button.active::after {
+ content: "";
+ position: absolute;
+ left: 10px; right: 10px; bottom: -1px;
+ height: 2.5px;
+ border-radius: 2px;
+ background: var(--accent);
+}
+
+/* ---------- KPI cards ---------- */
+.kpi-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: var(--gap);
+ margin-bottom: 22px;
+}
+.kpi {
+ background: var(--card-bg);
+ border: 1px solid var(--grid);
+ border-radius: var(--radius);
+ padding: 15px 16px 14px;
+ position: relative;
+ overflow: hidden;
+}
+.kpi .label {
+ font-size: 11.5px;
+ color: var(--muted);
+ font-weight: 600;
+}
+.kpi .value {
+ font-size: 26px;
+ font-weight: 700;
+ margin-top: 6px;
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+}
+.kpi .meta { margin-top: 5px; font-size: 12px; color: var(--muted); }
+/* .kpi .pos/.neg/.warn (not scoped to .meta) so KPIs that color the value
+ itself — e.g. Anomaly's "Last month change" — get the same semantics as
+ the meta-text convention. */
+.kpi .pos { color: var(--pos); font-weight: 600; }
+.kpi .neg { color: var(--neg); font-weight: 600; }
+.kpi .warn { color: var(--warn); font-weight: 600; }
+
+/* Tables compute the same threshold classes as KPI cards, so give them the same
+ meaning. Without these the classes are inert and every table renders its
+ thresholds as plain body text. */
+.dtable .pos { color: var(--pos); font-weight: 600; }
+.dtable .neg { color: var(--neg); font-weight: 600; }
+.dtable .warn { color: var(--warn); font-weight: 600; }
+
+/* ---------- KPI hierarchy (primary vs reference) ---------- */
+.kpi--primary .value { font-size: 28px; }
+.kpi--reference { opacity: 0.75; }
+
+/* ---------- KPI threshold tooltip ---------- */
+.kpi-tip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ border: 1px solid currentColor;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ font: 600 9px/1 var(--font-sans, -apple-system, sans-serif);
+ padding: 0;
+ margin-left: 4px;
+ vertical-align: middle;
+ transition: color 0.15s, border-color 0.15s;
+}
+.kpi-tip:hover, .kpi-tip:focus-visible {
+ color: var(--accent);
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+/* ---------- KPI threshold state: value text carries the signal ---------- */
+.threshold-green { border-color: var(--pos); }
+.threshold-amber { border-color: var(--warn); }
+.threshold-red { border-color: var(--neg); }
+.threshold-green .value { color: var(--pos); }
+.threshold-amber .value { color: var(--warn); }
+.threshold-red .value { color: var(--neg); }
+
+/* ---------- triage arrival context banner ---------- */
+.triage-callout {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ background: color-mix(in srgb, var(--warn) 10%, transparent);
+ border: 1px solid color-mix(in srgb, var(--warn) 28%, transparent);
+ border-radius: 8px;
+ padding: 10px 14px;
+ font-size: 13px;
+ font-weight: 500;
+ color: color-mix(in srgb, var(--warn) 75%, #000);
+ margin-bottom: 16px;
+}
+.triage-callout-icon { font-size: 15px; flex-shrink: 0; line-height: 1; }
+
+/* ---------- triage strip ---------- */
+.triage-strip {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: var(--gap);
+ margin-bottom: 22px;
+}
+.triage-tile {
+ appearance: none;
+ background: var(--card-bg);
+ border: 2px solid var(--grid);
+ border-radius: var(--radius);
+ padding: 16px 18px;
+ cursor: pointer;
+ text-align: left;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ transition: opacity 0.15s;
+ color: var(--text-color-default, #1f2328);
+}
+.triage-tile:hover { opacity: 0.82; }
+.triage-tile:active { transform: translateY(1px); }
+.triage-title {
+ font-size: 11.5px;
+ font-weight: 600;
+ color: var(--muted);
+}
+.triage-count {
+ font-size: 30px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.1;
+}
+.triage-badge {
+ display: inline-block;
+ font-size: 11px;
+ font-weight: 600;
+ background: color-mix(in srgb, var(--muted) 12%, transparent);
+ color: var(--muted);
+}
+.triage-tile.threshold-green .triage-badge {
+ background: color-mix(in srgb, var(--pos) 15%, transparent);
+ color: var(--pos);
+}
+.triage-tile.threshold-amber .triage-badge {
+ background: color-mix(in srgb, var(--warn) 15%, transparent);
+ color: var(--warn);
+}
+.triage-tile.threshold-red .triage-badge {
+ background: color-mix(in srgb, var(--neg) 15%, transparent);
+ color: var(--neg);
+}
+/* Teaser state: this tile's data hasn't loaded yet (visit the tab to
+ populate it), distinct from a real "no anomalies found" result. */
+.triage-tile.is-teaser {
+ border-style: dashed;
+ border-color: var(--grid);
+}
+.triage-tile.is-teaser .triage-count { color: var(--muted); }
+.triage-tile.is-teaser .triage-badge {
+ background: transparent;
+ border: 1px dashed var(--muted);
+ color: var(--muted);
+}
+.triage-cue {
+ font-size: 12px;
+ color: var(--muted);
+ margin-top: 2px;
+}
+@media (max-width: 640px) {
+ .triage-strip { grid-template-columns: 1fr; }
+}
+
+/* ---------- sections & panels ---------- */
+.section-title {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin: 26px 0 12px;
+}
+.section-title h2 {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+.section-title .domain {
+ font-size: 11px;
+ color: var(--muted);
+}
+
+/* Neutral scope caveat shown above a tab's sections. Deliberately not
+ .triage-callout: that carries a warning tone, and a scope statement is
+ context, not a problem to act on. */
+.scope-note {
+ margin: 18px 0 0;
+ padding: 10px 14px;
+ border: 1px solid var(--border-muted);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--muted) 6%, transparent);
+ font-size: 12px;
+ line-height: 1.55;
+ color: var(--muted);
+ max-width: 100%;
+}
+.scope-note strong { color: inherit; font-weight: 600; }
+
+.panel-grid {
+ display: grid;
+ gap: var(--gap);
+ grid-template-columns: repeat(12, 1fr);
+ /* Paired panels in a row share one height (grid default: stretch), so a
+ 2-col layout reads as symmetric rather than a jagged row of mismatched
+ card heights. Short, fixed-size content (a capped donut — see
+ .panel-body:has below) centers vertically to use the shared height
+ instead of leaving dead space pinned to the top. */
+}
+.panel {
+ background: var(--card-bg);
+ border: 1px solid var(--grid);
+ border-radius: var(--radius);
+ padding: 16px;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+.panel-body { flex: 1; display: flex; flex-direction: column; }
+/* A donut is a small, fixed-size (200x200) chart — it never grows to fill a
+ tall sibling's height. When the row stretches this panel to match, center
+ the donut in the extra space so it reads as balanced padding, not an
+ empty gap under a chart pinned to the top. */
+.panel-body:has(> .donut-wrap) { justify-content: center; }
+/* Same reasoning for a short table: a one-row result in a panel stretched to
+ match a tall sibling otherwise leaves ~390px of blank card below it, which
+ reads as a panel that failed to load. Centering only takes effect when there
+ is free space, so tall tables are unaffected. */
+.panel-body:has(> .table-scroll) { justify-content: center; }
+.panel.col-12 { grid-column: span 12; }
+.panel.col-9 { grid-column: span 9; }
+.panel.col-8 { grid-column: span 8; }
+.panel.col-7 { grid-column: span 7; }
+.panel.col-6 { grid-column: span 6; }
+.panel.col-5 { grid-column: span 5; }
+.panel.col-4 { grid-column: span 4; }
+.panel.col-3 { grid-column: span 3; }
+@media (max-width: 900px) {
+ .panel.col-9, .panel.col-8, .panel.col-7,
+ .panel.col-6, .panel.col-5, .panel.col-4,
+ .panel.col-3 { grid-column: span 12; }
+}
+.panel h3 {
+ margin: 0 0 2px;
+ font-size: 13.5px;
+ font-weight: 600;
+}
+.panel .panel-sub {
+ margin: 0 0 12px;
+ font-size: 11.5px;
+ color: var(--muted);
+}
+
+/* ---------- charts ---------- */
+svg { display: block; width: 100%; overflow: visible; }
+.axis-label, .tick { fill: var(--muted); font-size: 11px; }
+.grid-line { stroke: var(--grid); stroke-width: 1; }
+
+.legend { display: flex; flex-wrap: wrap; gap: 10px 16px; margin-top: 10px; }
+.legend .item { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
+.legend .swatch { width: 10px; height: 10px; border-radius: 3px; flex: none; }
+.legend .lv { color: var(--muted); font-variant-numeric: tabular-nums; }
+
+/* ---------- "missing data" convention (untagged / unclassified / blank) ---------- */
+/* Shared muted/dashed treatment so a placeholder never looks like a real
+ category rendered in a rotating palette color (donut legends, hbar
+ legends, and inline table swatches all route through this class). */
+.swatch--unknown {
+ border: 1.5px dashed var(--muted);
+ background: transparent !important;
+}
+
+.donut-wrap { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
+/* donut()'s viewBox is a fixed 1:1 square (180x180) — without a cap, the
+ generic `svg { width: 100% }` rule scales it to fill the whole panel
+ width, producing an oversized ring and (via grid row-stretch) dragging
+ sibling panels in the same row up to match its inflated height. */
+.donut-wrap svg { width: 200px; height: 200px; max-width: 100%; flex: none; }
+.donut-center .big { font-size: 18px; font-weight: 700; letter-spacing: -0.02em; }
+.donut-center .small { font-size: 11px; fill: var(--muted); }
+
+.hbar-row { font-variant-numeric: tabular-nums; }
+.hbar-row .name { fill: var(--text-color-default, #1f2328); font-size: 12px; }
+.hbar-row .val { fill: var(--muted); font-size: 11.5px; }
+.bar:hover, .arc:hover { opacity: 0.82; cursor: default; }
+/* Truncated labels: dotted underline hints there's more text on hover
+ (the tooltip already carries the full value). */
+.name--truncated { text-decoration: underline dotted; text-decoration-color: var(--muted); text-underline-offset: 2px; }
+td.truncate-hint,
+.truncate-hint { text-decoration: underline dotted; text-decoration-color: var(--muted); text-underline-offset: 2px; cursor: help; }
+
+/* ---------- data table ---------- */
+.dtable { width: 100%; border-collapse: collapse; font-size: 12.5px; font-variant-numeric: tabular-nums; }
+.dtable th, .dtable td { padding: 9px 10px; text-align: right; border-bottom: 1px solid var(--grid); white-space: nowrap; }
+.dtable th:first-child, .dtable td:first-child { text-align: left; }
+.dtable thead th { color: var(--muted); font-weight: 600; font-size: 11px; }
+.dtable tbody tr:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
+.dtable .model { display: inline-flex; align-items: center; gap: 7px; font-weight: 500; }
+/* Swatches are bare s at some call sites, where the default display:inline
+ would drop width/height entirely and render nothing. Pin the box explicitly so
+ the chip shows whether or not it sits inside an inline-flex .model wrapper. */
+.dtable .swatch { display: inline-block; vertical-align: middle; width: 9px; height: 9px; border-radius: 3px; flex: none; }
+.dtable .barcell { position: relative; }
+.dtable .minibar { display: inline-block; height: 7px; border-radius: 3px; vertical-align: middle; margin-left: 6px; }
+
+/* ---------- states ---------- */
+.error {
+ padding: 40px;
+ text-align: center;
+ color: var(--muted);
+ border: 1px solid var(--grid);
+ border-radius: var(--radius);
+ background: var(--card-bg);
+ max-width: 640px;
+ margin: 40px auto;
+}
+.error h2 { color: var(--neg); margin: 0 0 8px; font-size: 16px; }
+.error code {
+ font-family: var(--font-mono, ui-monospace, monospace);
+ font-size: 12px;
+ background: color-mix(in srgb, var(--muted) 14%, transparent);
+ padding: 2px 6px;
+ border-radius: 5px;
+}
+.error pre {
+ text-align: left;
+ white-space: pre-wrap;
+ font-size: 12px;
+ background: color-mix(in srgb, var(--muted) 10%, transparent);
+ padding: 10px 12px;
+ border-radius: 8px;
+ margin-top: 14px;
+}
+.error-action {
+ margin: 14px 0;
+ text-align: left;
+}
+.error-cmd {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: color-mix(in srgb, var(--muted) 10%, transparent);
+ border: 1px solid var(--grid);
+ border-radius: 8px;
+ padding: 8px 12px;
+ margin: 6px 0;
+}
+.error-cmd code {
+ flex: 1;
+ background: transparent;
+ padding: 0;
+}
+.error-detail { margin-top: 14px; }
+
+/* ---------- diagnostic rail ---------- */
+.diagnostic-rail {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 24px;
+ max-height: 24px;
+ overflow: hidden;
+ font-size: 11.5px;
+ color: var(--muted);
+ padding: 0 2px;
+ margin-top: 12px;
+ font-variant-numeric: tabular-nums;
+}
+.rail-sep { opacity: 0.45; user-select: none; }
+.rail-health--ok { color: var(--pos); }
+.rail-health--warn { color: var(--warn); }
+.rail-health--error { color: var(--neg); }
+.rail-time { cursor: default; }
+
+/* ---------- footer ---------- */
+.app-footer {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-top: 26px;
+ padding-top: 14px;
+ border-top: 1px solid var(--grid);
+ font-size: 11.5px;
+ color: var(--muted);
+}
+.app-footer #footer-meta { font-variant-numeric: tabular-nums; }
+.app-footer[hidden] { display: none; }
+
+.spin { animation: spin 0.8s linear infinite; display: inline-block; }
+@keyframes spin { to { transform: rotate(360deg); } }
+
+/* ---------- accessibility ---------- */
+*:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ border-radius: 3px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .spin { animation: none; }
+}
+
+/* ---------- panel-header (KQL escape hatch) ---------- */
+.panel-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 10px;
+}
+.panel-header > div { min-width: 0; }
+.panel-header h3 { margin-bottom: 2px; }
+.kql-btn {
+ flex: none;
+ background: transparent;
+ border: 1px solid var(--grid);
+ border-radius: 4px;
+ color: var(--muted);
+ cursor: pointer;
+ font: 600 10px/1 ui-monospace, monospace;
+ padding: 4px 7px;
+ opacity: 0.85;
+ transition: opacity 0.15s, background 0.15s, color 0.15s;
+}
+.kql-btn:hover {
+ opacity: 1;
+ background: color-mix(in srgb, var(--accent) 8%, transparent);
+ color: var(--accent);
+ border-color: var(--accent);
+}
+
+/* ---------- KQL dialog ---------- */
+#kql-dialog {
+ border: 1px solid var(--grid);
+ border-radius: var(--radius);
+ background: var(--card-bg);
+ color: inherit;
+ padding: 0;
+ width: min(780px, 96vw);
+}
+#kql-dialog::backdrop {
+ background: rgba(0, 0, 0, 0.55);
+ backdrop-filter: blur(2px);
+}
+.kql-dialog-inner {
+ display: flex;
+ flex-direction: column;
+ max-height: 90vh;
+}
+.kql-dialog-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 18px 12px;
+ border-bottom: 1px solid var(--grid);
+ gap: 12px;
+}
+.kql-dialog-header h3 { margin: 0; font-size: 14px; }
+#kql-text {
+ flex: 1;
+ border: none;
+ border-bottom: 1px solid var(--grid);
+ background: transparent;
+ color: inherit;
+ font: 12px/1.55 ui-monospace, Menlo, Consolas, monospace;
+ padding: 14px 18px;
+ resize: vertical;
+ min-height: 200px;
+ white-space: pre;
+ overflow-wrap: normal;
+ overflow-x: auto;
+}
+.kql-dialog-error {
+ margin: 0;
+ padding: 6px 18px 0;
+ font-size: 11.5px;
+ color: var(--neg);
+ min-height: 22px;
+}
+.kql-dialog-footer {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 10px 18px;
+}
+.btn-primary {
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent);
+}
+.btn-primary:hover {
+ background: color-mix(in srgb, var(--accent) 85%, #000);
+}
+
+/* ---------- Settings dialog ---------- */
+#settings-dialog {
+ border: 1px solid var(--grid);
+ border-radius: var(--radius);
+ background: var(--card-bg);
+ color: inherit;
+ padding: 0;
+ width: min(440px, 96vw);
+}
+#settings-dialog::backdrop {
+ background: rgba(0, 0, 0, 0.55);
+ backdrop-filter: blur(2px);
+}
+.settings-body { padding: 4px 18px 6px; display: flex; flex-direction: column; gap: 12px; }
+.settings-row { display: flex; flex-direction: column; gap: 4px; font-size: 12px; }
+.settings-row input {
+ border: 1px solid var(--grid);
+ border-radius: 6px;
+ background: transparent;
+ color: inherit;
+ font: 13px/1.4 ui-monospace, Menlo, Consolas, monospace;
+ padding: 7px 9px;
+}
+.settings-row input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
+.settings-hint { margin: 0; font-size: 11.5px; color: var(--muted); }
+
+/* ---------- KQL result table ---------- */
+.kql-result {
+ border-top: 1px solid var(--grid);
+ overflow: hidden;
+}
+.kql-result-meta {
+ margin: 0;
+ padding: 6px 18px;
+ font-size: 11.5px;
+ color: var(--muted);
+}
+.kql-result-scroll {
+ overflow-x: auto;
+ padding: 0 18px 14px;
+ max-height: 260px;
+ overflow-y: auto;
+}
+
+
+/* ---------- filter bar ---------- */
+.filter-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 20px;
+ margin: -12px 0 14px;
+ background: color-mix(in srgb, var(--accent) 7%, transparent);
+ border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
+ border-radius: 10px;
+ flex-wrap: wrap;
+}
+.filter-label {
+ font-size: 11.5px;
+ font-weight: 600;
+ color: var(--muted);
+ white-space: nowrap;
+}
+.filter-chips {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ flex: 1;
+}
+.filter-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 1px;
+ background: color-mix(in srgb, var(--accent) 14%, var(--card-bg));
+ border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
+ border-radius: 20px;
+ padding: 2px 2px 2px 10px;
+ font-size: 12px;
+ color: var(--text-color-default, #1f2328);
+ line-height: 1.5;
+}
+.filter-chip strong { font-weight: 600; }
+.chip-label { display: flex; gap: 4px; align-items: baseline; }
+.chip-remove {
+ all: unset;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ color: var(--muted);
+ font-size: 13px;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ margin-left: 2px;
+}
+.chip-remove:hover {
+ background: color-mix(in srgb, var(--muted) 18%, transparent);
+ color: var(--text-color-default, #1f2328);
+}
+.filter-reset {
+ appearance: none;
+ background: transparent;
+ border: 1px solid var(--grid);
+ border-radius: 6px;
+ cursor: pointer;
+ color: var(--muted);
+ font-size: 11.5px;
+ font-weight: 600;
+ padding: 3px 10px;
+ white-space: nowrap;
+}
+.filter-reset:hover {
+ border-color: color-mix(in srgb, var(--muted) 70%, transparent);
+ color: var(--text-color-default, #1f2328);
+}
+
+/* ---------- interactive chart elements ---------- */
+svg .hbar-filterable { cursor: pointer; }
+svg .hbar-filterable:hover rect.hbar { filter: brightness(1.12); }
+svg .hbar-filterable:hover text.name { fill: var(--accent); }
+svg .hbar-dimmed { opacity: 0.25; }
+svg .hbar-selected rect.hbar { stroke: var(--text-color-default, #1f2328); stroke-width: 1.5; stroke-opacity: 0.35; }
+svg .hbar-selected text.name { font-weight: 700; fill: var(--accent); }
+
+@media (prefers-reduced-motion: no-preference) {
+ svg .hbar-dimmed { transition: opacity 0.12s ease-out; }
+}
+
+/* ---------- filter bar show/hide transition ---------- */
+.filter-bar {
+ transition: opacity 0.15s ease-out, transform 0.15s ease-out;
+}
+.filter-bar[hidden] {
+ display: none !important;
+}
+
+/* ---------- loading skeleton ---------- */
+@keyframes shimmer {
+ 0% { background-position: -600px 0; }
+ 100% { background-position: 600px 0; }
+}
+.skeleton-card {
+ background: linear-gradient(
+ 90deg,
+ var(--grid) 25%,
+ color-mix(in srgb, var(--grid) 40%, transparent) 50%,
+ var(--grid) 75%
+ );
+ background-size: 1200px 100%;
+ animation: shimmer 1.4s linear infinite;
+ border-radius: var(--radius);
+ border: 1px solid var(--grid);
+}
+.skeleton-kpi-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: var(--gap);
+ margin-bottom: var(--gap);
+}
+.skeleton-kpi-grid .skeleton-card { height: 88px; }
+.skeleton-panel-lg { height: 220px; margin-bottom: var(--gap); }
+.skeleton-panel-sm { height: 160px; margin-bottom: var(--gap); }
+@media (prefers-reduced-motion: reduce) {
+ .skeleton-card { animation: none; }
+}
+
+/* ---------- experimental tool tabs (query editor) ---------- */
+.tool-panel { display: flex; flex-direction: column; gap: 12px; }
+.tool-banner {
+ background: color-mix(in srgb, var(--warn) 12%, transparent);
+ border: 1px solid color-mix(in srgb, var(--warn) 35%, transparent);
+ border-radius: var(--radius);
+ padding: 10px 14px;
+ font-size: 12.5px;
+ line-height: 1.5;
+ color: var(--text-color-default, inherit);
+}
+.tool-banner code { font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; }
+.tool-toolbar { display: flex; align-items: center; gap: 12px; }
+.tool-status { font-size: 12px; color: var(--muted); }
+/* Fixed height (not viewport-relative) so the editor doesn't crowd out the
+ result table below it -- this dashboard's other panels are all bounded,
+ normal-flow blocks (see .kql-result-scroll, .skeleton-panel-lg), and the
+ whole page scrolls rather than any one panel filling the viewport. */
+.monaco-host {
+ height: 360px;
+ border: 1px solid var(--grid);
+ border-radius: var(--radius);
+ overflow: hidden;
+}
+.monaco-fallback {
+ width: 100%;
+ height: 360px;
+ box-sizing: border-box;
+ font-family: var(--font-mono, ui-monospace, monospace);
+ font-size: 12.5px;
+ border: 1px solid var(--grid);
+ border-radius: var(--radius);
+ padding: 10px 12px;
+ background: var(--card-bg);
+ color: inherit;
+ resize: vertical;
+}
+
+/* ---------- capacity workspace ---------- */
+.capacity-tabs {
+ display: flex;
+ gap: 6px;
+ margin: 0 0 16px;
+ padding: 2px 0 8px;
+ overflow-x: auto;
+ scrollbar-width: thin;
+}
+.capacity-tabs button,
+.capacity-link {
+ appearance: none;
+ min-height: 44px;
+ border: 1px solid var(--grid);
+ border-radius: 9px;
+ background: var(--card-bg);
+ color: var(--text-color-default, #1f2328);
+ padding: 9px 12px;
+ font: inherit;
+ font-size: 12.5px;
+ font-weight: 600;
+ white-space: nowrap;
+ cursor: pointer;
+}
+.capacity-tabs button[aria-selected="true"] {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 12%, var(--card-bg));
+ color: var(--accent);
+}
+.capacity-tabs button:hover,
+.capacity-link:hover {
+ border-color: color-mix(in srgb, var(--accent) 65%, var(--grid));
+ background: color-mix(in srgb, var(--accent) 8%, var(--card-bg));
+}
+.capacity-header,
+.capacity-home-intro {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 18px;
+ margin: 0 0 18px;
+}
+.capacity-header h2,
+.capacity-home-intro h2 { margin: 0 0 5px; font-size: 20px; }
+.capacity-header p,
+.capacity-home-intro p { margin: 0; max-width: 76ch; color: var(--muted); }
+.capacity-badge {
+ flex: none;
+ border: 1px solid var(--grid);
+ border-radius: 999px;
+ padding: 5px 10px;
+ color: var(--muted);
+ font: 600 11px/1.3 var(--font-mono, ui-monospace, monospace);
+ white-space: nowrap;
+}
+.capacity-definition {
+ padding: 12px 14px;
+ border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--grid));
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--accent) 6%, transparent);
+ color: var(--text-color-default, #1f2328);
+}
+.capacity-definition p { margin: 0; }
+.capacity-selectors {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
+ gap: 12px;
+ margin: 0 0 18px;
+}
+.capacity-selector {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 600;
+}
+.capacity-selector select {
+ width: 100%;
+ min-height: 44px;
+ border: 1px solid var(--grid);
+ border-radius: 8px;
+ background: var(--card-bg);
+ color: var(--text-color-default, #1f2328);
+ padding: 8px 10px;
+ font: inherit;
+}
+.capacity-notice {
+ border: 1px solid var(--grid);
+ border-radius: 8px;
+ padding: 10px 12px;
+ margin: 0 0 12px;
+ color: var(--muted);
+ background: color-mix(in srgb, var(--muted) 5%, transparent);
+}
+.capacity-notice strong { color: var(--text-color-default, #1f2328); }
+.capacity-notice--warning {
+ border-color: color-mix(in srgb, var(--warn) 55%, var(--grid));
+ background: color-mix(in srgb, var(--warn) 9%, transparent);
+}
+.capacity-notice--error {
+ border-color: color-mix(in srgb, var(--neg) 55%, var(--grid));
+ background: color-mix(in srgb, var(--neg) 8%, transparent);
+}
+.capacity-table-scroll,
+.table-scroll {
+ width: 100%;
+ overflow-x: auto;
+ overscroll-behavior-inline: contain;
+ /* Overlay scrollbars leave a scrollable table with no visible cue, so a
+ clipped column reads as corrupted data ("$13,3") rather than as more
+ content to the right. The right-hand shadow is painted with
+ background-attachment: scroll while the masking gradient uses local, so it
+ appears only while there is further to scroll and clears at the end. */
+ background:
+ linear-gradient(to right, var(--card-bg) 30%, transparent) left center,
+ linear-gradient(to left, var(--card-bg) 30%, transparent) right center,
+ radial-gradient(farthest-side at 0 50%, color-mix(in srgb, var(--muted) 38%, transparent), transparent) left center,
+ radial-gradient(farthest-side at 100% 50%, color-mix(in srgb, var(--muted) 38%, transparent), transparent) right center;
+ background-repeat: no-repeat;
+ background-size: 34px 100%, 34px 100%, 12px 100%, 12px 100%;
+ background-attachment: local, local, scroll, scroll;
+ /* Keep a real scrollbar rather than relying on the overlay one. */
+ scrollbar-width: thin;
+ scrollbar-color: var(--border-muted) transparent;
+ padding-bottom: 2px;
+}
+.capacity-table-scroll::-webkit-scrollbar,
+.table-scroll::-webkit-scrollbar { height: 8px; }
+.capacity-table-scroll::-webkit-scrollbar-track,
+.table-scroll::-webkit-scrollbar-track { background: transparent; }
+.capacity-table-scroll::-webkit-scrollbar-thumb,
+.table-scroll::-webkit-scrollbar-thumb {
+ background: var(--border-muted);
+ border-radius: 4px;
+}
+.capacity-table-scroll::-webkit-scrollbar-thumb:hover,
+.table-scroll::-webkit-scrollbar-thumb:hover { background: var(--muted); }
+.capacity-table-scroll .dtable th:first-child,
+.capacity-table-scroll .dtable td:first-child,
+.table-scroll .dtable th:first-child,
+.table-scroll .dtable td:first-child,
+.capacity-heatmap th:first-child {
+ position: sticky;
+ left: 0;
+ background: var(--card-bg);
+ z-index: 1;
+}
+.capacity-state {
+ display: inline-flex;
+ align-items: center;
+ min-height: 24px;
+ border: 1px solid var(--grid);
+ border-radius: 999px;
+ padding: 2px 8px;
+ font-size: 11px;
+ font-weight: 600;
+}
+.capacity-state--healthy { border-color: var(--pos); }
+.capacity-state--watch,
+.capacity-state--action { border-color: var(--warn); }
+.capacity-state--exhausted,
+.capacity-state--invalid,
+.capacity-state--stale { border-color: var(--neg); }
+.capacity-state--unclassified,
+.capacity-state--inventory,
+.capacity-state--no-entitlement,
+.capacity-state--restricted,
+.capacity-state--missing { border-style: dashed; }
+.capacity-reason,
+.capacity-source-note {
+ display: block;
+ margin-top: 4px;
+ color: var(--muted);
+ font-size: 11px;
+ white-space: normal;
+}
+.capacity-layout {
+ display: grid;
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+ gap: var(--gap);
+}
+.capacity-panel {
+ grid-column: span 6;
+ min-width: 0;
+ border: 1px solid var(--grid);
+ border-radius: var(--radius);
+ background: var(--card-bg);
+ padding: 16px;
+}
+.capacity-panel:first-child,
+.capacity-panel:last-child { grid-column: span 12; }
+.capacity-panel--wide { grid-column: span 12; }
+.capacity-panel header h3 { margin: 0; font-size: 13.5px; }
+.capacity-panel header p { margin: 3px 0 12px; color: var(--muted); font-size: 11.5px; }
+.capacity-heatmap {
+ width: 100%;
+ border-spacing: 6px;
+ border-collapse: separate;
+ font-size: 12px;
+}
+.capacity-heatmap caption {
+ text-align: left;
+ color: var(--muted);
+ margin-bottom: 8px;
+}
+.capacity-heatmap th {
+ min-width: 120px;
+ padding: 7px;
+ text-align: left;
+ color: var(--muted);
+ font-size: 11px;
+}
+.capacity-cell {
+ min-width: 120px;
+ border: 1px solid var(--grid);
+ border-radius: 8px;
+ padding: 9px;
+ background: color-mix(in srgb, var(--muted) 5%, var(--card-bg));
+}
+.capacity-cell strong,
+.capacity-cell span { display: block; overflow-wrap: anywhere; }
+.capacity-cell span { margin-top: 3px; color: var(--muted); font-size: 11px; }
+.capacity-cell.capacity-state--healthy { border-color: var(--pos); }
+.capacity-cell.capacity-state--watch,
+.capacity-cell.capacity-state--action { border-color: var(--warn); }
+.capacity-cell.capacity-state--exhausted,
+.capacity-cell.capacity-state--invalid { border-color: var(--neg); }
+.capacity-cell.capacity-state--restricted { border-color: var(--neg); border-style: dashed; }
+.capacity-cell.capacity-state--no-entitlement { border-style: dashed; }
+
+.capacity-cell-flag {
+ display: block;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--neg);
+}
+
+.capacity-cell-detail {
+ display: block;
+ font-size: 11px;
+ color: var(--muted);
+}
+
+.capacity-heatmap--family th:first-child { min-width: 200px; }
+
+/* Family matrix: a bounded viewport so both scrollbars stay pinned to the panel
+ edge. Without the height cap the table grows past the window and the
+ horizontal bar sits thousands of pixels below the fold. */
+.capacity-matrix {
+ max-height: min(70vh, 620px);
+ overflow: auto;
+ overscroll-behavior: contain;
+ scrollbar-gutter: stable;
+ border: 1px solid var(--grid);
+ border-radius: var(--radius);
+ scrollbar-width: auto;
+ scrollbar-color: color-mix(in srgb, var(--muted) 45%, transparent) transparent;
+}
+/* macOS overlay scrollbars fade out when idle. The matrix has to advertise its
+ own scroll range, so the bars are drawn permanently. */
+.capacity-matrix::-webkit-scrollbar { width: 12px; height: 12px; }
+.capacity-matrix::-webkit-scrollbar-track {
+ background: color-mix(in srgb, var(--muted) 10%, transparent);
+}
+.capacity-matrix::-webkit-scrollbar-thumb {
+ background: color-mix(in srgb, var(--muted) 45%, transparent);
+ border: 3px solid var(--card-bg);
+ border-radius: 999px;
+}
+.capacity-matrix::-webkit-scrollbar-thumb:hover {
+ background: color-mix(in srgb, var(--muted) 70%, transparent);
+}
+.capacity-matrix::-webkit-scrollbar-corner { background: var(--card-bg); }
+
+/* Frozen panes. Gapless cells keep the sticky edges opaque and raise density. */
+.capacity-heatmap--family { border-spacing: 0; width: auto; }
+.capacity-heatmap--family thead th {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ background: var(--card-bg);
+ border-bottom: 1px solid var(--grid);
+ white-space: nowrap;
+}
+.capacity-heatmap--family tbody th:first-child {
+ position: sticky;
+ left: 0;
+ z-index: 1;
+ background: var(--card-bg);
+ border-right: 1px solid var(--grid);
+ border-bottom: 1px solid var(--grid);
+}
+.capacity-heatmap--family thead th:first-child {
+ z-index: 3;
+ border-right: 1px solid var(--grid);
+}
+.capacity-heatmap--family .capacity-cell {
+ border-radius: 0;
+ border-width: 0 1px 1px 0;
+ border-style: solid;
+ border-color: var(--grid);
+}
+/* Supply is the left bar: whether a deployment can land here at all. */
+.capacity-heatmap--family .capacity-supply--open { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--pos) 55%, transparent); }
+.capacity-heatmap--family .capacity-supply--partial { box-shadow: inset 3px 0 0 var(--warn); }
+.capacity-heatmap--family .capacity-supply--blocked {
+ box-shadow: inset 3px 0 0 var(--neg);
+ background: color-mix(in srgb, var(--neg) 7%, var(--card-bg));
+}
+.capacity-heatmap--family .capacity-supply--none { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--muted) 35%, transparent); }
+
+/* Demand is the number: how close this is to your own quota. */
+.capacity-heatmap--family .capacity-cell strong { font-variant-numeric: tabular-nums; }
+.capacity-demand--over strong { color: var(--neg-ink); font-weight: 700; }
+.capacity-demand--exhausted strong {
+ color: var(--neg-ink);
+ font-weight: 700;
+ text-decoration: underline;
+ text-decoration-thickness: 2px;
+ text-underline-offset: 2px;
+}
+.capacity-cell-mark { color: var(--neg-ink); font-weight: 600; }
+
+.capacity-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 28px;
+ margin: 0 0 10px;
+ font-size: 11px;
+ color: var(--muted);
+}
+.capacity-legend-group { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; }
+.capacity-legend-title { font-weight: 600; color: var(--text-color-default, #1f2328); }
+.capacity-legend ul { display: flex; flex-wrap: wrap; gap: 4px 12px; margin: 0; padding: 0; list-style: none; }
+.capacity-legend li { display: flex; align-items: center; gap: 5px; }
+.capacity-legend-bar { width: 3px; height: 12px; border-radius: 1px; background: var(--grid); }
+.capacity-legend-bar--open { background: color-mix(in srgb, var(--pos) 55%, transparent); }
+.capacity-legend-bar--partial { background: var(--warn); }
+.capacity-legend-bar--blocked { background: var(--neg); }
+.capacity-legend-bar--none { background: color-mix(in srgb, var(--muted) 35%, transparent); }
+.capacity-legend-ink { font-variant-numeric: tabular-nums; font-size: 11px; }
+.capacity-legend-ink.capacity-demand--under { color: var(--text-color-default, #1f2328); }
+.capacity-legend-ink.capacity-demand--over,
+.capacity-legend-ink.capacity-demand--exhausted { color: var(--neg-ink); font-weight: 700; }
+.capacity-legend-ink.capacity-demand--exhausted { text-decoration: underline; text-decoration-thickness: 2px; }
+
+.capacity-matrix-note {
+ margin: 0 0 8px;
+ color: var(--muted);
+ font-size: 11.5px;
+}
+
+/* Filter bar. 183 families by 19 regions is a lookup problem, not a browse
+ problem, so the reducer sits above the matrix rather than inside it. */
+.capacity-filters {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 10px 16px;
+ margin-bottom: 12px;
+}
+.capacity-filter-group {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ min-width: 0;
+}
+.capacity-filter-label {
+ color: var(--muted);
+ font-size: 10.5px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+.capacity-segments { display: flex; flex-wrap: wrap; gap: 4px; }
+/* Nineteen region chips wrap into a wall on a narrow panel and push the matrix
+ off screen. The group scrolls instead of growing. */
+.capacity-segments--regions {
+ max-height: 74px;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ scrollbar-width: thin;
+}
+.capacity-segment {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 6px;
+ min-height: 28px;
+ padding: 4px 10px;
+ border: 1px solid var(--grid);
+ border-radius: 999px;
+ background: transparent;
+ color: var(--text);
+ font: inherit;
+ font-size: 11.5px;
+ cursor: pointer;
+}
+.capacity-segment:hover { border-color: var(--muted); }
+.capacity-segment[aria-checked="true"],
+.capacity-segment[aria-pressed="true"],
+.capacity-segment[aria-selected="true"] {
+ border-color: var(--accent, var(--text));
+ background: color-mix(in srgb, var(--muted) 14%, transparent);
+ font-weight: 600;
+}
+.capacity-segment .capacity-segment-count {
+ color: var(--muted);
+ font-size: 10.5px;
+ font-variant-numeric: tabular-nums;
+}
+.capacity-segment[aria-checked="true"] .capacity-segment-count,
+.capacity-segment[aria-pressed="true"] .capacity-segment-count,
+.capacity-segment[aria-selected="true"] .capacity-segment-count { color: inherit; }
+.capacity-segment:focus-visible,
+.capacity-filter-search:focus-visible { outline: 2px solid var(--accent, var(--text)); outline-offset: 2px; }
+.capacity-filter-search {
+ min-height: 28px;
+ min-width: 190px;
+ padding: 4px 9px;
+ border: 1px solid var(--grid);
+ border-radius: 6px;
+ background: var(--card-bg);
+ color: var(--text);
+ font: inherit;
+ font-size: 12px;
+}
+.capacity-filter-summary {
+ margin-left: auto;
+ color: var(--muted);
+ font-size: 11.5px;
+ font-variant-numeric: tabular-nums;
+}
+.capacity-filter-reset {
+ min-height: 28px;
+ padding: 4px 10px;
+ border: 1px solid var(--grid);
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text);
+ font: inherit;
+ font-size: 11.5px;
+ cursor: pointer;
+}
+.capacity-filter-reset:hover { border-color: var(--muted); }
+.capacity-filter-reset:disabled { opacity: 0.45; cursor: not-allowed; }
+
+.capacity-detail-tabs {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 12px;
+}
+.capacity-detail-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 8px 16px;
+ margin-bottom: 10px;
+}
+.capacity-detail-hint,
+.capacity-detail-summary,
+.capacity-detail-pagination {
+ color: var(--muted);
+ font-size: 11.5px;
+}
+.capacity-detail-summary { margin-bottom: 8px; }
+.capacity-detail-pagination {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 10px;
+ font-variant-numeric: tabular-nums;
+}
+
+@media (max-width: 900px) {
+ .capacity-panel { grid-column: span 12; }
+}
+@media (max-width: 640px) {
+ body { padding-inline: 12px; }
+ .capacity-header,
+ .capacity-home-intro { flex-direction: column; }
+ .capacity-badge { white-space: normal; }
+ .capacity-tabs button { white-space: normal; min-width: 132px; }
+}
diff --git a/.github/extensions/ftk-local-dashboard/public/app.js b/.github/extensions/ftk-local-dashboard/public/app.js
new file mode 100644
index 000000000..e8271c52d
--- /dev/null
+++ b/.github/extensions/ftk-local-dashboard/public/app.js
@@ -0,0 +1,3442 @@
+/* FinOps hub dashboard — client renderer.
+ Dependency-free: KPIs + SVG charts (line, horizontal bar, donut).
+ Data comes from the extension's loopback /api endpoints. */
+
+"use strict";
+
+const PALETTE = [
+ "#3b82f6", "#10b981", "#8b5cf6", "#f59e0b", "#ef4444", "#06b6d4",
+ "#ec4899", "#84cc16", "#f97316", "#6366f1", "#14b8a6", "#a855f7",
+];
+
+// Shared "missing data" color: muted, never a rotating palette hue, so
+// Untagged/Unclassified/blank slices read as "no data" consistently across
+// every tab instead of looking like an ordinary category.
+const UNKNOWN_COLOR = "var(--muted)";
+
+export const CAPACITY_TABS = Object.freeze([
+ { id: "home", label: "Home" },
+ { id: "app-service", label: "App Service" },
+ { id: "azure-ai", label: "Azure AI" },
+ { id: "compute", label: "Compute" },
+ { id: "azure-sql", label: "Azure SQL" },
+ { id: "storage", label: "Storage" },
+ { id: "capacity-reservations", label: "Capacity reservations" },
+ { id: "premium-ssd-v2", label: "Premium SSD v2" },
+]);
+
+const state = {
+ preset: "all",
+ tab: "overview",
+ loading: false,
+ cache: {},
+ filters: {},
+ capacityClass: "home",
+ capacitySelections: {},
+ familyFilter: { status: "in-use", search: "", regions: [], mark: 70 },
+ capacityDetailTab: "families",
+ capacityFamilyPage: 1,
+ capacitySubscriptionSearch: "",
+ capacitySubscriptionPage: 1,
+ capacitySubscriptionData: null,
+ capacitySubscriptionLoading: false,
+ capacitySubscriptionError: null,
+ revision: 0,
+};
+const queryState = { rows: 0, health: "ok", refreshedAt: null, dataset: "Hub database" };
+
+/** Human-readable labels for filter dimensions (used in chips). */
+const FILTER_LABELS = {
+ ServiceName: "Service",
+ ServiceCategory: "Category",
+ RegionId: "Region",
+ x_ResourceGroupName: "Resource group",
+ SubAccountName: "Subscription",
+ CommitmentDiscountName: "Commitment",
+ x_SkuMeterSubcategory: "Meter",
+};
+
+/* ----------------------------------------------------------------- KQL templates */
+
+const PERIOD = "| where ChargePeriodStart >= datetime({start}) and ChargePeriodStart < datetime({end})";
+const NON_PURCH = "| where not(ChargeCategory == 'Purchase' and isnotempty(CommitmentDiscountCategory))";
+const AI_SCOPE = "| where x_SkuMeterSubcategory has 'OpenAI' and x_SkuDescription contains 'Token'";
+
+/* eslint-disable max-len */
+const PANEL_KQL = {
+ "overview-trend": ["Costs()", PERIOD, "| summarize Billed=sum(BilledCost), Effective=sum(EffectiveCost)", " by Month=format_datetime(startofmonth(ChargePeriodStart),'yyyy-MM')", "| order by Month asc"].join("\n"),
+ "overview-top-services": ["Costs()", PERIOD, "| summarize Cost=sum(EffectiveCost) by ServiceName", "| top 10 by Cost desc"].join("\n"),
+ "overview-service-category": ["Costs()", PERIOD, "| summarize Cost=sum(EffectiveCost) by ServiceCategory", "| where Cost > 0 | order by Cost desc"].join("\n"),
+ "overview-top-rgs": ["Costs()", PERIOD, "| where isnotempty(x_ResourceGroupName)", "| summarize Cost=sum(EffectiveCost) by x_ResourceGroupName", "| top 10 by Cost desc"].join("\n"),
+ "overview-top-regions": ["Costs()", PERIOD, "| where isnotempty(RegionId)", "| summarize Cost=sum(EffectiveCost) by RegionId", "| top 12 by Cost desc"].join("\n"),
+ "overview-rate-coverage": ["Costs()", PERIOD, "| summarize Cost=sum(EffectiveCost) by PricingCategory"].join("\n"),
+ "overview-savings": ["Costs()", PERIOD, NON_PURCH, "| extend neg=iff(ListCost=s and ChargePeriodStart 100 | extend PerCore=Eff/CH | top 10 by Eff desc"].join("\n"),
+ "usage-storage-tiers": ["Costs()", PERIOD, "| where ServiceCategory=='Storage' and ChargeCategory=='Usage'", "| extend Tier=case(x_SkuTier has_any('Hot','Standard','Premium'),'Frequent',x_SkuTier has_any('Cool','Cold','Archive'),'Infrequent','Unclassified')", "| summarize Cost=sum(EffectiveCost) by Tier", "| where Cost > 0 | order by Cost desc"].join("\n"),
+ "rate-savings": ["Costs()", PERIOD, NON_PURCH, "| extend neg=iff(ListCost0, cores*ConsumedQuantity, toreal(''))", "| extend t=iff(isempty(CommitmentDiscountType),'On Demand',CommitmentDiscountType)", "| summarize CoreHours=sum(ch) by t", "| where CoreHours > 0 | order by CoreHours desc"].join("\n"),
+ "rate-underutil": ["Costs()", PERIOD, "| where isnotempty(CommitmentDiscountName)", NON_PURCH, "| summarize Unused=sumif(EffectiveCost,CommitmentDiscountStatus=='Unused'), Total=sum(EffectiveCost) by CommitmentDiscountName", "| where Unused > 0 | top 10 by Unused desc"].join("\n"),
+ "alloc-hierarchy": ["Costs()", PERIOD, "| extend Org=tostring(Tags['org']), Project=tostring(Tags['Project']), Env=tostring(Tags['env'])", "| summarize Cost=sum(EffectiveCost) by Org, Project, Env", "| where Cost > 0 | top 12 by Cost desc"].join("\n"),
+ "alloc-tagging": ["Costs()", PERIOD, "| extend _t=iff(isnull(Tags) or array_length(bag_keys(Tags))==0,'Untagged','Tagged')", "| summarize Cost=sum(EffectiveCost) by _t"].join("\n"),
+ "alloc-tag-keys": ["Costs()", PERIOD, "| mv-expand k=bag_keys(Tags) to typeof(string)", "| where isnotempty(k) and k !in ('ftk-tool','ftk-version','cm-resource-parent','costanalysis-parent') and not(k startswith 'aks-managed-')", "| summarize Cost=sum(EffectiveCost) by k", "| top 12 by Cost desc"].join("\n"),
+ "alloc-by-subscription": ["Costs()", PERIOD, "| where isnotempty(SubAccountName)", "| summarize Cost=sum(EffectiveCost) by SubAccountName", "| top 10 by Cost desc"].join("\n"),
+};
+/* eslint-enable max-len */
+
+// Panel id -> the name of the query that produced it. Used to look up the KQL
+// the server actually executed, which keeps the panel "KQL" dialog honest
+// without a second hand-maintained copy of every query.
+const PANEL_QUERY = {
+ "ai-capability-trend": "capabilityTrend",
+ "ai-capability": "capability",
+ "ai-by-service": "byService",
+ "ai-token-demand": "monthly",
+ "ai-cpmt-trend": "monthly",
+ "ai-model-bench": "modelBench",
+ "ai-direction": "direction",
+ "ai-ml-gpu": "mlGpu",
+ "ai-ml-unit": "mlUnit",
+ "ai-search": "search",
+ "ai-cognitive": "cognitive",
+ "ai-allocation": "allocation",
+ "ai-by-owner": "byOwner",
+ "ai-posture": "posture",
+ "ai-drivers": "drivers",
+};
+
+let _kqlPanelId = null;
+let _loadAbort = null;
+let _capacitySubscriptionAbort = null;
+let _capacitySubscriptionFocusResults = false;
+
+/* ------------------------------------------------------------------ filter management */
+
+function filterKey() {
+ const entries = Object.entries(state.filters)
+ .filter(([, arr]) => arr && arr.length > 0)
+ .sort(([a], [b]) => a.localeCompare(b));
+ return entries.length > 0 ? "|" + JSON.stringify(entries) : "";
+}
+
+function cacheKey() {
+ if (state.tab === "capacity") {
+ return `${state.capacityClass}|${JSON.stringify(state.capacitySelections || {})}`;
+ }
+ return state.preset + filterKey();
+}
+
+function toggleFilter(dim, val) {
+ const arr = state.filters[dim] || [];
+ const idx = arr.indexOf(val);
+ if (idx >= 0) {
+ const next = arr.filter((v) => v !== val);
+ if (next.length === 0) delete state.filters[dim];
+ else state.filters[dim] = next;
+ } else {
+ state.filters[dim] = [...arr, val];
+ }
+ renderFilterBar();
+ void publishCanvasState({ filters: state.filters });
+ load();
+}
+
+function clearFilters() {
+ state.filters = {};
+ renderFilterBar();
+ void publishCanvasState({ filters: state.filters });
+ load();
+}
+
+// The tab strip scrolls horizontally below ~1000px and never moved on its own,
+// so deep-linking to a tab late in the strip left the nav looking like the first
+// tab was still selected. Called from every path that marks a tab active.
+function revealActiveTab() {
+ const active = el("tabs")?.querySelector("button[data-tab].active");
+ if (active && active.scrollIntoView) active.scrollIntoView({ inline: "nearest", block: "nearest" });
+}
+
+function syncCanvasControls() {
+ [...el("preset").querySelectorAll("button[data-preset]")].forEach((button) => {
+ button.classList.toggle("active", button.dataset.preset === state.preset);
+ });
+ [...el("tabs").querySelectorAll("button[data-tab]")].forEach((button) => {
+ const active = button.dataset.tab === state.tab;
+ button.classList.toggle("active", active);
+ button.setAttribute("aria-selected", active ? "true" : "false");
+ });
+ revealActiveTab();
+ const isTool = TOOL_TABS.has(state.tab);
+ const isCapacity = state.tab === "capacity";
+ el("preset").hidden = isTool || isCapacity;
+ el("refresh").hidden = isTool;
+ el("app-footer").hidden = isTool;
+ renderFilterBar();
+}
+
+function applySharedCanvasState(next, options = {}) {
+ if (!next || !Number.isInteger(next.revision) || next.revision < state.revision) return;
+ const previousTab = state.tab;
+ const previousCapacityClass = state.capacityClass;
+ const changed = next.tab !== state.tab || next.preset !== state.preset ||
+ next.capacityClass !== state.capacityClass ||
+ JSON.stringify(next.capacitySelections || {}) !== JSON.stringify(state.capacitySelections) ||
+ JSON.stringify(next.filters || {}) !== JSON.stringify(state.filters);
+ state.tab = next.tab;
+ state.preset = next.preset;
+ state.filters = next.filters || {};
+ state.capacityClass = next.capacityClass || "home";
+ state.capacitySelections = next.capacitySelections || {};
+ state.revision = next.revision;
+ if (previousCapacityClass !== state.capacityClass) resetCapacityDetail();
+ syncCanvasControls();
+ if (previousTab === "monaco" && state.tab !== "monaco") disposeMonacoEditor();
+ if (changed || options.forceReload) {
+ if (options.forceReload) {
+ state.cache = {};
+ invalidateCapacitySubscriptions();
+ }
+ const hash = state.tab === "capacity"
+ ? `#tab=capacity&capacity=${state.capacityClass}`
+ : `#tab=${state.tab}`;
+ history.replaceState({ tab: state.tab, capacityClass: state.capacityClass }, "", hash);
+ load();
+ }
+}
+
+async function publishCanvasState(patch) {
+ const send = async (expectedRevision) => {
+ const response = await fetch("/api/session-state", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ...patch, expectedRevision }),
+ });
+ return { response, body: await response.json() };
+ };
+
+ try {
+ let result = await send(state.revision);
+ if (result.response.status === 409 && result.body.state) {
+ const merged = { ...result.body.state, ...patch, revision: result.body.state.revision };
+ applySharedCanvasState(merged);
+ result = await send(result.body.state.revision);
+ }
+ if (!result.response.ok || result.body.error) throw new Error(result.body.error || "Could not share canvas state.");
+ applySharedCanvasState(result.body);
+ } catch (err) {
+ console.error("[ftk-dashboard] state synchronization failed:", err);
+ }
+}
+
+async function pollCanvasState() {
+ try {
+ const next = await fetch("/api/session-state").then((response) => response.json());
+ if (!Number.isInteger(next.revision) || next.revision <= state.revision) return;
+ const previous = window.__cfg || {};
+ const config = await fetch("/api/config").then((response) => response.json());
+ const connectionChanged = config.clusterUri !== previous.clusterUri || config.database !== previous.database;
+ window.__cfg = config;
+ applySharedCanvasState(next, { forceReload: connectionChanged });
+ } catch {
+ // The extension may be restarting; the next poll will reconnect.
+ }
+}
+
+function renderFilterBar() {
+ const bar = document.getElementById("filter-bar");
+ const chips = document.getElementById("filter-chips");
+ if (!bar || !chips) return;
+ const entries = state.tab === "capacity"
+ ? []
+ : Object.entries(state.filters).filter(([, arr]) => arr && arr.length > 0);
+ if (entries.length === 0) {
+ bar.hidden = true;
+ chips.innerHTML = "";
+ return;
+ }
+ bar.hidden = false;
+ chips.innerHTML = entries.flatMap(([dim, vals]) =>
+ vals.map((val) => {
+ const label = FILTER_LABELS[dim] || dim;
+ return `` +
+ `${esc(label)} ${esc(val)}` +
+ `` +
+ ``;
+ })
+ ).join("");
+}
+
+/* ------------------------------------------------------------------ utils */
+
+function fmtMoney(n) {
+ if (n == null || isNaN(n)) return "$0";
+ const sign = n < 0 ? "-" : "";
+ const a = Math.abs(n);
+ if (a >= 1e6) return `${sign}$${(a / 1e6).toFixed(2)}M`;
+ if (a >= 1e3) return `${sign}$${(a / 1e3).toFixed(1)}K`;
+ return `${sign}$${a.toFixed(a < 100 ? 2 : 0)}`;
+}
+function fmtMoneyFull(n) {
+ if (n == null || isNaN(n)) return "$0";
+ return n.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
+}
+function fmtPct(x, d = 1) {
+ if (x == null || isNaN(x)) return "—";
+ return `${(x * 100).toFixed(d)}%`;
+}
+function fmtInt(n) {
+ return (n ?? 0).toLocaleString("en-US");
+}
+function fmtTokens(n) {
+ if (n == null || isNaN(n)) return "0";
+ const a = Math.abs(n);
+ if (a >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
+ if (a >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
+ if (a >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
+ return `${Math.round(n)}`;
+}
+function fmtPerM(costPer1K) {
+ // costPer1K is $ per 1,000 tokens -> show $ per 1,000,000 tokens
+ const v = (costPer1K || 0) * 1000;
+ return `$${v.toFixed(2)}`;
+}
+// Unit rates span many orders of magnitude ($75 per 1K core-hours down to
+// $0.00015 per VM-hour), so scale precision to the value. fmtMoneyFull rounds
+// to whole dollars and would collapse every sub-dollar rate to "$0".
+export function fmtRate(n) {
+ if (n == null || isNaN(n)) return "—";
+ if (n === 0) return "$0.00";
+ const abs = Math.abs(n);
+ if (abs >= 1) return `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
+ if (abs >= 0.01) return `$${n.toFixed(3)}`;
+ return `$${Number(n.toPrecision(2))}`;
+}
+// Consumed quantities are not integers (VM-hours, request units), and
+// toLocaleString's 3-decimal default renders "22.033" one row above "22,247" —
+// two numbers three orders of magnitude apart distinguished only by the
+// separator glyph. Abbreviate above 1K so the magnitude is unambiguous.
+export function fmtQty(n) {
+ if (n == null || isNaN(n)) return "—";
+ const a = Math.abs(n);
+ if (a >= 1e6) return `${(n / 1e6).toFixed(2)}M`;
+ if (a >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
+ if (a === 0) return "0";
+ return `${Number(n.toFixed(2))}`;
+}
+// A money column is read as a right-aligned stack, so every cell in it must
+// share one precision. Pick that precision once from the column's own maximum:
+// large columns drop cents (nobody reads cents next to $178,528), small columns
+// keep them. A nonzero value too small for the chosen precision renders as a
+// floor marker rather than "$0.00", which would read as missing data.
+export function moneyColumn(rows, ...keys) {
+ const vals = [];
+ for (const r of rows || []) for (const k of keys) {
+ const v = Math.abs(+r[k]); if (v > 0 && isFinite(v)) vals.push(v);
+ }
+ const dp = vals.length && Math.max(...vals) >= 1000 ? 0 : 2;
+ return fixedDollars(dp);
+}
+
+// Rates span far more orders of magnitude than money ($0.0004 to $74 in one
+// table), so they need their own ladder — but still one precision per column,
+// because a reader compares cells down a column, not against their magnitude.
+export function rateColumn(rows, ...keys) {
+ const vals = [];
+ for (const r of rows || []) for (const k of keys) {
+ const v = Math.abs(+r[k]); if (v > 0 && isFinite(v)) vals.push(v);
+ }
+ const max = vals.length ? Math.max(...vals) : 1;
+ const dp = max >= 1 ? 2 : max >= 0.01 ? 3 : 4;
+ return fixedDollars(dp);
+}
+
+// Values below half a unit would round to "$0.00" and read as free, so they get
+// a floor marker instead.
+function fixedDollars(dp) {
+ const unit = Math.pow(10, -dp);
+ return (n) => {
+ if (n == null || isNaN(n)) return "—";
+ if (n === 0) return `$${(0).toFixed(dp)}`;
+ if (Math.abs(n) < unit / 2) return `<$${unit.toFixed(dp)}`;
+ const body = Math.abs(n).toLocaleString("en-US", { minimumFractionDigits: dp, maximumFractionDigits: dp });
+ return `${n < 0 ? "-" : ""}$${body}`;
+ };
+}
+// Axis ticks share one scale, so they need one precision — unlike a cell, where
+// fmtRate scales precision to the individual value. Mixing them puts "$0.500"
+// directly above "$1.00" on the same axis.
+function axisRate(n) {
+ if (n == null || isNaN(n)) return "—";
+ return `$${Number(n).toFixed(2)}`;
+}
+
+export function fmtShare(x, d = 1) {
+ // A row with visible nonzero cost must never report "0.0%" — that reads as a
+ // broken calculation and makes the column visibly fail to sum to 100%.
+ if (x == null || isNaN(x)) return "—";
+ const floor = 1 / Math.pow(10, d + 2);
+ if (x > 0 && x < floor) return `<${(floor * 100).toFixed(d)}%`;
+ return `${(x * 100).toFixed(d)}%`;
+}
+function fmtMonth(ym) {
+ // "2025-04" -> "Apr ’25"
+ if (!ym || typeof ym !== "string") return String(ym ?? "—");
+ const [y, m] = ym.split("-");
+ if (!y || !m) return ym;
+ const names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+ return `${names[+m - 1] || m} '${y.slice(2)}`;
+}
+function fmtDayRange(min, max) {
+ if (!min || !max) return "—";
+ const f = (s) => {
+ const d = new Date(s);
+ if (isNaN(d)) return String(s);
+ return d.toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
+ };
+ return `${f(min)} – ${f(max)}`;
+}
+function esc(s) {
+ return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
+}
+function trunc(s, n) {
+ s = String(s ?? "");
+ if (s.length <= n) return s;
+ // Middle-ellipsis: keep a short tail visible so structurally similar long
+ // identifiers (e.g. two reservation IDs differing only in their suffix)
+ // don't collide into the same truncated string.
+ const keepEnd = Math.min(8, Math.floor(n * 0.35));
+ const keepStart = Math.max(1, n - keepEnd - 1);
+ return `${s.slice(0, keepStart)}…${s.slice(-keepEnd)}`;
+}
+function el(id) { return document.getElementById(id); }
+function fmtRelativeTime(date) {
+ if (!date) return "—";
+ const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
+ const diffSec = (date - Date.now()) / 1000;
+ const abs = Math.abs(diffSec);
+ if (abs < 60) return rtf.format(Math.round(diffSec), "second");
+ if (abs < 3600) return rtf.format(Math.round(diffSec / 60), "minute");
+ if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), "hour");
+ return rtf.format(Math.round(diffSec / 86400), "day");
+}
+function svgEl(w, h, body, label = "") {
+ const ariaAttr = label ? ` aria-label="${esc(label)}"` : "";
+ return ``;
+}
+
+/* ----------------------------------------------------------------- charts */
+
+// Shared single-left-axis gridline + y-tick-label renderer, used by every
+// chart with one money-scaled y-axis (lineChart, anomalyChart, forecastChart).
+// tokenTrendChart keeps its own dual-axis (token + cost) tick renderer — a
+// different concept (two scales, two tick labels per line), not merged here.
+function yAxisGrid(m, W, ih, yMax, ticks, valFmt) {
+ let g = "";
+ for (let t = 0; t <= ticks; t++) {
+ const val = (yMax / ticks) * t;
+ const yy = m.t + ih - (ih / ticks) * t;
+ g += ``;
+ // The zero tick otherwise takes a different branch of the money/token
+ // formatters ("$0.00" beneath "$4.3K"), leaving one tick in a format the
+ // rest of the axis doesn't share.
+ const label = t === 0 ? valFmt(0).replace(/\.0+\b/, "") : valFmt(val);
+ g += `${label}`;
+ }
+ return g;
+}
+
+// Round an axis ceiling up to a 1 / 2 / 2.5 / 5 x 10^n step so gridlines land on
+// values a reader can actually use. Always rounds up, so a series can never
+// exceed the plotted maximum.
+export function niceMax(v, ticks = 4) {
+ if (!(v > 0) || !isFinite(v)) return 1;
+ const raw = v / ticks;
+ const mag = Math.pow(10, Math.floor(Math.log10(raw)));
+ const norm = raw / mag;
+ const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag;
+ return step * ticks;
+}
+
+// Shared index-thinned x-axis month-label renderer: shows a label at evenly
+// spaced indices (max ~12) plus always the last row, for any chart whose rows
+// run left-to-right one-per-month. anomalyChart uses a different, month-
+// change-detection variant over daily rows and keeps its own logic.
+function xAxisMonthLabels(rows, xFn, H, monthField = "Month") {
+ let g = "";
+ const n = rows.length;
+ const step = Math.ceil(n / 12);
+ // Emit the evenly-stepped indices, then append the final month only when it
+ // isn't already on the grid *and* it clears the previous label by a full step.
+ // Otherwise the last two ticks crowd at half the spacing of every other pair.
+ const idx = [];
+ for (let i = 0; i < n; i += step) idx.push(i);
+ const last = n - 1;
+ if (last >= 0 && idx[idx.length - 1] !== last) {
+ if (last - idx[idx.length - 1] >= step) idx.push(last);
+ else idx[idx.length - 1] = last;
+ }
+ idx.forEach((i) => {
+ g += `${esc(fmtMonth(rows[i][monthField]))}`;
+ });
+ return g;
+}
+
+function lineChart(rows) {
+ // rows: [{Month, Billed, Effective}]
+ // Flatter aspect ratio (vs. 280 previously): a ~15-point monthly line has
+ // low vertical information density, so a wide-but-short viewBox avoids the
+ // chart dominating the tab when rendered at panel width.
+ const W = 760, H = 200;
+ const m = { l: 56, r: 18, t: 16, b: 34 };
+ const iw = W - m.l - m.r, ih = H - m.t - m.b;
+ if (!rows || rows.length === 0) return emptyChart(W, H, "Monthly cost trend — no data");
+ const max = Math.max(...rows.map((r) => Math.max(r.Billed || 0, r.Effective || 0)), 1);
+ const yMax = max * 1.12;
+ const n = rows.length;
+ const x = (i) => m.l + (n === 1 ? iw / 2 : (i / (n - 1)) * iw);
+ const y = (v) => m.t + ih - (v / yMax) * ih;
+
+ let g = "";
+ // gridlines + y ticks
+ g += yAxisGrid(m, W, ih, yMax, 4, fmtMoney);
+ // x labels (thin out if crowded)
+ g += xAxisMonthLabels(rows, x, H);
+ // area under effective
+ const ptsE = rows.map((r, i) => `${x(i)},${y(r.Effective || 0)}`);
+ const area = `M${m.l},${y(0)} L${ptsE.join(" L")} L${x(n - 1)},${y(0)} Z`;
+ g += ``;
+ // billed line (muted, dashed)
+ const ptsB = rows.map((r, i) => `${x(i)},${y(r.Billed || 0)}`).join(" L");
+ g += ``;
+ // effective line
+ g += ``;
+ // dots + hover titles
+ rows.forEach((r, i) => {
+ g += `${esc(fmtMonth(r.Month))}\nEffective ${fmtMoneyFull(r.Effective)}\nBilled ${fmtMoneyFull(r.Billed)}`;
+ });
+ const legend = legendHtml([
+ { label: "Effective cost", color: PALETTE[0] },
+ { label: "Billed cost", color: "var(--muted)" },
+ ]);
+ return svgEl(W, H, g, "Monthly cost trend — billed vs effective cost") + legend;
+}
+
+function hbar(rows, nameKey, valKey, opts = {}) {
+ const data = (rows || []).map((r) => ({ name: String(r[nameKey] ?? "—"), val: +r[valKey] || 0 }))
+ .filter((r) => r.val > 0);
+ if (data.length === 0) return `
No data in range.
`;
+ const max = Math.max(...data.map((d) => d.val), 1);
+ const total = data.reduce((s, d) => s + d.val, 0);
+ const rowH = 30, padR = 64, nameW = opts.nameW ?? 142;
+ // The name budget is a character count, so it has to track nameW or wide
+ // panels truncate names that had ~100 viewBox units of free gutter beside them.
+ const nameChars = opts.nameChars ?? Math.max(12, Math.floor(nameW / 7.1));
+ const W = 540, H = data.length * rowH + 6;
+ const barX = nameW + 8, barW = W - barX - padR;
+ const valFmt = opts.valFmt || fmtMoney;
+ // filterDim: by default use nameKey; pass null to opt-out of filtering
+ const filterDim = "filterDim" in opts ? opts.filterDim : nameKey;
+ const activeVals = filterDim && state.filters[filterDim];
+ const hasFilter = activeVals && activeVals.length > 0;
+ let g = "";
+ data.forEach((d, i) => {
+ const yy = i * rowH + 4;
+ const cy = yy + rowH / 2;
+ const w = Math.max(2, (d.val / max) * barW);
+ const color = opts.color || PALETTE[i % PALETTE.length];
+ const pct = total > 0 ? (d.val / total) : 0;
+ const isSelected = hasFilter && activeVals.includes(d.name);
+ const isDimmed = hasFilter && !isSelected;
+ let cls = "hbar-row";
+ if (filterDim) cls += " hbar-filterable";
+ if (isSelected) cls += " hbar-selected";
+ if (isDimmed) cls += " hbar-dimmed";
+ const isTruncated = d.name.length > nameChars;
+ const dimAttr = filterDim ? ` data-filter-dim="${esc(filterDim)}" data-filter-val="${esc(d.name)}"` : "";
+ const interactiveAttrs = filterDim
+ ? ` tabindex="0" role="button" aria-pressed="${isSelected ? 'true' : 'false'}" aria-label="Filter by ${esc(d.name)}, ${valFmt(d.val)}"`
+ : ` tabindex="0" aria-label="${esc(d.name)}, ${valFmt(d.val)}"`;
+ g += ``;
+ g += `${esc(trunc(d.name, nameChars))}${esc(d.name)}`;
+ g += `${esc(d.name)}\n${fmtMoneyFull(d.val)} · ${fmtPct(pct)}`;
+ g += `${valFmt(d.val)}`;
+ g += ``;
+ });
+ return svgEl(W, H, g, opts.label || "");
+}
+
+function donut(slices, opts = {}) {
+ const data = (slices || []).filter((s) => (+s.value || 0) > 0);
+ const total = data.reduce((s, d) => s + (+d.value || 0), 0);
+ if (total <= 0) return `
No data in range.
`;
+ const size = 180, cx = size / 2, cy = size / 2, R = 80, r = 50;
+ let a0 = 0, g = "";
+ if (data.length === 1) {
+ g += `${esc(data[0].label)}\n${fmtMoneyFull(data[0].value)} · 100%`;
+ } else {
+ // Give near-zero slices a minimum visible arc so they aren't rendered as
+ // an invisible sliver, mirroring hbar()'s Math.max(2, ...) width floor.
+ // The angle deficit is subtracted from the single largest slice so the
+ // total stays exactly 360°.
+ const minAngle = 4;
+ const angles = data.map((d) => (d.value / total) * 360);
+ let deficit = 0;
+ const boosted = angles.map((a) => {
+ if (a < minAngle) { deficit += minAngle - a; return minAngle; }
+ return a;
+ });
+ if (deficit > 0) {
+ const maxIdx = boosted.reduce((best, a, i) => (a > boosted[best] ? i : best), 0);
+ boosted[maxIdx] = Math.max(minAngle, boosted[maxIdx] - deficit);
+ }
+ data.forEach((d, i) => {
+ const frac = d.value / total;
+ const a1 = a0 + boosted[i];
+ g += `${esc(d.label)}\n${fmtMoneyFull(d.value)} · ${fmtPct(frac)}`;
+ a0 = a1;
+ });
+ }
+ const centerBig = opts.centerBig ?? fmtMoney(total);
+ const centerSmall = opts.centerSmall ?? "total";
+ g += `${esc(centerBig)}`;
+ g += `${esc(centerSmall)}`;
+ const legend = legendHtml(data.map((d) => ({
+ label: d.label, color: d.color, isUnknown: d.isUnknown,
+ value: opts.valueFmt ? opts.valueFmt(d) : `${fmtMoney(d.value)} · ${fmtPct(d.value / total)}`,
+ })));
+ return `
`;
+}
+
+// Inline swatch for raw table cells (outside donut/hbar). isUnknown renders
+// the shared dashed/muted "no data" treatment instead of a rotating palette
+// color, matching legendHtml's isUnknown handling.
+function swatchHtml(color, isUnknown = false) {
+ return ``;
+}
+
+// Generic data table. cols: [{label, align?, get:(row,i)=>htmlString}]. rows: any[].
+function tableHtml(cols, rows, emptyMsg = "No data in range.") {
+ if (!rows || rows.length === 0) return `
${esc(emptyMsg)}
`;
+ const head = cols.map((c) => `
${esc(c.label)}
`).join("");
+ const body = rows.map((r, i) => `
${cols.map((c) => `
${c.get(r, i)}
`).join("")}
`).join("");
+ return `
${head}
${body}
`;
+}
+
+// Shared "cost breakdown" list row: an optional color swatch, a label, and a
+// money value. Used by the Overview and Rate tabs' savings-breakdown panels —
+// same concept, same markup, previously implemented twice independently.
+function costBreakdownRow(label, val, accent) {
+ return `
`;
+}
+
+function isPartialMonth() {
+ const now = new Date();
+ return now.getDate() < new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
+}
+
+const KPI_TIPS = {
+ "Untagged cost": "% of spend on resources missing tags. Target: <10% · Review: <25% · Urgent: ≥25%. Tagging enables accurate showback and chargeback.",
+ "Commitment waste": "% of RI/savings-plan spend on unused capacity. Target: <10% · Review: <20% · Urgent: ≥20%. Idle commitments erode net savings.",
+ "Effective savings rate": "Negotiated + commitment savings as % of list price. Higher = better. Enterprise customers typically target ≥15–20%.",
+ "Commitment coverage": "Compute spend covered by RIs or savings plans. Target: ≥60% for steady workloads. Higher coverage → lower effective rate.",
+ "Compute coverage": "On-demand core-hours offset by commitments. Target: ≥60%. Tracks whether savings plan scope is sufficient.",
+ "MACC burn rate": "Microsoft Azure Consumption Commitment utilization. Target: ≥90% to avoid forfeiting unused balance at term end.",
+ "Anomaly days": "Days where daily cost deviated significantly from the expected baseline (STL decomposition). Review flagged dates for unexpected spend.",
+ "Hourly cost / core": "Compute effective cost per core-hour actually consumed this period — the real, paid-for unit rate.",
+ "Effective cost / core": "Compute effective cost per core-hour, including unused commitment waste spread across usage — the fully-loaded unit cost if that waste is charged back.",
+ "Unpredicted variance": "Net effective cost variance between actual spend and the anomaly baseline on flagged days (FinOps KPI: Total Unpredicted Variance of Spend). Positive = spent more than expected.",
+ "Anomaly detection rate": "Effective cost on anomaly-flagged days as % of total effective spend (FinOps KPI: Anomaly Cost %). The day-count ratio shown alongside is a separate reference stat, not the derivation of this percentage.",
+ "Last month change": "Month-over-month % change in effective cost vs. the prior month. Watch for spikes or drops that don't match expected seasonality.",
+ "Forecast next month": "Projected effective cost for next month using time-series decomposition (FinOps KPI: Cost Forecasting). Based on historical trend + seasonality, not a guarantee.",
+ "Visibility delay": "Median (P50) delay between when cost was incurred and when it appeared in the FinOps hub (FinOps KPI: Cost Visibility Delay). On local/demo data without a live Cost Management connector, a large delay is expected.",
+ "Tag policy compliance": "% of effective cost on resources with all required tag keys present and non-empty (FinOps KPI: Tagging Policy Compliance).",
+ "Subscriptions": "Distinct subscriptions (billing accounts) with cost activity in the selected period.",
+ "Allocated cost": "Effective cost with ownership attribution — a cost center, owner, or ownership tag — the complement of Unallocated cost.",
+};
+
+function kpiCard(label, value, meta, accent, thresholdClass, tier) {
+ // Hierarchy tier is now explicitly assigned by each tab's render*() call
+ // site (via the 6th `tier` argument) rather than an incomplete global
+ // label allow-list, so every tab consciously designates its own hero
+ // metric. `accent` is kept for call-site compatibility but unused.
+ const hierarchyClass = tier === "primary" ? "kpi--primary" : tier === "reference" ? "kpi--reference" : "";
+
+ // Combine threshold and hierarchy classes
+ const classArray = [thresholdClass, hierarchyClass].filter(Boolean);
+ const cls = classArray.length > 0 ? ` ${classArray.join(" ")}` : "";
+
+ const tip = KPI_TIPS[label];
+ const tipHtml = tip ? ` ` : "";
+
+ return `
+ ${panelHtml("token-trend", 12, "Token volume & AI cost trend", "Monthly token consumption (bars) and effective AI cost (line).", tokenTrendChart(d.trend))}
+ ${panelHtml("token-by-model", 6, "AI cost by model", "Effective cost per model family.",
+ hbar((d.models || []).map((m) => ({ Model: m.Model, Cost: m.Cost })), "Model", "Cost", { label: "AI cost by model" }))}
+ ${panelHtml("token-direction", 6, "Token direction mix", "Input vs cached input vs output — by token volume.",
+ donut(dirSlices, {
+ centerBig: fmtTokens(k.tokens), centerSmall: "tokens",
+ valueFmt: (s) => `${fmtTokens(s.value)} · ${fmtMoney(s.cost)}`,
+ label: "Token direction mix",
+ }))}
+
+
+
Model efficiency
Rate & usage optimization
+
+ ${panelHtml("token-model-table", 12, "Cost per 1M tokens by model", "Unit economics for model selection — sorted by effective cost.", tokenModelTable(d.models, k.eff))}
+
+
+
AI cost allocation
Showback & chargeback
+
+ ${panelHtml("token-by-app", 12, "AI cost by application", "Azure OpenAI effective cost and token volume by application, team, environment, and cost center.", aiByAppTable(d.byApplication))}
+
⚠100% of AI cost (${fmtMoney(totalCost)}) is untagged — no application-level chargeback is currently possible. Tag Azure OpenAI resources with an application tag to enable it.
`;
+}
+
+/* --------------------------------------------- AI & emerging workloads render */
+
+// Middle-ellipsis a cell value and expose the full string on hover, so long
+// meter and series names shorten predictably instead of overflowing the
+// `white-space: nowrap` table cells.
+function nameCell(value, n) {
+ // `??` alone lets an empty string through, which renders as a blank cell and
+ // reads as a rendering failure rather than as absent data.
+ const s = String(value ?? "").trim() || "—";
+ const short = trunc(s, n);
+ return short === s ? esc(s) : `${esc(short)}`;
+}
+
+// Wrap a table that can exceed its panel width. The first column stays pinned
+// while the numeric columns scroll, so a row never loses its label.
+function wideTable(html) {
+ return `
`;
+ const money = moneyColumn(rows, "Covered");
+ return wideTable(tableHtml([
+ { label: "Dimension", align: "left", get: (r) => esc(r.Dimension) },
+ { label: "Covered cost", get: (r) => money(r.Covered) },
+ { label: "Coverage", get: (r) => {
+ const pct = r.Covered / k.allocTotal;
+ const cls = pct >= 0.85 ? "pos" : pct >= 0.65 ? "warn" : "neg";
+ return `${fmtShare(pct)}`;
+ } },
+ ], rows));
+}
+
+function aiPostureTable(k) {
+ // Counts are descriptive: a zero means no AI-scoped records were ingested,
+ // which is a different statement from "no opportunity exists".
+ const rows = [
+ {
+ Signal: "Commitment coverage",
+ Value: k.committedShare == null ? "—" : fmtPct(k.committedShare),
+ Note: k.committedShare ? "AI/ML estate cost on a commitment discount" : "No AI/ML spend is on a commitment discount",
+ },
+ {
+ Signal: "AI-scoped rate recommendations",
+ Value: fmtInt(k.recommendations),
+ Note: k.recommendations > 0 ? "Open recommendations touching AI/ML resource types" : "None ingested for AI/ML resource types",
+ },
+ {
+ Signal: "AI-scoped commitment transactions",
+ Value: fmtInt(k.transactions),
+ Note: k.transactions > 0 ? "Purchase or refund events matching AI/GPU descriptions" : "None ingested matching AI/GPU descriptions",
+ },
+ ];
+ return wideTable(tableHtml([
+ { label: "Signal", align: "left", get: (r) => esc(r.Signal) },
+ { label: "Value", get: (r) => r.Value },
+ { label: "Detail", align: "left", get: (r) => `${esc(r.Note)}` },
+ ], rows));
+}
+
+function aiDriversTable(rows, k) {
+ const money = moneyColumn(rows, "Prev", "Cost");
+ const delta = moneyColumn(rows, "Change");
+ // Below half a cent the change is a rounding artefact, not a movement: format
+ // it as a flat zero so it can't render as a signed "-$0.00 (-0.0%)" and can't
+ // pick up a directional colour.
+ const EPS = 0.005;
+ return wideTable(tableHtml([
+ { label: "Service", align: "left", get: (r) => nameCell(r.Service, 26) },
+ { label: "Meter", align: "left", get: (r) => nameCell(r.Meter, 26) },
+ { label: "Prior month", get: (r) => money(r.Prev) },
+ { label: k.closedMonth ? fmtMonth(k.closedMonth) : "Latest month", get: (r) => money(r.Cost) },
+ { label: "Change", get: (r) => {
+ const chg = Math.abs(r.Change || 0) < EPS ? 0 : r.Change;
+ const cls = chg > 0 ? "neg" : chg < 0 ? "pos" : "muted";
+ if (chg === 0) return `no change`;
+ // A zero baseline has no percentage; say so rather than leaving the
+ // cell ragged against the rows that carry one.
+ const pct = r.Prev > 0
+ ? ` (${chg > 0 ? "+" : ""}${fmtShare(chg / r.Prev, 1)})`
+ : ` (new)`;
+ return `${chg > 0 ? "+" : ""}${delta(chg)}${pct}`;
+ } },
+ ], rows, "No month-over-month movement in range."));
+}
+
+function renderAi(p) {
+ const content = el("content");
+ if (!p) return;
+ if (p.error) return renderError(p);
+ if (p.empty) {
+ content.innerHTML = `
No AI or emerging workload data
+
No AI, machine learning, or GPU-accelerated spend was found in the Hub database for this period.
+
This view scopes to the AI and Machine Learning service category, Azure AI Search, Azure Databricks, and GPU VM series (NC/ND/NV/NG). Ingest cost data covering those workloads, then refresh.
This view scopes to the AI and Machine Learning service category plus Azure AI Search, Azure Databricks, and GPU VM series (NC/ND/NV/NG). GPU capacity bought outside those services — or AI work running on general-purpose compute — will not appear here.${partialNote}
+
+
AI/ML estate
Workload composition
+
+ ${panelHtml("ai-capability-trend", 12, "AI spend by capability over time", "Monthly effective cost split across AI capability groups.", aiCapabilityChart(d.capabilityTrend))}
+ ${panelHtml("ai-capability", 6, "Estate composition", "Effective cost and distinct services per capability.", aiCapabilityTable(d.capability, k.estate))}
+ ${panelHtml("ai-by-service", 6, "Estate spend by service", "Top billing services in the AI/ML estate.",
+ hbar(d.byService, "Service", "Cost", { filterDim: "ServiceName", nameW: 210, color: PALETTE[2], label: "AI/ML estate spend by service" }))}
+
+
+
Token & model economics
Unit economics
+
+ ${panelHtml("ai-token-demand", 6, "Token demand", "Monthly token volume across all foundation model meters.",
+ monthAreaChart(d.monthly, { valueKey: "Tokens", color: PALETTE[2], valFmt: fmtTokens, label: "Monthly token volume" }))}
+ ${panelHtml("ai-cpmt-trend", 6, "Cost per 1M tokens", "Blended effective rate — the direction of travel matters more than the level.",
+ monthAreaChart(cpmtTrend, { valueKey: "Cpmt", color: PALETTE[5], valFmt: axisRate, tipFmt: fmtRate, label: "Blended cost per 1M tokens" }))}
+ ${panelHtml("ai-model-bench", 6, "Model family benchmark", "Cost per 1M tokens by model family — the input to model selection.", aiModelBenchTable(d.modelBench))}
+ ${panelHtml("ai-direction", 6, "Token direction mix", "Input, cached input, output, and embedding meters.", aiDirectionTable(d.direction))}
+
+ ${panelHtml("ai-allocation", 6, "Allocation coverage", "Share of AI/ML estate cost carrying each accountability dimension.", aiAllocationTable(k))}
+ ${panelHtml("ai-by-owner", 6, "Estate spend by owner", "Owner or team tag, falling back to cost center then resource group. Tag values are folded case-insensitively.",
+ hbar(d.byOwner, "Owner", "Cost", { filterDim: null, nameW: 210, color: PALETTE[2], label: "AI/ML estate spend by owner" }))}
+ ${panelHtml("ai-posture", 12, "Commitment & rate posture", "Whether AI/ML spend is on a commitment, and which AI-scoped rate signals were ingested.", aiPostureTable(k))}
+ ${panelHtml("ai-drivers", 12, "Top movers", `Largest AI/ML meters, ${k.closedMonth ? `${esc(fmtMonth(k.closedMonth))} against the month before it` : "latest month against the month before it"}.`, aiDriversTable(d.drivers, k))}
+
⚠${fmtInt(anomDays.length)} anomal${anomDays.length === 1 ? "y day" : "y days"} detected — ${fmtMoney(anomCost)} in flagged spend. Review the chart below.
⚠${fmtInt(underutilCount)} underutilized commitment${underutilCount === 1 ? "" : "s"} found — ${fmtMoney(cm.Unused)} in unused spend. See the commitments panel below.
+ ${panelHtml("rate-commit-score", 6, "Commitment utilization score", "Per-commitment utilization (used vs potential) from the formal CUS KPI.", commitUtilTable(d.commitmentUtilScore))}
+ ${panelHtml("rate-top-txns", 6, "Top commitment transactions", "Largest RI and savings plan purchases by billed cost. Effective cost is $0 by design — amortization credits the cost to the months the commitment is consumed, not the purchase month.", topCommitTxnTable(d.topCommitmentTxns))}
+
VM family by region across the estate. The left bar is supply, meaning whether a deployment can land at all. The percentage is demand against your own quota, and it turns red past the ${mark}% mark. Regional core quota is summed across subscriptions and is never duplicated across zones.
+ ${payload.classId === "compute"
+ ? capacityPanel("Estate capacity by VM family and region", "Supply and demand at family grain. Zone restrictions are descriptive; they never change regional core quota.", computeFamilyHeatmap(payload), true)
+ : ""}
+ ${payload.classId === "compute"
+ ? capacityPanel("Filtered capacity detail", "Every row matches the matrix controls above. Switch to Subscriptions for server-paged detail across the full estate.", computeCapacityDetail(payload), true)
+ : capacityPanel("Current quota", `${payload.table?.rowLimit || 250}-row bound${payload.table?.truncated ? " reached" : ""}. Raw rows remain visible when calculations are disabled.`, capacityCurrentTable(payload))}
+ ${payload.classId === "compute" ? "" : capacityPanel("Observed history", "Ingestion time is ADX arrival time. Missing days are not inferred.", capacityHistory(payload))}
+ ${capacityPanel("Subscription × region", "Quota color is available only for exact enabled metrics. Inventory uses neutral density.", capacityHeatmap(payload))}
+ ${capacityPanel("Parallel billed demand", payload.demand?.capability?.sourceNote || "Billed usage stays separate from quota.", capacityDemandHistory(payload))}
+ ${payload.classId === "capacity-reservations"
+ ? capacityPanel("Inventory and billing reconciliation", "Used and Unused are accounting statuses, not reserved-capacity utilization.", capacityReconciliation(payload))
+ : ""}
+
The dashboard queried ${esc(p.clusterUri || "")} (database ${esc(p.database || "Hub")}) but the request failed.
+
+
Start the Kusto emulator, then run:
+
+ Initialize-FinOpsHubLocal
+
+
+
Then refresh this dashboard.
+
+
+ Show error detail
+
${esc(p.error)}
+
+
`;
+}
+
+/* ------------------------------------------------------- experimental tabs */
+
+const KUSTO_MONACO_VERSION = "15.0.0";
+
+let _monacoEditor = null;
+let _monacoModel = null;
+let _monacoApi = null;
+
+/**
+ * @kusto/monaco-kusto's jsdelivr `+esm` bundle imports its own pinned copy of
+ * "monaco-editor" by exact CDN URL (version + subpath baked in at jsdelivr's
+ * build time). Since browser ES module caching is keyed by exact URL string,
+ * importing monaco-editor via any other URL -- even the "same" version --
+ * yields a second, unrelated monaco instance, and `monaco.languages.kusto`
+ * never registers on the one our own code holds. So instead of guessing a
+ * monaco-editor version/path, discover the exact specifier kusto-monaco uses
+ * and import through that.
+ */
+async function resolveSharedMonacoEditorUrl() {
+ const kustoBundleUrl = `https://cdn.jsdelivr.net/npm/@kusto/monaco-kusto@${KUSTO_MONACO_VERSION}/+esm`;
+ const kustoBundleSrc = await fetch(kustoBundleUrl).then((r) => r.text());
+ const match = /from"(\/npm\/monaco-editor@[^"]+)"/.exec(kustoBundleSrc);
+ if (!match) throw new Error("could not locate monaco-editor import in @kusto/monaco-kusto bundle");
+ return { kustoBundleUrl, monacoEditorUrl: `https://cdn.jsdelivr.net${match[1]}` };
+}
+
+/**
+ * Chromium refuses to construct a Worker (classic or module) from a
+ * cross-origin script URL at all, even with permissive CORS headers -- so
+ * `new Worker("https://cdn.jsdelivr.net/...")` throws a SecurityError
+ * unconditionally. Work around this by fetching the script ourselves and
+ * handing the browser a same-origin `blob:` URL instead. jsdelivr's `+esm`
+ * bundles reference their own dependencies via root-relative specifiers
+ * (e.g. `"/npm/..."`), which don't resolve against a `blob:` base, so those
+ * are rewritten to fully-qualified jsdelivr URLs first.
+ */
+async function blobWorkerUrl(scriptUrl) {
+ let src = await fetch(scriptUrl).then((r) => r.text());
+ src = src.replace(/(["'])\/npm\//g, "$1https://cdn.jsdelivr.net/npm/");
+ return URL.createObjectURL(new Blob([src], { type: "text/javascript" }));
+}
+
+function disposeMonacoEditor() {
+ // editor.dispose() only tears down the view widget -- the text model is a
+ // separate disposable and leaks (along with its worker) if not disposed
+ // too, which matters here since renderMonacoTab() re-creates both every
+ // time the tab is (re-)entered, e.g. after a cluster switch.
+ if (_monacoEditor) {
+ try { _monacoEditor.dispose(); } catch { /* best-effort cleanup */ }
+ _monacoEditor = null;
+ }
+ if (_monacoModel) {
+ try { _monacoModel.dispose(); } catch { /* best-effort cleanup */ }
+ _monacoModel = null;
+ }
+}
+
+async function renderMonacoTab() {
+ const content = el("content");
+ content.innerHTML = `
+
+
+ Experimental — A KQL query editor with real autocomplete via
+ @kusto/monaco-kusto, loaded from a CDN with no build step. Suggestions are
+ grounded in this Hub database's live schema.
+ Docs ↗
+
+
+
+ Loading query editor…
+
+
+
+
+ `;
+
+ const statusEl = el("monaco-status");
+ const hostEl = el("monaco-host");
+
+ try {
+ if (!_monacoApi) {
+ statusEl.textContent = "Loading query editor + KQL language support from CDN…";
+ const { kustoBundleUrl, monacoEditorUrl } = await resolveSharedMonacoEditorUrl();
+ const monacoBase = monacoEditorUrl.replace(/\/esm\/.*$/, "");
+ // Import monaco-editor via the exact URL @kusto/monaco-kusto itself
+ // imports it from, so both packages share one module instance
+ // (required for monaco.languages.kusto to register on our copy).
+ _monacoApi = await import(monacoEditorUrl);
+ const [genericWorkerUrl, kustoWorkerUrl] = await Promise.all([
+ blobWorkerUrl(`${monacoBase}/esm/vs/editor/editor.worker.js/+esm`),
+ blobWorkerUrl(`https://cdn.jsdelivr.net/npm/@kusto/monaco-kusto@${KUSTO_MONACO_VERSION}/release/esm/kusto.worker.js/+esm`),
+ ]);
+ self.MonacoEnvironment = {
+ getWorker(_moduleId, label) {
+ return new Worker(label === "kusto" ? kustoWorkerUrl : genericWorkerUrl, { type: "module" });
+ },
+ };
+ await import(kustoBundleUrl);
+ }
+ const monaco = _monacoApi;
+
+ disposeMonacoEditor();
+ // Seed from whatever was last saved server-side (survives page reloads,
+ // including the host restarting this extension's server process), not a
+ // hardcoded sample -- see saveQueryState() below for how it gets there.
+ const initialQuery = (window.__cfg && window.__cfg.lastQuery) || "Costs\n| take 20";
+ const model = monaco.editor.createModel(initialQuery, "kusto");
+ _monacoModel = model;
+ _monacoEditor = monaco.editor.create(hostEl, {
+ model,
+ theme: document.documentElement.getAttribute("data-color-mode") === "dark" ? "vs-dark" : "vs",
+ automaticLayout: true,
+ minimap: { enabled: false },
+ fontSize: 13,
+ });
+ _monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => runMonacoQuery());
+ _monacoEditor.onDidChangeModelContent(() => scheduleQueryStateSave(_monacoEditor.getValue()));
+
+ statusEl.textContent = "Fetching database schema…";
+ try {
+ const cfg = window.__cfg || {};
+ const schemaRes = await fetch("/api/schema").then((r) => r.json());
+ const kustoLang = monaco.languages?.kusto;
+ if (schemaRes.schema && kustoLang?.getKustoWorker) {
+ const workerAccessor = await kustoLang.getKustoWorker();
+ const worker = await workerAccessor(model.uri);
+ await worker.setSchemaFromShowSchema(schemaRes.schema, cfg.clusterUri || "", cfg.database || "Hub");
+ statusEl.textContent = `Ready — schema loaded from ${esc(cfg.database || "Hub")}.`;
+ } else {
+ statusEl.textContent = schemaRes.error
+ ? `Ready — schema unavailable: ${esc(schemaRes.error)}`
+ : "Ready — KQL language service didn't register (autocomplete may be limited).";
+ }
+ } catch (schemaErr) {
+ statusEl.textContent = `Ready — schema load failed: ${esc(schemaErr.message || String(schemaErr))}`;
+ }
+ } catch (err) {
+ // Graceful fallback: never leave the tab blank if the CDN load fails
+ // (e.g. cross-origin module workers unsupported in this webview).
+ const initialQuery = (window.__cfg && window.__cfg.lastQuery) || "Costs\n| take 20";
+ hostEl.innerHTML = ``;
+ el("monaco-fallback").addEventListener("input", (e) => scheduleQueryStateSave(e.target.value));
+ statusEl.textContent = `Query editor failed to load here (${esc(err.message || String(err))}) — using a plain text editor instead.`;
+ }
+ el("monaco-run").addEventListener("click", () => runMonacoQuery());
+}
+
+// Debounced autosave of the query editor's text to the server (see
+// /api/query-state in extension.mjs), so an in-progress, unrun query
+// survives a page reload -- e.g. the host restarting this extension's server
+// process, which reassigns its ephemeral port and forces a fresh load.
+let _queryStateSaveTimer = null;
+function scheduleQueryStateSave(query) {
+ clearTimeout(_queryStateSaveTimer);
+ _queryStateSaveTimer = setTimeout(() => {
+ fetch("/api/query-state", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ query }),
+ }).catch(() => { /* best-effort; the next successful save will catch up */ });
+ }, 600);
+}
+
+async function runMonacoQuery() {
+ const runBtn = el("monaco-run");
+ const statusEl = el("monaco-status");
+ const resultEl = el("monaco-result");
+ const kql = _monacoEditor ? _monacoEditor.getValue().trim() : (el("monaco-fallback")?.value || "").trim();
+ if (!kql) return;
+ runBtn.disabled = true;
+ const prevStatus = statusEl.textContent;
+ statusEl.textContent = "Running…";
+ try {
+ const res = await fetch("/api/kql", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ kql }),
+ });
+ const data = await res.json();
+ if (data.error) {
+ resultEl.innerHTML = `