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<string> } + +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=<JSON>` 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(ListCost<ContractedCost,real(0),ListCost-ContractedCost) +| extend com=iff(ContractedCost<EffectiveCost,real(0),ContractedCost-EffectiveCost) +| extend tot=iff(ListCost<EffectiveCost,real(0),ListCost-EffectiveCost) +| summarize List=sum(ListCost), Effective=sum(EffectiveCost), Negotiated=sum(neg), Commitment=sum(com), Total=sum(tot)`, + // commitment-discount-waste (grand total, effective-cost basis) + commitment: `Costs() ${period} | where isnotempty(CommitmentDiscountId) ${NON_PURCHASE} +| summarize Unused=sumif(EffectiveCost,CommitmentDiscountStatus=='Unused'), Total=sum(EffectiveCost)`, + // compute-spend-commitment-coverage + computeCoverage: `Costs() ${period} ${NON_PURCHASE} | where ServiceCategory=='Compute' +| summarize Committed=sumif(EffectiveCost,isnotempty(CommitmentDiscountCategory)), Contracted=sum(ContractedCost)`, + // commitment-discount-utilization — consumed core-hours by commitment type + coreHours: `Costs() ${period} +| extend cores=toint(coalesce(x_SkuDetails.VCPUs, x_SkuDetails.vCores, 0)) +| extend ch=iff(cores>0, 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<e ${NON_PURCHASE} +| summarize DC=sum(EffectiveCost) by bin(ChargePeriodStart,1d) +| make-series Cost=sum(DC) default=0.0 on ChargePeriodStart from s to e step 1d +| extend (flag,score,baseline)=series_decompose_anomalies(Cost,1.5) +| mv-expand Day=ChargePeriodStart to typeof(datetime), Cost to typeof(real), flag to typeof(real), baseline to typeof(real) +| project Day, Cost=toreal(Cost), Flag=toint(flag), Baseline=toreal(baseline)`, + // monthly-cost-change-percentage + monthlyChange: `Costs() ${period} | summarize Eff=sum(EffectiveCost) by M=startofmonth(ChargePeriodStart) +| order by M asc | extend PrevEff=prev(Eff) +| project Month=format_datetime(M,'yyyy-MM'), EffChangePct=iff(isempty(PrevEff),0.0,(Eff-PrevEff)*100.0/PrevEff), Eff`, + // cost-forecasting-model (monthly, forecasts past the last data month) + forecast: `let s=datetime(${win.dataMin}); Costs() | where 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 <title> 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 <span>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<ContractedCost,real(0),ListCost-ContractedCost)", "| extend com=iff(ContractedCost<EffectiveCost,real(0),ContractedCost-EffectiveCost)", "| summarize List=sum(ListCost), Effective=sum(EffectiveCost), Negotiated=sum(neg), Commitment=sum(com)"].join("\n"), + "overview-cost-allocation": ["Costs()", PERIOD, "| extend _t=iff(isnull(Tags) or array_length(bag_keys(Tags))==0,'Untagged','Tagged')", "| summarize Cost=sum(EffectiveCost) by _t"].join("\n"), + "token-trend": ["Costs()", PERIOD, AI_SCOPE, "| summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost)", " by Month=format_datetime(startofmonth(ChargePeriodStart),'yyyy-MM')", "| order by Month asc"].join("\n"), + "token-by-model": ["Costs()", PERIOD, AI_SCOPE, "| extend Model=replace_regex(x_SkuDescription,@'^Azure OpenAI[^-]+-\\s*','')", "| extend Model=replace_regex(Model,@'(?i)[\\s-]+(inp|outp|chat|media).*$','')", "| summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost) by Model", "| top 12 by Cost desc"].join("\n"), + "token-direction": ["Costs()", PERIOD, AI_SCOPE, "| extend Direction=case(x_SkuDescription has 'Outp','Output',x_SkuDescription contains 'cached','Cached input',x_SkuDescription has 'Inp','Input','Other')", "| summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost) by Direction"].join("\n"), + "token-model-table": ["Costs()", PERIOD, AI_SCOPE, "| extend Model=replace_regex(x_SkuDescription,@'^Azure OpenAI[^-]+-\\s*','')", "| extend Model=replace_regex(Model,@'(?i)[\\s-]+(inp|outp|chat|media).*$','')", "| summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost) by Model", "| extend CostPer1K=iff(Tokens==0,0.0,Cost/Tokens*1000)", "| top 12 by Cost desc"].join("\n"), + "anomaly-daily": ["let s=datetime({start}); let e=datetime({end});", "Costs()", "| where ChargePeriodStart>=s and ChargePeriodStart<e", "| summarize DC=sum(EffectiveCost) by bin(ChargePeriodStart,1d)", "| make-series Cost=sum(DC) default=0.0 on ChargePeriodStart from s to e step 1d", "| extend (flag,score,baseline)=series_decompose_anomalies(Cost,1.5)", "| mv-expand Day=ChargePeriodStart to typeof(datetime), Cost to typeof(real), flag to typeof(real), baseline to typeof(real)", "| project Day, Cost=toreal(Cost), Flag=toint(flag), Baseline=toreal(baseline)"].join("\n"), + "anomaly-mom": ["Costs()", PERIOD, "| summarize Eff=sum(EffectiveCost) by M=startofmonth(ChargePeriodStart)", "| order by M asc | extend PrevEff=prev(Eff)", "| project Month=format_datetime(M,'yyyy-MM'), EffChangePct=iff(isempty(PrevEff),0.0,(Eff-PrevEff)*100.0/PrevEff), Eff"].join("\n"), + "anomaly-forecast": ["Costs()", PERIOD, "| summarize Eff=sum(EffectiveCost) by bin(ChargePeriodStart,1d)", "| make-series Actual=sum(Eff) default=0.0 on ChargePeriodStart step 1d", "| extend Fc=series_decompose_forecast(Actual,90)", "| mv-expand Day=ChargePeriodStart to typeof(datetime), Actual to typeof(real), Fc to typeof(real)", "| summarize Actual=sum(toreal(Actual)), Forecast=sum(toreal(Fc)) by M=startofmonth(Day)", "| order by M asc | project Month=format_datetime(M,'yyyy-MM'), Actual, Forecast"].join("\n"), + "usage-top-types": ["Costs()", PERIOD, "| where isnotempty(ResourceType)", "| summarize Resources=dcount(ResourceId), Cost=sum(EffectiveCost) by ResourceType", "| top 10 by Cost desc"].join("\n"), + "usage-per-core-series": ["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"].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(ListCost<ContractedCost,real(0),ListCost-ContractedCost)", "| extend com=iff(ContractedCost<EffectiveCost,real(0),ContractedCost-EffectiveCost)", "| extend tot=iff(ListCost<EffectiveCost,real(0),ListCost-EffectiveCost)", "| summarize List=sum(ListCost), Effective=sum(EffectiveCost), Negotiated=sum(neg), Commitment=sum(com), Total=sum(tot)"].join("\n"), + "rate-commit-util": ["Costs()", PERIOD, "| where isnotempty(CommitmentDiscountId)", NON_PURCH, "| summarize Unused=sumif(EffectiveCost,CommitmentDiscountStatus=='Unused'), Total=sum(EffectiveCost)"].join("\n"), + "rate-core-hours": ["Costs()", PERIOD, "| extend cores=toint(coalesce(x_SkuDetails.VCPUs, x_SkuDetails.vCores, 0))", "| extend ch=iff(cores>0, 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 `<span class="filter-chip" data-dim="${esc(dim)}" data-val="${esc(val)}">` + + `<span class="chip-label"><strong>${esc(label)}</strong> ${esc(val)}</span>` + + `<button class="chip-remove" data-dim="${esc(dim)}" data-val="${esc(val)}" ` + + `aria-label="Remove filter ${esc(label)}: ${esc(val)}" type="button">×</button>` + + `</span>`; + }) + ).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 `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" role="img"${ariaAttr}>${body}</svg>`; +} + +/* ----------------------------------------------------------------- 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 += `<line class="grid-line" x1="${m.l}" y1="${yy}" x2="${W - m.r}" y2="${yy}"/>`; + // 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 += `<text class="tick" x="${m.l - 8}" y="${yy + 4}" text-anchor="end">${label}</text>`; + } + 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 += `<text class="tick" x="${xFn(i)}" y="${H - 12}" text-anchor="middle">${esc(fmtMonth(rows[i][monthField]))}</text>`; + }); + 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 += `<path d="${area}" fill="${PALETTE[0]}" fill-opacity="0.10"/>`; + // billed line (muted, dashed) + const ptsB = rows.map((r, i) => `${x(i)},${y(r.Billed || 0)}`).join(" L"); + g += `<path d="M${ptsB}" fill="none" stroke="var(--muted)" stroke-width="1.5" stroke-dasharray="4 3" opacity="0.7"/>`; + // effective line + g += `<path d="M${ptsE.join(" L")}" fill="none" stroke="${PALETTE[0]}" stroke-width="2.5"/>`; + // dots + hover titles + rows.forEach((r, i) => { + g += `<circle class="bar" tabindex="0" cx="${x(i)}" cy="${y(r.Effective || 0)}" r="3.2" fill="${PALETTE[0]}"><title>${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 `
${svgEl(size, size, g, opts.label || "")}
${legend}
`; +} + +function donutSeg(cx, cy, R, r, a0, a1) { + const polar = (rad, ang) => { + const a = ((ang - 90) * Math.PI) / 180; + return [cx + rad * Math.cos(a), cy + rad * Math.sin(a)]; + }; + const large = a1 - a0 > 180 ? 1 : 0; + const [x0, y0] = polar(R, a0), [x1, y1] = polar(R, a1); + const [x2, y2] = polar(r, a1), [x3, y3] = polar(r, a0); + return `M${x0} ${y0} A${R} ${R} 0 ${large} 1 ${x1} ${y1} L${x2} ${y2} A${r} ${r} 0 ${large} 0 ${x3} ${y3} Z`; +} + +function legendHtml(items) { + return `
${items.map((it) => + `${esc(it.label)}${ + it.value ? `${esc(it.value)}` : ""}`).join("")}
`; +} + +// 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 `
+ ${ + accent ? `` : ""}${esc(label)} + ${fmtMoney(val)}
`; +} + +// rows: [{label, val, accent?}]. footerLabel/footerValue render an optional +// trailing summary stat (e.g. "Effective savings rate — 42%") below the rows. +function costBreakdownTable(rows, footerLabel, footerValue) { + const footer = footerLabel + ? `
+ ${esc(footerLabel)} + ${footerValue} +
` + : ""; + return `
${rows.map((r) => costBreakdownRow(r.label, r.val, r.accent)).join("")}${footer}
`; +} + +function emptyChart(W, H, label = "No data") { + return svgEl(W, H, `No data`, label); +} + + +function tokenTrendChart(rows) { + // rows: [{Month, Tokens, Cost}] — bars = token volume (left axis), line = AI cost (right axis) + // Flatter aspect ratio (vs. 280 previously) — same rationale as lineChart: + // ~15 monthly points don't need 280 units of vertical resolution. + const W = 760, H = 200; + const m = { l: 56, r: 58, 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, "AI token volume and cost trend — no data"); + const tokMax = Math.max(...rows.map((r) => r.Tokens || 0), 1) * 1.14; + const costMax = Math.max(...rows.map((r) => r.Cost || 0), 1) * 1.14; + const n = rows.length; + const cx = (i) => m.l + ((i + 0.5) / n) * iw; + const yTok = (v) => m.t + ih - (v / tokMax) * ih; + const yCost = (v) => m.t + ih - (v / costMax) * ih; + const bw = Math.max(4, (iw / n) * 0.62); + + let g = ""; + const ticks = 4; + for (let t = 0; t <= ticks; t++) { + const yy = m.t + ih - (ih / ticks) * t; + g += ``; + g += `${fmtTokens((tokMax / ticks) * t)}`; + g += `${fmtMoney((costMax / ticks) * t)}`; + } + // token bars (left axis) + rows.forEach((r, i) => { + const top = yTok(r.Tokens || 0); + const h = Math.max(0, m.t + ih - top); + g += `${esc(fmtMonth(r.Month))}\n${fmtTokens(r.Tokens)} tokens\n${fmtMoneyFull(r.Cost)}`; + }); + // cost line (right axis) + const pts = rows.map((r, i) => `${cx(i)},${yCost(r.Cost || 0)}`); + g += ``; + rows.forEach((r, i) => { + g += `${esc(fmtMonth(r.Month))}\n${fmtMoneyFull(r.Cost)}`; + }); + // x labels + g += xAxisMonthLabels(rows, cx, H); + const legend = legendHtml([ + { label: "Token volume", color: PALETTE[2] }, + { label: "AI effective cost", color: PALETTE[3] }, + ]); + return svgEl(W, H, g, "AI token volume and cost trend") + legend; +} + +// Fixed capability colors so the stacked chart, its legend, and the capability +// table all agree on which hue means which workload. A rotating index would +// re-colour a capability whenever the estate mix changes month to month. +const AI_CAPABILITY_COLORS = { + "GPU / accelerated compute": PALETTE[9], + "Foundation models (LLM)": PALETTE[2], + "AI Search / retrieval": PALETTE[5], + "ML platform & compute": PALETTE[0], + "ML / analytics platform": PALETTE[1], + "Cognitive services": PALETTE[3], + "Bot & agents": PALETTE[6], + "Other AI/ML": PALETTE[7], +}; +const aiColor = (capability) => AI_CAPABILITY_COLORS[capability] ?? UNKNOWN_COLOR; + +function aiCapabilityChart(rows) { + // rows: [{Month, Capability, Cost}] — stacked columns, one stack per month. + 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, "AI spend by capability over time — no data"); + + const months = [...new Set(rows.map((r) => r.Month))].sort(); + // Order the stack by total spend so the dominant capability sits at the base + // and the thin slices stay adjacent to the axis labels. + const totals = new Map(); + rows.forEach((r) => totals.set(r.Capability, (totals.get(r.Capability) ?? 0) + (r.Cost || 0))); + const caps = [...totals.entries()].sort((a, b) => b[1] - a[1]).map(([c]) => c); + const at = new Map(rows.map((r) => [`${r.Month}|${r.Capability}`, r.Cost || 0])); + + const monthTotals = months.map((mo) => caps.reduce((s, c) => s + (at.get(`${mo}|${c}`) ?? 0), 0)); + const yMax = niceMax(Math.max(...monthTotals, 1) * 1.02, 4); + const n = months.length; + const cx = (i) => m.l + ((i + 0.5) / n) * iw; + const bw = Math.max(4, (iw / n) * 0.62); + + let g = yAxisGrid(m, W, ih, yMax, 4, fmtMoney); + months.forEach((mo, i) => { + let acc = 0; + caps.forEach((c) => { + const v = at.get(`${mo}|${c}`) ?? 0; + if (v <= 0) return; + const h = (v / yMax) * ih; + const yTop = m.t + ih - ((acc + v) / yMax) * ih; + acc += v; + g += `${esc(fmtMonth(mo))}\n${esc(c)}\n${fmtMoneyFull(v)}`; + }); + }); + g += xAxisMonthLabels(months.map((mo) => ({ Month: mo })), cx, H); + const legend = legendHtml(caps.map((c) => ({ label: c, color: aiColor(c) }))); + return svgEl(W, H, g, "AI spend by capability over time") + legend; +} + +function monthAreaChart(rows, opts) { + // rows: [{Month, }] — filled area with a stroked top edge. + const { valueKey, color = PALETTE[0], valFmt = fmtMoney, tipFmt = valFmt, label = "Trend" } = opts; + 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; + const data = (rows || []).filter((r) => isFinite(r[valueKey])); + if (data.length === 0) return emptyChart(W, H, `${label} — no data`); + + const yMax = niceMax(Math.max(...data.map((r) => r[valueKey]), 1) * 1.02, 4); + const n = data.length; + const cx = (i) => m.l + ((i + 0.5) / n) * iw; + const y = (v) => m.t + ih - (v / yMax) * ih; + + let g = yAxisGrid(m, W, ih, yMax, 4, valFmt); + const pts = data.map((r, i) => `${cx(i)},${y(r[valueKey])}`); + const base = m.t + ih; + g += ``; + g += ``; + data.forEach((r, i) => { + g += `${esc(fmtMonth(r.Month))}\n${tipFmt(r[valueKey])}`; + }); + g += xAxisMonthLabels(data, cx, H); + return svgEl(W, H, g, label); +} + +function anomalyChart(rows) { + // rows: [{Day, Cost, Flag, Baseline}] + // Flattened to match lineChart/tokenTrendChart's aspect ratio (was 280) so + // this full-width daily chart doesn't read as taller/heavier than the other + // trend charts across tabs — daily granularity needs horizontal, not + // vertical, resolution. + 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, "Daily anomaly detection — no data"); + const max = Math.max(...rows.map((r) => Math.max(r.Cost || 0, r.Baseline || 0)), 1) * 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 / max) * ih; + let g = ""; + g += yAxisGrid(m, W, ih, max, 4, fmtMoney); + // month x labels + let lastMonth = ""; + rows.forEach((r, i) => { + const mo = String(r.Day).slice(0, 7); + if (mo !== lastMonth) { + lastMonth = mo; + g += `${esc(fmtMonth(mo))}`; + } + }); + // baseline (dashed) + cost line + const base = rows.map((r, i) => `${x(i)},${y(r.Baseline || 0)}`).join(" L"); + g += ``; + const cost = rows.map((r, i) => `${x(i)},${y(r.Cost || 0)}`).join(" L"); + g += ``; + // anomaly markers + rows.forEach((r, i) => { + if (r.Flag !== 0) { + const up = r.Flag > 0; + g += `${esc(String(r.Day).slice(0, 10))}\n${fmtMoneyFull(r.Cost)} (${up ? "spike" : "drop"})\nbaseline ${fmtMoneyFull(r.Baseline)}`; + } + }); + const legend = legendHtml([ + { label: "Daily effective cost", color: PALETTE[0] }, + { label: "Expected baseline", color: "var(--muted)" }, + { label: "Spike", color: PALETTE[4] }, + { label: "Drop", color: PALETTE[1] }, + ]); + return svgEl(W, H, g, "Daily anomaly detection — cost vs expected baseline") + legend; +} + +function momBars(rows) { + // rows: [{Month, EffChangePct}] — diverging bars (cost up = red, down = green) + const W = 760, H = 240; + const m = { l: 44, r: 14, t: 14, b: 34 }; + const iw = W - m.l - m.r, ih = H - m.t - m.b; + const data = (rows || []).filter((r) => isFinite(r.EffChangePct)); + if (data.length === 0) return emptyChart(W, H, "Month-over-month effective cost change — no data"); + const maxAbs = Math.max(...data.map((r) => Math.abs(r.EffChangePct)), 5); + const n = data.length; + const y0 = m.t + ih / 2; // zero line + const cx = (i) => m.l + ((i + 0.5) / n) * iw; + const bw = Math.max(5, (iw / n) * 0.6); + const yScale = (v) => (v / maxAbs) * (ih / 2); + let g = ``; + data.forEach((r, i) => { + const v = r.EffChangePct; + const h = Math.abs(yScale(v)); + const yTop = v >= 0 ? y0 - h : y0; + const color = v > 0 ? PALETTE[4] : PALETTE[1]; + g += `${esc(fmtMonth(r.Month))}\n${v > 0 ? "+" : ""}${v.toFixed(1)}%`; + }); + g += xAxisMonthLabels(data, cx, H); + return svgEl(W, H, g, "Month-over-month effective cost change"); +} + +function forecastChart(rows, splitMonth) { + // rows: [{Month, Actual, Forecast}] — actual solid up to splitMonth, forecast dashed onward + const W = 760, H = 280; + 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, "Cost forecast — no data"); + const max = Math.max(...rows.map((r) => Math.max(r.Actual || 0, r.Forecast || 0)), 1) * 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 / max) * ih; + let g = ""; + g += yAxisGrid(m, W, ih, max, 4, fmtMoney); + const splitIdx = rows.findIndex((r) => r.Month >= splitMonth); + const sIdx = splitIdx < 0 ? n - 1 : splitIdx; + // shaded forecast region + g += ``; + // actual line up to split + const actualPts = rows.slice(0, sIdx + 1).map((r, i) => `${x(i)},${y(r.Actual || 0)}`); + if (actualPts.length > 1) g += ``; + // forecast line from split onward + const fcPts = rows.slice(sIdx).map((r, i) => `${x(sIdx + i)},${y(r.Forecast || 0)}`); + if (fcPts.length > 1) g += ``; + // x labels + g += xAxisMonthLabels(rows, x, H); + rows.forEach((r, i) => { + const isFc = i >= sIdx; + g += `${esc(fmtMonth(r.Month))}\n${isFc ? "forecast " + fmtMoneyFull(r.Forecast) : "actual " + fmtMoneyFull(r.Actual)}`; + }); + const legend = legendHtml([ + { label: "Actual", color: PALETTE[0] }, + { label: "Forecast", color: PALETTE[3] }, + ]); + return svgEl(W, H, g, "Cost forecast — actual vs projected") + legend; +} + +/* --------------------------------------------------------------- KPI calc */ + +function deriveKpis(d) { + const s = d.summary?.[0] || {}; + const list = s.List || 0, eff = s.Effective || 0, contracted = s.Contracted || 0, billed = s.Billed || 0; + const savings = list - eff; + const esr = list > 0 ? savings / list : 0; + const negotiated = list - contracted; + const commitment = contracted - eff; + + const tagMap = Object.fromEntries((d.tagged || []).map((r) => [r._t, r.Cost || 0])); + const tagged = tagMap.Tagged || 0, untagged = tagMap.Untagged || 0; + const tagTotal = tagged + untagged; + const untaggedPct = tagTotal > 0 ? untagged / tagTotal : 0; + + const priceMap = Object.fromEntries((d.pricing || []).map((r) => [r.PricingCategory, r.Cost || 0])); + const committed = priceMap.Committed || 0; + const priceTotal = Object.values(priceMap).reduce((a, b) => a + b, 0); + const coverage = priceTotal > 0 ? committed / priceTotal : 0; + + const trend = d.trend || []; + let mom = null, lastMonthVal = null, lastMonthLabel = null; + if (trend.length >= 1) { + const last = trend[trend.length - 1]; + lastMonthVal = last.Effective || 0; + lastMonthLabel = fmtMonth(last.Month); + if (trend.length >= 2) { + const prev = trend[trend.length - 2].Effective || 0; + mom = prev > 0 ? (lastMonthVal - prev) / prev : null; + } + } + + return { + billed, eff, list, contracted, savings, esr, negotiated, commitment, + tagged, untagged, untaggedPct, committed, coverage, + resources: s.Resources || 0, services: s.Services || 0, + subscriptions: s.Subscriptions || 0, regions: s.Regions || 0, + mom, lastMonthVal, lastMonthLabel, + }; +} + +function kpiThreshold(pct, greenMax, amberMax) { + if (pct < greenMax) return "threshold-green"; + if (pct < amberMax) return "threshold-amber"; + return "threshold-red"; +} + +const VALID_TABS = ["overview", "allocation", "rate", "usage", "anomaly", "tokenomics", "ai", "capacity", "monaco"]; + +// "Tool" tabs are experiments that don't follow the KPI dashboard pipeline +// (no preset/filter-driven queries, no response caching) — they render their +// own surface and manage their own state. +const TOOL_TABS = new Set(["monaco"]); + +function switchTab(tabId, opts = {}) { + if (!VALID_TABS.includes(tabId) || (!opts.force && state.loading) || tabId === state.tab) return; + const leavingMonaco = state.tab === "monaco"; + state.tab = tabId; + [...el("tabs").querySelectorAll("button")].forEach((b) => { + const active = b.dataset.tab === tabId; + b.classList.toggle("active", active); + b.setAttribute("aria-selected", active ? "true" : "false"); + }); + revealActiveTab(); + const isTool = TOOL_TABS.has(tabId); + el("preset").hidden = isTool || tabId === "capacity"; + el("refresh").hidden = isTool; + el("app-footer").hidden = isTool; + if (isTool) el("filter-bar").hidden = true; + if (leavingMonaco && tabId !== "monaco") disposeMonacoEditor(); + if (!opts.skipHash) { + const url = new URL(location.href); + url.hash = tabId === "capacity" + ? `tab=capacity&capacity=${state.capacityClass}` + : `tab=${tabId}`; + history.pushState({ tab: tabId }, "", url); + } + if (!opts.skipPublish) void publishCanvasState({ tab: tabId }); + load(); +} + +function tabFromHash() { + const m = /tab=([a-z]+)/.exec(location.hash); + return m && VALID_TABS.includes(m[1]) ? m[1] : null; +} + +function capacityClassFromHash() { + const match = /(?:^|&)capacity=([a-z0-9-]+)/.exec(location.hash.replace(/^#/, "")); + return match && CAPACITY_TABS.some((item) => item.id === match[1]) ? match[1] : null; +} + +export function nextCapacityTabIndex(currentIndex, key, count = CAPACITY_TABS.length) { + if (!Number.isInteger(currentIndex) || currentIndex < 0 || currentIndex >= count || count < 1) return -1; + if (key === "Home") return 0; + if (key === "End") return count - 1; + if (key === "ArrowRight" || key === "ArrowDown") return (currentIndex + 1) % count; + if (key === "ArrowLeft" || key === "ArrowUp") return (currentIndex - 1 + count) % count; + return currentIndex; +} + +function selectCapacityClass(classId, options = {}) { + if (!CAPACITY_TABS.some((item) => item.id === classId) || state.loading) return; + const changed = classId !== state.capacityClass; + state.capacityClass = classId; + if (changed) { + state.capacitySelections = {}; + resetCapacityDetail(); + } + if (!options.skipHash) { + history.pushState({ tab: "capacity", capacityClass: classId }, "", `#tab=capacity&capacity=${classId}`); + } + if (!options.skipPublish) { + void publishCanvasState({ capacityClass: classId, capacitySelections: state.capacitySelections }); + } + if (changed || options.force) load(); +} + +function resetCapacityDetail() { + invalidateCapacitySubscriptions(); + state.capacityDetailTab = "families"; + state.capacityFamilyPage = 1; + state.capacitySubscriptionSearch = ""; +} + +function invalidateCapacitySubscriptions() { + if (_capacitySubscriptionAbort) _capacitySubscriptionAbort.abort(); + _capacitySubscriptionAbort = null; + _capacitySubscriptionFocusResults = false; + state.capacitySubscriptionPage = 1; + state.capacitySubscriptionData = null; + state.capacitySubscriptionLoading = false; + state.capacitySubscriptionError = null; +} + +// The family matrix filters run against the payload already in memory, so they +// re-render without a round trip. Re-rendering replaces the search input, so its +// focus and caret are restored by hand. +function setFamilyFilter(patch) { + state.familyFilter = { ...state.familyFilter, ...patch }; + state.capacityFamilyPage = 1; + state.capacitySubscriptionPage = 1; + state.capacitySubscriptionData = null; + const active = document.activeElement; + const search = document.getElementById("family-search"); + const hadSearchFocus = search && active === search; + const caret = hadSearchFocus ? search.selectionStart : null; + const lensFocus = !hadSearchFocus && active?.dataset?.familyStatus ? state.familyFilter.status : null; + const regionFocus = !hadSearchFocus && active?.dataset?.familyRegion ? active.dataset.familyRegion : null; + const markFocus = !hadSearchFocus && active?.dataset?.familyMark ? String(state.familyFilter.mark) : null; + render(); + if (state.capacityDetailTab === "subscriptions") { + queueMicrotask(() => void loadCapacitySubscriptions()); + } + if (hadSearchFocus) { + const next = document.getElementById("family-search"); + if (!next) return; + next.focus(); + if (caret !== null) next.setSelectionRange(caret, caret); + return; + } + const selector = lensFocus + ? `[data-family-status="${CSS.escape(lensFocus)}"]` + : regionFocus ? `[data-family-region="${CSS.escape(regionFocus)}"]` + : markFocus ? `[data-family-mark="${CSS.escape(markFocus)}"]` : null; + if (selector) document.querySelector(selector)?.focus(); +} + +function applyCapacitySelection(kind, value) { + if (!["quota", "metric", "demand"].includes(kind) || state.loading) return; + const next = { ...state.capacitySelections }; + const selectionName = `${kind}Selection`; + if (!value) { + delete next[selectionName]; + if (kind === "quota") delete next.metricSelection; + } else { + const payload = currentPayload(); + const rows = kind === "demand" + ? payload?.demand?.selectors?.items + : payload?.selectors?.items; + const row = rows?.[Number(value)]; + const selection = capacitySelectionFromRow(kind, state.capacityClass, row); + if (!selection) return; + next[selectionName] = selection; + if (kind === "quota") { + const metric = capacitySelectionFromRow("metric", state.capacityClass, row); + if (metric && Object.values(metric).every(Boolean)) next.metricSelection = metric; + else delete next.metricSelection; + } + } + state.capacitySelections = next; + void publishCanvasState({ capacitySelections: next }); + load(); +} + +function setCapacityDetailTab(tab) { + if (!["families", "subscriptions"].includes(tab) || tab === state.capacityDetailTab) return; + state.capacityDetailTab = tab; + render(); + document.querySelector(`[data-capacity-detail-tab="${CSS.escape(tab)}"]`)?.focus(); + if (tab === "subscriptions" && !state.capacitySubscriptionData) { + void loadCapacitySubscriptions(); + } +} + +function renderPreservingSubscriptionSearchFocus() { + const search = document.querySelector("[data-capacity-subscription-search]"); + const focused = search && document.activeElement === search; + const detailTab = document.activeElement?.dataset?.capacityDetailTab; + const caret = focused ? search.selectionStart : null; + render(); + if (focused) { + const next = document.querySelector("[data-capacity-subscription-search]"); + next?.focus(); + if (caret !== null) next?.setSelectionRange(caret, caret); + } else if (detailTab) { + document.querySelector(`[data-capacity-detail-tab="${CSS.escape(detailTab)}"]`)?.focus(); + } else if (_capacitySubscriptionFocusResults) { + const results = document.querySelector("#capacity-subscription-summary, #capacity-detail-panel [role=alert]"); + if (results) { + results.focus(); + _capacitySubscriptionFocusResults = false; + } + } +} + +async function loadCapacitySubscriptions() { + if (state.capacityClass !== "compute" || state.capacityDetailTab !== "subscriptions") return; + if (_capacitySubscriptionAbort) _capacitySubscriptionAbort.abort(); + const controller = new AbortController(); + _capacitySubscriptionAbort = controller; + state.capacitySubscriptionLoading = true; + state.capacitySubscriptionError = null; + renderPreservingSubscriptionSearchFocus(); + try { + const response = await fetch("/api/capacity-subscriptions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + status: state.familyFilter.status, + familySearch: state.familyFilter.search, + regions: state.familyFilter.regions, + subscriptionSearch: state.capacitySubscriptionSearch, + page: state.capacitySubscriptionPage, + pageSize: 50, + }), + signal: controller.signal, + }); + const body = await response.json(); + if (!response.ok || body.error) throw new Error(body.error || "Could not load subscriptions."); + state.capacitySubscriptionData = body; + } catch (err) { + if (err.name === "AbortError") return; + state.capacitySubscriptionError = err.message || "Could not load subscriptions."; + } finally { + if (_capacitySubscriptionAbort === controller) { + state.capacitySubscriptionLoading = false; + renderPreservingSubscriptionSearchFocus(); + } + } +} + +function moveCapacityTabFocus(target, key) { + const tabs = [...document.querySelectorAll("[data-capacity-class]")]; + const currentIndex = tabs.indexOf(target); + const nextIndex = nextCapacityTabIndex(currentIndex, key, tabs.length); + tabs.forEach((tab, index) => { + tab.tabIndex = index === nextIndex ? 0 : -1; + }); + tabs[nextIndex]?.focus(); +} + +/* --------------------------------------------------------- triage strip */ + +function buildTriageTile(title, count, cue, tabId) { + const isTeaser = count === null; + const cls = isTeaser ? "is-teaser" : count === 0 ? "threshold-green" : count <= 4 ? "threshold-amber" : "threshold-red"; + const badge = isTeaser ? "Not loaded" : count === 0 ? "Good" : count <= 4 ? "Review" : "Urgent"; + const display = isTeaser ? "—" : count === 0 ? "None" : fmtInt(count); + return ``; +} + +function renderTriageStrip(d) { + // Anomalies: reuse anomaly tab cache when loaded (use same cache key for consistency) + const anomPayload = state.cache["anomaly"]?.[cacheKey()]; + const daily = anomPayload?.data?.daily || []; + const anomCount = anomPayload ? daily.filter((r) => r.Flag !== 0).length : null; + const anomCue = anomCount === null ? "Visit Anomalies & forecast tab to load" + : anomCount === 0 ? "No anomalies detected" + : "Review flagged cost days"; + + // Overspend: months in trend where effective cost rose >20% vs prior month + const trend = d.trend || []; + let overspendCount = 0; + for (let i = 1; i < trend.length; i++) { + const prev = trend[i - 1].Effective || 0; + const curr = trend[i].Effective || 0; + if (prev > 0 && curr > prev * 1.20) overspendCount++; + } + const overspendCue = overspendCount === 0 + ? "Spend within expected range" + : `${overspendCount} month${overspendCount === 1 ? "" : "s"} with >20% spike`; + + // Savings opportunities: underutilized commitments from rate tab cache when loaded + const ratePayload = state.cache["rate"]?.[cacheKey()]; + const byCommitment = ratePayload?.data?.byCommitment || []; + const savingsCount = ratePayload ? byCommitment.filter((r) => (r.Unused || 0) > 0).length : null; + const savingsCue = savingsCount === null ? "Visit Rate optimization tab to load" + : savingsCount === 0 ? "Commitments fully utilized" + : "Underutilized commitments found"; + + return `
+ ${buildTriageTile("Anomalies", anomCount, anomCue, "anomaly")} + ${buildTriageTile("Overspend", overspendCount, overspendCue, "usage")} + ${buildTriageTile("Savings Opportunities", savingsCount, savingsCue, "rate")} +
`; +} + +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 `
+
${esc(label)}${tipHtml}
+
${value}
+
${meta}
+
`; +} + +/* ---------------------------------------------------------------- render */ + +function panelHtml(id, span, title, sub, body) { + const subHtml = sub ? `

