diff --git a/.github/extensions/ftk-local-dashboard/PRODUCT.md b/.github/extensions/ftk-local-dashboard/PRODUCT.md new file mode 100644 index 000000000..65ab9a021 --- /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 local FinOps hub dashboard that connects to a Kusto emulator running in Docker. Provides the same six analysis views as a deployed FinOps hub (cost overview, allocation, rate optimization, usage & unit economics, anomalies & forecast, AI tokenomics) — but entirely on-device. Success means a practitioner can load cost data, switch views, and spot the insight they need in one sitting without touching Azure. + +## 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 (tabs, segmented control, refresh) have ARIA roles and labels. Reduced motion is respected via `@media (prefers-reduced-motion)`. diff --git a/.github/extensions/ftk-local-dashboard/extension.mjs b/.github/extensions/ftk-local-dashboard/extension.mjs new file mode 100644 index 000000000..89ecd798b --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/extension.mjs @@ -0,0 +1,309 @@ +// Extension: ftk-local-dashboard +// A FinOps dashboard canvas for the local (ftklocal) Kusto emulator. +// +// open() boots a per-instance loopback HTTP server that serves the static +// dashboard (public/) and two JSON endpoints (/api/config, /api/dashboard). +// The dashboard renderer fetches /api/dashboard, which runs the FinOps query +// layer (kusto.mjs) against the emulator's Hub database. + +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; +import { runQuery, getDashboard, getTokenomics, getAllocation, getRate, getUsage, getAnomaly } from "./kusto.mjs"; + +const GETTERS = { + overview: getDashboard, + tokenomics: getTokenomics, + allocation: getAllocation, + rate: getRate, + usage: getUsage, + anomaly: getAnomaly, +}; + +const PUBLIC_DIR = new URL("./public/", import.meta.url); +const DEFAULT_CLUSTER = "http://localhost:8082"; +const DEFAULT_DB = "Hub"; + +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"], +}; + +// instanceId -> { server, url, clusterUri, database } +const servers = new Map(); + +/** Parse `?filters=<JSON>` from a URL search params; returns plain object (column->array). */ +function parseFilters(url) { + const raw = url.searchParams.get("filters"); + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; + } catch { /* ignore malformed */ } + return {}; +} + +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: "Could not query the local 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; + + 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") { + sendJson(res, 200, { clusterUri: entry.clusterUri, database: entry.database, instanceId: entry.instanceId }); + return; + } + + if (path === "/api/dashboard") { + const preset = url.searchParams.get("preset") || "all"; + const filters = parseFilters(url); + try { + 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") { + const preset = url.searchParams.get("preset") || "all"; + const filters = parseFilters(url); + try { + 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") { + const name = url.searchParams.get("name") || "overview"; + const preset = url.searchParams.get("preset") || "all"; + const filters = parseFilters(url); + const getter = GETTERS[name]; + if (!getter) { sendJson(res, 200, { error: `Unknown view '${name}'` }); return; } + try { + const payload = await getter(entry.clusterUri, entry.database, preset, filters); + sendJson(res, 200, payload); + } catch (err) { + sendQueryError(res, entry, name, err); + } + return; + } + + if (path === "/api/kql" && req.method === "POST") { + let body = ""; + for await (const chunk of req) body += chunk; + let kql, database; + try { ({ kql, database } = JSON.parse(body)); } catch { sendJson(res, 400, { error: "Invalid JSON" }); return; } + if (!kql) { sendJson(res, 400, { error: "Missing kql" }); return; } + try { + const rows = await runQuery(entry.clusterUri, database || entry.database, kql); + sendJson(res, 200, { rows }); + } 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"); +} + +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 new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + 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, + }; +} + +await joinSession({ + canvases: [ + createCanvas({ + id: "ftk-local-dashboard", + displayName: "FinOps hub local dashboard", + description: "Live FinOps dashboard for the local (ftklocal) Kusto emulator with six views: cost overview, allocation, rate optimization, usage & unit economics, anomalies & forecast, and AI tokenomics.", + inputSchema: { + type: "object", + properties: { + clusterUri: { type: "string", description: "Base URI of the Kusto emulator. Default http://localhost:8082." }, + database: { type: "string", description: "Database name. Default Hub." }, + }, + }, + actions: [ + { + name: "summary", + description: "Run the dashboard queries against the emulator and return headline FinOps KPIs (effective cost, savings, ESR, untagged %, commitment coverage, top services) for a time window.", + inputSchema: { + type: "object", + properties: { + preset: { type: "string", enum: ["all", "12m", "6m", "3m"], description: "Time window. Default all." }, + }, + }, + handler: async (ctx) => { + const entry = servers.get(ctx.instanceId); + const clusterUri = entry?.clusterUri || DEFAULT_CLUSTER; + const database = entry?.database || DEFAULT_DB; + const preset = ctx.input?.preset || "all"; + try { + const payload = await getDashboard(clusterUri, database, preset); + return headline(payload); + } catch (err) { + logError(`Could not query summary for ${clusterUri}/${database}`, err); + throw new CanvasError("query_failed", "Could not query the local FinOps hub. Check the extension logs for details."); + } + }, + }, + { + name: "tokenomics", + description: "Run the AI token-economics queries against the emulator and return headline token KPIs (AI token cost, total tokens, blended cost per 1M tokens, cached-input share, AI share of cloud, top models, direction mix) for a time window.", + inputSchema: { + type: "object", + properties: { + preset: { type: "string", enum: ["all", "12m", "6m", "3m"], description: "Time window. Default all." }, + }, + }, + handler: async (ctx) => { + const entry = servers.get(ctx.instanceId); + const clusterUri = entry?.clusterUri || DEFAULT_CLUSTER; + const database = entry?.database || DEFAULT_DB; + const preset = ctx.input?.preset || "all"; + try { + const payload = await getTokenomics(clusterUri, database, preset); + return tokenHeadline(payload); + } catch (err) { + logError(`Could not query tokenomics for ${clusterUri}/${database}`, err); + throw new CanvasError("query_failed", "Could not query the local FinOps hub. Check the extension logs for details."); + } + }, + }, + ], + open: async (ctx) => { + const clusterUri = (ctx.input?.clusterUri || DEFAULT_CLUSTER).trim(); + const database = (ctx.input?.database || DEFAULT_DB).trim(); + let entry = servers.get(ctx.instanceId); + if (!entry) { + entry = { instanceId: ctx.instanceId, clusterUri, database }; + await startServer(entry); + servers.set(ctx.instanceId, entry); + } else { + // Reopen may carry updated connection input. + entry.clusterUri = clusterUri; + entry.database = database; + } + return { title: "FinOps hub · local", url: entry.url, status: `${clusterUri} · ${database}` }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (entry) { + servers.delete(ctx.instanceId); + await new Promise((resolve) => entry.server.close(() => resolve())); + } + }, + }), + ], +}); diff --git a/.github/extensions/ftk-local-dashboard/kusto.mjs b/.github/extensions/ftk-local-dashboard/kusto.mjs new file mode 100644 index 000000000..be4ab832e --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/kusto.mjs @@ -0,0 +1,420 @@ +// KQL query layer for the FinOps hub local (ftklocal) Kusto emulator. +// +// Talks to the Kusto emulator 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). + +const DEFAULT_TIMEOUT_MS = 20000; + +/** + * Run a single KQL query against the emulator and return rows as objects. + * Throws on transport error or Kusto error payload. + */ +export async function runQuery(clusterUri, database, csl, timeoutMs = DEFAULT_TIMEOUT_MS) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let res; + try { + res = await fetch(`${clusterUri.replace(/\/+$/, "")}/v1/rest/query`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ db: database, csl }), + signal: controller.signal, + }); + } catch (err) { + if (err?.name === "AbortError") { + throw new Error(`Timed out after ${timeoutMs}ms reaching ${clusterUri}`); + } + throw new Error(`Could not reach Kusto emulator at ${clusterUri}: ${err?.message ?? err}`); + } finally { + clearTimeout(timer); + } + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Kusto returned HTTP ${res.status}. ${text.slice(0, 300)}`); + } + + const json = await res.json(); + // v1 query API returns { Tables: [ { TableName, Columns:[{ColumnName}], Rows:[[...]] }, ... ] } + // An error surfaces as an OneApiErrors / Exceptions payload instead. + 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 table = Array.isArray(json?.Tables) ? json.Tables[0] : null; + 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]]))); +} + +// --- 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. + */ +function buildFilterWhere(filters) { + if (!filters || typeof filters !== "object") return ""; + const clauses = Object.entries(filters) + .filter(([, vals]) => Array.isArray(vals) && vals.length > 0) + .map(([col, vals]) => { + const quoted = vals.map((v) => `'${String(v).replace(/'/g, "''")}'`).join(", "); + return `| where ${col} in (${quoted})`; + }); + return clauses.length > 0 ? "\n" + clauses.join("\n") : ""; +} + + + +/** + * 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 + summary: `Costs() ${period} ${AI_SCOPE} | summarize Tokens=sum(ConsumedQuantity), Effective=sum(EffectiveCost), List=sum(ListCost), Models=dcount(x_SkuDescription), 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 evidence 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 hasEvidence = 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,hasEvidence), 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 + 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') +| 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 + topResourceTypes: `Costs() ${period} | where isnotempty(ResourceType) | summarize Count=count(), 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); +} 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..ad772efa0 --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/public/app.css @@ -0,0 +1,697 @@ +: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)); +} + +* { 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); } + +.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 .meta .pos { color: var(--pos); font-weight: 600; } +.kpi .meta .neg { color: var(--neg); font-weight: 600; } +.kpi .meta .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); +} +.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; + 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); +} + +.panel-grid { + display: grid; + gap: var(--gap); + grid-template-columns: repeat(12, 1fr); +} +.panel { + background: var(--card-bg); + border: 1px solid var(--grid); + border-radius: var(--radius); + padding: 16px; + min-width: 0; +} +.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; } + +.donut-wrap { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; } +.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; } + +/* ---------- 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; } +.dtable .swatch { 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; } + +.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); +} + +/* ---------- 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; } +} 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..c133dd6e2 --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/public/app.js @@ -0,0 +1,1491 @@ +/* FinOps hub local 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", +]; + +const state = { preset: "all", tab: "overview", loading: false, cache: {}, filters: {} }; +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 Count=count(), 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')", "| 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 */ + +let _kqlPanelId = null; +let _loadAbort = null; + +/* ------------------------------------------------------------------ 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() { + return state.preset + filterKey(); +} + +function filterParam() { + const entries = Object.entries(state.filters) + .filter(([, arr]) => arr && arr.length > 0); + if (entries.length === 0) return ""; + return "&filters=" + encodeURIComponent(JSON.stringify(Object.fromEntries(entries))); +} + +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(); + load(); +} + +function clearFilters() { + state.filters = {}; + renderFilterBar(); + load(); +} + +function renderFilterBar() { + const bar = document.getElementById("filter-bar"); + const chips = document.getElementById("filter-chips"); + if (!bar || !chips) return; + const entries = 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)}`; +} +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 ?? ""); + return s.length > n ? s.slice(0, n - 1) + "…" : s; +} +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 */ + +function lineChart(rows) { + // rows: [{Month, Billed, Effective}] + 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); + 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 + const ticks = 4; + for (let t = 0; t <= ticks; t++) { + const val = (yMax / ticks) * t; + const yy = y(val); + g += `<line class="grid-line" x1="${m.l}" y1="${yy}" x2="${W - m.r}" y2="${yy}"/>`; + g += `<text class="tick" x="${m.l - 8}" y="${yy + 4}" text-anchor="end">${fmtMoney(val)}</text>`; + } + // x labels (thin out if crowded) + const step = Math.ceil(n / 12); + rows.forEach((r, i) => { + if (i % step === 0 || i === n - 1) { + g += `<text class="tick" x="${x(i)}" y="${H - 12}" text-anchor="middle">${esc(fmtMonth(r.Month))}</text>`; + } + }); + // 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" 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) + 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; + const W = 540, H = data.length * rowH + 6; + const barX = nameW + 8, barW = W - barX - padR; + // 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 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)}, ${fmtMoney(d.val)}"` : ""; + g += ``; + g += `${esc(trunc(d.name, 20))}${esc(d.name)}`; + g += `${esc(d.name)}\n${fmtMoneyFull(d.val)} · ${fmtPct(pct)}`; + g += `${fmtMoney(d.val)}`; + g += ``; + }); + return svgEl(W, H, g); +} + +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 { + data.forEach((d) => { + const frac = d.value / total; + const a1 = a0 + frac * 360; + 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, + value: opts.valueFmt ? opts.valueFmt(d) : `${fmtMoney(d.value)} · ${fmtPct(d.value / total)}`, + }))); + return `
${svgEl(size, size, g)}
${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("")}
`; +} + +// 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}
`; +} + +function emptyChart(W, H) { + return svgEl(W, H, `No data`); +} + +function tokenTrendChart(rows) { + // rows: [{Month, Tokens, Cost}] — bars = token volume (left axis), line = AI cost (right axis) + const W = 760, H = 280; + 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); + 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 + const step = Math.ceil(n / 12); + rows.forEach((r, i) => { + if (i % step === 0 || i === n - 1) { + g += `${esc(fmtMonth(r.Month))}`; + } + }); + const legend = legendHtml([ + { label: "Token volume", color: PALETTE[2] }, + { label: "AI effective cost", color: PALETTE[3] }, + ]); + return svgEl(W, H, g) + legend; +} + +function anomalyChart(rows) { + // rows: [{Day, Cost, Flag, Baseline}] + 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); + 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 = ""; + const ticks = 4; + for (let t = 0; t <= ticks; t++) { + const yy = m.t + ih - (ih / ticks) * t; + g += ``; + g += `${fmtMoney((max / ticks) * t)}`; + } + // 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[5] }, + ]); + return svgEl(W, H, g) + 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); + 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)}%`; + const step = Math.ceil(n / 12); + if (i % step === 0 || i === n - 1) { + g += `${esc(fmtMonth(r.Month))}`; + } + }); + return svgEl(W, H, g); +} + +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); + 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 = ""; + const ticks = 4; + for (let t = 0; t <= ticks; t++) { + const yy = m.t + ih - (ih / ticks) * t; + g += ``; + g += `${fmtMoney((max / ticks) * t)}`; + } + 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 + const step = Math.ceil(n / 12); + rows.forEach((r, i) => { + if (i % step === 0 || i === n - 1) { + g += `${esc(fmtMonth(r.Month))}`; + } + }); + 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) + 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"; +} + +function switchTab(tabId) { + if (state.loading || tabId === state.tab) return; + state.tab = tabId; + [...el("tabs").querySelectorAll("button")].forEach((b) => { + b.classList.toggle("active", b.dataset.tab === tabId); + b.setAttribute("aria-selected", b.dataset.tab === tabId ? "true" : "false"); + }); + load(); +} + +/* --------------------------------------------------------- triage strip */ + +function buildTriageTile(title, count, cue, tabId) { + const cls = count === null ? "" : count === 0 ? "threshold-green" : count <= 4 ? "threshold-amber" : "threshold-red"; + const badge = count === null ? "Unknown" : count === 0 ? "Good" : count <= 4 ? "Review" : "Urgent"; + const display = count === null ? "—" : 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: <5% · Review: <10% · Urgent: ≥10%. 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.", +}; + +function kpiCard(label, value, meta, accent, thresholdClass) { + // Determine hierarchy class based on label + const primaryLabels = ["Untagged cost", "Commitment waste", "Effective savings rate"]; + const referenceLabels = ["Commitment coverage", "Compute coverage", "Total tokens", "Anomaly days"]; + + let hierarchyClass = ""; + if (primaryLabels.includes(label)) { + hierarchyClass = "kpi--primary"; + } else if (referenceLabels.includes(label)) { + hierarchyClass = "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; + el("kql-text").value = 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)), + // 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]), + // 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 + 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]), + ].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"))} + ${panelHtml("overview-service-category", 6, "Cost by service category", "Where spend concentrates across categories.", hbar(d.serviceCategory, "ServiceCategory", "Cost"))} +
+ +

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"))} + ${panelHtml("overview-top-regions", 6, "Cost by region", "Regional spend for placement & sustainability review.", hbar(d.topRegions, "RegionId", "Cost"))} +
+ +

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" }))} + ${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: PALETTE[3] }, + ], { centerBig: fmtPct(1 - k.untaggedPct), centerSmall: "tagged" }))} +
+ `; + content.innerHTML = html; +} + +function savingsTable(k) { + const row = (label, val, accent) => + `
+ ${ + accent ? `` : ""}${esc(label)} + ${fmtMoney(val)}
`; + return `
+ ${row("List cost", k.list, "var(--muted)")} + ${row("Negotiated savings", k.negotiated, PALETTE[8])} + ${row("Commitment savings", k.commitment, PALETTE[1])} + ${row("Effective cost", k.eff, 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 SKUs`, PALETTE[0]), + // supporting KPIs + kpiCard("AI token cost", fmtMoney(k.eff), `${fmtPct(k.aiShare, 2)} of all cloud cost`, PALETTE[2]), + 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 token SKUs`, 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"))} + ${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)}`, + }))} +
+ +

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 · ai-cost-by-application
+
+ ${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 ` + ${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); + return ` + + ${data.map((r, i) => { + const share = totalCost > 0 ? (r.EffectiveCost || 0) / totalCost : 0; + const color = PALETTE[i % PALETTE.length]; + return ` + + + + + + + + + `; + }).join("")} +
ApplicationTeamEnvironmentCost centerTokensEffective cost$/1M tokens% of AI
${esc(r.Application || "(untagged)")}${esc(r.Team || "—")}${esc(r.Environment || "—")}${esc(r.CostCenter || "—")}${fmtTokens(r.TokenCount)}${fmtMoney(r.EffectiveCost)}${fmtPerM(r.CostPer1KTokens)}${fmtPct(share)}
`; +} + +/* --------------------------------------------- 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 kpis = [ + // reference KPIs first (no primaries in this tab) + kpiCard("Anomaly days", fmtInt(anomDays.length), + `${fmtMoney(anomCost)} on flagged days`, PALETTE[5]), + // supporting KPIs + kpiCard("Anomaly detection rate", fmtPct(rate, 2), + `${anomDays.length} flagged of ${daily.length} days`, PALETTE[4]), + kpiCard("Unpredicted variance", fmtMoney(variance), + `net spend vs baseline on anomaly days`, PALETTE[3]), + kpiCard("Last month change", lastMc ? `${lastMc.EffChangePct > 0 ? "+" : ""}${lastMc.EffChangePct.toFixed(1)}%` : "—", + lastMc ? `effective cost · ${esc(fmtMonth(lastMc.Month))}` : "", PALETTE[lastMc && lastMc.EffChangePct > 0 ? 4 : 1]), + kpiCard("Forecast next month", nextFc ? fmtMoney(nextFc.Forecast) : "—", + nextFc ? `projected · ${esc(fmtMonth(nextFc.Month))}` : "", PALETTE[0]), + kpiCard("Visibility delay", p50Days != null ? `${p50Days.toFixed(0)}d` : "—", + `median ingestion lag (P50)`, PALETTE[2]), + ].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]), + kpiCard("Effective cost / core", `$${effPerCore.toFixed(3)}`, + `incl. unused commitment`, PALETTE[2]), + 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.Count || 0, cost: r.Cost || 0, + pct: total > 0 ? (r.Cost || 0) / total : 0, + })); + const typeTable = tableHtml([ + { label: "Resource type", align: "left", get: (r, i) => `${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": PALETTE[6] }; + const tierSlices = (d.storageTiers || []).map((r) => ({ label: r.Tier, value: r.Cost || 0, color: tierColors[r.Tier] || PALETTE[6] })); + 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"))} + ${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" }))} +
+ `; +} + +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; + + const kpis = [ + // primary KPIs first + kpiCard("Effective savings rate", fmtPct(esr), + `${fmtMoney(s.Total)} total savings · vs. list price`, PALETTE[1]), + kpiCard("Commitment waste", fmtPct(waste), + `${fmtMoney(cm.Unused)} unused · of commitment spend`, PALETTE[3], + kpiThreshold(waste, 0.10, 0.20)), + // 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]), + // supporting KPIs + kpiCard("Committed core-hours", fmtPct(coreShare), + `RI + savings plan vs on-demand`, PALETTE[8]), + ].join(""); + + const savingsBreak = `
+ ${rateRow("List cost", s.List, "var(--muted)")} + ${rateRow("Negotiated savings", s.Negotiated, PALETTE[8])} + ${rateRow("Commitment savings", s.Commitment, PALETTE[1])} + ${rateRow("Effective cost", s.Effective, 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", 7, "Savings breakdown", "List → effective cost by discount type (effective savings rate).", savingsBreak)} + ${panelHtml("rate-commit-util", 5, "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" }))} + ${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`, + }))} + ${panelHtml("rate-underutil", 6, "Underutilized commitments", "Reservations & plans with the most unused cost.", + hbar(d.byCommitment, "CommitmentDiscountName", "Unused"))} +
+ +

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.", topCommitTxnTable(d.topCommitmentTxns))} +
+ `; +} + +function rateRow(label, val, accent) { + return `
+ ${ + accent ? `` : ""}${esc(label)} + ${fmtMoney(val)}
`; +} + +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)), + // supporting KPIs + kpiCard("Allocation accuracy", fmtPct(aai), + `directly attributed effective cost`, PALETTE[1]), + kpiCard("Unallocated cost", fmtPct(unallocPct), + `${fmtMoney(total - c.Attributed)} lacks allocation evidence`, 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) => `${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); + + 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: PALETTE[3] }, + ], { centerBig: fmtPct(1 - untaggedPct), centerSmall: "tagged" }))} + ${panelHtml("alloc-tag-keys", 6, "Cost by tag key", "Effective cost touched by each governance tag.", hbar(d.tagKeys, "k", "Cost", { filterDim: null }))} + ${panelHtml("alloc-by-subscription", 6, "Cost by subscription", "Spend per billing scope for showback.", hbar(d.bySubscription, "SubAccountName", "Cost"))} +
+ `; +} + +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)}
+
+
`; +} + +/* ----------------------------------------------------------------- driver */ + +const ENDPOINT = { + overview: "/api/view?name=overview", + allocation: "/api/view?name=allocation", + rate: "/api/view?name=rate", + usage: "/api/view?name=usage", + anomaly: "/api/view?name=anomaly", + tokenomics: "/api/view?name=tokenomics", +}; + +function currentPayload() { + return state.cache[state.tab]?.[cacheKey()]; +} + +function render() { + const p = currentPayload(); + if (!p) return; + try { + if (state.tab === "tokenomics") renderTokenomics(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 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, 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(`${ENDPOINT[tab]}&preset=${encodeURIComponent(state.preset)}${filterParam()}`, { 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 that the Kusto emulator is running." }; + } finally { + state.loading = false; + setRefreshSpinning(false); + el("content")?.setAttribute("aria-busy", "false"); + } + updateChrome(); + render(); +} + +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(); + } +} + +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)); + 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 + load(); + }); + + // 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 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(); + }); + + // Keyboard activation for filterable hbar rows (Enter / Space) + document.addEventListener("keydown", (e) => { + 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 = await fetch("/api/config").then((r) => r.json()); + window.__cfg = cfg; + } catch { window.__cfg = {}; } + wireControls(); + load(); +} + +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..3e1a68977 --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/public/index.html @@ -0,0 +1,69 @@ + + + + + + FinOps hub — local dashboard + + + +
+
+

FinOps hub · local

+

Connecting to the Kusto emulator…

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

KQL query

+ +
+ + + +
+
+ + + + diff --git a/.gitignore b/.gitignore index 198952770..42ce37628 100644 --- a/.gitignore +++ b/.gitignore @@ -387,3 +387,6 @@ src/templates/finops-hub-copilot-studio/knowledge/query-catalog.md todo/ done/ release/scloud-occurrence-report.md + +# impeccable design skill cache +.impeccable/ diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 000000000..07fbf365d --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,42 @@ +# FinOps hub local — product context + +## What this is + +A live cost-analytics dashboard embedded in GitHub Copilot CLI as a canvas extension. +It connects to a locally-running Kusto emulator (ftklocal) loaded with the FinOps hub +schema and renders interactive KPI cards, charts, and triage signals — all grounded in +the FinOps Framework and the finops-toolkit query catalog. + +## Register + +product — design serves the product, the dashboard is the tool, not the brand. + +## Users + +FinOps practitioners and cloud engineers who run `Initialize-FinOpsHubLocal` and want +to explore their Azure cost data locally without provisioning cloud infrastructure. +They are technical (comfortable with KQL, PowerShell, Azure), time-conscious, and +treat the dashboard as a professional diagnostic surface rather than a consumer app. + +## Goals + +- Show the six FinOps Framework capability areas (overview, allocation, rate, usage, + anomaly, tokenomics) with actionable KPIs and charts. +- Let engineers drill down by clicking chart elements to slice the full dataset by + service, category, region, resource group, or subscription. +- Surface triage signals (anomalies, overspend, savings gaps) at a glance. +- Stay snappy: all data is local, no network calls beyond the loopback emulator. + +## Non-goals + +- Not a production monitoring tool (no alerting, no SLAs). +- Not a multi-user shared dashboard (single-engineer local only). +- Not a replacement for Cost Management in the Azure portal. + +## Design constraints + +- Zero external dependencies: vanilla JS/CSS, no build step, no npm packages. +- Served by a Node.js HTTP server inside the Copilot extension process. +- Embeds in the Copilot CLI canvas panel (variable width, ~800–1400 px typical). +- Must respect GitHub's CSS custom properties for light/dark theming. +- All interactivity must be keyboard-accessible and meet WCAG 2.1 AA contrast. diff --git a/docs-mslearn/TOC.yml b/docs-mslearn/TOC.yml index 4d48e0aff..caafb6f84 100644 --- a/docs-mslearn/TOC.yml +++ b/docs-mslearn/TOC.yml @@ -128,6 +128,8 @@ href: toolkit/hubs/finops-hubs-overview.md - name: Create or upgrade hubs href: toolkit/hubs/deploy.md + - name: Run hubs locally + href: toolkit/hubs/run-hubs-locally.md - name: Configure private networking href: toolkit/hubs/private-networking.md - name: Configure scopes @@ -246,6 +248,8 @@ href: toolkit/powershell/hubs/deploy-finopshub.md - name: Initialize-FinOpsHubDeployment href: toolkit/powershell/hubs/initialize-finopshubdeployment.md + - name: Initialize-FinOpsHubLocal + href: toolkit/powershell/hubs/initialize-finopshublocal.md - name: Register-FinOpsHubProviders href: toolkit/powershell/hubs/register-finopshubproviders.md - name: Remove-FinOpsHub diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index 1f7974bef..c573b1cbd 100644 --- a/docs-mslearn/toolkit/changelog.md +++ b/docs-mslearn/toolkit/changelog.md @@ -3,7 +3,7 @@ title: FinOps toolkit changelog description: Review the latest features and enhancements in the FinOps toolkit, including updates to FinOps hubs, Power BI reports, and more. author: MSBrett ms.author: brettwil -ms.date: 06/19/2026 +ms.date: 06/21/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -47,6 +47,9 @@ _Released June 2026_ ### [FinOps hubs](hubs/finops-hubs-overview.md) v15 +- **Added** + - Added a [Run hubs locally](hubs/run-hubs-locally.md) guide to stand up a FinOps hub in a local Kusto emulator container and ingest cost data using the same KQL, transforms, and open data as a deployed hub. + - Added a build-generated `finops-hub-local-opendata.kql` release artifact that loads the open data reference tables from CSV, so the local hub guide stays in sync with published open data instead of hard-coding schemas. - **Changed** - Added a callout to the `config_RunBackfillJob` backfill option clarifying that it isn't supported on Microsoft Customer Agreement (MCA) billing accounts or billing profiles ([#2113](https://github.com/microsoft/finops-toolkit/issues/2113)). - **Fixed** @@ -70,6 +73,8 @@ _Released June 2026_ ### [PowerShell module](powershell/powershell-commands.md) v15 +- **Added** + - Added [Initialize-FinOpsHubLocal](powershell/hubs/initialize-finopshublocal.md) to set up a local FinOps hub in a running Kusto emulator with one command. - **Fixed** - Fixed [Get-FinOpsCostExport](powershell/cost/get-finopscostexport.md) `-RunHistory` to return the complete run history ([#2063](https://github.com/microsoft/finops-toolkit/issues/2063)). - Bumped the `Az.Accounts` required-module minimum to 2.17.0 so dependency resolution can't land on a version missing the `Get-AzAccessToken -AsSecureString` parameter that `Invoke-Rest` relies on ([#2185](https://github.com/microsoft/finops-toolkit/issues/2185)). diff --git a/docs-mslearn/toolkit/hubs/run-hubs-locally.md b/docs-mslearn/toolkit/hubs/run-hubs-locally.md new file mode 100644 index 000000000..4632e8640 --- /dev/null +++ b/docs-mslearn/toolkit/hubs/run-hubs-locally.md @@ -0,0 +1,289 @@ +--- +title: Run FinOps hubs locally +description: Stand up a FinOps hub on your own hardware in a local container and ingest cost data, using the same KQL and open data as a deployed hub. +author: MSBrett +ms.author: brettwil +ms.date: 06/29/2026 +ms.topic: how-to +ms.service: finops +ms.subservice: finops-toolkit +ms.reviewer: brettwil +#customer intent: As a FinOps user, I want to run a FinOps hub locally so I can explore my cost data without deploying Azure resources. +--- + + + +# Run FinOps hubs locally + +This article shows how to run a FinOps hub on your own hardware using the [Kusto emulator](/azure/data-explorer/kusto-emulator-overview), a free local container. It uses the same analytics KQL, transforms, and open data as a deployed [FinOps hub](finops-hubs-overview.md) — only the host changes from Azure Data Explorer to a local container. Run the PowerShell blocks below in order; each is a step you (or an agent) can run one after another. + +A local hub is useful when you need full hub analysis without managed daily refresh: exploring the data model, validating large customer datasets, supporting consulting deliveries, or working from on-premises, other-cloud, or disconnected environments where you can bring exported data to the machine. It isn't a replacement for a deployed hub — there's no scheduled ingestion, networking, security, or sharing. + +
+ +## Prerequisites + +- [Docker](https://docs.docker.com/get-docker/), to run the Kusto emulator container, with at least 16 GB of memory available to the container. Start Docker before you run the commands and verify `docker info` works in the same PowerShell session. +- [PowerShell 7](/powershell/scripting/install/installing-powershell) — every command in this article is PowerShell. +- [Azure PowerShell](/powershell/azure/install-azure-powershell) (the `Az` modules), only needed to download cost data from an Azure storage account. Sign in first with `Connect-AzAccount` and select the subscription that holds your cost data — this article never runs a sign-in command. With `-UseConnectedAccount`, your identity also needs storage data-plane access, such as **Storage Blob Data Reader**, on the account or container. +- FOCUS cost exports as Parquet, available on the local machine. The emulator reads cost data from a local folder, so the files must be on disk — but you can download them from a [FinOps hub storage account](configure-scopes.md) or [Cost Management exports](/cost-management-billing/costs/tutorial-improved-exports) (shown below), or copy them from any other source into the same folder structure. + +For background on the emulator and its platform requirements, see the [Kusto emulator overview](/azure/data-explorer/kusto-emulator-overview) and [installation guide](/azure/data-explorer/kusto-emulator-install). + +
+ +## Start the emulator + +Create a working folder outside the repository, then start the emulator. Two folders are mounted into the container: exports (read-only at `/data/export`) and a data folder where the engine persists its databases. Mounting the data folder means your databases survive container restarts and removals. For other ways to run the container, see [Install the Kusto emulator](/azure/data-explorer/kusto-emulator-install). + +```powershell +$exportPath = '../exports/local-hub' +$dataPath = '../exports/local-hub-data' + +New-Item -ItemType Directory -Force -Path $exportPath | Out-Null +New-Item -ItemType Directory -Force -Path $dataPath | Out-Null +docker run -d --name finops-hub-local --platform linux/amd64 ` + -p 8082:8080 -m 16g -e ACCEPT_EULA=Y ` + -v "$((Resolve-Path $exportPath).Path):/data/export:ro" ` + -v "$((Resolve-Path $dataPath).Path):/kustodata" ` + mcr.microsoft.com/azuredataexplorer/kustainer-linux:latest +``` + +To restart after stopping the container, run `docker start finops-hub-local`. Your databases reload automatically from the data folder — no need to repeat the setup steps. + +All later steps talk to the emulator's HTTP endpoint, so define a small helper to send a command. Management commands (those starting with `.`) go to `/v1/rest/mgmt`; queries go to `/v1/rest/query`. The endpoint has no authentication. + +```powershell +$hub = 'http://localhost:8082/v1/rest' +function Invoke-Kusto { + param([string]$Database, [string]$Command, [ValidateSet('mgmt','query')][string]$Endpoint = 'mgmt') + Invoke-RestMethod -Uri "$hub/$Endpoint" -Method Post -ContentType 'application/json' ` + -Body (@{ db = $Database; csl = $Command } | ConvertTo-Json) +} +``` + +Wait for the engine to answer before continuing: + +```powershell +do { + Start-Sleep -Seconds 3 + $ready = try { Invoke-Kusto NetDefaultDB '.show version'; $true } catch { Write-Host 'waiting for emulator...'; $false } +} until ($ready) +Write-Host 'emulator ready' +``` + +
+ +## Set up the hub + +Set up the databases, schema, and open data in one step with the [FinOps toolkit PowerShell module](../powershell/powershell-commands.md), or follow the next three sections to do it by hand. Both run the same released scripts and produce the same result. + +To use the module, stage the open data CSVs in the mounted folder first (the emulator reads them from there), then run one command: + +```powershell +New-Item -ItemType Directory -Force -Path "$exportPath/open-data" | Out-Null +foreach ($f in 'PricingUnits', 'Regions', 'ResourceTypes', 'Services') { + Invoke-WebRequest "https://github.com/microsoft/finops-toolkit/releases/latest/download/$f.csv" -OutFile "$exportPath/open-data/$f.csv" +} + +Install-Module -Name FinOpsToolkit -Scope CurrentUser +Initialize-FinOpsHubLocal -ClusterUri 'http://localhost:8082' -RawRetentionInDays 90 +``` + +`Initialize-FinOpsHubLocal` creates the `Ingestion` and `Hub` databases, applies the released setup scripts, and loads open data from `/data/export/open-data`. It doesn't manage the container or ingest cost data — start the emulator first and ingest your exports later. Add `-SkipOpenData` to skip open data. When it finishes, skip ahead to [Download your cost data](#download-your-cost-data). + +To set everything up by hand instead, continue with the steps below. + +
+ +## Create the two databases + +A FinOps hub uses two databases: `Ingestion` for raw and transformed data, and `Hub` for the view functions you query. + +```powershell +foreach ($db in 'Ingestion', 'Hub') { + Invoke-Kusto NetDefaultDB ".create database $db persist (@`"/kustodata/dbs/$db/md`", @`"/kustodata/dbs/$db/data`")" | Out-Null +} +``` + +
+ +## Load the schema + +Download the hub's setup scripts from the latest toolkit release and load each into its database. These are the same `finops-hub-fabric-setup-*.kql` files used to set up a [Microsoft Fabric hub](deploy.md#optional-set-up-microsoft-fabric) — each is a single `.execute database script` that creates every table, mapping, transform function, and update policy. + +The Ingestion script has one placeholder, `$$rawRetentionInDays$$` (how long to keep raw data). Replace it with a number of days before loading. + +```powershell +$release = 'https://github.com/microsoft/finops-toolkit/releases/latest/download' +New-Item -ItemType Directory -Force -Path "$exportPath/setup" | Out-Null +Invoke-WebRequest "$release/finops-hub-fabric-setup-Ingestion.kql" -OutFile "$exportPath/setup/setup-Ingestion.kql" +Invoke-WebRequest "$release/finops-hub-fabric-setup-Hub.kql" -OutFile "$exportPath/setup/setup-Hub.kql" + +# Ingestion: raw tables, transforms, final tables, update policies +$ingestion = (Get-Content "$exportPath/setup/setup-Ingestion.kql" -Raw) -replace '\$\$rawRetentionInDays\$\$', '90' +Invoke-Kusto Ingestion $ingestion + +# Hub: the Costs/Prices/Transactions view functions +Invoke-Kusto Hub (Get-Content "$exportPath/setup/setup-Hub.kql" -Raw) +``` + +Each response is a table with one row per statement. The Hub functions reference `database('Ingestion').*`, which resolves because both databases exist. + +
+ +## Load the open data + +FinOps hubs enrich cost data with open-data reference tables (regions, services, resource types, and pricing units). Download the reference CSVs and the matching load script from the toolkit release, then run the script. The load script (`finops-hub-local-opendata.kql`) is generated by the toolkit build from the same open data a deployed hub uses, so its schema always matches the published CSVs. + +The emulator's first external-data read right after setup sometimes returns no rows without raising an error, so load the open data, check the tables filled, and retry if they didn't: + +```powershell +New-Item -ItemType Directory -Force -Path "$exportPath/open-data" | Out-Null +foreach ($f in 'PricingUnits', 'Regions', 'ResourceTypes', 'Services') { + Invoke-WebRequest "$release/$f.csv" -OutFile "$exportPath/open-data/$f.csv" +} +Invoke-WebRequest "$release/finops-hub-local-opendata.kql" -OutFile "$exportPath/setup/load-open-data.kql" + +# Point the load script at the mounted open-data folder. +$openData = (Get-Content "$exportPath/setup/load-open-data.kql" -Raw) -replace '\$\$openDataPath\$\$', '/data/export/open-data' + +# Load, verify the tables filled, and retry if the first read came back empty. +foreach ($attempt in 1..5) { + Invoke-Kusto Ingestion $openData | Out-Null + $empty = 'PricingUnits', 'Regions', 'ResourceTypes', 'Services' | Where-Object { + (Invoke-Kusto Ingestion "$_ | count" -Endpoint query).Tables[0].Rows[0][0] -eq 0 + } + if (-not $empty) { Write-Host 'open data loaded'; break } + if ($attempt -eq 5) { throw "Open data tables still empty: $($empty -join ', ')" } + Start-Sleep -Seconds 2 +} +``` + +
+ +## Download your cost data + +Download FOCUS cost and price exports from your storage account into the export folder, using your existing Azure sign-in. This works against either the **msexports** or **ingestion** container of a FinOps hub, or any container holding Cost Management exports. Only the data files (`manifest.json` and `*.parquet`) are downloaded. + +Set these to match your storage account, then run the download: + +> [!NOTE] +> This download step is specific to Azure Storage. The local hub uses the same FOCUS transforms as a deployed hub, which are aligned to [FOCUS](../../focus/what-is-focus.md) and validated against Microsoft Cost Management cost data. If your FOCUS exports already live elsewhere, copy each dataset's `manifest.json` and `*.parquet` files into `$exportPath` and skip to [Ingest the data](#ingest-the-data). + +```powershell +$account = '' +$container = 'ingestion' # or: msexports +$prefix = '' # optional, e.g. 'billingAccounts/00000000' for msexports + +$ctx = New-AzStorageContext -StorageAccountName $account -UseConnectedAccount +Get-AzStorageBlob -Container $container -Context $ctx -Prefix $prefix | + Where-Object { $_.Name -match '(manifest\.json|\.parquet)$' } | + ForEach-Object { + Get-AzStorageBlobContent -Container $container -Context $ctx -Blob $_.Name ` + -Destination $exportPath -Force | Out-Null + } +``` + +> [!NOTE] +> FinOps hub storage accounts use a hierarchical namespace, where each folder is also a zero-byte marker blob. Downloading only `manifest.json` and `*.parquet` skips those markers, which otherwise collide with same-named folders on disk. `-UseConnectedAccount` reuses your existing Azure sign-in. + +
+ +## Ingest the data + +Ingest each Parquet file into the matching raw table. As rows land, the update policy created by the schema runs the FOCUS transform automatically and appends the result to the final table — the same mechanism a deployed hub uses. Cost exports go to `Costs_raw`; price sheets go to `Prices_raw`. + +For large exports, use a small amount of parallelism. Start with about one ingestion thread per 8 GB of emulator memory. For the 16 GB container below, use two threads; if you increase the container to 32 GB, four threads is a good starting point. + +Ingest price sheets before cost data. The cost transform enriches rows with missing prices from `Prices_final_v1_2`, and the update policy runs as each file lands, so any prices must already be in place when costs are ingested. + +```powershell +$ingestConcurrency = 2 + +# Group exports by manifest type, then ingest each dataset's Parquet files +$ingestJobs = Get-ChildItem $exportPath -Recurse -Filter manifest.json | ForEach-Object { + # Default to FocusCost; treat as PriceSheet only when the manifest type or path says so. + $manifest = Get-Content $_.FullName -Raw + $isPrice = $manifest -match '"type"\s*:\s*"PriceSheet"' -or $_.FullName -match '(?i)price' + $target = if ($isPrice) { 'Prices_raw', 'Prices_raw_mapping' } else { 'Costs_raw', 'Costs_raw_mapping' } + Get-ChildItem $_.Directory -Filter *.parquet | ForEach-Object { + [PSCustomObject]@{ + Table = $target[0] + Mapping = $target[1] + File = $_.FullName + # Prices first (0) then costs (1): the cost transform enriches from Prices_final_v1_2. + Phase = if ($isPrice) { 0 } else { 1 } + } + } +} + +# Ingest prices, then costs. Each phase finishes before the next starts. +foreach ($phase in $ingestJobs | Group-Object Phase | Sort-Object Name) { + $phase.Group | ForEach-Object -Parallel { + $hub = $using:hub + $exportPath = $using:exportPath + function Invoke-Kusto { + param([string]$Database, [string]$Command, [ValidateSet('mgmt','query')][string]$Endpoint = 'mgmt') + Invoke-RestMethod -Uri "$hub/$Endpoint" -Method Post -ContentType 'application/json' ` + -Body (@{ db = $Database; csl = $Command } | ConvertTo-Json) + } + function Invoke-Ingest { + param([string]$Table, [string]$Mapping, [string]$File) + $rel = [IO.Path]::GetRelativePath((Resolve-Path $exportPath), $File) -replace '\\', '/' + $path = "/data/export/$rel" + Invoke-Kusto Ingestion ".ingest into table $Table (h@'$path') with (format='parquet', ingestionMappingReference='$Mapping')" | Out-Null + Write-Host " ingested $(Split-Path $File -Leaf)" + } + Invoke-Ingest $_.Table $_.Mapping $_.File + } -ThrottleLimit $ingestConcurrency +} +``` + +If the emulator exits with code 137, it ran out of memory. Reduce `$ingestConcurrency`, increase the container memory, or ingest one month at a time and restart the emulator between batches. For very large imports, keep a per-file log so you can retry failed files without restarting the entire load. + +Confirm the data landed and the final tables filled in: + +```powershell +foreach ($t in 'Costs_raw', 'Costs_final_v1_2', 'Prices_raw', 'Prices_final_v1_2') { + $count = (Invoke-Kusto Ingestion "$t | count" -Endpoint query).Tables[0].Rows[0][0] + Write-Host "$t`: $count" +} +``` + +> [!TIP] +> Ingesting raw data is all you need — the final tables populate themselves through the update policy. Keep the policy enabled so each file's transform stays small; it scales to tens of millions of rows without running out of memory. + +
+ +## Query your data + +The `Hub` database exposes the same view functions a deployed hub does. Query them directly: + +```powershell +$result = Invoke-Kusto Hub 'Costs_v1_2 | summarize EffectiveCost = round(sum(EffectiveCost), 2) by ServiceCategory | top 10 by EffectiveCost' -Endpoint query +$result.Tables[0].Rows +``` + +You now have a working local FinOps hub. To explore it visually, connect the [Azure Data Explorer web UI](https://dataexplorer.azure.com) to `http://localhost:8082`, or point Power BI and other tools at the same endpoint. + +
+ +## Clean up + +Remove the container when you're done. Your downloaded data stays in the export folder you created (`$exportPath`), but removing the container deletes its databases — re-creating it means re-loading the schema and open data and re-ingesting. To keep the loaded data between sessions, use `docker stop finops-hub-local` instead and restart it later with `docker start finops-hub-local`. + +```powershell +docker rm -f finops-hub-local +``` + +
+ +## Related content + +- [FinOps hubs overview](finops-hubs-overview.md) +- [Create and update FinOps hubs](deploy.md) +- [FinOps hub data model](data-model.md) +- [How data is processed](data-processing.md) + +
diff --git a/docs-mslearn/toolkit/powershell/hubs/initialize-finopshublocal.md b/docs-mslearn/toolkit/powershell/hubs/initialize-finopshublocal.md new file mode 100644 index 000000000..9ed4bc2f7 --- /dev/null +++ b/docs-mslearn/toolkit/powershell/hubs/initialize-finopshublocal.md @@ -0,0 +1,80 @@ +--- +title: Initialize-FinOpsHubLocal command +description: Set up a local FinOps hub in a running Kusto emulator using the Initialize-FinOpsHubLocal command in the FinOpsToolkit module. +author: MSBrett +ms.author: brettwil +ms.date: 06/21/2026 +ms.topic: reference +ms.service: finops +ms.subservice: finops-toolkit +ms.reviewer: brettwil +#customer intent: As a FinOps user, I want to set up a local FinOps hub with one command so I can explore my cost data without deploying Azure resources. +--- + +# Initialize-FinOpsHubLocal command + +The **Initialize-FinOpsHubLocal** command sets up a local FinOps hub in a running [Kusto emulator](/azure/data-explorer/kusto-emulator-overview). It creates the `Ingestion` and `Hub` databases, then downloads and applies the released FinOps hub setup scripts and open data load script, so the local hub uses the same KQL, transforms, and open data as a deployed hub. + +The command doesn't install Docker or manage the emulator container, and it doesn't ingest cost data. Start the emulator first, then run this command against its endpoint. For the full walkthrough, see [Run FinOps hubs locally](../../hubs/run-hubs-locally.md). + +
+ +## Syntax + +```powershell +Initialize-FinOpsHubLocal ` + [-ClusterUri ] ` + [-ReleaseUri ] ` + [-RawRetentionInDays ] ` + [-OpenDataPath ] ` + [-SkipOpenData] ` + [-Destination ] ` + [-WhatIf] +``` + +
+ +## Parameters + +| Name | Description | +| --------------------- | --------------------------------------------------------------------------------------------------------------- | +| `‑ClusterUri` | Optional. Base URI of the running Kusto emulator. Default = `http://localhost:8082`. | +| `‑ReleaseUri` | Optional. Base URI to download the setup scripts and open data load script from. Default = the latest FinOps toolkit GitHub release. Point this at a local file server or a specific release to run offline or pin a version. | +| `‑RawRetentionInDays` | Optional. Number of days to keep raw data in the `Ingestion` database. Default = `90`. | +| `‑OpenDataPath` | Optional. Path, as seen by the emulator, to the folder that holds the open data CSV files. Default = `/data/export/open-data`. | +| `‑SkipOpenData` | Optional. Skips loading the open data reference tables. Default = `false`. | +| `‑Destination` | Optional. Local folder used to download the setup scripts. Default = temp folder. | +| `‑WhatIf` | Optional. Shows what would happen if the command runs without actually running it. | + +
+ +## Examples + +### Set up a local hub + +```powershell +Initialize-FinOpsHubLocal +``` + +Sets up a local FinOps hub in the emulator at `http://localhost:8082` using the latest release. + +### Set up the schema only + +```powershell +Initialize-FinOpsHubLocal ` + -RawRetentionInDays 30 ` + -SkipOpenData +``` + +Creates the databases and applies the schema with 30-day raw retention, and skips loading open data. + +
+ +## Related content + +Related solutions: + +- [Run FinOps hubs locally](../../hubs/run-hubs-locally.md) +- [FinOps hubs](../../hubs/finops-hubs-overview.md) + +
diff --git a/docs-mslearn/toolkit/powershell/powershell-commands.md b/docs-mslearn/toolkit/powershell/powershell-commands.md index d80414150..5dbef4db0 100644 --- a/docs-mslearn/toolkit/powershell/powershell-commands.md +++ b/docs-mslearn/toolkit/powershell/powershell-commands.md @@ -3,7 +3,7 @@ title: FinOps toolkit PowerShell module description: Automate and scale your FinOps efforts using the FinOps toolkit PowerShell module, which includes commands to manage FinOps solutions. author: flanakin ms.author: micflan -ms.date: 04/01/2026 +ms.date: 06/21/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -64,6 +64,7 @@ The FinOps toolkit PowerShell module includes commands to manage FinOps solution - [Deploy-FinOpsHub](hubs/Deploy-FinOpsHub.md) – Deploy your first hub or update to the latest version. - [Get-FinOpsHub](hubs/Get-FinOpsHub.md) – Get details about your FinOps hub instance. - [Initialize-FinOpsHubDeployment](hubs/Initialize-FinOpsHubDeployment.md) – Initializes the deployment for FinOps hubs. +- [Initialize-FinOpsHubLocal](hubs/initialize-finopshublocal.md) – Set up a local FinOps hub in a running Kusto emulator. - [Register-FinOpsHubProviders](hubs/Register-FinOpsHubProviders.md) – Registers resource providers for FinOps hubs. - [Remove-FinOpsHub](hubs/Remove-FinOpsHub.md) – Deletes a FinOps hub instance. diff --git a/src/powershell/Private/Invoke-FinOpsHubLocalCommand.ps1 b/src/powershell/Private/Invoke-FinOpsHubLocalCommand.ps1 new file mode 100644 index 000000000..0e36c6d88 --- /dev/null +++ b/src/powershell/Private/Invoke-FinOpsHubLocalCommand.ps1 @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + .SYNOPSIS + Sends a single command or query to a local FinOps hub (Kusto emulator) HTTP endpoint. + + .DESCRIPTION + Posts a management command or query to a running Kusto emulator over its REST API. Used by Initialize-FinOpsHubLocal to set up a local FinOps hub. This command does not manage the container; the emulator must already be running. + + .PARAMETER ClusterUri + Required. Base URI of the running Kusto emulator (for example, http://localhost:8082). + + .PARAMETER Database + Required. Name of the database to run the command against. + + .PARAMETER Command + Required. The management command (starting with '.') or query to run. + + .PARAMETER Endpoint + Optional. The REST endpoint to use: 'mgmt' for management commands (default) or 'query' for queries. +#> +function Invoke-FinOpsHubLocalCommand +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory)] + [string] + $ClusterUri, + + [Parameter(Mandatory)] + [string] + $Database, + + [Parameter(Mandatory)] + [string] + $Command, + + [Parameter()] + [ValidateSet('mgmt', 'query')] + [string] + $Endpoint = 'mgmt' + ) + + $uri = '{0}/v1/rest/{1}' -f $ClusterUri.TrimEnd('/'), $Endpoint + $body = @{ db = $Database; csl = $Command } | ConvertTo-Json -Compress + return Invoke-RestMethod -Uri $uri -Method 'Post' -ContentType 'application/json' -Body $body +} diff --git a/src/powershell/Public/Initialize-FinOpsHubLocal.ps1 b/src/powershell/Public/Initialize-FinOpsHubLocal.ps1 new file mode 100644 index 000000000..286c9d866 --- /dev/null +++ b/src/powershell/Public/Initialize-FinOpsHubLocal.ps1 @@ -0,0 +1,169 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + .SYNOPSIS + Sets up a local FinOps hub in a running Kusto emulator. + + .DESCRIPTION + The Initialize-FinOpsHubLocal command configures a local FinOps hub in a running Kusto emulator. It creates the Ingestion and Hub databases, then downloads and applies the released FinOps hub setup scripts and open data load script so the local hub uses the same KQL, transforms, and open data as a deployed hub. + + This command does not install Docker or manage the emulator container. Start the emulator first, then run this command against its endpoint. It does not ingest cost data; stage and ingest your own exports after setup. + + .PARAMETER ClusterUri + Optional. Base URI of the running Kusto emulator. Default = http://localhost:8082. + + .PARAMETER ReleaseUri + Optional. Base URI to download the setup scripts and open data load script from. Default = the latest FinOps toolkit GitHub release. Point this at a local file server or a specific release to run offline or pin a version. + + .PARAMETER RawRetentionInDays + Optional. Number of days to keep raw data in the Ingestion database. Default = 90. + + .PARAMETER OpenDataPath + Optional. Path, as seen by the emulator, to the folder that holds the open data CSV files. Default = /data/export/open-data. + + .PARAMETER SkipOpenData + Optional. Skips loading the open data reference tables. Default = false. + + .PARAMETER Destination + Optional. Local folder used to download the setup scripts. Default = temp folder. + + .EXAMPLE + Initialize-FinOpsHubLocal + + Sets up a local FinOps hub in the emulator at http://localhost:8082 using the latest release. + + .EXAMPLE + Initialize-FinOpsHubLocal -ClusterUri 'http://localhost:8082' -RawRetentionInDays 30 -SkipOpenData + + Sets up the databases and schema with 30-day raw retention and skips loading open data. + + .LINK + https://aka.ms/ftk/Initialize-FinOpsHubLocal +#> +function Initialize-FinOpsHubLocal +{ + [CmdletBinding(SupportsShouldProcess)] + param + ( + [Parameter()] + [string] + $ClusterUri = 'http://localhost:8082', + + [Parameter()] + [string] + $ReleaseUri = 'https://github.com/microsoft/finops-toolkit/releases/latest/download', + + [Parameter()] + [ValidateRange(1, [int]::MaxValue)] + [int] + $RawRetentionInDays = 90, + + [Parameter()] + [string] + $OpenDataPath = '/data/export/open-data', + + [Parameter()] + [switch] + $SkipOpenData, + + [Parameter()] + [string] + $Destination = [System.IO.Path]::GetTempPath() + ) + + $progress = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + + try + { + # Verify the emulator is reachable. This command does not start the container. + try + { + $null = Invoke-FinOpsHubLocalCommand -ClusterUri $ClusterUri -Database 'NetDefaultDB' -Command '.show version' + } + catch + { + throw ($script:LocalizedData.HubLocal_Initialize_NotReachable -f $ClusterUri) + } + + # Download the required release assets by name from the release URI. + $assetNames = @('finops-hub-fabric-setup-Ingestion.kql', 'finops-hub-fabric-setup-Hub.kql') + if (-not $SkipOpenData) + { + $assetNames += 'finops-hub-local-opendata.kql' + } + + New-Directory -Path $Destination + $scripts = @{} + foreach ($assetName in $assetNames) + { + $filePath = Join-Path -Path $Destination -ChildPath $assetName + $null = Invoke-WebRequest -Uri "$($ReleaseUri.TrimEnd('/'))/$assetName" -OutFile $filePath -Verbose:$false + $scripts[$assetName] = Get-Content -Path $filePath -Raw + } + + # Create the Ingestion and Hub databases + foreach ($database in @('Ingestion', 'Hub')) + { + $createCommand = '.create database {0} persist (@"/kustodata/dbs/{0}/md", @"/kustodata/dbs/{0}/data")' -f $database + if ($PSCmdlet.ShouldProcess($database, 'Create database')) + { + $null = Invoke-FinOpsHubLocalCommand -ClusterUri $ClusterUri -Database 'NetDefaultDB' -Command $createCommand + } + } + + # Apply the Ingestion setup with the requested raw retention + $ingestionScript = $scripts['finops-hub-fabric-setup-Ingestion.kql'] -replace '\$\$rawRetentionInDays\$\$', $RawRetentionInDays + if ($PSCmdlet.ShouldProcess('Ingestion', 'Apply setup script')) + { + $null = Invoke-FinOpsHubLocalCommand -ClusterUri $ClusterUri -Database 'Ingestion' -Command $ingestionScript + } + + # Apply the Hub setup + if ($PSCmdlet.ShouldProcess('Hub', 'Apply setup script')) + { + $null = Invoke-FinOpsHubLocalCommand -ClusterUri $ClusterUri -Database 'Hub' -Command $scripts['finops-hub-fabric-setup-Hub.kql'] + } + + # Load the open data reference tables + if (-not $SkipOpenData) + { + $openDataScript = $scripts['finops-hub-local-opendata.kql'] -replace '\$\$openDataPath\$\$', $OpenDataPath + if ($PSCmdlet.ShouldProcess('Ingestion', 'Load open data')) + { + # The emulator's first external data read after setup can return no rows without + # raising an error. Load, verify the tables filled, and retry before giving up. + $openDataTables = @('PricingUnits', 'Regions', 'ResourceTypes', 'Services') + $maxAttempts = 5 + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) + { + $null = Invoke-FinOpsHubLocalCommand -ClusterUri $ClusterUri -Database 'Ingestion' -Command $openDataScript + $empty = @($openDataTables | Where-Object { + [int64](Invoke-FinOpsHubLocalCommand -ClusterUri $ClusterUri -Database 'Ingestion' -Command "$_ | count" -Endpoint 'query').Tables[0].Rows[0][0] -eq 0 + }) + + if ($empty.Count -eq 0) + { + break + } + + if ($attempt -eq $maxAttempts) + { + throw ($script:LocalizedData.HubLocal_Initialize_OpenDataEmpty -f $maxAttempts, ($empty -join ', ')) + } + + Start-Sleep -Seconds 2 + } + } + } + } + catch + { + throw $_.Exception.Message + } + finally + { + $ProgressPreference = $progress + } +} diff --git a/src/powershell/Tests/Integration/Initialize-FinOpsHubLocal.Tests.ps1 b/src/powershell/Tests/Integration/Initialize-FinOpsHubLocal.Tests.ps1 new file mode 100644 index 000000000..92b4f66df --- /dev/null +++ b/src/powershell/Tests/Integration/Initialize-FinOpsHubLocal.Tests.ps1 @@ -0,0 +1,117 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Integration tests for Initialize-FinOpsHubLocal. These run the command against a real +# running Kusto emulator and download real release artifacts over HTTP -- no mocks. +# +# They are skipped unless the following environment variables point at live infrastructure: +# FTK_LOCAL_EMULATOR_URI Base URI of a running Kusto emulator (for example, http://localhost:8087). +# FTK_LOCAL_RELEASE_URI Base URI serving the release artifacts (for example, a local file server). +# FTK_LOCAL_EXPORT_PATH Host path mounted into the emulator at /data/export, holding FOCUS exports +# and an open-data subfolder. + +if (-not (Get-Module -Name 'FinOpsToolkit')) +{ + $rootDirectory = ((Get-Item -Path $PSScriptRoot).Parent.Parent).FullName + $modulePath = (Get-ChildItem -Path $rootDirectory -Include 'FinOpsToolKit.psm1' -Recurse).FullName + Import-Module -FullyQualifiedName $modulePath +} + +$script:emulatorUri = $env:FTK_LOCAL_EMULATOR_URI +$script:releaseUri = $env:FTK_LOCAL_RELEASE_URI +$script:exportPath = $env:FTK_LOCAL_EXPORT_PATH +$script:ready = [bool]$script:emulatorUri -and [bool]$script:releaseUri -and [bool]$script:exportPath + +Describe 'Initialize-FinOpsHubLocal' { + BeforeAll { + $script:hub = $env:FTK_LOCAL_EMULATOR_URI + $script:release = $env:FTK_LOCAL_RELEASE_URI + $script:exports = $env:FTK_LOCAL_EXPORT_PATH + # Recompute readiness from the environment: discovery-scope variables do not flow into BeforeAll. + $script:ready = [bool]$script:hub -and [bool]$script:release -and [bool]$script:exports + + function Invoke-Q + { + param([string]$Database, [string]$Command, [ValidateSet('mgmt', 'query')][string]$Endpoint = 'query') + Invoke-RestMethod -Uri "$script:hub/v1/rest/$Endpoint" -Method 'Post' -ContentType 'application/json' -Body (@{ db = $Database; csl = $Command } | ConvertTo-Json) + } + + function Get-Count + { + param([string]$Database, [string]$Table) + [int64](Invoke-Q -Database $Database -Command "$Table | count" -Endpoint 'query').Tables[0].Rows[0][0] + } + + if ($script:ready) + { + # Real setup: download real artifacts over HTTP, create databases, apply schema, load open data. + Initialize-FinOpsHubLocal -ClusterUri $script:hub -ReleaseUri $script:release -RawRetentionInDays 90 -OpenDataPath '/data/export/open-data' -Destination (Join-Path $TestDrive 'setup') + + # Real ordered ingest: prices first, then costs (the documented pattern). + $jobs = Get-ChildItem $script:exports -Recurse -Filter manifest.json | ForEach-Object { + $manifest = Get-Content $_.FullName -Raw + $isPrice = $manifest -match '"type"\s*:\s*"PriceSheet"' -or $_.FullName -match '(?i)price' + $tableInfo = if ($isPrice) { 'Prices_raw', 'Prices_raw_mapping' } else { 'Costs_raw', 'Costs_raw_mapping' } + Get-ChildItem $_.Directory -Filter *.parquet | ForEach-Object { + [PSCustomObject]@{ Table = $tableInfo[0]; Mapping = $tableInfo[1]; File = $_.FullName; Phase = [int](-not $isPrice) } + } + } + + $script:pricesBeforeCostsPhase = $null + foreach ($phase in $jobs | Group-Object Phase | Sort-Object Name) + { + if ($phase.Name -eq '1') { $script:pricesBeforeCostsPhase = Get-Count -Database 'Ingestion' -Table 'Prices_raw' } + foreach ($job in $phase.Group) + { + $rel = [System.IO.Path]::GetRelativePath((Resolve-Path $script:exports), $job.File) -replace '\\', '/' + Invoke-Q -Database 'Ingestion' -Endpoint 'mgmt' -Command ".ingest into table $($job.Table) (h@'/data/export/$rel') with (format='parquet', ingestionMappingReference='$($job.Mapping)')" | Out-Null + } + } + } + } + + It 'creates the Ingestion and Hub databases' -Skip:(-not $script:ready) { + $databases = (Invoke-Q -Database 'NetDefaultDB' -Command '.show databases' -Endpoint 'mgmt').Tables[0].Rows | ForEach-Object { $_[0] } + $databases | Should -Contain 'Ingestion' + $databases | Should -Contain 'Hub' + } + + It 'loads all four open data tables with rows' -Skip:(-not $script:ready) { + Get-Count -Database 'Ingestion' -Table 'PricingUnits' | Should -BeGreaterThan 0 + Get-Count -Database 'Ingestion' -Table 'Regions' | Should -BeGreaterThan 0 + Get-Count -Database 'Ingestion' -Table 'ResourceTypes' | Should -BeGreaterThan 0 + Get-Count -Database 'Ingestion' -Table 'Services' | Should -BeGreaterThan 0 + } + + It 'applies the requested raw retention to Costs_raw' -Skip:(-not $script:ready) { + $policy = (Invoke-Q -Database 'Ingestion' -Command '.show table Costs_raw policy retention' -Endpoint 'mgmt').Tables[0].Rows[0][2] | ConvertFrom-Json + $policy.SoftDeletePeriod | Should -Be '90.00:00:00' + } + + It 'ingests prices before costs' -Skip:(-not $script:ready) { + # Captured just before the costs phase started: prices were already present. + $script:pricesBeforeCostsPhase | Should -BeGreaterThan 0 + } + + It 'fills the final cost and price tables through the update policy' -Skip:(-not $script:ready) { + $costsRaw = Get-Count -Database 'Ingestion' -Table 'Costs_raw' + $costsFinal = Get-Count -Database 'Ingestion' -Table 'Costs_final_v1_2' + $costsRaw | Should -BeGreaterThan 0 + $costsFinal | Should -Be $costsRaw + } + + It 'enriches costs with open data and prices' -Skip:(-not $script:ready) { + $total = Get-Count -Database 'Hub' -Table 'Costs_v1_2' + $enriched = Get-Count -Database 'Hub' -Table 'Costs_v1_2 | where isnotempty(ServiceCategory)' + $priced = Get-Count -Database 'Hub' -Table 'Costs_v1_2 | where isnotempty(ListUnitPrice) and ListUnitPrice > 0' + $total | Should -BeGreaterThan 0 + $enriched | Should -Be $total + $priced | Should -BeGreaterThan 0 + } + + It 'throws a clear error when the emulator is unreachable' -Skip:(-not $script:ready) { + # Real dead port -- no mock. + { Initialize-FinOpsHubLocal -ClusterUri 'http://localhost:1' -ReleaseUri $script:release -Destination (Join-Path $TestDrive 'unreachable') } | + Should -Throw '*Could not reach the Kusto emulator*' + } +} diff --git a/src/powershell/Tests/Integration/Toolkit.Tests.ps1 b/src/powershell/Tests/Integration/Toolkit.Tests.ps1 index c154dc5c6..6a43b02dd 100644 --- a/src/powershell/Tests/Integration/Toolkit.Tests.ps1 +++ b/src/powershell/Tests/Integration/Toolkit.Tests.ps1 @@ -49,6 +49,7 @@ Describe 'Get-FinOpsToolkitVersion' { CheckFile "finops-hub-dashboard.json" '0.8' $null CheckFile "finops-hub-fabric-setup-Hub.kql" '0.10' $null CheckFile "finops-hub-fabric-setup-Ingestion.kql" '0.10' $null + CheckFile "finops-hub-local-opendata.kql" '15.0' $null CheckFile "finops-hub-v$verStr.zip" $null $null CheckFile "finops-workbooks-v$verStr.zip" '0.6' $null CheckFile "governance-workbook-v$verStr.zip" '0.1' '0.5' diff --git a/src/powershell/en-US/FinOpsToolkit.strings.psd1 b/src/powershell/en-US/FinOpsToolkit.strings.psd1 index bf003b56b..fa4c05a8c 100644 --- a/src/powershell/en-US/FinOpsToolkit.strings.psd1 +++ b/src/powershell/en-US/FinOpsToolkit.strings.psd1 @@ -16,6 +16,9 @@ ConvertFrom-StringData -StringData @' Hub_Remove_Failed = FinOps hub could not be deleted. {0}. Hub_Remove_NotFound = FinOps hub '{0}' not found. + HubLocal_Initialize_NotReachable = Could not reach the Kusto emulator at '{0}'. Start the local hub container before running this command. See https://aka.ms/finops/hubs/local. + HubLocal_Initialize_OpenDataEmpty = Open data tables were still empty after {0} attempts: {1}. The emulator's first external data read after setup can return no rows; rerun the command to retry. + HubProviders_Register_AlreadyRegistered = Resource provider {0} is already registered. HubProviders_Register_Register = Registering resource provider {0}. HubProviders_Register_RegisterError = Error registering resource provider: {0}. diff --git a/src/templates/finops-hub/.build.config b/src/templates/finops-hub/.build.config index 2dd495e60..3b4f76c15 100644 --- a/src/templates/finops-hub/.build.config +++ b/src/templates/finops-hub/.build.config @@ -45,6 +45,12 @@ "modules/Microsoft.FinOpsHubs/Analytics/scripts/HubSetup_v1_2.kql", "modules/Microsoft.FinOpsHubs/Analytics/scripts/HubSetup_Latest.kql" ] + }, + { + "name": "finops-hub-local-opendata.kql", + "files": [ + "modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_OpenDataExternal.kql" + ] } ] } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_OpenDataExternal.kql b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_OpenDataExternal.kql new file mode 100644 index 000000000..22ca67d06 --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_OpenDataExternal.kql @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//====================================================================================================================== +// Ingestion database / Open data load (local emulator) +// Populates the open data tables (PricingUnits, Regions, ResourceTypes, Services) from CSV files staged on disk. +// A deployed hub loads these tables via Data Factory; this script is the local/emulator stand-in for that step and is +// published as a standalone release artifact (finops-hub-local-opendata.kql) for the "Run hubs locally" guide. +// The $$openDataPath$$ placeholder is replaced with the directory that holds the open data CSVs before the script runs. +//====================================================================================================================== + +// For allowed commands, see https://learn.microsoft.com/azure/data-explorer/database-script + +.set-or-replace PricingUnits <| externaldata(x_PricingUnitDescription: string, AccountTypes: string, x_PricingBlockSize: real, PricingUnit: string)[@'$$openDataPath$$/PricingUnits.csv'] with (format='csv', ignoreFirstRecord=true) | project-away AccountTypes +.set-or-replace Regions <| externaldata(ResourceLocation: string, RegionId: string, RegionName: string)[@'$$openDataPath$$/Regions.csv'] with (format='csv', ignoreFirstRecord=true) +.set-or-replace ResourceTypes <| externaldata(x_ResourceType: string, SingularDisplayName: string, PluralDisplayName: string, LowerSingularDisplayName: string, LowerPluralDisplayName: string, IsPreview: bool, Description: string, IconUri: string, Links: string)[@'$$openDataPath$$/ResourceTypes.csv'] with (format='csv', ignoreFirstRecord=true) | project-away Links +.set-or-replace Services <| externaldata(x_ConsumedService: string, x_ResourceType: string, ServiceName: string, ServiceCategory: string, ServiceSubcategory: string, PublisherName: string, x_PublisherCategory: string, x_Environment: string, x_ServiceModel: string)[@'$$openDataPath$$/Services.csv'] with (format='csv', ignoreFirstRecord=true)