${sub}

` : ""; + return `
+

${title}

${subHtml}
+
${body}
+
`; +} + +function openKqlDialog(panelId) { + _kqlPanelId = panelId; + // Prefer the query the server actually executed for this panel; fall back to + // the static map for tabs that don't publish their queries yet. + const served = currentPayload()?.kql?.[PANEL_QUERY[panelId]]; + el("kql-text").value = served || PANEL_KQL[panelId] || ""; + el("kql-error").textContent = ""; + const prev = document.getElementById("kql-result"); + if (prev) prev.remove(); + el("kql-dialog").showModal(); +} + +async function executeKql() { + const kql = el("kql-text").value.trim(); + const errEl = el("kql-error"); + const runBtn = el("kql-run"); + if (!kql) return; + errEl.textContent = ""; + const prev = document.getElementById("kql-result"); + if (prev) prev.remove(); + runBtn.disabled = true; + runBtn.textContent = "Running…"; + try { + const res = await fetch("/api/kql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kql }), + }); + if (!res.ok) { errEl.textContent = `Server error ${res.status}`; return; } + const data = await res.json(); + if (data.error) { + errEl.textContent = data.error; + } else { + const rows = data.rows || []; + if (!rows.length) { + errEl.textContent = "Query returned no rows."; + } else { + el("kql-dialog").close(); + renderKqlResultInPanel(_kqlPanelId, rows); + } + } + } catch (err) { + errEl.textContent = "Request failed: " + err.message; + } finally { + runBtn.disabled = false; + runBtn.textContent = "Run"; + } +} + +function renderKqlResultInPanel(panelId, rows) { + const panelBody = document.querySelector(`[data-panel-id="${panelId}"] .panel-body`); + if (!panelBody) return; + const cols = Object.keys(rows[0]); + const head = cols.map((c) => `${esc(c)}`).join(""); + const body = rows.slice(0, 200).map((r) => + `${cols.map((c) => `${esc(String(r[c] ?? ""))}`).join("")}` + ).join(""); + panelBody.innerHTML = `

${rows.length} rows${rows.length > 200 ? " (showing first 200)" : ""}

${head}${body}
`; +} + +function renderOverview(p) { + const content = el("content"); + if (!p) return; + if (p.error) return renderError(p); + if (p.empty) { + content.innerHTML = `

No cost data

The Hub database has no rows yet. Ingest cost data, then refresh.

`; + return; + } + const k = deriveKpis(p.data); + + const momClass = k.mom == null ? "" : k.mom > 0 ? "neg" : "pos"; // cost up = bad + const momTxt = k.mom == null ? "—" : `${k.mom > 0 ? "▲" : "▼"} ${fmtPct(Math.abs(k.mom))}`; + + const partialHtml = isPartialMonth() ? ` · partial month` : ""; + const maccRow = p.data.macc?.[0] || { ConsumptionAmount: 0, CommitmentAmount: 0, CommitmentBurnPercent: 0 }; + + const kpis = [ + // primary KPIs first + kpiCard("Untagged cost", fmtPct(k.untaggedPct), + `${fmtMoney(k.untagged)} on untagged resources`, PALETTE[3], + kpiThreshold(k.untaggedPct, 0.10, 0.25), "primary"), + // supporting KPIs + kpiCard("Effective cost", fmtMoney(k.eff), `Billed ${fmtMoney(k.billed)}`, PALETTE[0]), + kpiCard("Total savings", fmtMoney(k.savings), + `${fmtPct(k.esr)} effective savings rate`, PALETTE[1]), + // reference KPIs + kpiCard("Commitment coverage", fmtPct(k.coverage), + `${fmtMoney(k.committed)} of compute spend`, PALETTE[5], undefined, "reference"), + // supporting KPIs + kpiCard("Tracked resources", fmtInt(k.resources), + `${fmtInt(k.services)} services · ${fmtInt(k.subscriptions)} subs · ${fmtInt(k.regions)} regions`, PALETTE[2]), + kpiCard("Latest month", k.lastMonthVal == null ? "—" : fmtMoney(k.lastMonthVal), + k.mom == null ? (k.lastMonthLabel ? `${esc(k.lastMonthLabel)}${partialHtml}` : (isPartialMonth() ? `partial month` : "")) : `${momTxt} vs prior · ${esc(k.lastMonthLabel)}${partialHtml}`, PALETTE[4]), + // macc-consumption-vs-commitment — MACC burn rate. Demote to reference + // tier when unconfigured (N/A) so an empty card doesn't take full + // primary-grid visual weight. + kpiCard("MACC burn rate", + maccRow.CommitmentAmount > 0 ? fmtPct(maccRow.CommitmentBurnPercent / 100) : "N/A", + maccRow.CommitmentAmount > 0 + ? `${fmtMoney(maccRow.ConsumptionAmount)} of ${fmtMoney(maccRow.CommitmentAmount)} committed` + : "No Microsoft Azure Consumption Commitment data", + PALETTE[7], undefined, maccRow.CommitmentAmount > 0 ? undefined : "reference"), + ].join(""); + + const d = p.data; + const html = ` + ${renderTriageStrip(d)} +
${kpis}
+ +

Understand usage & cost

FinOps Framework
+
+ ${panelHtml("overview-trend", 12, "Monthly cost trend", "Billed vs effective cost by month — executive run-rate view.", lineChart(d.trend))} + ${panelHtml("overview-top-services", 6, "Top services by cost", "Effective cost by Azure service.", hbar(d.topServices, "ServiceName", "Cost", { label: "Top services by cost" }))} + ${panelHtml("overview-service-category", 6, "Cost by service category", "Where spend concentrates across categories.", hbar(d.serviceCategory, "ServiceCategory", "Cost", { label: "Cost by service category" }))} +
+ +

Optimize usage & cost

FinOps Framework
+
+ ${panelHtml("overview-top-rgs", 6, "Top resource groups", "Largest cost owners for allocation & accountability.", hbar(d.topResourceGroups, "x_ResourceGroupName", "Cost", { label: "Top resource groups" }))} + ${panelHtml("overview-top-regions", 6, "Cost by region", "Regional spend for placement & sustainability review.", hbar(d.topRegions, "RegionId", "Cost", { label: "Cost by region" }))} +
+ +

Quantify business value

FinOps Framework
+
+ ${panelHtml("overview-rate-coverage", 4, "Rate coverage", "Committed vs on-demand (standard) effective cost.", donut([ + { label: "Committed", value: k.committed, color: PALETTE[1] }, + { label: "On-demand", value: Math.max(0, k.eff - k.committed), color: PALETTE[0] }, + ], { centerBig: fmtPct(k.coverage), centerSmall: "covered", label: "Rate coverage" }))} + ${panelHtml("overview-savings", 4, "Savings breakdown", "List → effective, by discount type.", savingsTable(k))} + ${panelHtml("overview-cost-allocation", 4, "Cost allocation", "Tagged vs untagged effective cost.", donut([ + { label: "Tagged", value: k.tagged, color: PALETTE[1] }, + { label: "Untagged", value: k.untagged, color: UNKNOWN_COLOR, isUnknown: true }, + ], { centerBig: fmtPct(1 - k.untaggedPct), centerSmall: "tagged", label: "Cost allocation" }))} +
+ `; + content.innerHTML = html; +} + +function savingsTable(k) { + return costBreakdownTable([ + { label: "List cost", val: k.list, accent: "var(--muted)" }, + { label: "Negotiated savings", val: k.negotiated, accent: PALETTE[8] }, + { label: "Commitment savings", val: k.commitment, accent: PALETTE[1] }, + { label: "Effective cost", val: k.eff, accent: PALETTE[0] }, + ], "Effective savings rate", fmtPct(k.esr)); +} + +/* ----------------------------------------------------- tokenomics render */ + +function deriveTokenKpis(d) { + 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; + const cachedShare = inTok + cachedTok > 0 ? cachedTok / (inTok + cachedTok) : 0; + return { + tokens, eff, cloud, + blendedPer1K: tokens > 0 ? eff / tokens * 1000 : 0, + cachedShare, + aiShare: cloud > 0 ? eff / cloud : 0, + models: s.Models || 0, + resources: s.Resources || 0, + }; +} + +function renderTokenomics(p) { + const content = el("content"); + if (!p) return; + if (p.error) return renderError(p); + if (p.empty) { + content.innerHTML = `

No AI token data

+

No Azure OpenAI token meters were found in the Hub database for this period.

+

Tokenomics tracks meters where x_SkuMeterSubcategory contains “OpenAI” and the SKU is billed in tokens. Ingest Azure OpenAI usage, then refresh.

`; + return; + } + const d = p.data; + const k = deriveTokenKpis(d); + + const dirColors = { "Input": PALETTE[0], "Cached input": PALETTE[1], "Output": PALETTE[3], "Other": PALETTE[6] }; + const dirSlices = (d.direction || []).map((r) => ({ + label: r.Direction, value: r.Tokens || 0, cost: r.Cost || 0, color: dirColors[r.Direction] || PALETTE[6], + })); + + const kpis = [ + // reference KPIs first (no primaries in this tab) + kpiCard("Total tokens", fmtTokens(k.tokens), `across ${fmtInt(k.models)} model families`, PALETTE[0], undefined, "reference"), + // supporting KPIs + kpiCard("AI token cost", fmtMoney(k.eff), `${fmtPct(k.aiShare, 2)} of all cloud cost`, PALETTE[2], undefined, "primary"), + kpiCard("Blended rate", fmtPerM(k.blendedPer1K), `per 1M tokens (effective)`, PALETTE[5]), + kpiCard("Cached input", fmtPct(k.cachedShare), + `${fmtPct(k.cachedShare)} of input tokens cached`, PALETTE[1]), + kpiCard("AI resources", fmtInt(k.resources), `Azure OpenAI deployments`, PALETTE[4]), + kpiCard("Models in use", fmtInt(k.models), `distinct model families`, PALETTE[8]), + ].join(""); + + content.innerHTML = ` +
${kpis}
+ +

AI token economics

Token Consumption Metrics KPI
+
+ ${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))} +
+ `; +} + +function tokenModelTable(models, totalCost) { + const rows = (models || []).filter((m) => (m.Tokens || 0) > 0); + if (rows.length === 0) return `

No token data in range.

`; + const maxPer1K = Math.max(...rows.map((m) => m.CostPer1K || 0), 1e-9); + const body = rows.map((m, i) => { + const color = PALETTE[i % PALETTE.length]; + const share = totalCost > 0 ? (m.Cost || 0) / totalCost : 0; + const barW = Math.max(2, ((m.CostPer1K || 0) / maxPer1K) * 90); + return ` + ${swatchHtml(color)}${esc(m.Model)} + ${fmtTokens(m.Tokens)} + ${fmtMoneyFull(m.Cost)} + ${fmtPerM(m.CostPer1K)} + ${fmtPct(share)} + `; + }).join(""); + return ` + + ${body} +
ModelTokensEffective cost$ / 1M tokens% of AI cost
`; +} + +function aiByAppTable(rows) { + const data = (rows || []).filter((r) => (r.EffectiveCost || 0) > 0); + if (data.length === 0) return `

No tagged AI cost data. Tag Azure OpenAI resources with application, team, or environment tags.

`; + const totalCost = data.reduce((a, r) => a + (r.EffectiveCost || 0), 0); + const untaggedCount = data.filter((r) => !r.Application).length; + const callout = untaggedCount === data.length + ? `
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.
` + : ""; + return callout + ` + + ${data.map((r, i) => { + const share = totalCost > 0 ? (r.EffectiveCost || 0) / totalCost : 0; + const isUnknown = !r.Application; + const color = PALETTE[i % PALETTE.length]; + return ` + + + + + + + + + `; + }).join("")} +
ApplicationTeamEnvironmentCost centerTokensEffective cost$/1M tokens% of AI
${swatchHtml(color, isUnknown)}${esc(r.Application || "(untagged)")}${esc(r.Team || "—")}${esc(r.Environment || "—")}${esc(r.CostCenter || "—")}${fmtTokens(r.TokenCount)}${fmtMoney(r.EffectiveCost)}${fmtPerM(r.CostPer1KTokens)}${fmtPct(share)}
`; +} + +/* --------------------------------------------- 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 `
${html}
`; +} + +function aiCapabilityTable(rows, estate) { + const money = moneyColumn(rows, "Cost"); + return wideTable(tableHtml([ + { label: "Capability", align: "left", get: (r) => + `${swatchHtml(aiColor(r.Capability))}${nameCell(r.Capability, 26)}` }, + { label: "Services", get: (r) => fmtInt(r.Services) }, + { label: "Cost", get: (r) => money(r.Cost) }, + { label: "Share", get: (r) => estate > 0 ? fmtShare(r.Cost / estate, 1) : "—" }, + ], rows, "No AI/ML estate cost in range.")); +} + +function aiModelBenchTable(rows) { + const money = moneyColumn(rows, "Cost"); + const rate = rateColumn(rows, "Cpmt"); + return wideTable(tableHtml([ + { label: "Model family", align: "left", get: (r) => nameCell(r.Family, 26) }, + { label: "Tokens", get: (r) => fmtTokens(r.Tokens) }, + { label: "Cost", get: (r) => money(r.Cost) }, + { label: "$ / 1M tokens", get: (r) => rate(r.Cpmt) }, + ], rows, "No foundation model token meters in range.")); +} + +function aiDirectionTable(rows) { + const rate = rateColumn(rows, "Cpmt"); + const total = (rows || []).reduce((s, r) => s + (r.Tokens || 0), 0); + return wideTable(tableHtml([ + { label: "Direction", align: "left", get: (r) => nameCell(r.Direction, 26) }, + { label: "Tokens", get: (r) => fmtTokens(r.Tokens) }, + { label: "Share", get: (r) => total > 0 ? fmtShare(r.Tokens / total, 1) : "—" }, + { label: "$ / 1M tokens", get: (r) => rate(r.Cpmt) }, + ], rows, "No foundation model token meters in range.")); +} + +export function deriveAiKpis(d, lastClosedMonth) { + const months = d.monthly || []; + const sum = (key) => months.reduce((s, r) => s + (r[key] || 0), 0); + const cloud = sum("Cloud"), estate = sum("Estate"), mlGpu = sum("MlGpu"); + const tokens = sum("Tokens"), tokenCost = sum("TokenCost"); + + // Anchor month-over-month to the last *closed* month reported by the server. + // The newest month in the window is normally a partial ingestion month, and + // comparing it against a full month reports a collapse that isn't real. + const closedIdx = lastClosedMonth ? months.findIndex((r) => r.Month === lastClosedMonth) : -1; + const closed = closedIdx >= 0 ? months[closedIdx] : null; + const prior = closedIdx > 0 ? months[closedIdx - 1] : null; + const mom = closed && prior && prior.Estate > 0 ? (closed.Estate - prior.Estate) / prior.Estate : null; + + const a = (d.allocation || [])[0] || {}; + const allocTotal = a.Total || 0; + const appCoverage = allocTotal > 0 ? (a.App || 0) / allocTotal : null; + + const posture = (d.posture || [])[0] || {}; + + return { + cloud, estate, mlGpu, tokens, tokenCost, + estateShare: cloud > 0 ? estate / cloud : 0, + mlGpuShare: estate > 0 ? mlGpu / estate : 0, + cpmt: tokens > 0 ? (tokenCost / tokens) * 1000000 : null, + mom, closedMonth: lastClosedMonth, hasClosedMonth: !!closed, + partialMonth: months.length > 0 && months[months.length - 1].Month !== lastClosedMonth + ? months[months.length - 1].Month : null, + alloc: a, allocTotal, appCoverage, + committedShare: posture.Total > 0 ? (posture.Committed || 0) / posture.Total : null, + recommendations: ((d.recommendations || [])[0] || {}).Count ?? 0, + transactions: ((d.transactions || [])[0] || {}).Count ?? 0, + }; +} + +function aiAllocationTable(k) { + const rows = [ + { Dimension: "Application tag", Covered: k.alloc.App || 0 }, + { Dimension: "Owner / team tag", Covered: k.alloc.Owner || 0 }, + { Dimension: "Cost center", Covered: k.alloc.CostCenter || 0 }, + { Dimension: "Resource group", Covered: k.alloc.ResourceGroup || 0 }, + ]; + if (k.allocTotal <= 0) return `

No AI/ML estate cost in range.

`; + 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.

`; + return; + } + const d = p.data; + const k = deriveAiKpis(d, p.lastClosedMonth); + + const momTxt = k.mom == null ? null : `${k.mom > 0 ? "+" : ""}${fmtPct(k.mom, 1)}`; + const momCls = k.mom == null ? "muted" : k.mom > 0 ? "neg" : "pos"; + const estateMeta = momTxt + ? `${momTxt} vs prior · ${esc(fmtMonth(k.closedMonth))}` + : `${fmtPct(k.estateShare, 1)} of all cloud cost`; + + const covCls = k.appCoverage == null ? undefined + : k.appCoverage >= 0.85 ? "threshold-green" : k.appCoverage >= 0.65 ? "threshold-amber" : "threshold-red"; + + const cpmtTrend = (d.monthly || []) + .filter((r) => (r.Tokens || 0) > 0) + .map((r) => ({ Month: r.Month, Cpmt: (r.TokenCost / r.Tokens) * 1000000 })); + + const kpis = [ + kpiCard("AI/ML estate spend", fmtMoney(k.estate), estateMeta, PALETTE[2], undefined, "primary"), + kpiCard("ML & GPU compute", fmtMoney(k.mlGpu), `${fmtPct(k.mlGpuShare, 1)} of AI/ML estate`, PALETTE[9]), + kpiCard("Token volume", fmtTokens(k.tokens), `${fmtMoney(k.tokenCost)} in token meters`, PALETTE[0]), + kpiCard("Cost per 1M tokens", k.cpmt == null ? "—" : fmtRate(k.cpmt), + k.cpmt == null ? "No token meters in range" : "Blended across all model families", PALETTE[5]), + kpiCard("AI allocation coverage", k.appCoverage == null ? "—" : fmtPct(k.appCoverage), + k.appCoverage == null ? "No AI/ML estate cost in range" : "Carrying an application tag", + PALETTE[3], covCls), + kpiCard("AI share of cloud", fmtPct(k.estateShare, 1), `${fmtMoney(k.estate)} of ${fmtMoney(k.cloud)}`, PALETTE[1], undefined, "reference"), + ].join(""); + + // One money scale per detail table, derived from that table's own maximum. + const mlGpuMoney = moneyColumn(d.mlGpu, "Cost"); + const mlUnitMoney = moneyColumn(d.mlUnit, "Cost"); + const mlUnitVmRate = rateColumn(d.mlUnit, "PerVmHour"); + const mlUnitCoreRate = rateColumn(d.mlUnit, "Per1KCoreHours"); + const searchMoney = moneyColumn(d.search, "Cost"); + const cognitiveMoney = moneyColumn(d.cognitive, "Cost"); + + const partialNote = k.partialMonth + ? ` Month-over-month figures compare ${esc(fmtMonth(k.closedMonth))} against the month before it; ${esc(fmtMonth(k.partialMonth))} is still ingesting and is excluded from those comparisons.` + : ""; + + content.innerHTML = ` +
${kpis}
+ +

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))} +
+ +

Workload detail

Compute, retrieval & applied AI
+
+ ${panelHtml("ai-ml-gpu", 12, "ML platform & GPU compute", "Components behind machine learning and accelerated compute spend.", + wideTable(tableHtml([ + { label: "Component", align: "left", get: (r) => nameCell(r.Component, 28) }, + { label: "Unit", align: "left", get: (r) => nameCell(r.Unit, 16) }, + { label: "Quantity", get: (r) => fmtQty(r.Quantity) }, + { label: "Cost", get: (r) => mlGpuMoney(r.Cost) }, + ], d.mlGpu)))} + ${panelHtml("ai-search", 12, "AI Search / retrieval", "Azure AI Search meters supporting retrieval-augmented generation.", + wideTable(tableHtml([ + { label: "Meter", align: "left", get: (r) => nameCell(r.Meter, 28) }, + { label: "Unit", align: "left", get: (r) => nameCell(r.Unit, 16) }, + { label: "Quantity", get: (r) => fmtQty(r.Quantity) }, + { label: "Cost", get: (r) => searchMoney(r.Cost) }, + ], d.search, "No Azure AI Search meters in range.")))} + ${panelHtml("ai-ml-unit", 6, "ML compute unit economics", "Effective rate per VM-hour and per 1K core-hours by VM series.", + wideTable(tableHtml([ + { label: "Series", align: "left", get: (r) => nameCell(r.Series, 24) }, + { label: "VM hours", get: (r) => fmtQty(r.VmHours) }, + { label: "$ / VM-hour", get: (r) => mlUnitVmRate(r.PerVmHour) }, + { label: "$ / 1K core-hours", get: (r) => mlUnitCoreRate(r.Per1KCoreHours) }, + { label: "Cost", get: (r) => mlUnitMoney(r.Cost) }, + ], d.mlUnit, "No ML virtual machine meters in range.")))} + ${panelHtml("ai-cognitive", 6, "Cognitive & applied AI", "Speech, vision, language, and video services, excluding token meters.", + wideTable(tableHtml([ + { label: "Service", align: "left", get: (r) => nameCell(r.Service, 30) }, + { label: "Quantity", get: (r) => fmtQty(r.Units) }, + { label: "Cost", get: (r) => cognitiveMoney(r.Cost) }, + ], d.cognitive, "No cognitive or applied AI meters in range.")))} +
+ +

Allocation & posture

Accountability & rate optimization
+
+ ${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))} +
+ `; +} + +/* --------------------------------------------- anomalies & forecast render */ + +function renderAnomaly(p) { + const content = el("content"); + if (!p) return; + if (p.error) return renderError(p); + if (p.empty) { + content.innerHTML = `

No cost data

The Hub database has no rows yet.

`; + return; + } + const d = p.data; + const daily = d.daily || []; + const anomDays = daily.filter((r) => r.Flag !== 0); + const totalCost = daily.reduce((a, r) => a + (r.Cost || 0), 0); + const anomCost = anomDays.reduce((a, r) => a + (r.Cost || 0), 0); + const variance = Math.abs(anomDays.reduce((a, r) => a + ((r.Cost || 0) - (r.Baseline || 0)), 0)); + const rate = totalCost > 0 ? anomCost / totalCost : 0; + + const fc = d.forecast || []; + const dataMaxMonth = (p.window?.dataMax || "").slice(0, 7); + const nextFc = fc.find((r) => r.Month > dataMaxMonth); + + const mc = (d.monthlyChange || []).filter((r) => isFinite(r.EffChangePct)); + // last complete month (skip the partial dataMax month for the headline KPI) + const completeMc = mc.filter((r) => r.Month < dataMaxMonth); + const lastMc = completeMc[completeMc.length - 1] || mc[mc.length - 1]; + + const fr = d.freshness?.[0] || {}; + const p50Days = fr.P50 != null ? fr.P50 / 24 : null; + + const mcClass = lastMc == null ? "" : lastMc.EffChangePct > 0 ? "neg" : "pos"; // cost up = bad + const mcArrow = lastMc == null ? "" : lastMc.EffChangePct > 0 ? "▲" : "▼"; + const mcValue = lastMc == null ? "—" : `${mcArrow} ${fmtPct(Math.abs(lastMc.EffChangePct) / 100)}`; + + const kpis = [ + // reference KPIs first (no primaries in this tab) + kpiCard("Anomaly days", fmtInt(anomDays.length), + `${fmtMoney(anomCost)} on flagged days`, undefined, undefined, "reference"), + // supporting KPIs + kpiCard("Anomaly detection rate", fmtPct(rate, 2), + `% of effective spend on flagged days · ${fmtInt(anomDays.length)} of ${fmtInt(daily.length)} days flagged`, undefined), + kpiCard("Unpredicted variance", fmtMoney(variance), + `net spend vs baseline on anomaly days`, undefined), + kpiCard("Last month change", mcValue, + lastMc ? `effective cost · ${esc(fmtMonth(lastMc.Month))}` : "", undefined), + kpiCard("Forecast next month", nextFc ? fmtMoney(nextFc.Forecast) : "—", + nextFc ? `projected · ${esc(fmtMonth(nextFc.Month))}` : "", undefined), + kpiCard("Visibility delay", p50Days != null ? `${p50Days.toFixed(0)}d` : "—", + `median ingestion lag (P50)`, undefined), + ].join(""); + + const triageCallout = anomDays.length > 0 + ? `
${fmtInt(anomDays.length)} anomal${anomDays.length === 1 ? "y day" : "y days"} detected — ${fmtMoney(anomCost)} in flagged spend. Review the chart below.
` + : ""; + + content.innerHTML = ` + ${triageCallout} +
${kpis}
+ +

Cost anomalies

Anomaly management capability
+
+ ${panelHtml("anomaly-daily", 12, "Daily cost & detected anomalies", "Daily effective cost vs the expected baseline (STL decomposition); markers flag spikes & drops.", anomalyChart(daily))} +
+ +

Trend & forecast

Forecasting · Data freshness
+
+ ${panelHtml("anomaly-mom", 6, "Month-over-month change", "Effective cost % change vs prior month (red = increase).", momBars(mc))} + ${panelHtml("anomaly-forecast", 6, "Cost forecast", "Monthly effective cost, actual vs forecast (next 3 months).", forecastChart(fc, dataMaxMonth))} +
+ `; +} + +/* ----------------------------------------------- usage & unit economics render */ + +function renderUsage(p) { + const content = el("content"); + if (!p) return; + if (p.error) return renderError(p); + if (p.empty) { + content.innerHTML = `

No cost data

The Hub database has no rows yet.

`; + return; + } + const d = p.data; + const c = d.compute?.[0] || {}; + const s = d.storage?.[0] || {}; + const coreHours = c.CoreHours || 0; + const hourlyPerCore = coreHours > 0 ? c.ComputeEff / coreHours : 0; + const effPerCore = coreHours > 0 ? (c.ComputeEff + (c.UnusedCommit || 0)) / coreHours : 0; + const gbMonths = s.GBMonths || 0; + const perGB = gbMonths > 0 ? s.Cost / gbMonths : 0; + const total = d.total?.[0]?.Total || 0; + + const kpis = [ + kpiCard("Hourly cost / core", `$${hourlyPerCore.toFixed(3)}`, + `per consumed vCPU-hour`, PALETTE[0], undefined, "primary"), + kpiCard("Effective cost / core", `$${effPerCore.toFixed(3)}`, + `incl. unused commitment`, PALETTE[2], undefined, "reference"), + kpiCard("Compute core-hours", fmtTokens(coreHours), + `${fmtMoney(c.ComputeEff)} VM usage`, PALETTE[1]), + kpiCard("Storage rate", `$${(perGB * 1024).toFixed(3)}`, + `per TB-month (effective)`, PALETTE[5]), + kpiCard("Storage volume", `${fmtTokens(gbMonths)}`, + `GB-months stored`, PALETTE[8]), + kpiCard("Storage cost", fmtMoney(s.Cost), + `effective storage spend`, PALETTE[3]), + ].join(""); + + const typeRows = (d.topResourceTypes || []).map((r) => ({ + type: r.ResourceType, count: r.Resources || 0, cost: r.Cost || 0, + pct: total > 0 ? (r.Cost || 0) / total : 0, + })); + const typeTable = tableHtml([ + { label: "Resource type", align: "left", get: (r, i) => `${swatchHtml(PALETTE[i % PALETTE.length])}${esc(r.type)}` }, + { label: "Resources", get: (r) => fmtInt(r.count) }, + { label: "Effective cost", get: (r) => fmtMoneyFull(r.cost) }, + { label: "% of total", get: (r) => fmtPct(r.pct) }, + ], typeRows); + + const tierColors = { "Frequent": PALETTE[1], "Infrequent": PALETTE[5], "Unclassified": UNKNOWN_COLOR }; + const tierSlices = (d.storageTiers || []).map((r) => ({ label: r.Tier, value: r.Cost || 0, color: tierColors[r.Tier] || PALETTE[6], isUnknown: r.Tier === "Unclassified" })); + const freqShare = (() => { + const t = tierSlices.reduce((a, x) => a + x.value, 0); + const f = (d.storageTiers || []).find((r) => r.Tier === "Frequent"); + return t > 0 ? (f?.Cost || 0) / t : 0; + })(); + + content.innerHTML = ` +
${kpis}
+ +

Usage & unit economics

Usage optimization · Unit economics
+
+ ${panelHtml("usage-top-types", 12, "Top resource types by cost", "Resource count and effective spend per resource type.", typeTable)} + ${panelHtml("usage-per-core-series", 6, "Compute cost per core by VM series", "Effective cost per vCPU-hour — highlights expensive (e.g. GPU) cores.", + hbar(d.perCoreSeries, "x_SkuMeterSubcategory", "PerCore", { valFmt: (v) => `$${v.toFixed(3)}`, label: "Compute cost per core by VM series" }))} + ${panelHtml("usage-storage-tiers", 6, `Storage tier distribution`, `Effective storage cost by access tier (${fmtPct(freqShare)} classified frequent).`, + donut(tierSlices, { centerBig: fmtMoney(s.Cost), centerSmall: "storage", label: "Storage tier distribution" }))} +
+ `; +} + +function renderRate(p) { + const content = el("content"); + if (!p) return; + if (p.error) return renderError(p); + if (p.empty) { + content.innerHTML = `

No cost data

The Hub database has no rows yet.

`; + return; + } + const d = p.data; + const s = d.savings?.[0] || {}; + const cm = d.commitment?.[0] || {}; + const cc = d.computeCoverage?.[0] || {}; + const esr = s.List > 0 ? s.Total / s.List : 0; + const cmTotal = cm.Total || 0; + const util = cmTotal > 0 ? (cmTotal - (cm.Unused || 0)) / cmTotal : 0; + const waste = cmTotal > 0 ? (cm.Unused || 0) / cmTotal : 0; + const coverage = cc.Contracted > 0 ? cc.Committed / cc.Contracted : 0; + const coreTotal = (d.coreHours || []).reduce((a, r) => a + (r.CoreHours || 0), 0); + const committedCore = (d.coreHours || []).filter((r) => r.t !== "On Demand").reduce((a, r) => a + (r.CoreHours || 0), 0); + const coreShare = coreTotal > 0 ? committedCore / coreTotal : 0; + // Single source of truth for "Commitment waste" coloring: derive the meta + // text color from the same threshold the card border uses, instead of a + // separately hardcoded 0.1 cutoff that could silently drift out of sync. + const wasteThreshold = kpiThreshold(waste, 0.10, 0.20); + const wasteMetaCls = wasteThreshold === "threshold-red" ? "neg" : wasteThreshold === "threshold-amber" ? "warn" : "pos"; + + const kpis = [ + // primary KPIs first + kpiCard("Effective savings rate", fmtPct(esr), + `${fmtMoney(s.Total)} total savings · vs. list price`, PALETTE[1], undefined, "primary"), + kpiCard("Commitment waste", fmtPct(waste), + `${fmtMoney(cm.Unused)} unused · of commitment spend`, PALETTE[3], + wasteThreshold, "primary"), + // supporting KPIs + kpiCard("Total savings", fmtMoney(s.Total), + `of ${fmtMoney(s.List)} list cost`, PALETTE[2]), + (() => { + const cusRow = (d.commitmentUtilScore || []).find((r) => r.CommitmentDiscountName === '(Grand Total)'); + const cusScore = cusRow ? cusRow.Score / 100 : util; + return kpiCard("Commitment utilization", fmtPct(cusScore), + cusRow + ? `${fmtMoney(cusRow.Amount)} utilized of ${fmtMoney(cusRow.Potential)} potential` + : `${fmtMoney(cmTotal - (cm.Unused || 0))} of ${fmtMoney(cmTotal)} used`, + PALETTE[0]); + })(), + // reference KPIs + kpiCard("Compute coverage", fmtPct(coverage), + `compute spend on commitments`, PALETTE[5], undefined, "reference"), + // supporting KPIs + kpiCard("Committed core-hours", fmtPct(coreShare), + `RI + savings plan vs on-demand`, PALETTE[8], undefined, "reference"), + ].join(""); + + const savingsBreak = costBreakdownTable([ + { label: "List cost (excl. commitment purchases)", val: s.List, accent: "var(--muted)" }, + { label: "Negotiated savings", val: s.Negotiated, accent: PALETTE[8] }, + { label: "Commitment savings", val: s.Commitment, accent: PALETTE[1] }, + { label: "Effective cost", val: s.Effective, accent: PALETTE[0] }, + ], "Effective savings rate", fmtPct(esr)); + + const coreColors = { "On Demand": PALETTE[0], "Reservation": PALETTE[1], "Savings Plan": PALETTE[4] }; + const coreSlices = (d.coreHours || []).map((r) => ({ label: r.t, value: r.CoreHours || 0, color: coreColors[r.t] || PALETTE[6] })); + + const underutilCount = (d.byCommitment || []).filter((r) => (r.Unused || 0) > 0).length; + const rateCallout = underutilCount > 0 + ? `
${fmtInt(underutilCount)} underutilized commitment${underutilCount === 1 ? "" : "s"} found — ${fmtMoney(cm.Unused)} in unused spend. See the commitments panel below.
` + : ""; + + content.innerHTML = ` + ${rateCallout} +
${kpis}
+ +

Rate optimization

Rate optimization capability
+
+ ${panelHtml("rate-savings", 6, "Savings breakdown", "List → effective cost by discount type (effective savings rate).", savingsBreak)} + ${panelHtml("rate-commit-util", 6, "Commitment utilization", "Used vs unused commitment effective cost.", + donut([ + { label: "Used", value: cmTotal - (cm.Unused || 0), color: PALETTE[1] }, + { label: "Unused (waste)", value: cm.Unused || 0, color: PALETTE[3] }, + ], { centerBig: fmtPct(util), centerSmall: "utilized", label: "Commitment utilization" }))} + ${panelHtml("rate-core-hours", 6, "Core-hour coverage", "Consumed core-hours by commitment type.", + donut(coreSlices, { + centerBig: fmtPct(coreShare), centerSmall: "committed", + valueFmt: (s) => `${fmtTokens(s.value)} core-hrs`, + label: "Core-hour coverage", + }))} + ${panelHtml("rate-underutil", 6, "Underutilized commitments", "Reservations & plans with the most unused cost.", + hbar(d.byCommitment, "CommitmentDiscountName", "Unused", { label: "Underutilized commitments" }))} +
+ +

Commitment transactions

Rate optimization · Commitment purchasing
+
+ ${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))} +
+ `; +} + +function commitUtilTable(rows) { + const data = (rows || []).filter((r) => r.CommitmentDiscountName !== '(Grand Total)' && (r.Potential || 0) > 0); + if (data.length === 0) return `

No commitment data in range.

`; + return ` + + ${data.map((r) => { + const score = r.Score || 0; + const cls = score < 70 ? "neg" : score < 90 ? "warn" : "pos"; + const barW = Math.max(2, (score / 100) * 90); + return ` + + + + + + `; + }).join("")} +
CommitmentTypeScoreUtilizedPotential
${esc(r.CommitmentDiscountName)}${esc(r.CommitmentDiscountType || r.CommitmentDiscountCategory || "")}${fmtPct(score / 100)}${fmtMoney(r.Amount)}${fmtMoney(r.Potential)}
`; +} + +function topCommitTxnTable(rows) { + const data = rows || []; + if (data.length === 0) return `

No commitment transactions in range.

`; + return ` + + ${data.map((r) => ` + + + + + `).join("")} +
CommitmentTypeBilled costEffective cost
${esc(r.CommitmentDiscountName || "(unknown)")}${esc(r.CommitmentDiscountType || "")}${fmtMoney(r.BilledCost)}${fmtMoney(r.EffectiveCost)}
`; +} + +/* ----------------------------------------------------- allocation render */ + +function renderAllocation(p) { + const content = el("content"); + if (!p) return; + if (p.error) return renderError(p); + if (p.empty) { + content.innerHTML = `

No cost data

The Hub database has no rows yet.

`; + return; + } + const d = p.data; + const c = d.core?.[0] || {}; + const total = c.Total || 0; + const aai = total > 0 ? c.Attributed / total : 0; + const untaggedPct = total > 0 ? c.Untagged / total : 0; + const unallocPct = total > 0 ? (total - c.Attributed) / total : 0; + const compliancePct = total > 0 ? c.Compliant / total : 0; + + const kpis = [ + // primary KPIs first + kpiCard("Untagged cost", fmtPct(untaggedPct), + `${fmtMoney(c.Untagged)} with no tags`, PALETTE[3], + kpiThreshold(untaggedPct, 0.10, 0.25), "primary"), + // supporting KPIs + kpiCard("Allocation accuracy", fmtPct(aai), + `directly attributed effective cost`, PALETTE[1]), + kpiCard("Unallocated cost", fmtPct(unallocPct), + `${fmtMoney(total - c.Attributed)} lacks ownership attribution`, PALETTE[4]), + kpiCard("Tag policy compliance", fmtPct(compliancePct), + `keys: CostCenter · env · org`, PALETTE[5]), + kpiCard("Subscriptions", fmtInt(c.Subs), + `billing scopes in range`, PALETTE[0]), + kpiCard("Allocated cost", fmtMoney(c.Attributed), + `of ${fmtMoney(total)} total`, PALETTE[2]), + ].join(""); + + const hierRows = (d.hierarchy || []).map((r) => ({ + org: r.Org || "—", project: r.Project || "—", env: r.Env || "—", cost: r.Cost || 0, + pct: total > 0 ? (r.Cost || 0) / total : 0, + })); + const hierTable = tableHtml([ + { + label: "Org", align: "left", get: (r, i) => { + const isUnknown = r.org === "—" && r.project === "—" && r.env === "—"; + return `${swatchHtml(PALETTE[i % PALETTE.length], isUnknown)}${esc(r.org)}`; + }, + }, + { label: "Project", align: "left", get: (r) => esc(r.project) }, + { label: "Environment", align: "left", get: (r) => esc(r.env) }, + { label: "Effective cost", get: (r) => fmtMoneyFull(r.cost) }, + { label: "% of total", get: (r) => fmtPct(r.pct) }, + ], hierRows); + + // Flag case-variant duplicate tag keys (e.g. "CostCenter" vs "costcenter") + // so the governance issue is called out, not hidden by treating them as + // separate keys. + const tagKeyRows = d.tagKeys || []; + const lowerCounts = {}; + tagKeyRows.forEach((r) => { const lk = String(r.k).toLowerCase(); lowerCounts[lk] = (lowerCounts[lk] || 0) + 1; }); + const dupKeys = tagKeyRows.filter((r) => lowerCounts[String(r.k).toLowerCase()] > 1).map((r) => r.k); + const tagKeyNote = dupKeys.length > 0 + ? ` Note: ${dupKeys.map((k) => `${esc(k)}`).join(" vs ")} are case-variant duplicates of the same governance key — likely inconsistent tagging, not distinct keys.` + : ""; + + content.innerHTML = ` +
${kpis}
+ +

Cost allocation

Allocation capability
+
+ ${panelHtml("alloc-hierarchy", 8, "Cost by financial hierarchy", "Org → project → environment (from resource tags), with share of total.", hierTable)} + ${panelHtml("alloc-tagging", 4, "Tagging coverage", "Tagged vs untagged effective cost.", + donut([ + { label: "Tagged", value: total - c.Untagged, color: PALETTE[1] }, + { label: "Untagged", value: c.Untagged, color: UNKNOWN_COLOR, isUnknown: true }, + ], { centerBig: fmtPct(1 - untaggedPct), centerSmall: "tagged", label: "Tagging coverage" }))} + ${panelHtml("alloc-tag-keys", 6, "Cost by tag key", `Effective cost touched by each governance tag.${tagKeyNote}`, hbar(d.tagKeys, "k", "Cost", { filterDim: null, label: "Cost by tag key" }))} + ${panelHtml("alloc-by-subscription", 6, "Cost by subscription", "Spend per billing scope for showback.", hbar(d.bySubscription, "SubAccountName", "Cost", { label: "Cost by subscription" }))} +
+ `; +} + +const CAPACITY_ACTIONS = Object.freeze({ + "app-service": "Validate region access and SKU availability separately before requesting an exact SKU quota increase.", + "azure-ai": "Validate model availability, deployment scope, and actual capacity separately from the provider quota row.", + compute: "Check both total regional and applicable VM-family vCPU quota, then validate SKU, zone, and physical capacity separately.", + "azure-sql": "Use the exact SQL metric and service workflow. Do not treat countdown or negative-limit rows as generic utilization.", + storage: "Validate ingestion and expected subscription-region coverage before drawing a Storage quota conclusion.", + "capacity-reservations": "Inspect reservation quantity, SKU, zones, sharing, associations, and utilization in Azure; inventory count is not reserved capacity.", + "premium-ssd-v2": "Inspect disk zone, attachment, IOPS, throughput, and service quota separately; observed GiB is inventory, not quota.", +}); + +const CAPACITY_STATE_LABELS = Object.freeze({ + healthy: "Healthy", + watch: "Watch", + action: "Action", + exhausted: "Exhausted", + restricted: "Region restricted", + "zone-restricted": "All zones restricted", + "no-entitlement": "No quota", + inventory: "Observed inventory", + unclassified: "Unknown or unclassified", + stale: "Stale", + invalid: "Invalid or conflict", +}); + +export function capacitySelectionFromRow(kind, classId, row) { + if (!row || typeof row !== "object") return null; + if (kind === "quota") { + if (classId === "capacity-reservations" || classId === "premium-ssd-v2") { + return { resourceId: String(row.ResourceId || "") }; + } + return { + subAccountId: String(row.SubAccountId || ""), + location: String(row.location || ""), + resourceName: String(row.ResourceName || ""), + unit: String(row.unit || ""), + sourceVersion: String(row.x_SourceVersion || ""), + }; + } + if (kind === "metric") { + return { + resourceName: String(row.ResourceName || ""), + unit: String(row.unit || ""), + sourceVersion: String(row.x_SourceVersion || ""), + }; + } + const selection = { + meterCategory: String(row.x_SkuMeterCategory || ""), + meterSubcategory: String(row.x_SkuMeterSubcategory || ""), + meter: String(row.SkuMeter || ""), + priceId: String(row.SkuPriceId || ""), + currency: String(row.BillingCurrency || ""), + }; + if (classId === "premium-ssd-v2") selection.resourceId = String(row.InventoryResourceId || ""); + else selection.unit = String(row.ConsumedUnit || ""); + if (classId === "capacity-reservations") { + selection.capacityReservationId = String(row.CapacityReservationId || ""); + selection.capacityReservationStatus = String(row.CapacityReservationStatus || ""); + } + return selection; +} + +function sameCapacitySelection(left, right) { + return JSON.stringify(left || null) === JSON.stringify(right || null); +} + +function capacityNavigationHtml() { + return ``; +} + +function capacityPanel(title, subtitle, body, wide = false) { + return `
+

${esc(title)}

${subtitle ? `

${esc(subtitle)}

` : ""}
+
${body}
+
`; +} + +function capacityStateToken(semantic = {}) { + const stateName = semantic.state || "unclassified"; + const label = CAPACITY_STATE_LABELS[stateName] || stateName; + return `${esc(label)}`; +} + +function capacityHomeTable(classes) { + const rows = classes || []; + return `
+ + ${rows.map((item) => { + const summary = item.summary || {}; + const observed = Number(summary.Observations || 0) > 0; + return ` + + + + + + + `; + }).join("")} +
Quota areaTypeObservationsResourcesSnapshot daysLast seen
${esc(item.sourceNote)}
${esc(item.quotaType === "inventory" ? "Inventory" : "Provider metric")}${observed ? fmtInt(summary.Observations) : "Not reported"}${observed ? fmtInt(summary.Resources) : "—"}${observed ? fmtInt(summary.DistinctDays) : "—"}${summary.LatestObservation ? esc(fmtRelativeTime(new Date(summary.LatestObservation))) : "—"}
`; +} + +function capacitySelectorHtml(kind, classId, items, currentSelection) { + const isDemand = kind === "demand"; + const isMetric = kind === "metric"; + const label = isDemand ? "Billed demand series" : isMetric ? "Quota metric" : "Quota or inventory series"; + const options = (items || []).map((row, index) => { + const selection = capacitySelectionFromRow(kind, classId, row); + const selected = sameCapacitySelection(selection, currentSelection); + const display = isDemand + ? classId === "premium-ssd-v2" + ? `${row.DiskName || row.InventoryResourceId} · ${row.SkuMeter || "No matched cost"} · ${row.BillingCurrency || "—"}` + : `${row.SkuMeter || row.x_SkuMeterSubcategory || "Unknown meter"} · ${row.ConsumedUnit || "—"} · ${row.BillingCurrency || "—"}` + : isMetric + ? `${row.displayName || row.ResourceName || "Unknown metric"} · ${row.unit || "—"}` + : `${row.ResourceName || row.displayName || "Unknown"} · ${row.SubAccountId || "—"} · ${row.location || "—"}`; + const disabled = Object.values(selection || {}).some((value) => !value); + return ``; + }).join(""); + return ``; +} + +function formatCapacityValue(value) { + const number = Number(value); + if (!Number.isFinite(number)) return "—"; + return number.toLocaleString("en-US", { maximumFractionDigits: 2 }); +} + +function capacityCurrentTable(payload) { + const inventory = payload.contract?.quotaType === "inventory"; + const rows = payload.table?.rows || []; + const columns = [ + { label: "Subscription", align: "left", get: (row) => esc(trunc(row.SubAccountId || "—", 28)) }, + { label: "Region", align: "left", get: (row) => esc(row.location || "—") }, + { label: inventory ? "Resource" : "Metric", align: "left", get: (row) => `${esc(row.displayName || row.ResourceName || "—")}` }, + { label: inventory && payload.classId === "premium-ssd-v2" ? "Size GiB" : "Current", get: (row) => inventory && payload.classId === "capacity-reservations" ? "Observed" : formatCapacityValue(row.currentValue) }, + { label: "Limit", get: (row) => inventory ? "Not applicable" : formatCapacityValue(row.limit) }, + { label: "Unit", get: (row) => inventory && payload.classId === "premium-ssd-v2" ? "GiB" : esc(row.unit || "—") }, + { label: "Quota status", align: "left", get: (row) => `${capacityStateToken(row.semantic)}${esc(row.semantic?.sourceNote || "")}` }, + { label: "Ingested", get: (row) => esc(fmtRelativeTime(new Date(row.x_IngestionTime))) }, + ]; + return `
${tableHtml(columns, rows, payload.contract?.emptyLabel)}
`; +} + +export function capacityHeatmapCell(row, classId) { + if (classId === "capacity-reservations") { + return { value: Number(row.ObservedObjects || 0), text: `${fmtInt(row.ObservedObjects)} groups`, state: "inventory" }; + } + if (classId === "premium-ssd-v2") { + return { value: Number(row.ObservedGiB || 0), text: `${formatCapacityValue(row.ObservedGiB)} GiB`, state: "inventory" }; + } + const semantic = row.semantic || {}; + return { + value: semantic.utilizationPercent, + text: Number.isFinite(semantic.utilizationPercent) ? `${semantic.utilizationPercent.toFixed(1)}%` : (CAPACITY_STATE_LABELS[semantic.state] || "Not reported"), + state: semantic.state || "unclassified", + }; +} + +function capacityHeatmap(payload) { + const heatmap = payload.heatmap || {}; + if (heatmap.status === "heatmap-disabled") { + return `
Heatmap disabled. More than ${fmtInt(heatmap.limit)} observed cells matched. Refine the filters; no partial matrix was rendered.
`; + } + if (heatmap.status === "no-selection") { + return `
Select one exact quota metric to enable the subscription-by-region matrix.
`; + } + const rows = heatmap.rows || []; + if (!rows.length) return `
No quota data is available for this selection.
`; + if (payload.contract?.quotaType !== "inventory" && !rows.some((row) => row.semantic?.capability === "enabled")) { + return `
Heatmap unavailable. The selected metric is descriptive-only and cannot receive quota-health color.
`; + } + const subscriptions = [...new Set(rows.map((row) => row.SubAccountId || "Unknown subscription"))].sort(); + const regions = [...new Set(rows.map((row) => row.location || "Unknown region"))].sort(); + const cells = new Map(rows.map((row) => [`${row.SubAccountId || "Unknown subscription"}|${row.location || "Unknown region"}`, row])); + return `
+ + ${regions.map((region) => ``).join("")} + ${subscriptions.map((subscription) => ` + + ${regions.map((region) => { + const row = cells.get(`${subscription}|${region}`); + if (!row) return ``; + const cell = capacityHeatmapCell(row, payload.classId); + return ``; + }).join("")} + `).join("")} +
Subscription by region. Every colored cell includes the same value and state in text.
Subscription${esc(region)}
${esc(trunc(subscription, 20))}Not reported${esc(cell.text)}${esc(CAPACITY_STATE_LABELS[cell.state] || cell.state)}
`; +} + +// A family and region pair is only meaningful to a capacity manager through one +// of these lenses. Counts are always computed over the unfiltered set so a lens +// can never become a dead end. +export const FAMILY_STATUS_FILTERS = [ + { id: "in-use", label: "In use", match: (row) => Number(row.CoresUsed || 0) > 0 }, + { + id: "restricted", + label: "Restricted", + match: (row) => Boolean(row.semantic?.regionRestricted) + || (Array.isArray(row.ZonesRestricted) && row.ZonesRestricted.length > 0), + }, + { id: "no-quota", label: "No quota", match: (row) => Number(row.CoresTotal || 0) <= 0 }, + { id: "all", label: "All", match: () => true }, +]; + +export const HIGH_WATER_MARKS = [60, 70, 80, 90]; +export const DEFAULT_HIGH_WATER_MARK = 70; + +// Demand is a separate channel from supply. The mark is where the operator wants +// to start reacting, so crossing it is the alarm and 100% stays visibly distinct. +export function familyDemandTier(utilizationPercent, mark = DEFAULT_HIGH_WATER_MARK) { + if (!Number.isFinite(utilizationPercent)) return "none"; + if (utilizationPercent >= 100) return "exhausted"; + return utilizationPercent >= mark ? "over" : "under"; +} + +export function filterFamilyRows(rows, filter = {}) { + const list = Array.isArray(rows) ? rows : []; + const lens = FAMILY_STATUS_FILTERS.find((item) => item.id === filter.status) || FAMILY_STATUS_FILTERS[0]; + const needle = String(filter.search || "").trim().toLowerCase(); + const regions = Array.isArray(filter.regions) ? filter.regions : []; + return list.filter((row) => { + if (!lens.match(row)) return false; + if (regions.length && !regions.includes(row.Location || "Unknown region")) return false; + if (!needle) return true; + return `${row.Family || ""} ${row.FamilyKey || ""}`.toLowerCase().includes(needle); + }); +} + +function familyFilterBar(rows, filter, shownCells) { + const counts = new Map(FAMILY_STATUS_FILTERS.map((lens) => [lens.id, rows.filter(lens.match).length])); + const lensButtons = FAMILY_STATUS_FILTERS.map((lens) => ``).join(""); + + // Region chips list only the regions the active lens and search can still reach. + const reachable = filterFamilyRows(rows, { ...filter, regions: [] }); + const regions = [...new Set(reachable.map((row) => row.Location || "Unknown region"))].sort(); + const regionButtons = regions.map((region) => ``).join(""); + + const mark = Number(filter.mark) || DEFAULT_HIGH_WATER_MARK; + const markButtons = HIGH_WATER_MARKS.map((value) => { + const over = rows.filter((row) => familyDemandTier(row.semantic?.utilizationPercent, value) !== "under" + && Number.isFinite(row.semantic?.utilizationPercent)).length; + return ``; + }).join(""); + + const filtered = filter.status !== "all" || filter.search || filter.regions.length; + return `
+
+ Show +
${lensButtons}
+
+
+ + +
+ ${regions.length > 1 ? `
+ Region +
${regionButtons}
+
` : ""} +
+ High-water mark +
${markButtons}
+
+

${fmtInt(shownCells)} of ${fmtInt(rows.length)} cells + ${filtered ? `` : ""}

+
`; +} + +function familyMatrixLegend(mark) { + const supply = [ + ["open", "Open"], + ["partial", "Some zones restricted"], + ["blocked", "Region or all zones restricted"], + ["none", "No quota"], + ].map(([id, label]) => `
  • ${esc(label)}
  • `).join(""); + const demand = [ + ["under", `Under ${mark}%`], + ["over", `Over ${mark}% mark`], + ["exhausted", "At or over 100%"], + ].map(([id, label]) => `
  • ${esc(label)}
  • `).join(""); + return `
    +
    Supply, the left bar
      ${supply}
    +
    Demand, the percentage
      ${demand}
    +
    `; +} + +function computeFamilyHeatmap(payload) { + const family = payload.familyHeatmap || {}; + if (family.status === "not-applicable") return ""; + if (family.status === "heatmap-disabled") { + return `
    Family view disabled. More than ${fmtInt(family.limit)} family and region pairs matched. Narrow the subscription or region filter.
    `; + } + const rows = family.rows || []; + if (!rows.length) { + return `
    No Compute family quota was reported for this scope. Check ingestion for ComputeUsage and ComputeResourceSku.
    `; + } + const filter = state.familyFilter; + const visible = filterFamilyRows(rows, filter); + const filterBar = familyFilterBar(rows, filter, visible.length); + if (!visible.length) { + return `${filterBar}
    No family and region pair matches these filters. Clear them to see all ${fmtInt(rows.length)} cells.
    `; + } + const families = [...new Map(visible.map((row) => [row.FamilyKey, row.Family || row.FamilyKey])).entries()] + .sort((a, b) => String(a[1]).localeCompare(String(b[1]))); + const regions = [...new Set(visible.map((row) => row.Location || "Unknown region"))].sort(); + const cells = new Map(visible.map((row) => [`${row.FamilyKey}|${row.Location || "Unknown region"}`, row])); + const mark = Number(filter.mark) || DEFAULT_HIGH_WATER_MARK; + return `${filterBar} +

    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.

    + ${familyMatrixLegend(mark)} +
    + + ${regions.map((region) => ``).join("")} + ${families.map(([key, label]) => ` + + ${regions.map((region) => { + const row = cells.get(`${key}|${region}`); + if (!row) return ``; + const semantic = row.semantic || {}; + const restrictedZones = row.ZonesRestricted || []; + const zonesPresent = Number(row.ZonesPresentCount || 0); + const zoneNote = zonesPresent + ? (restrictedZones.length + ? `${restrictedZones.length} of ${zonesPresent} zones restricted` + : `${zonesPresent} zones open`) + : "No zone mapping"; + const detail = Number.isFinite(semantic.headroomCores) + ? `${fmtInt(semantic.headroomCores)} cores free` + : ""; + // A region restriction is supply news even when quota still exists. + const supplyNote = semantic.regionRestricted && semantic.state !== "restricted" + ? `Region restricted` + : ""; + const tier = familyDemandTier(semantic.utilizationPercent, mark); + // Colour never carries the alarm alone, so crossing the mark is spelled out. + const markNote = tier === "over" ? `Over ${mark}% mark` + : tier === "exhausted" ? `Quota exhausted` + : ""; + const title = `${label} · ${region} · ${fmtInt(row.CoresUsed)} of ${fmtInt(row.CoresTotal)} cores · ${zoneNote}` + + (restrictedZones.length ? ` · restricted: ${restrictedZones.join(", ")}` : "") + + (semantic.regionRestricted ? " · every SKU in this family is restricted for the subscription" : ""); + return ``; + }).join("")} + `).join("")} +
    VM family${esc(region)}
    ${esc(trunc(label, 28))}Not reported + ${esc(semantic.text || "Not reported")} + ${esc(CAPACITY_STATE_LABELS[semantic.state] || semantic.state || "Unknown")} + ${supplyNote} + ${markNote} + ${esc(detail)} + ${esc(zoneNote)} +
    `; +} + +function familySupplyLabel(row) { + const supply = row.semantic?.supply; + if (supply === "blocked") return row.semantic?.regionRestricted ? "Region restricted" : "All zones restricted"; + if (supply === "partial") return "Some zones restricted"; + if (supply === "open") return "Open"; + return "No quota"; +} + +function familyZoneLabel(row) { + const present = Number(row.ZonesPresentCount || 0); + const restricted = Array.isArray(row.ZonesRestricted) ? row.ZonesRestricted.length : 0; + if (!present) return "Not mapped"; + return restricted ? `${restricted} of ${present} restricted` : `${present} open`; +} + +function computeFamilyDetail(payload) { + const rows = filterFamilyRows(payload.familyHeatmap?.rows || [], state.familyFilter); + if (!rows.length) { + return `
    No family and region combination matches the filters above.
    `; + } + const pageSize = 50; + const totalPages = Math.ceil(rows.length / pageSize); + const page = Math.min(state.capacityFamilyPage, totalPages); + const pageRows = rows.slice((page - 1) * pageSize, page * pageSize); + const table = `
    ${tableHtml([ + { label: "VM family", align: "left", get: (row) => esc(row.Family || row.FamilyKey || "—") }, + { label: "Region", align: "left", get: (row) => esc(row.Location || "—") }, + { label: "Used cores", get: (row) => fmtInt(row.CoresUsed) }, + { label: "Quota", get: (row) => fmtInt(row.CoresTotal) }, + { label: "Headroom", get: (row) => Number.isFinite(row.semantic?.headroomCores) ? fmtInt(row.semantic.headroomCores) : "—" }, + { label: "Subscriptions", get: (row) => fmtInt(row.Subscriptions) }, + { label: "Supply", align: "left", get: (row) => esc(familySupplyLabel(row)) }, + { label: "Zones", align: "left", get: (row) => esc(familyZoneLabel(row)) }, + ], pageRows, "No family and region combination matches the filters above.")}
    `; + const pagination = totalPages > 1 + ? `
    + + Page ${fmtInt(page)} of ${fmtInt(totalPages)} + +
    ` + : ""; + return `
    ${fmtInt(rows.length)} matching family and region combinations
    ${table}${pagination}`; +} + +function computeSubscriptionDetail() { + const search = `
    + + Matches the Show, VM family, and region filters above. +
    `; + if (state.capacitySubscriptionLoading) { + return `${search}
    Loading matching subscriptions…
    `; + } + if (state.capacitySubscriptionError) { + return `${search}`; + } + const data = state.capacitySubscriptionData; + if (!data) return `${search}
    Select this tab to load matching subscriptions.
    `; + const rows = data.rows || []; + const totalPages = Number(data.totalPages || 0); + const summary = `
    ${fmtInt(data.totalSubscriptions)} matching subscriptions
    `; + if (!rows.length) { + return `${search}${summary}
    No subscription matches these filters.
    `; + } + const table = `
    ${tableHtml([ + { label: "Subscription ID", align: "left", get: (row) => `${esc(row.SubscriptionId || "—")}` }, + { label: "Families", get: (row) => fmtInt(row.Families) }, + { label: "Regions", get: (row) => fmtInt(row.Regions) }, + { label: "Used cores", get: (row) => fmtInt(row.CoresUsed) }, + { label: "Quota", get: (row) => fmtInt(row.CoresTotal) }, + { label: "Headroom", get: (row) => row.HeadroomCores != null && Number.isFinite(Number(row.HeadroomCores)) ? fmtInt(row.HeadroomCores) : "—" }, + { label: "Restrictions", get: (row) => fmtInt(row.RestrictedRows) }, + { label: "Last ingested", get: (row) => row.LastIngestion ? esc(fmtRelativeTime(new Date(row.LastIngestion))) : "—" }, + ], rows, "No subscription matches these filters.")}
    `; + const page = Number(data.page || 1); + return `${search} + ${summary} + ${table} +
    + + Page ${fmtInt(page)} of ${fmtInt(totalPages)} + +
    `; +} + +function computeCapacityDetail(payload) { + const tab = state.capacityDetailTab; + return `
    + + +
    +
    ${tab === "subscriptions" ? computeSubscriptionDetail() : computeFamilyDetail(payload)}
    `; +} + +function capacityHistory(payload) { + const history = payload.history || {}; + if (history.status === "no-selection") return `
    Select one exact source row to view its observed history.
    `; + if (history.status === "disabled") return `
    History is disabled. ${esc(history.reasonCode || "")}
    `; + if (history.mode === "current-only") { + return `
    Collecting ${payload.contract?.quotaType === "inventory" ? "inventory" : "quota"} history — 1 day available. Trend, growth, forecast, runway, and breach dates remain disabled.
    `; + } + return tableHtml([ + { label: "UTC day", align: "left", get: (row) => esc(String(row.Day || "").slice(0, 10)) }, + { label: "Current", get: (row) => formatCapacityValue(row.currentValue) }, + { label: "Limit", get: (row) => payload.contract?.quotaType === "inventory" ? "Not applicable" : formatCapacityValue(row.limit) }, + { label: "Unit", get: (row) => payload.classId === "premium-ssd-v2" ? "GiB" : esc(row.unit || "—") }, + { label: "Ingested", get: (row) => esc(String(row.x_IngestionTime || "")) }, + ], history.points || [], "No history is available for this exact source key."); +} + +function capacityDemandHistory(payload) { + const series = payload.series || {}; + if (series.status === "disabled") { + return `
    Billed-demand series disabled. ${esc(series.reasonCode || "")}
    `; + } + if (series.status === "no-selection") { + return `
    Select one exact meter, unit, price, and currency series. Different meters and currencies are never combined.
    `; + } + const isDisk = payload.classId === "premium-ssd-v2"; + return tableHtml([ + { label: "UTC day", align: "left", get: (row) => esc(String(row.Day || "").slice(0, 10)) }, + ...(isDisk ? [] : [{ label: "Billed quantity", get: (row) => formatCapacityValue(row.BilledQuantity) }]), + { label: "Unit", get: (row) => isDisk ? "Not classified" : esc(row.ConsumedUnit || series.unit || "—") }, + { label: "Effective cost", get: (row) => `${formatCapacityValue(row.EffectiveCost)} ${esc(row.BillingCurrency || "")}` }, + { label: "Rows", get: (row) => fmtInt(row.Rows) }, + ], series.points || [], "No billed usage matched this exact series."); +} + +function capacityReconciliation(payload) { + const rows = payload.reconciliation?.rows || []; + return tableHtml([ + { label: "Capacity reservation group", align: "left", get: (row) => `${esc(row.GroupName || trunc(row.GroupResourceId, 36))}` }, + { label: "Match", align: "left", get: (row) => esc(row.ReconciliationState || "unknown") }, + { label: "Used hours", get: (row) => formatCapacityValue(row.UsedHours) }, + { label: "Unused hours", get: (row) => formatCapacityValue(row.UnusedHours) }, + { label: "Reservations", get: (row) => fmtInt(row.ReservationCount) }, + { label: "Linked resources", get: (row) => fmtInt(row.LinkedResources) }, + { label: "Currency", get: (row) => esc(row.BillingCurrency || "—") }, + ], rows, "No capacity reservation inventory or linked billing data is available."); +} + +function renderCapacity(payload) { + const content = el("content"); + if (!payload) return; + if (payload.error) return renderError(payload); + const nav = capacityNavigationHtml(); + if (payload.classId === "home") { + content.innerHTML = `${nav}
    +
    +

    Capacity workspace

    +

    Quota entitlement, billed demand, inventory, physical supply, and pricing commitments are separate data sources.

    +
    + ${capacityPanel("Quota coverage", "Seven independent quota areas; no combined health score or ranking.", capacityHomeTable(payload.classes))} +
    `; + return; + } + + const rows = payload.table?.rows || []; + const statusCounts = rows.reduce((counts, row) => { + const key = row.semantic?.state || "unclassified"; + counts[key] = (counts[key] || 0) + 1; + return counts; + }, {}); + const enabledCount = rows.filter((row) => row.semantic?.capability === "enabled").length; + const coverage = payload.coverage || {}; + const notReported = coverage.state === "not-reported" + ? `
    Not reported — collection outcome unknown. ${esc(payload.contract?.emptyLabel || "")}
    ` + : ""; + const schemaWarnings = [payload.schema?.quota, payload.schema?.costs] + .filter((schema) => schema && !schema.available) + .map((schema) => `${schema.source}: ${schema.missingFields.join(", ")}`); + const schemaNotice = schemaWarnings.length + ? `
    Source fields unavailable. ${esc(schemaWarnings.join(" · "))}
    ` + : ""; + const quotaSelectors = payload.selectors?.items || []; + const demandSelectors = payload.demand?.selectors?.items || []; + const familyRows = payload.familyHeatmap?.rows || []; + const kpis = payload.classId === "compute" + ? [ + kpiCard("Family-region pairs", fmtInt(familyRows.length), "Estate totals; subscriptions are aggregated before display"), + kpiCard("In use", fmtInt(familyRows.filter(FAMILY_STATUS_FILTERS[0].match).length), "Family and region pairs using cores"), + kpiCard("Restricted", fmtInt(familyRows.filter(FAMILY_STATUS_FILTERS[1].match).length), "Region or zone restrictions"), + kpiCard("No quota", fmtInt(familyRows.filter(FAMILY_STATUS_FILTERS[2].match).length), "No regional family quota"), + ].join("") + : [ + kpiCard("Observations", fmtInt(coverage.observations), `${fmtInt(coverage.resources)} current resource keys`, undefined, undefined, "reference"), + kpiCard("Snapshot days", fmtInt(coverage.distinctDays), coverage.distinctDays < 2 ? "No trend can be inferred" : "Compatible history is evaluated per exact key"), + kpiCard("Latest ingestion", coverage.lastObservation ? esc(fmtRelativeTime(new Date(coverage.lastObservation))) : "—", "ADX arrival time, not provider observation time"), + kpiCard("Enabled", fmtInt(enabledCount), "Rows with approved semantics"), + kpiCard("Unclassified", fmtInt(statusCounts.unclassified), "Raw rows retained; registry review required"), + kpiCard("Stale", fmtInt(statusCounts.stale), "Older than 48 hours; arithmetic disabled"), + ].join(""); + + content.innerHTML = `${nav}
    +
    +

    ${esc(payload.contract?.title || payload.classId)}

    +

    ${esc(payload.capability?.sourceNote || payload.contract?.sourceNote || "")}

    +
    + ${notReported}${schemaNotice} +
    Next action: ${esc(CAPACITY_ACTIONS[payload.classId] || "Review the source rows before taking action.")}
    +
    ${kpis}
    +
    + ${capacitySelectorHtml(payload.classId === "compute" ? "metric" : "quota", payload.classId, quotaSelectors, + payload.classId === "compute" ? state.capacitySelections.metricSelection : state.capacitySelections.quotaSelection)} + ${capacitySelectorHtml("demand", payload.classId, demandSelectors, state.capacitySelections.demandSelection)} +
    +
    + ${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)) + : ""} +
    +
    `; +} + +function renderError(p) { + el("content").innerHTML = `
    +

    Can’t reach the FinOps hub

    +

    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 = `

    ${esc(data.error)}

    `; + } else { + const rows = data.rows || []; + if (!rows.length) { + resultEl.innerHTML = `

    Query returned no rows.

    `; + } else { + const cols = Object.keys(rows[0]); + const head = cols.map((c) => `${esc(c)}`).join(""); + const body = rows.slice(0, 200).map((r) => + `${cols.map((c) => `${esc(String(r[c] ?? ""))}`).join("")}` + ).join(""); + const note = rows.length > 200 ? ` (showing first 200)` : ""; + resultEl.innerHTML = `

    ${rows.length} rows${note}

    ${head}${body}
    `; + } + } + } catch (err) { + resultEl.innerHTML = `

    Request failed: ${esc(err.message)}

    `; + } finally { + runBtn.disabled = false; + statusEl.textContent = prevStatus; + } +} + +/* ----------------------------------------------------------------- driver */ + +function currentPayload() { + return state.cache[state.tab]?.[cacheKey()]; +} + +function render() { + const p = currentPayload(); + if (!p) return; + try { + if (p.error) renderError(p); + else if (state.tab === "tokenomics") renderTokenomics(p); + else if (state.tab === "ai") renderAi(p); + else if (state.tab === "allocation") renderAllocation(p); + else if (state.tab === "rate") renderRate(p); + else if (state.tab === "usage") renderUsage(p); + else if (state.tab === "anomaly") renderAnomaly(p); + else if (state.tab === "capacity") renderCapacity(p); + else renderOverview(p); + } catch (err) { + console.error("[ftk-dashboard] render error:", err); + renderError({ error: `Render error in ${state.tab}: ${err.message}` }); + } +} + +async function load() { + const tab = state.tab; + if (TOOL_TABS.has(tab)) { + el("source-line").textContent = "Experimental tab — not part of the FinOps KPI pipeline."; + el("footer-meta").textContent = ""; + renderMonacoTab(); + return; + } + const key = cacheKey(); + if (state.cache[tab]?.[key]) { updateChrome(); render(); return; } + + // Cancel any in-flight request for a superseded tab/preset + if (_loadAbort) _loadAbort.abort(); + _loadAbort = new AbortController(); + const { signal } = _loadAbort; + + state.cache[tab] = state.cache[tab] || {}; + state.loading = true; + setRefreshSpinning(true); + const contentEl = el("content"); + contentEl.setAttribute("aria-busy", "true"); + contentEl.innerHTML = ` +
    + ${'
    '.repeat(6)} +
    +
    +
    + `; + try { + const res = await fetch("/api/view", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: tab, + preset: state.preset, + filters: state.filters, + ...(tab === "capacity" + ? { capacityClass: state.capacityClass, capacitySelections: state.capacitySelections } + : {}), + }), + signal, + }); + state.cache[tab][key] = await res.json(); + } catch (err) { + if (err.name === "AbortError") return; // superseded by a newer load(); discard silently + console.error("[ftk-dashboard] fetch failed:", err); + state.cache[tab][key] = { error: "Could not load data. Check the FinOps hub connection and authentication." }; + } finally { + state.loading = false; + setRefreshSpinning(false); + el("content")?.setAttribute("aria-busy", "false"); + } + updateChrome(); + render(); + if (tab === "capacity" && state.capacityClass === "compute" && + state.capacityDetailTab === "subscriptions" && !state.capacitySubscriptionData) { + void loadCapacitySubscriptions(); + } +} + +function setRefreshSpinning(on) { + const b = el("refresh"); + if (b) b.innerHTML = on ? ` Refresh` : `↻ Refresh`; +} + +function renderDiagnosticRail() { + const railEl = el("diagnostic-rail"); + if (!railEl) return; + const { rows, health, refreshedAt, dataset } = queryState; + const relTime = fmtRelativeTime(refreshedAt); + const absTime = refreshedAt ? refreshedAt.toLocaleString() : ""; + const rowTxt = `${fmtInt(rows)} rows`; + const healthLabel = health === "error" ? "● error" : health === "warn" ? "● warn" : "● ok"; + railEl.innerHTML = + `${esc(dataset)}` + + `` + + `${rowTxt}` + + `` + + `${healthLabel}` + + `` + + `${esc(relTime)}`; +} + +function updateChrome() { + const p = currentPayload(); + const w = p && p.window; + if (w && w.dataMin) { + el("source-line").innerHTML = + `Hub database · ${esc(window.__cfg?.clusterUri || "localhost:8082")}`; + queryState.dataset = `Hub database · ${fmtDayRange(w.dataMin, w.dataMax)}`; + queryState.rows = w.rows || 0; + queryState.health = queryState.rows === 0 ? "warn" : "ok"; + queryState.refreshedAt = p.generatedAt ? new Date(p.generatedAt) : new Date(); + el("footer-meta").textContent = `window ${w.start} → ${w.end}`; + renderDiagnosticRail(); + } else if (p && p.error) { + el("source-line").textContent = "Connection failed — see panel below."; + el("footer-meta").textContent = ""; + queryState.rows = 0; + queryState.health = "error"; + queryState.refreshedAt = new Date(); + queryState.dataset = "Hub database"; + renderDiagnosticRail(); + } else if (p && state.tab === "capacity") { + const observations = p.classId === "home" + ? (p.classes || []).reduce((sum, item) => sum + Number(item.summary?.Observations || 0), 0) + : Number(p.coverage?.observations || 0); + el("source-line").innerHTML = + `Hub capacity · ${esc(window.__cfg?.clusterUri || "localhost:8082")}`; + el("footer-meta").textContent = p.classId === "home" ? "seven quota areas" : p.contract?.title || p.classId; + queryState.rows = observations; + queryState.health = p.error ? "error" : observations > 0 ? "ok" : "warn"; + queryState.refreshedAt = p.generatedAt ? new Date(p.generatedAt) : new Date(); + queryState.dataset = p.classId === "home" ? "Capacity overview" : p.contract?.title || "Capacity"; + renderDiagnosticRail(); + } +} + +/** Open the connection-settings dialog, prefilled from the current config. */ +function openSettingsDialog() { + el("settings-cluster").value = window.__cfg?.clusterUri || ""; + el("settings-database").value = window.__cfg?.database || ""; + el("settings-error").textContent = ""; + el("settings-dialog").showModal(); + el("settings-cluster").focus(); +} + +/** POST the edited connection settings, then reconnect and re-query. */ +async function saveSettings() { + const clusterUri = el("settings-cluster").value.trim(); + const database = el("settings-database").value.trim(); + if (!clusterUri) { + el("settings-error").textContent = "Cluster URI is required."; + return; + } + const btn = el("settings-save"); + const original = btn.textContent; + btn.disabled = true; + btn.textContent = "Saving…"; + try { + const res = await fetch("/api/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clusterUri, database: database || "Hub" }), + }); + const body = await res.json(); + if (!res.ok || body.error) throw new Error(body.error || "Save failed"); + window.__cfg = body; + el("settings-dialog").close(); + state.cache = {}; // stale data belongs to the old connection + invalidateCapacitySubscriptions(); + load(); + } catch (err) { + el("settings-error").textContent = err.message || "Could not save settings."; + } finally { + btn.disabled = false; + btn.textContent = original; + } +} + +function wireControls() { + el("preset").addEventListener("click", (e) => { + const btn = e.target.closest("button[data-preset]"); + if (!btn || state.loading) return; + state.preset = btn.dataset.preset; + [...el("preset").querySelectorAll("button")].forEach((b) => b.classList.toggle("active", b === btn)); + void publishCanvasState({ preset: state.preset }); + load(); + }); + el("tabs").addEventListener("click", (e) => { + const btn = e.target.closest("button[data-tab]"); + if (btn) switchTab(btn.dataset.tab); + }); + el("refresh").addEventListener("click", () => { + if (state.loading) return; + if (state.cache[state.tab]) delete state.cache[state.tab][cacheKey()]; // force re-query + if (state.tab === "capacity" && state.capacityClass === "compute") invalidateCapacitySubscriptions(); + load(); + }); + + // Settings dialog controls + el("settings-open").addEventListener("click", openSettingsDialog); + el("settings-close").addEventListener("click", () => el("settings-dialog").close()); + el("settings-save").addEventListener("click", saveSettings); + + // KQL dialog controls + el("kql-close").addEventListener("click", () => el("kql-dialog").close()); + el("kql-copy").addEventListener("click", () => { + const btn = el("kql-copy"); + navigator.clipboard.writeText(el("kql-text").value) + .then(() => { btn.textContent = "Copied!"; setTimeout(() => { btn.textContent = "Copy"; }, 1500); }) + .catch(() => { btn.textContent = "Failed"; setTimeout(() => { btn.textContent = "Copy"; }, 1500); }); + }); + el("kql-run").addEventListener("click", executeKql); + + // KQL escape-hatch buttons (event delegation — buttons injected by panelHtml) + document.addEventListener("click", (e) => { + const capacityTab = e.target.closest("[data-capacity-class]"); + if (capacityTab) { + selectCapacityClass(capacityTab.dataset.capacityClass); + return; + } + const btn = e.target.closest(".kql-btn[data-panel-id]"); + if (btn) openKqlDialog(btn.dataset.panelId); + // hbar click-to-filter + const hbarRow = e.target.closest(".hbar-filterable[data-filter-dim]"); + if (hbarRow) { + const dim = hbarRow.dataset.filterDim; + const val = hbarRow.dataset.filterVal; + if (dim && val) toggleFilter(dim, val); + } + // chip remove + const chipRemove = e.target.closest(".chip-remove[data-dim]"); + if (chipRemove) { + const dim = chipRemove.dataset.dim; + const val = chipRemove.dataset.val; + if (dim && val) toggleFilter(dim, val); + } + // reset all + if (e.target.closest("#filter-reset")) clearFilters(); + + const lens = e.target.closest("[data-family-status]"); + if (lens) { + setFamilyFilter({ status: lens.dataset.familyStatus }); + return; + } + const regionChip = e.target.closest("[data-family-region]"); + if (regionChip) { + const region = regionChip.dataset.familyRegion; + const current = state.familyFilter.regions; + setFamilyFilter({ + regions: current.includes(region) ? current.filter((item) => item !== region) : [...current, region], + }); + return; + } + const markChip = e.target.closest("[data-family-mark]"); + if (markChip) { + setFamilyFilter({ mark: Number(markChip.dataset.familyMark) }); + return; + } + if (e.target.closest("[data-family-reset]")) { + setFamilyFilter({ status: "all", search: "", regions: [] }); + return; + } + const detailTab = e.target.closest("[data-capacity-detail-tab]"); + if (detailTab) { + setCapacityDetailTab(detailTab.dataset.capacityDetailTab); + return; + } + const subscriptionPage = e.target.closest("[data-capacity-subscription-page]"); + if (subscriptionPage && !subscriptionPage.disabled) { + state.capacitySubscriptionPage = Number(subscriptionPage.dataset.capacitySubscriptionPage); + _capacitySubscriptionFocusResults = true; + void loadCapacitySubscriptions(); + return; + } + const familyPage = e.target.closest("[data-capacity-family-page]"); + if (familyPage && !familyPage.disabled) { + state.capacityFamilyPage = Number(familyPage.dataset.capacityFamilyPage); + render(); + document.querySelector("#capacity-family-summary")?.focus(); + } + }); + + document.addEventListener("change", (e) => { + const selector = e.target.closest("select[data-capacity-selector]"); + if (selector) applyCapacitySelection(selector.dataset.capacitySelector, selector.value); + }); + + let familySearchTimer; + let subscriptionSearchTimer; + document.addEventListener("input", (e) => { + const search = e.target.closest("[data-family-search]"); + if (search) { + clearTimeout(familySearchTimer); + const value = search.value; + familySearchTimer = setTimeout(() => setFamilyFilter({ search: value }), 160); + return; + } + const subscriptionSearch = e.target.closest("[data-capacity-subscription-search]"); + if (subscriptionSearch) { + clearTimeout(subscriptionSearchTimer); + const value = subscriptionSearch.value; + subscriptionSearchTimer = setTimeout(() => { + state.capacitySubscriptionSearch = value; + state.capacitySubscriptionPage = 1; + void loadCapacitySubscriptions(); + }, 250); + } + }); + + // Keyboard activation and roving focus for interactive data controls. + document.addEventListener("keydown", (e) => { + const detailTab = e.target.closest("[data-capacity-detail-tab]"); + if (detailTab && ["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) { + e.preventDefault(); + const tabs = ["families", "subscriptions"]; + const current = tabs.indexOf(detailTab.dataset.capacityDetailTab); + const next = e.key === "Home" ? 0 + : e.key === "End" ? tabs.length - 1 + : e.key === "ArrowRight" ? (current + 1) % tabs.length + : (current - 1 + tabs.length) % tabs.length; + setCapacityDetailTab(tabs[next]); + return; + } + const lens = e.target.closest("[data-family-status]"); + if (lens && ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) { + e.preventDefault(); + const order = FAMILY_STATUS_FILTERS.map((item) => item.id); + const index = order.indexOf(lens.dataset.familyStatus); + const next = nextCapacityTabIndex(index, e.key, order.length); + if (next >= 0) setFamilyFilter({ status: order[next] }); + return; + } + const markKey = e.target.closest("[data-family-mark]"); + if (markKey && ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) { + e.preventDefault(); + const index = HIGH_WATER_MARKS.indexOf(Number(markKey.dataset.familyMark)); + const next = nextCapacityTabIndex(index, e.key, HIGH_WATER_MARKS.length); + if (next >= 0) setFamilyFilter({ mark: HIGH_WATER_MARKS[next] }); + return; + } + const capacityTab = e.target.closest("[data-capacity-class]"); + if (capacityTab) { + if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) { + e.preventDefault(); + moveCapacityTabFocus(capacityTab, e.key); + return; + } + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + selectCapacityClass(capacityTab.dataset.capacityClass); + return; + } + } + if (e.key !== "Enter" && e.key !== " ") return; + const hbarRow = e.target.closest(".hbar-filterable[data-filter-dim]"); + if (hbarRow) { + e.preventDefault(); + const dim = hbarRow.dataset.filterDim; + const val = hbarRow.dataset.filterVal; + if (dim && val) toggleFilter(dim, val); + } + }); + + let t; + window.addEventListener("resize", () => { clearTimeout(t); t = setTimeout(render, 180); }); +} + +async function init() { + try { + const [cfg, sharedState] = await Promise.all([ + fetch("/api/config").then((r) => r.json()), + fetch("/api/session-state").then((r) => r.json()), + ]); + window.__cfg = cfg; + if (Number.isInteger(sharedState.revision)) { + state.tab = sharedState.tab; + state.preset = sharedState.preset; + state.filters = sharedState.filters || {}; + state.capacityClass = sharedState.capacityClass || "home"; + state.capacitySelections = sharedState.capacitySelections || {}; + state.revision = sharedState.revision; + } + } catch { window.__cfg = {}; } + wireControls(); + syncCanvasControls(); + + // Restore tab from URL hash (bookmarking / back-forward support), or + // normalize the hash to reflect the default tab so the URL is always + // shareable. + const initialTab = tabFromHash(); + const initialCapacityClass = capacityClassFromHash(); + if (initialCapacityClass) state.capacityClass = initialCapacityClass; + const initialHash = (initialTab || state.tab) === "capacity" + ? `#tab=capacity&capacity=${state.capacityClass}` + : `#tab=${initialTab || state.tab}`; + if (initialTab && initialTab !== state.tab) { + switchTab(initialTab, { skipHash: true }); + history.replaceState({ tab: initialTab, capacityClass: state.capacityClass }, "", initialHash); + } else { + history.replaceState({ tab: state.tab, capacityClass: state.capacityClass }, "", initialHash); + revealActiveTab(); + load(); + } + + window.addEventListener("popstate", () => { + const tab = tabFromHash() || "overview"; + const capacityClass = capacityClassFromHash() || "home"; + if (tab === "capacity") state.capacityClass = capacityClass; + if (tab !== state.tab) switchTab(tab, { skipHash: true }); + else if (tab === "capacity") selectCapacityClass(capacityClass, { skipHash: true, skipPublish: true, force: true }); + }); + setInterval(pollCanvasState, 1000); +} + +if (typeof window !== "undefined" && typeof document !== "undefined") init(); diff --git a/.github/extensions/ftk-local-dashboard/public/index.html b/.github/extensions/ftk-local-dashboard/public/index.html new file mode 100644 index 000000000..8f152ea04 --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/public/index.html @@ -0,0 +1,97 @@ + + + + + + FinOps hub dashboard + + + +
    +
    +

    FinOps hub dashboard

    +

    Connecting to the Hub database…

    +
    +
    +
    + + + + +
    + + +
    +
    + + + + + +
    +
    Loading cost data…
    +
    + +
    + +
    + + Grounded in the FinOps Framework domains & the FinOps toolkit query catalog. +
    + + +
    +
    +

    KQL query

    + +
    + + + +
    +
    + + +
    +
    +

    Connection settings

    + +
    +
    + + +

    Use a local loopback HTTP endpoint or a remote *.kusto.windows.net HTTPS cluster. Remote hubs use your current Azure CLI sign-in. Credentials are never saved.

    +
    + + +
    +
    + + + + diff --git a/.github/extensions/ftk-local-dashboard/test/ftk-local-dashboard.test.mjs b/.github/extensions/ftk-local-dashboard/test/ftk-local-dashboard.test.mjs new file mode 100644 index 000000000..1319cc5cd --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/test/ftk-local-dashboard.test.mjs @@ -0,0 +1,1044 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import test from "node:test"; + +process.env.FTK_LOCAL_DASHBOARD_TEST = "1"; + +const kusto = await import("../kusto.mjs"); +const extension = await import("../extension.mjs"); +const app = await import("../public/app.js"); + +const QUOTA_SCHEMA_FIELDS = [ + "ResourceId", "ResourceName", "SubAccountId", "location", "currentValue", + "limit", "unit", "x_SourceType", "x_SourceVersion", "x_IngestionTime", +]; +const COST_SCHEMA_FIELDS = [ + "ChargePeriodStart", "ProviderName", "ChargeCategory", "ResourceId", + "SubAccountId", "RegionId", "x_ResourceType", "x_SkuMeterCategory", + "x_SkuMeterSubcategory", "SkuMeter", "SkuPriceId", "EffectiveCost", + "BillingCurrency", "ConsumedQuantity", "ConsumedUnit", + "CapacityReservationId", "CapacityReservationStatus", +]; + +function kustoResponse(rows, columns = rows[0] ? Object.keys(rows[0]) : []) { + return new Response(JSON.stringify({ + Tables: [{ + TableName: "Table_0", + Columns: columns.map((ColumnName) => ({ ColumnName })), + Rows: rows.map((row) => columns.map((column) => row[column])), + }], + }), { headers: { "Content-Type": "application/json" } }); +} + +async function startCapacityServer(t, options = {}) { + const quotaFields = options.quotaFields || QUOTA_SCHEMA_FIELDS; + const costFields = options.costFields || COST_SCHEMA_FIELDS; + const server = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + const { csl } = JSON.parse(body); + const fields = csl.startsWith("Quota() | getschema") + ? quotaFields + : csl.startsWith("Costs() | getschema") + ? costFields + : null; + const rows = fields + ? fields.map((ColumnName) => ({ ColumnName, ColumnType: "System.String" })) + : typeof options.rows === "function" + ? options.rows(csl) + : []; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(await kustoResponse(rows).text()); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => server.close()); + return `http://127.0.0.1:${server.address().port}`; +} + +test("connection validation permits only local loopback or remote Kusto origins", () => { + assert.deepEqual( + kusto.normalizeConnection("http://LOCALHOST:8082/", " Hub "), + { clusterUri: "http://localhost:8082", database: "Hub", mode: "local", authentication: "none" } + ); + assert.equal( + kusto.normalizeConnection("https://example-cluster.westus.kusto.windows.net", "Hub").mode, + "remote" + ); + for (const uri of [ + "http://example.com", + "https://example.com", + "https://kusto.windows.net", + "https://user:pass@cluster.westus.kusto.windows.net", + "https://cluster.westus.kusto.windows.net/path", + "https://cluster.westus.kusto.windows.net?x=1", + ]) { + assert.throws(() => kusto.normalizeConnection(uri, "Hub")); + } +}); + +test("local dashboard semantics stay unauthenticated and preserve the payload shape", async (t) => { + const requests = []; + const server = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + const { csl } = JSON.parse(body); + requests.push(req.headers); + const rows = csl.includes("MinDate=min") + ? [{ MinDate: "2025-01-01T00:00:00Z", MaxDate: "2025-04-01T00:00:00Z", Rows: 4 }] + : []; + const columns = rows[0] ? Object.keys(rows[0]) : []; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(await kustoResponse(rows, columns).text()); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => server.close()); + + const { port } = server.address(); + const payload = await kusto.getDashboard(`http://127.0.0.1:${port}`, "Hub"); + assert.equal(payload.empty, false); + assert.deepEqual(Object.keys(payload.data), [ + "summary", "tagged", "pricing", "trend", "serviceCategory", + "topServices", "topResourceGroups", "topRegions", "chargeCategory", "macc", + ]); + assert.equal(requests.length, 11); + assert.ok(requests.every((headers) => headers.authorization === undefined)); + assert.ok(requests.every((headers) => headers["x-ms-readonly"] === "true")); +}); + +test("remote requests deduplicate tokens, add read-only headers, and recover after failure", async () => { + kusto.resetKustoAuthForTests(); + let providerCalls = 0; + let release; + const provider = async () => { + providerCalls++; + await new Promise((resolve) => { release = resolve; }); + return { accessToken: "secret-token", expires_on: Math.floor(Date.now() / 1000) + 3600 }; + }; + const seen = []; + const fetchImpl = async (_url, options) => { + seen.push(options.headers); + return kustoResponse([{ Ready: 1 }]); + }; + const first = kusto.runQuery("https://cluster.westus.kusto.windows.net", "Hub", "print Ready=1", { tokenProvider: provider, fetchImpl }); + const second = kusto.runQuery("https://cluster.westus.kusto.windows.net", "Hub", "print Ready=1", { tokenProvider: provider, fetchImpl }); + await new Promise((resolve) => setImmediate(resolve)); + release(); + await Promise.all([first, second]); + assert.equal(providerCalls, 1); + assert.equal(seen.length, 2); + assert.ok(seen.every((headers) => headers.Authorization === "Bearer secret-token")); + assert.ok(seen.every((headers) => headers["x-ms-readonly"] === "true")); + assert.notEqual(seen[0]["x-ms-client-request-id"], seen[1]["x-ms-client-request-id"]); + + kusto.resetKustoAuthForTests(); + await assert.rejects(() => kusto.runQuery( + "https://cluster.westus.kusto.windows.net", + "Hub", + "print Ready=1", + { tokenProvider: async () => { throw new Error("temporary"); }, fetchImpl } + )); + await kusto.runQuery( + "https://cluster.westus.kusto.windows.net", + "Hub", + "print Ready=1", + { + tokenProvider: async () => ({ accessToken: "recovered", expires_on: Math.floor(Date.now() / 1000) + 3600 }), + fetchImpl, + } + ); +}); + +test("transport errors are actionable and authentication failures redact provider output", async () => { + await assert.rejects( + () => kusto.runQuery("http://localhost:8082", "Hub", "print Ready=1", { + fetchImpl: async () => { throw new Error("ECONNREFUSED"); }, + }), + /Could not reach Kusto at http:\/\/localhost:8082: ECONNREFUSED/ + ); + + kusto.resetKustoAuthForTests(); + await assert.rejects( + () => kusto.runQuery("https://cluster.westus.kusto.windows.net", "Hub", "print Ready=1", { + tokenProvider: async () => { throw new Error("secret-token-value"); }, + fetchImpl: async () => assert.fail("fetch must not run without a token"), + }), + (err) => /Azure CLI could not acquire/.test(err.message) && !err.message.includes("secret-token-value") + ); +}); + +test("response parsing detects partial failures and enforces the byte limit before parsing", async () => { + assert.throws(() => kusto.parseKustoResponse({ + Tables: [ + { TableName: "Table_0", Columns: [{ ColumnName: "Value" }], Rows: [[1]] }, + { + TableName: "Table_2", + Columns: [ + { ColumnName: "Severity" }, + { ColumnName: "StatusCode" }, + { ColumnName: "StatusDescription" }, + ], + Rows: [[2, -1, "Partial query failure"]], + }, + ], + }), /Partial query failure/); + await assert.rejects(() => kusto.readBoundedBody(new Response("12345"), 4), /4-byte limit/); +}); + +test("filter encoding keeps adversarial values inside one Kusto string literal", () => { + const values = [ + "O'Reilly", + "back\\slash", + "line\r\nbreak", + "x; .drop table Costs", + "// comment", + "/* comment */", + "東京", + ".show tables", + ]; + const where = kusto.buildFilterWhere({ ServiceName: values }); + for (const value of values) assert.ok(where.includes(JSON.stringify(value))); + assert.equal((where.match(/\| where/g) || []).length, 1); + assert.throws(() => kusto.buildFilterWhere({ BadColumn: ["x"] }), /Unsupported filter/); + assert.throws(() => kusto.validateFilters({ ServiceName: Array(9).fill("x") }), /at most 8/); +}); + +test("capacity registry is exact, versioned, and fail-closed", () => { + assert.equal(Object.keys(kusto.CAPACITY_CLASS_REGISTRY).length, 7); + assert.equal(Object.keys(kusto.CAPACITY_METRIC_REGISTRY).length, 3); + + const enabled = kusto.resolveCapacityMetric({ + x_SourceType: " computeusage ", + x_SourceVersion: "1.0-USAGE", + ResourceName: " CORES ", + unit: " count ", + }); + assert.equal(enabled.capability, "enabled"); + assert.equal(enabled.metricRole, "total-regional-vcpu"); + + assert.deepEqual( + kusto.resolveCapacityMetric({ + x_SourceType: "ComputeUsage", + x_SourceVersion: "1.0-usage", + ResourceName: "cores-extra", + unit: "Count", + }).capability, + "descriptive-only" + ); + assert.equal(kusto.resolveCapacityMetric({ + x_SourceType: "ComputeUsage", + x_SourceVersion: "2.0-usage", + ResourceName: "cores", + unit: "Count", + }).reasonCode, "source-version-mismatch"); + assert.equal(kusto.resolveCapacityMetric({ + x_SourceType: "AppServiceUsage", + x_SourceVersion: "1.0-usage", + ResourceName: "P1v3", + unit: "Instances", + }).reasonCode, "unclassified-metric"); + assert.equal(kusto.resolveCapacityMetric({ + x_SourceType: "UnknownUsage", + x_SourceVersion: "1.0", + ResourceName: "cores", + unit: "Count", + }).capability, "disabled"); +}); + +test("capacity observation precedence handles invalid, stale, unclassified, and limit states", () => { + const now = new Date("2026-08-23T12:00:00Z"); + const base = { + x_SourceType: "ComputeUsage", + x_SourceVersion: "1.0-usage", + ResourceName: "cores", + unit: "Count", + currentValue: 79, + limit: 100, + x_IngestionTime: "2026-08-23T10:00:00Z", + }; + assert.equal(kusto.classifyCapacityObservation(base, now).state, "healthy"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: 80 }, now).state, "watch"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: 90 }, now).state, "action"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: 100 }, now).state, "exhausted"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: 0, limit: 0 }, now).state, "no-entitlement"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: 1, limit: 0 }, now).reasonCode, "conflicting-provider-values"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: null, x_IngestionTime: "2026-08-20T10:00:00Z" }, now).reasonCode, "invalid-provider-values"); + assert.equal(kusto.classifyCapacityObservation({ ...base, x_IngestionTime: "2026-08-21T12:00:00Z" }, now).state, "healthy"); + assert.equal(kusto.classifyCapacityObservation({ ...base, x_IngestionTime: "2026-08-21T11:59:59Z" }, now).state, "stale"); + + const unknown = { ...base, ResourceName: "regionalFamilyCores" }; + assert.equal(kusto.classifyCapacityObservation(unknown, now).state, "unclassified"); + assert.equal(kusto.classifyCapacityObservation({ ...unknown, x_IngestionTime: "2026-08-20T10:00:00Z" }, now).state, "stale"); + + const sqlNegative = kusto.classifyCapacityObservation({ + ...base, + x_SourceType: "SqlSubscriptionUsage", + x_SourceVersion: "1.0-sql", + ResourceName: "RegionalVCoreQuotaForSQLDBAndDW", + limit: -1, + }, now); + assert.equal(sqlNegative.state, "unclassified"); + assert.match(sqlNegative.sourceNote, /interpretation unverified/i); +}); + +test("inventory observations never receive quota arithmetic", () => { + const result = kusto.classifyCapacityObservation({ + x_SourceType: "PremiumSSDv2Disk", + x_SourceVersion: "1.0-disk", + ResourceId: "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Compute/disks/d1", + ResourceName: "d1", + unit: "", + currentValue: 128, + limit: null, + x_IngestionTime: "2026-08-23T10:00:00Z", + }, new Date("2026-08-23T12:00:00Z")); + assert.equal(result.state, "inventory"); + assert.equal(result.currentValue, 128); + assert.equal(result.limit, null); + assert.equal(result.utilizationPercent, null); + assert.equal(result.headroom, null); +}); + +test("capacity history gates activate only supported readings", () => { + assert.equal(kusto.resolveCapacityHistoryCapability(1).mode, "current-only"); + assert.equal(kusto.resolveCapacityHistoryCapability(2).mode, "observed-delta"); + assert.equal(kusto.resolveCapacityHistoryCapability(3).mode, "provisional-runway"); + assert.equal(kusto.resolveCapacityHistoryCapability(7).mode, "trend-runway"); + assert.equal(kusto.resolveCapacityHistoryCapability(7, { quotaType: "inventory" }).mode, "observed-history"); +}); + +test("capacity KQL is bounded and preserves semantic dimensions", () => { + const current = kusto.buildCapacityCurrentQuery("compute"); + const selectors = kusto.buildCapacitySelectorQuery("compute"); + const subscriptions = kusto.buildComputeSubscriptionQuery({ + status: "in-use", + familySearch: "Dsv5", + regions: ["eastus"], + subscriptionSearch: "64e3", + page: 2, + pageSize: 50, + }); + const heatmap = kusto.buildCapacityHeatmapQuery("compute", { + resourceName: "cores", + unit: "Count", + sourceVersion: "1.0-usage", + }); + const demand = kusto.buildCapacityDemandSelectorQuery("compute"); + const reconciliation = kusto.buildCapacityReservationReconciliationQuery(); + const disk = kusto.buildCapacityDemandSelectorQuery("premium-ssd-v2"); + + assert.match(current, /\| take 251$/); + assert.match(selectors, /\| take 501$/); + assert.match(selectors, /summarize displayName=take_any\(displayName\).+by ResourceName, unit, x_SourceType, x_SourceVersion/s); + assert.doesNotMatch(selectors, /by ResourceId/); + assert.match(subscriptions, /where CoresUsed > 0/); + assert.match(subscriptions, /Family contains "Dsv5"/); + assert.match(subscriptions, /Location in~ \("eastus"\)/); + assert.match(subscriptions, /SubscriptionId startswith "64e3"/); + assert.match(subscriptions, /RowNumber between \(51 \.\. 100\)/); + assert.match(subscriptions, /real\(null\)/); + assert.match(subscriptions, /\| take 50$/); + assert.throws(() => kusto.buildComputeSubscriptionQuery({ regions: "eastus" }), /must be an array/); + assert.match(heatmap, /\| take 501$/); + assert.match(demand, /\| take 501$/); + for (const dimension of ["ConsumedUnit", "x_SkuMeterCategory", "x_SkuMeterSubcategory", "SkuMeter", "SkuPriceId", "BillingCurrency"]) { + assert.ok(demand.includes(dimension)); + } + assert.match(reconciliation, /\| join kind=fullouter billed on GroupKey/); + assert.match(reconciliation, /inventory-only/); + assert.match(reconciliation, /cost-only/); + assert.match(disk, /\| join kind=leftouter diskCost on JoinResourceId/); + assert.match(disk, /InventoryResourceId/); +}); + +test("all seven capacity classes return the bounded payload contract", async (t) => { + const clusterUri = await startCapacityServer(t); + for (const classId of Object.keys(kusto.CAPACITY_CLASS_REGISTRY)) { + const payload = await kusto.getCapacity(clusterUri, "Hub", classId); + assert.equal(payload.classId, classId); + assert.equal(payload.contract.id, classId); + assert.equal(payload.schema.quota.available, true); + assert.equal(payload.schema.costs.available, true); + assert.equal(payload.table.rowLimit, 250); + assert.equal(payload.selectors.itemLimit, 500); + assert.equal(payload.history.pointLimit, 430); + assert.equal(payload.heatmap.limit, 500); + assert.equal(payload.series.pointLimit, 430); + assert.equal(payload.demand.selectors.itemLimit, 500); + } +}); + +test("missing schema fields disable only panels that depend on that source", async (t) => { + const missingQuota = await startCapacityServer(t, { + quotaFields: QUOTA_SCHEMA_FIELDS.filter((field) => field !== "x_SourceVersion"), + }); + const quotaPayload = await kusto.getCapacity(missingQuota, "Hub", "compute"); + assert.equal(quotaPayload.schema.quota.available, false); + assert.equal(quotaPayload.schema.costs.available, true); + assert.equal(quotaPayload.capability.mode, "disabled"); + assert.equal(quotaPayload.history.status, "disabled"); + assert.equal(quotaPayload.demand.capability.mode, "parallel"); + + const missingCost = await startCapacityServer(t, { + costFields: COST_SCHEMA_FIELDS.filter((field) => field !== "BillingCurrency"), + }); + const costPayload = await kusto.getCapacity(missingCost, "Hub", "compute"); + assert.equal(costPayload.schema.quota.available, true); + assert.equal(costPayload.schema.costs.available, false); + assert.equal(costPayload.capability.mode, "descriptive-only"); + assert.equal(costPayload.history.status, "no-selection"); + assert.equal(costPayload.demand.capability.mode, "disabled"); + assert.equal(costPayload.series.status, "disabled"); +}); + +test("capacity selections reject unknown fields and keys outside the selector catalog", async (t) => { + assert.throws( + () => extension.validateViewInput({ + name: "capacity", + capacityClass: "compute", + capacitySelections: { quotaSelection: { resourceName: "cores", injected: "value" } }, + }), + /Unsupported quotaSelection field/ + ); + assert.throws( + () => extension.validateViewInput({ name: "capacity", capacityClass: "unknown" }), + /Unsupported capacity class/ + ); + + const selectorRow = { + ResourceId: "/subscriptions/one/providers/Microsoft.Compute/locations/eastus/usages/cores", + ResourceName: "cores", + SubAccountId: "one", + location: "eastus", + unit: "Count", + x_SourceType: "ComputeUsage", + x_SourceVersion: "1.0-usage", + x_IngestionTime: "2026-08-23T12:00:00Z", + }; + const clusterUri = await startCapacityServer(t, { rows: () => [selectorRow] }); + await assert.rejects( + () => kusto.getCapacity(clusterUri, "Hub", "compute", { + quotaSelection: { + subAccountId: "one", + location: "westus", + resourceName: "cores", + unit: "Count", + sourceVersion: "1.0-usage", + }, + }), + /not present in the bounded selector catalog/ + ); +}); + +test("capacity navigation, selection, and heatmap helpers preserve accessible text parity", () => { + assert.equal(app.nextCapacityTabIndex(0, "ArrowLeft"), 7); + assert.equal(app.nextCapacityTabIndex(7, "ArrowRight"), 0); + assert.equal(app.nextCapacityTabIndex(4, "Home"), 0); + assert.equal(app.nextCapacityTabIndex(2, "End"), 7); + assert.equal(app.nextCapacityTabIndex(3, "Enter"), 3); + + const sourceRow = { + SubAccountId: "subscription", + location: "eastus", + ResourceName: "cores", + unit: "Count", + x_SourceVersion: "1.0-usage", + }; + assert.deepEqual(app.capacitySelectionFromRow("quota", "compute", sourceRow), { + subAccountId: "subscription", + location: "eastus", + resourceName: "cores", + unit: "Count", + sourceVersion: "1.0-usage", + }); + assert.deepEqual(app.capacitySelectionFromRow("metric", "compute", sourceRow), { + resourceName: "cores", + unit: "Count", + sourceVersion: "1.0-usage", + }); + assert.deepEqual(app.capacityHeatmapCell({ + semantic: { utilizationPercent: 91.25, state: "action" }, + }, "compute"), { + value: 91.25, + text: "91.3%", + state: "action", + }); +}); + +test("capacity markup retains module loading, tab semantics, and visible text states", async () => { + const [html, source, css] = await Promise.all([ + readFile(new URL("../public/index.html", import.meta.url), "utf8"), + readFile(new URL("../public/app.js", import.meta.url), "utf8"), + readFile(new URL("../public/app.css", import.meta.url), "utf8"), + ]); + assert.match(html, /data-tab="capacity"/); + assert.match(html, /