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..7924c0d21 --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/kusto.mjs @@ -0,0 +1,427 @@ +// 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. Models is collapsed + // through the same MODEL_FAMILY normalization as the "models" query + // below, so "Models in use" counts distinct model families, not raw + // (and often duplicated) billing-SKU description strings. + summary: `Costs() ${period} ${AI_SCOPE} | ${MODEL_FAMILY} | summarize Tokens=sum(ConsumedQuantity), Effective=sum(EffectiveCost), List=sum(ListCost), Models=dcount(Model), Resources=dcount(ResourceId), Rows=count()`, + // Total cloud effective cost in the window — for AI share-of-spend + totalCloud: `Costs() ${period} | summarize Effective=sum(EffectiveCost)`, + // ai-token-usage-breakdown — direction mix (input/cached/output) + direction: `Costs() ${period} ${AI_SCOPE} | ${DIRECTION} | summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost) by Direction`, + // ai-model-cost-comparison — by model family with cost per 1K tokens + models: `Costs() ${period} ${AI_SCOPE} | ${MODEL_FAMILY} | summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost), List=sum(ListCost) by Model | extend CostPer1K=iff(Tokens==0, 0.0, Cost/Tokens*1000) | top 12 by Cost desc`, + // ai-daily-trend (monthly variant) — token volume + AI cost over time + trend: `Costs() ${period} ${AI_SCOPE} | summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost) by Month=format_datetime(startofmonth(ChargePeriodStart),'yyyy-MM') | order by Month asc`, + // ai-cost-by-application — AI cost showback by app/team/env/cost-center + byApplication: `Costs() ${period} ${AI_SCOPE} +| extend Application = tostring(Tags['application']), Team = tostring(Tags['team']) +| extend CostCenter = coalesce(tostring(Tags['cost-center']), tostring(Tags['CostCenter']), '') +| extend Environment = tostring(Tags['environment']) +| summarize TokenCount=sum(ConsumedQuantity), EffectiveCost=sum(EffectiveCost) + by Application, Team, CostCenter, Environment +| extend CostPer1KTokens = iff(TokenCount == 0, 0.0, EffectiveCost / TokenCount * 1000) +| top 12 by EffectiveCost desc`, + }; + + const entries = Object.entries(queries); + const results = await Promise.all(entries.map(([, csl]) => runQuery(clusterUri, database, csl))); + const data = Object.fromEntries(entries.map(([key], i) => [key, results[i]])); + + const tokenRows = data.summary?.[0]?.Rows ?? 0; + return { + window: win, + empty: tokenRows === 0, + data, + generatedAt: new Date().toISOString(), + }; +} + +// --- shared page runner ------------------------------------------------------- +// Resolves the window, builds a named map of KQL queries from the period clause, +// runs them in parallel, and returns { window, empty, data, generatedAt }. +async function runPage(clusterUri, database, preset, buildQueries, filters = {}) { + const win = await resolveWindow(clusterUri, database, preset); + if (win.empty) return { window: win, empty: true, generatedAt: new Date().toISOString() }; + const filterWhere = buildFilterWhere(filters); + const period = `| where ChargePeriodStart >= datetime(${win.start}) and ChargePeriodStart < datetime(${win.end})${filterWhere}`; + const queries = buildQueries(period, win); + const entries = Object.entries(queries); + const results = await Promise.all(entries.map(([, csl]) => runQuery(clusterUri, database, csl))); + const data = Object.fromEntries(entries.map(([key], i) => [key, results[i]])); + return { window: win, empty: false, data, generatedAt: new Date().toISOString() }; +} + +// --- Allocation page ---------------------------------------------------------- +// FinOps "Allocation" capability. Grounded in catalog queries: +// percentage-untagged-costs, percentage-unallocated-costs, tagging-policy-compliance, +// allocation-accuracy-index, cost-by-financial-hierarchy. Tag policy keys are tuned +// to this estate's taxonomy (CostCenter/env/org); allocation 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. Excludes Azure/FTK + // auto-injected tags (ftk-*, cm-*, costanalysis-parent, aks-managed-*) + // so governance-relevant keys aren't crowded out by system noise. + tagKeys: `Costs() ${period} +| mv-expand k=bag_keys(Tags) to typeof(string) +| where isnotempty(k) and k !in ('ftk-tool','ftk-version','cm-resource-parent','costanalysis-parent') and not(k startswith 'aks-managed-') +| summarize Cost=sum(EffectiveCost) by k +| top 12 by Cost desc`, + // Cost by subscription (SubAccountName) + bySubscription: `Costs() ${period} | where isnotempty(SubAccountName) | summarize Cost=sum(EffectiveCost) by SubAccountName | top 10 by Cost desc`, + }), filters); +} + +// --- Rate optimization page --------------------------------------------------- +// FinOps "Rate Optimization" capability. Grounded in catalog queries: +// savings-summary-report, commitment-discount-waste, compute-spend-commitment-coverage, +// commitment-discount-utilization. (cost-optimization-index/COIN is omitted because it +// depends on Recommendations(), which is empty in this estate, so it would always read 100.) +// Commitment utilization is derived as the effective-cost complement of waste, the cleanest +// single-basis definition for a grand-total KPI. +export async function getRate(clusterUri, database, preset = "all", filters = {}) { + return runPage(clusterUri, database, preset, (period) => ({ + // savings-summary-report — ESR + negotiated/commitment/total savings + savings: `Costs() ${period} ${NON_PURCHASE} +| extend neg=iff(ListCost<ContractedCost,real(0),ListCost-ContractedCost) +| extend com=iff(ContractedCost<EffectiveCost,real(0),ContractedCost-EffectiveCost) +| extend tot=iff(ListCost<EffectiveCost,real(0),ListCost-EffectiveCost) +| summarize List=sum(ListCost), Effective=sum(EffectiveCost), Negotiated=sum(neg), Commitment=sum(com), Total=sum(tot)`, + // commitment-discount-waste (grand total, effective-cost basis) + commitment: `Costs() ${period} | where isnotempty(CommitmentDiscountId) ${NON_PURCHASE} +| summarize Unused=sumif(EffectiveCost,CommitmentDiscountStatus=='Unused'), Total=sum(EffectiveCost)`, + // compute-spend-commitment-coverage + computeCoverage: `Costs() ${period} ${NON_PURCHASE} | where ServiceCategory=='Compute' +| summarize Committed=sumif(EffectiveCost,isnotempty(CommitmentDiscountCategory)), Contracted=sum(ContractedCost)`, + // commitment-discount-utilization — consumed core-hours by commitment type + coreHours: `Costs() ${period} +| extend cores=toint(coalesce(x_SkuDetails.VCPUs, x_SkuDetails.vCores, 0)) +| extend ch=iff(cores>0, cores*ConsumedQuantity, toreal('')) +| extend t=iff(isempty(CommitmentDiscountType),'On Demand',CommitmentDiscountType) +| summarize CoreHours=sum(ch) by t | where CoreHours > 0 | order by CoreHours desc`, + // Per-commitment waste — which reservations/plans are underutilized + byCommitment: `Costs() ${period} | where isnotempty(CommitmentDiscountName) ${NON_PURCHASE} +| summarize Unused=sumif(EffectiveCost,CommitmentDiscountStatus=='Unused'), Total=sum(EffectiveCost) by CommitmentDiscountName +| where Unused > 0 | top 10 by Unused desc`, + // commitment-utilization-score (formal KPI) — per-commitment and grand-total utilization score + commitmentUtilScore: `let rows = materialize(Costs() ${period} | where isnotempty(CommitmentDiscountId) +| extend Potential = case(ChargeCategory == 'Purchase', toreal(0), isnotempty(CommitmentDiscountCategory), toreal(EffectiveCost), toreal(0)) +| extend Amount = iff(CommitmentDiscountStatus == 'Used', Potential, toreal(0))); +let byCommit = rows | summarize Amount=sum(Amount), Potential=sum(Potential) by CommitmentDiscountName, CommitmentDiscountCategory, CommitmentDiscountType +| extend Score = iff(Potential > 0, Amount / Potential * 100.0, 0.0); +union byCommit, (byCommit | summarize Amount=sum(Amount), Potential=sum(Potential) +| extend CommitmentDiscountName='(Grand Total)', CommitmentDiscountCategory='', CommitmentDiscountType='', Score=iff(Potential>0, Amount/Potential*100.0, 0.0)) +| project CommitmentDiscountName, CommitmentDiscountCategory, CommitmentDiscountType, Amount, Potential, Score | order by Potential desc`, + // top-commitment-transactions — largest RI/SP purchases + topCommitmentTxns: `Costs() ${period} | where ChargeCategory != 'Usage' and isnotempty(CommitmentDiscountType) and BilledCost > 0 +| summarize BilledCost=sum(BilledCost), EffectiveCost=sum(EffectiveCost) + by CommitmentDiscountName, CommitmentDiscountType, CommitmentDiscountCategory +| top 10 by BilledCost desc`, + }), filters); +} + +// --- Usage & unit economics page ---------------------------------------------- +// FinOps "Usage Optimization" + "Unit Economics". Grounded in catalog queries: +// compute-cost-per-core, cost-per-gb-stored, storage-tier-distribution, top-resource-types-by-cost. +export async function getUsage(clusterUri, database, preset = "all", filters = {}) { + return runPage(clusterUri, database, preset, (period) => ({ + // compute-cost-per-core + compute: `Costs() ${period} ${NON_PURCHASE} +| extend vm = x_SkuMeterCategory in ('Virtual Machines','Virtual Machine Licenses') and ChargeCategory=='Usage' +| extend isComputeCommit = x_SkuMeterCategory in ('Virtual Machines','Virtual Machine Licenses') +| extend cores = iff(vm, toint(coalesce(x_SkuDetails.VCPUs, x_SkuDetails.vCores)), toint('')) +| extend ch = iff(vm and isnotempty(cores), toreal(cores*ConsumedQuantity), toreal('')) +| summarize ComputeEff=sumif(EffectiveCost,vm), UnusedCommit=sumif(EffectiveCost, CommitmentDiscountStatus=='Unused' and isnotempty(CommitmentDiscountCategory) and isComputeCommit), CoreHours=sum(ch)`, + // cost-per-gb-stored + storage: `Costs() ${period} | where ServiceCategory=='Storage' and ChargeCategory=='Usage' +| extend gb = case(ConsumedUnit endswith 'PB', toreal(ConsumedQuantity)*1048576.0, ConsumedUnit endswith 'TB', toreal(ConsumedQuantity)*1024.0, ConsumedUnit endswith 'MB', toreal(ConsumedQuantity)/1024.0, toreal(ConsumedQuantity)) +| summarize Cost=sum(EffectiveCost), GBMonths=sum(gb)`, + // storage-tier-distribution + storageTiers: `Costs() ${period} | where ServiceCategory=='Storage' and ChargeCategory=='Usage' +| extend Tier = case( + x_SkuTier in ('Hot','Standard','Premium'), 'Frequent', + x_SkuTier in ('Cool','Cold','Archive'), 'Infrequent', + x_SkuMeterSubcategory has_any ('Hot','Standard','Premium','Frequent'), 'Frequent', + x_SkuMeterSubcategory has_any ('Cool','Cold','Archive'), 'Infrequent', + 'Unclassified') +| summarize Cost=sum(EffectiveCost) by Tier | where Cost > 0 | order by Cost desc`, + // top-resource-types-by-cost — Resources is a distinct-resource count + // (dcount(ResourceId)), matching the same "Resources" label semantics + // used by the Overview summary KPI, not a row count. + topResourceTypes: `Costs() ${period} | where isnotempty(ResourceType) | summarize Resources=dcount(ResourceId), Cost=sum(EffectiveCost) by ResourceType | top 10 by Cost desc`, + // compute-cost-per-core grouped by VM series — where the expensive cores are + perCoreSeries: `Costs() ${period} | where x_SkuMeterCategory in ('Virtual Machines','Virtual Machine Licenses') and ChargeCategory=='Usage' +| extend cores=toint(coalesce(x_SkuDetails.VCPUs, x_SkuDetails.vCores)) +| extend ch=iff(isnotempty(cores), toreal(cores*ConsumedQuantity), toreal('')) +| summarize Eff=sum(EffectiveCost), CH=sum(ch) by x_SkuMeterSubcategory +| where CH > 100 | extend PerCore=Eff/CH | top 10 by Eff desc`, + // grand total for share-of-cost on the resource-type table + total: `Costs() ${period} | summarize Total=sum(EffectiveCost)`, + }), filters); +} + +// --- Anomalies & forecast page ------------------------------------------------ +// FinOps "Anomaly Management" + "Forecasting" + data-freshness (Data Ingestion). +// Grounded in catalog queries: cost-anomaly-detection, anomaly-detection-rate, +// anomaly-variance-total, monthly-cost-change-percentage, cost-forecasting-model, +// data-update-frequency, cost-visibility-delay. Time-series array outputs are +// flattened with mv-expand so the renderer can chart them. +export async function getAnomaly(clusterUri, database, preset = "all", filters = {}) { + return runPage(clusterUri, database, preset, (period, win) => { + // forecast uses full history for accuracy; horizon = 4 months past the last data month + const dmax = new Date(win.dataMax); + const monthStart = new Date(Date.UTC(dmax.getUTCFullYear(), dmax.getUTCMonth(), 1)); + const horizon = new Date(Date.UTC(dmax.getUTCFullYear(), dmax.getUTCMonth() + 4, 1)); + const isoH = horizon.toISOString().slice(0, 10); + const fcDays = Math.round((horizon - monthStart) / 86400000); + return { + // cost-anomaly-detection + anomaly-variance-total (flattened daily series) + daily: `let s=datetime(${win.start}); let e=datetime(${win.end}); +Costs() | where ChargePeriodStart>=s and ChargePeriodStart<e ${NON_PURCHASE} +| summarize DC=sum(EffectiveCost) by bin(ChargePeriodStart,1d) +| make-series Cost=sum(DC) default=0.0 on ChargePeriodStart from s to e step 1d +| extend (flag,score,baseline)=series_decompose_anomalies(Cost,1.5) +| mv-expand Day=ChargePeriodStart to typeof(datetime), Cost to typeof(real), flag to typeof(real), baseline to typeof(real) +| project Day, Cost=toreal(Cost), Flag=toint(flag), Baseline=toreal(baseline)`, + // monthly-cost-change-percentage + monthlyChange: `Costs() ${period} | summarize Eff=sum(EffectiveCost) by M=startofmonth(ChargePeriodStart) +| order by M asc | extend PrevEff=prev(Eff) +| project Month=format_datetime(M,'yyyy-MM'), EffChangePct=iff(isempty(PrevEff),0.0,(Eff-PrevEff)*100.0/PrevEff), Eff`, + // cost-forecasting-model (monthly, forecasts past the last data month) + forecast: `let s=datetime(${win.dataMin}); Costs() | where ChargePeriodStart>=s +| summarize Eff=sum(EffectiveCost) by bin(ChargePeriodStart,1d) +| make-series Actual=sum(Eff) default=0.0 on ChargePeriodStart from s to datetime(${isoH}) step 1d +| extend Fc=series_decompose_forecast(Actual,${fcDays}) +| mv-expand Day=ChargePeriodStart to typeof(datetime), Actual to typeof(real), Fc to typeof(real) +| extend M=startofmonth(Day) +| summarize Actual=sum(toreal(Actual)), Forecast=sum(toreal(Fc)) by M +| order by M asc | project Month=format_datetime(M,'yyyy-MM'), Actual, Forecast`, + // data-update-frequency + cost-visibility-delay + freshness: `Costs() ${period} | where isnotnull(x_IngestionTime) +| summarize LastUpdate=max(x_IngestionTime), Rows=count(), P50=percentile(todouble((x_IngestionTime-ChargePeriodEnd)/1h),50), P90=percentile(todouble((x_IngestionTime-ChargePeriodEnd)/1h),90)`, + }; + }, filters); +} 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..8cb148fc8 --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/public/app.css @@ -0,0 +1,743 @@ +: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 .pos/.neg/.warn (not scoped to .meta) so KPIs that color the value + itself — e.g. Anomaly's "Last month change" — get the same semantics as + the meta-text convention. */ +.kpi .pos { color: var(--pos); font-weight: 600; } +.kpi .neg { color: var(--neg); font-weight: 600; } +.kpi .warn { color: var(--warn); font-weight: 600; } + +/* ---------- KPI hierarchy (primary vs reference) ---------- */ +.kpi--primary .value { font-size: 28px; } +.kpi--reference { opacity: 0.75; } + +/* ---------- KPI threshold tooltip ---------- */ +.kpi-tip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 50%; + border: 1px solid currentColor; + background: transparent; + color: var(--muted); + cursor: pointer; + font: 600 9px/1 var(--font-sans, -apple-system, sans-serif); + padding: 0; + margin-left: 4px; + vertical-align: middle; + transition: color 0.15s, border-color 0.15s; +} +.kpi-tip:hover, .kpi-tip:focus-visible { + color: var(--accent); + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +/* ---------- KPI threshold state: value text carries the signal ---------- */ +.threshold-green { border-color: var(--pos); } +.threshold-amber { border-color: var(--warn); } +.threshold-red { border-color: var(--neg); } +.threshold-green .value { color: var(--pos); } +.threshold-amber .value { color: var(--warn); } +.threshold-red .value { color: var(--neg); } + +/* ---------- triage arrival context banner ---------- */ +.triage-callout { + display: flex; + align-items: center; + gap: 10px; + background: color-mix(in srgb, var(--warn) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--warn) 28%, transparent); + border-radius: 8px; + padding: 10px 14px; + font-size: 13px; + font-weight: 500; + color: color-mix(in srgb, var(--warn) 75%, #000); + margin-bottom: 16px; +} +.triage-callout-icon { font-size: 15px; flex-shrink: 0; line-height: 1; } + +/* ---------- triage strip ---------- */ +.triage-strip { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--gap); + margin-bottom: 22px; +} +.triage-tile { + appearance: none; + background: var(--card-bg); + border: 2px solid var(--grid); + border-radius: var(--radius); + padding: 16px 18px; + cursor: pointer; + text-align: left; + display: flex; + flex-direction: column; + gap: 4px; + transition: opacity 0.15s; + color: var(--text-color-default, #1f2328); +} +.triage-tile:hover { opacity: 0.82; } +.triage-tile:active { transform: translateY(1px); } +.triage-title { + font-size: 11.5px; + font-weight: 600; + color: var(--muted); +} +.triage-count { + font-size: 30px; + font-weight: 700; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + line-height: 1.1; +} +.triage-badge { + display: inline-block; + font-size: 11px; + font-weight: 600; + background: color-mix(in srgb, var(--muted) 12%, transparent); + color: var(--muted); +} +.triage-tile.threshold-green .triage-badge { + background: color-mix(in srgb, var(--pos) 15%, transparent); + color: var(--pos); +} +.triage-tile.threshold-amber .triage-badge { + background: color-mix(in srgb, var(--warn) 15%, transparent); + color: var(--warn); +} +.triage-tile.threshold-red .triage-badge { + background: color-mix(in srgb, var(--neg) 15%, transparent); + color: var(--neg); +} +/* Teaser state: this tile's data hasn't loaded yet (visit the tab to + populate it), distinct from a real "no anomalies found" result. */ +.triage-tile.is-teaser { + border-style: dashed; + border-color: var(--grid); +} +.triage-tile.is-teaser .triage-count { color: var(--muted); } +.triage-tile.is-teaser .triage-badge { + background: transparent; + border: 1px dashed var(--muted); + color: var(--muted); +} +.triage-cue { + font-size: 12px; + color: var(--muted); + margin-top: 2px; +} +@media (max-width: 640px) { + .triage-strip { grid-template-columns: 1fr; } +} + +/* ---------- sections & panels ---------- */ +.section-title { + display: flex; + align-items: baseline; + 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); + /* Paired panels in a row share one height (grid default: stretch), so a + 2-col layout reads as symmetric rather than a jagged row of mismatched + card heights. Short, fixed-size content (a capped donut — see + .panel-body:has below) centers vertically to use the shared height + instead of leaving dead space pinned to the top. */ +} +.panel { + background: var(--card-bg); + border: 1px solid var(--grid); + border-radius: var(--radius); + padding: 16px; + min-width: 0; + display: flex; + flex-direction: column; +} +.panel-body { flex: 1; display: flex; flex-direction: column; } +/* A donut is a small, fixed-size (200x200) chart — it never grows to fill a + tall sibling's height. When the row stretches this panel to match, center + the donut in the extra space so it reads as balanced padding, not an + empty gap under a chart pinned to the top. */ +.panel-body:has(> .donut-wrap) { justify-content: center; } +.panel.col-12 { grid-column: span 12; } +.panel.col-9 { grid-column: span 9; } +.panel.col-8 { grid-column: span 8; } +.panel.col-7 { grid-column: span 7; } +.panel.col-6 { grid-column: span 6; } +.panel.col-5 { grid-column: span 5; } +.panel.col-4 { grid-column: span 4; } +.panel.col-3 { grid-column: span 3; } +@media (max-width: 900px) { + .panel.col-9, .panel.col-8, .panel.col-7, + .panel.col-6, .panel.col-5, .panel.col-4, + .panel.col-3 { grid-column: span 12; } +} +.panel h3 { + margin: 0 0 2px; + font-size: 13.5px; + font-weight: 600; +} +.panel .panel-sub { + margin: 0 0 12px; + font-size: 11.5px; + color: var(--muted); +} + +/* ---------- charts ---------- */ +svg { display: block; width: 100%; overflow: visible; } +.axis-label, .tick { fill: var(--muted); font-size: 11px; } +.grid-line { stroke: var(--grid); stroke-width: 1; } + +.legend { display: flex; flex-wrap: wrap; gap: 10px 16px; margin-top: 10px; } +.legend .item { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; } +.legend .swatch { width: 10px; height: 10px; border-radius: 3px; flex: none; } +.legend .lv { color: var(--muted); font-variant-numeric: tabular-nums; } + +/* ---------- "missing data" convention (untagged / unclassified / blank) ---------- */ +/* Shared muted/dashed treatment so a placeholder never looks like a real + category rendered in a rotating palette color (donut legends, hbar + legends, and inline table swatches all route through this class). */ +.swatch--unknown { + border: 1.5px dashed var(--muted); + background: transparent !important; +} + +.donut-wrap { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; } +/* donut()'s viewBox is a fixed 1:1 square (180x180) — without a cap, the + generic `svg { width: 100% }` rule scales it to fill the whole panel + width, producing an oversized ring and (via grid row-stretch) dragging + sibling panels in the same row up to match its inflated height. */ +.donut-wrap svg { width: 200px; height: 200px; max-width: 100%; flex: none; } +.donut-center .big { font-size: 18px; font-weight: 700; letter-spacing: -0.02em; } +.donut-center .small { font-size: 11px; fill: var(--muted); } + +.hbar-row { font-variant-numeric: tabular-nums; } +.hbar-row .name { fill: var(--text-color-default, #1f2328); font-size: 12px; } +.hbar-row .val { fill: var(--muted); font-size: 11.5px; } +.bar:hover, .arc:hover { opacity: 0.82; cursor: default; } +/* Truncated labels: dotted underline hints there's more text on hover + (the <title> tooltip already carries the full value). */ +.name--truncated { text-decoration: underline dotted; text-decoration-color: var(--muted); text-underline-offset: 2px; } +td.truncate-hint { text-decoration: underline dotted; text-decoration-color: var(--muted); text-underline-offset: 2px; cursor: help; } + +/* ---------- data table ---------- */ +.dtable { width: 100%; border-collapse: collapse; font-size: 12.5px; font-variant-numeric: tabular-nums; } +.dtable th, .dtable td { padding: 9px 10px; text-align: right; border-bottom: 1px solid var(--grid); white-space: nowrap; } +.dtable th:first-child, .dtable td:first-child { text-align: left; } +.dtable thead th { color: var(--muted); font-weight: 600; font-size: 11px; } +.dtable tbody tr:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); } +.dtable .model { display: inline-flex; align-items: center; gap: 7px; font-weight: 500; } +.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..2143c0eba --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/public/app.js @@ -0,0 +1,1605 @@ +/* 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", +]; + +// Shared "missing data" color: muted, never a rotating palette hue, so +// Untagged/Unclassified/blank slices read as "no data" consistently across +// every tab instead of looking like an ordinary category. +const UNKNOWN_COLOR = "var(--muted)"; + +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 Resources=dcount(ResourceId), Cost=sum(EffectiveCost) by ResourceType", "| top 10 by Cost desc"].join("\n"), + "usage-per-core-series": ["Costs()", PERIOD, "| where x_SkuMeterCategory in ('Virtual Machines','Virtual Machine Licenses') and ChargeCategory=='Usage'", "| extend cores=toint(coalesce(x_SkuDetails.VCPUs, x_SkuDetails.vCores))", "| extend ch=iff(isnotempty(cores), toreal(cores*ConsumedQuantity), toreal(''))", "| summarize Eff=sum(EffectiveCost), CH=sum(ch) by x_SkuMeterSubcategory", "| where CH > 100 | extend PerCore=Eff/CH | top 10 by Eff desc"].join("\n"), + "usage-storage-tiers": ["Costs()", PERIOD, "| where ServiceCategory=='Storage' and ChargeCategory=='Usage'", "| extend Tier=case(x_SkuTier has_any('Hot','Standard','Premium'),'Frequent',x_SkuTier has_any('Cool','Cold','Archive'),'Infrequent','Unclassified')", "| summarize Cost=sum(EffectiveCost) by Tier", "| where Cost > 0 | order by Cost desc"].join("\n"), + "rate-savings": ["Costs()", PERIOD, NON_PURCH, "| extend neg=iff(ListCost<ContractedCost,real(0),ListCost-ContractedCost)", "| extend com=iff(ContractedCost<EffectiveCost,real(0),ContractedCost-EffectiveCost)", "| extend tot=iff(ListCost<EffectiveCost,real(0),ListCost-EffectiveCost)", "| summarize List=sum(ListCost), Effective=sum(EffectiveCost), Negotiated=sum(neg), Commitment=sum(com), Total=sum(tot)"].join("\n"), + "rate-commit-util": ["Costs()", PERIOD, "| where isnotempty(CommitmentDiscountId)", NON_PURCH, "| summarize Unused=sumif(EffectiveCost,CommitmentDiscountStatus=='Unused'), Total=sum(EffectiveCost)"].join("\n"), + "rate-core-hours": ["Costs()", PERIOD, "| extend cores=toint(coalesce(x_SkuDetails.VCPUs, x_SkuDetails.vCores, 0))", "| extend ch=iff(cores>0, cores*ConsumedQuantity, toreal(''))", "| extend t=iff(isempty(CommitmentDiscountType),'On Demand',CommitmentDiscountType)", "| summarize CoreHours=sum(ch) by t", "| where CoreHours > 0 | order by CoreHours desc"].join("\n"), + "rate-underutil": ["Costs()", PERIOD, "| where isnotempty(CommitmentDiscountName)", NON_PURCH, "| summarize Unused=sumif(EffectiveCost,CommitmentDiscountStatus=='Unused'), Total=sum(EffectiveCost) by CommitmentDiscountName", "| where Unused > 0 | top 10 by Unused desc"].join("\n"), + "alloc-hierarchy": ["Costs()", PERIOD, "| extend Org=tostring(Tags['org']), Project=tostring(Tags['Project']), Env=tostring(Tags['env'])", "| summarize Cost=sum(EffectiveCost) by Org, Project, Env", "| where Cost > 0 | top 12 by Cost desc"].join("\n"), + "alloc-tagging": ["Costs()", PERIOD, "| extend _t=iff(isnull(Tags) or array_length(bag_keys(Tags))==0,'Untagged','Tagged')", "| summarize Cost=sum(EffectiveCost) by _t"].join("\n"), + "alloc-tag-keys": ["Costs()", PERIOD, "| mv-expand k=bag_keys(Tags) to typeof(string)", "| where isnotempty(k) and k !in ('ftk-tool','ftk-version','cm-resource-parent','costanalysis-parent') and not(k startswith 'aks-managed-')", "| summarize Cost=sum(EffectiveCost) by k", "| top 12 by Cost desc"].join("\n"), + "alloc-by-subscription": ["Costs()", PERIOD, "| where isnotempty(SubAccountName)", "| summarize Cost=sum(EffectiveCost) by SubAccountName", "| top 10 by Cost desc"].join("\n"), +}; +/* eslint-enable max-len */ + +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 ?? ""); + if (s.length <= n) return s; + // Middle-ellipsis: keep a short tail visible so structurally similar long + // identifiers (e.g. two reservation IDs differing only in their suffix) + // don't collide into the same truncated string. + const keepEnd = Math.min(8, Math.floor(n * 0.35)); + const keepStart = Math.max(1, n - keepEnd - 1); + return `${s.slice(0, keepStart)}…${s.slice(-keepEnd)}`; +} +function el(id) { return document.getElementById(id); } +function fmtRelativeTime(date) { + if (!date) return "—"; + const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); + const diffSec = (date - Date.now()) / 1000; + const abs = Math.abs(diffSec); + if (abs < 60) return rtf.format(Math.round(diffSec), "second"); + if (abs < 3600) return rtf.format(Math.round(diffSec / 60), "minute"); + if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), "hour"); + return rtf.format(Math.round(diffSec / 86400), "day"); +} +function svgEl(w, h, body, label = "") { + const ariaAttr = label ? ` aria-label="${esc(label)}"` : ""; + return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" role="img"${ariaAttr}>${body}</svg>`; +} + +/* ----------------------------------------------------------------- charts */ + +// Shared single-left-axis gridline + y-tick-label renderer, used by every +// chart with one money-scaled y-axis (lineChart, anomalyChart, forecastChart). +// tokenTrendChart keeps its own dual-axis (token + cost) tick renderer — a +// different concept (two scales, two tick labels per line), not merged here. +function yAxisGrid(m, W, ih, yMax, ticks, valFmt) { + let g = ""; + for (let t = 0; t <= ticks; t++) { + const val = (yMax / ticks) * t; + const yy = m.t + ih - (ih / ticks) * t; + g += `<line class="grid-line" x1="${m.l}" y1="${yy}" x2="${W - m.r}" y2="${yy}"/>`; + g += `<text class="tick" x="${m.l - 8}" y="${yy + 4}" text-anchor="end">${valFmt(val)}</text>`; + } + return g; +} + +// Shared index-thinned x-axis month-label renderer: shows a label at evenly +// spaced indices (max ~12) plus always the last row, for any chart whose rows +// run left-to-right one-per-month. anomalyChart uses a different, month- +// change-detection variant over daily rows and keeps its own logic. +function xAxisMonthLabels(rows, xFn, H, monthField = "Month") { + let g = ""; + const n = rows.length; + const step = Math.ceil(n / 12); + rows.forEach((r, i) => { + if (i % step === 0 || i === n - 1) { + g += `<text class="tick" x="${xFn(i)}" y="${H - 12}" text-anchor="middle">${esc(fmtMonth(r[monthField]))}</text>`; + } + }); + return g; +} + +function lineChart(rows) { + // rows: [{Month, Billed, Effective}] + // Flatter aspect ratio (vs. 280 previously): a ~15-point monthly line has + // low vertical information density, so a wide-but-short viewBox avoids the + // chart dominating the tab when rendered at panel width. + const W = 760, H = 200; + const m = { l: 56, r: 18, t: 16, b: 34 }; + const iw = W - m.l - m.r, ih = H - m.t - m.b; + if (!rows || rows.length === 0) return emptyChart(W, H, "Monthly cost trend — no data"); + const max = Math.max(...rows.map((r) => Math.max(r.Billed || 0, r.Effective || 0)), 1); + const yMax = max * 1.12; + const n = rows.length; + const x = (i) => m.l + (n === 1 ? iw / 2 : (i / (n - 1)) * iw); + const y = (v) => m.t + ih - (v / yMax) * ih; + + let g = ""; + // gridlines + y ticks + g += yAxisGrid(m, W, ih, yMax, 4, fmtMoney); + // x labels (thin out if crowded) + g += xAxisMonthLabels(rows, x, H); + // area under effective + const ptsE = rows.map((r, i) => `${x(i)},${y(r.Effective || 0)}`); + const area = `M${m.l},${y(0)} L${ptsE.join(" L")} L${x(n - 1)},${y(0)} Z`; + g += `<path d="${area}" fill="${PALETTE[0]}" fill-opacity="0.10"/>`; + // billed line (muted, dashed) + const ptsB = rows.map((r, i) => `${x(i)},${y(r.Billed || 0)}`).join(" L"); + g += `<path d="M${ptsB}" fill="none" stroke="var(--muted)" stroke-width="1.5" stroke-dasharray="4 3" opacity="0.7"/>`; + // effective line + g += `<path d="M${ptsE.join(" L")}" fill="none" stroke="${PALETTE[0]}" stroke-width="2.5"/>`; + // dots + hover titles + rows.forEach((r, i) => { + g += `<circle class="bar" tabindex="0" cx="${x(i)}" cy="${y(r.Effective || 0)}" r="3.2" fill="${PALETTE[0]}"><title>${esc(fmtMonth(r.Month))}\nEffective ${fmtMoneyFull(r.Effective)}\nBilled ${fmtMoneyFull(r.Billed)}`; + }); + const legend = legendHtml([ + { label: "Effective cost", color: PALETTE[0] }, + { label: "Billed cost", color: "var(--muted)" }, + ]); + return svgEl(W, H, g, "Monthly cost trend — billed vs effective cost") + legend; +} + +function hbar(rows, nameKey, valKey, opts = {}) { + const data = (rows || []).map((r) => ({ name: String(r[nameKey] ?? "—"), val: +r[valKey] || 0 })) + .filter((r) => r.val > 0); + if (data.length === 0) return `

No data in range.

`; + const max = Math.max(...data.map((d) => d.val), 1); + const total = data.reduce((s, d) => s + d.val, 0); + const rowH = 30, padR = 64, nameW = opts.nameW ?? 142; + const W = 540, H = data.length * rowH + 6; + const barX = nameW + 8, barW = W - barX - padR; + const valFmt = opts.valFmt || fmtMoney; + // filterDim: by default use nameKey; pass null to opt-out of filtering + const filterDim = "filterDim" in opts ? opts.filterDim : nameKey; + const activeVals = filterDim && state.filters[filterDim]; + const hasFilter = activeVals && activeVals.length > 0; + let g = ""; + data.forEach((d, i) => { + const yy = i * rowH + 4; + const cy = yy + rowH / 2; + const w = Math.max(2, (d.val / max) * barW); + const color = opts.color || PALETTE[i % PALETTE.length]; + const pct = total > 0 ? (d.val / total) : 0; + const isSelected = hasFilter && activeVals.includes(d.name); + const isDimmed = hasFilter && !isSelected; + let cls = "hbar-row"; + if (filterDim) cls += " hbar-filterable"; + if (isSelected) cls += " hbar-selected"; + if (isDimmed) cls += " hbar-dimmed"; + const isTruncated = d.name.length > 20; + const dimAttr = filterDim ? ` data-filter-dim="${esc(filterDim)}" data-filter-val="${esc(d.name)}"` : ""; + const interactiveAttrs = filterDim + ? ` tabindex="0" role="button" aria-pressed="${isSelected ? 'true' : 'false'}" aria-label="Filter by ${esc(d.name)}, ${valFmt(d.val)}"` + : ` tabindex="0" aria-label="${esc(d.name)}, ${valFmt(d.val)}"`; + g += ``; + g += `${esc(trunc(d.name, 20))}${esc(d.name)}`; + g += `${esc(d.name)}\n${fmtMoneyFull(d.val)} · ${fmtPct(pct)}`; + g += `${valFmt(d.val)}`; + g += ``; + }); + return svgEl(W, H, g, opts.label || ""); +} + +function donut(slices, opts = {}) { + const data = (slices || []).filter((s) => (+s.value || 0) > 0); + const total = data.reduce((s, d) => s + (+d.value || 0), 0); + if (total <= 0) return `

No data in range.

`; + const size = 180, cx = size / 2, cy = size / 2, R = 80, r = 50; + let a0 = 0, g = ""; + if (data.length === 1) { + g += `${esc(data[0].label)}\n${fmtMoneyFull(data[0].value)} · 100%`; + } else { + // Give near-zero slices a minimum visible arc so they aren't rendered as + // an invisible sliver, mirroring hbar()'s Math.max(2, ...) width floor. + // The angle deficit is subtracted from the single largest slice so the + // total stays exactly 360°. + const minAngle = 4; + const angles = data.map((d) => (d.value / total) * 360); + let deficit = 0; + const boosted = angles.map((a) => { + if (a < minAngle) { deficit += minAngle - a; return minAngle; } + return a; + }); + if (deficit > 0) { + const maxIdx = boosted.reduce((best, a, i) => (a > boosted[best] ? i : best), 0); + boosted[maxIdx] = Math.max(minAngle, boosted[maxIdx] - deficit); + } + data.forEach((d, i) => { + const frac = d.value / total; + const a1 = a0 + boosted[i]; + g += `${esc(d.label)}\n${fmtMoneyFull(d.value)} · ${fmtPct(frac)}`; + a0 = a1; + }); + } + const centerBig = opts.centerBig ?? fmtMoney(total); + const centerSmall = opts.centerSmall ?? "total"; + g += `${esc(centerBig)}`; + g += `${esc(centerSmall)}`; + const legend = legendHtml(data.map((d) => ({ + label: d.label, color: d.color, isUnknown: d.isUnknown, + value: opts.valueFmt ? opts.valueFmt(d) : `${fmtMoney(d.value)} · ${fmtPct(d.value / total)}`, + }))); + return `
${svgEl(size, size, g, opts.label || "")}
${legend}
`; +} + +function donutSeg(cx, cy, R, r, a0, a1) { + const polar = (rad, ang) => { + const a = ((ang - 90) * Math.PI) / 180; + return [cx + rad * Math.cos(a), cy + rad * Math.sin(a)]; + }; + const large = a1 - a0 > 180 ? 1 : 0; + const [x0, y0] = polar(R, a0), [x1, y1] = polar(R, a1); + const [x2, y2] = polar(r, a1), [x3, y3] = polar(r, a0); + return `M${x0} ${y0} A${R} ${R} 0 ${large} 1 ${x1} ${y1} L${x2} ${y2} A${r} ${r} 0 ${large} 0 ${x3} ${y3} Z`; +} + +function legendHtml(items) { + return `
${items.map((it) => + `${esc(it.label)}${ + it.value ? `${esc(it.value)}` : ""}`).join("")}
`; +} + +// Inline swatch for raw table cells (outside donut/hbar). isUnknown renders +// the shared dashed/muted "no data" treatment instead of a rotating palette +// color, matching legendHtml's isUnknown handling. +function swatchHtml(color, isUnknown = false) { + return ``; +} + +// Generic data table. cols: [{label, align?, get:(row,i)=>htmlString}]. rows: any[]. +function tableHtml(cols, rows, emptyMsg = "No data in range.") { + if (!rows || rows.length === 0) return `

${esc(emptyMsg)}

`; + const head = cols.map((c) => `${esc(c.label)}`).join(""); + const body = rows.map((r, i) => `${cols.map((c) => `${c.get(r, i)}`).join("")}`).join(""); + return `${head}${body}
`; +} + +// Shared "cost breakdown" list row: an optional color swatch, a label, and a +// money value. Used by the Overview and Rate tabs' savings-breakdown panels — +// same concept, same markup, previously implemented twice independently. +function costBreakdownRow(label, val, accent) { + return `
+ ${ + accent ? `` : ""}${esc(label)} + ${fmtMoney(val)}
`; +} + +// rows: [{label, val, accent?}]. footerLabel/footerValue render an optional +// trailing summary stat (e.g. "Effective savings rate — 42%") below the rows. +function costBreakdownTable(rows, footerLabel, footerValue) { + const footer = footerLabel + ? `
+ ${esc(footerLabel)} + ${footerValue} +
` + : ""; + return `
${rows.map((r) => costBreakdownRow(r.label, r.val, r.accent)).join("")}${footer}
`; +} + +function emptyChart(W, H, label = "No data") { + return svgEl(W, H, `No data`, label); +} + + +function tokenTrendChart(rows) { + // rows: [{Month, Tokens, Cost}] — bars = token volume (left axis), line = AI cost (right axis) + // Flatter aspect ratio (vs. 280 previously) — same rationale as lineChart: + // ~15 monthly points don't need 280 units of vertical resolution. + const W = 760, H = 200; + const m = { l: 56, r: 58, t: 16, b: 34 }; + const iw = W - m.l - m.r, ih = H - m.t - m.b; + if (!rows || rows.length === 0) return emptyChart(W, H, "AI token volume and cost trend — no data"); + const tokMax = Math.max(...rows.map((r) => r.Tokens || 0), 1) * 1.14; + const costMax = Math.max(...rows.map((r) => r.Cost || 0), 1) * 1.14; + const n = rows.length; + const cx = (i) => m.l + ((i + 0.5) / n) * iw; + const yTok = (v) => m.t + ih - (v / tokMax) * ih; + const yCost = (v) => m.t + ih - (v / costMax) * ih; + const bw = Math.max(4, (iw / n) * 0.62); + + let g = ""; + const ticks = 4; + for (let t = 0; t <= ticks; t++) { + const yy = m.t + ih - (ih / ticks) * t; + g += ``; + g += `${fmtTokens((tokMax / ticks) * t)}`; + g += `${fmtMoney((costMax / ticks) * t)}`; + } + // token bars (left axis) + rows.forEach((r, i) => { + const top = yTok(r.Tokens || 0); + const h = Math.max(0, m.t + ih - top); + g += `${esc(fmtMonth(r.Month))}\n${fmtTokens(r.Tokens)} tokens\n${fmtMoneyFull(r.Cost)}`; + }); + // cost line (right axis) + const pts = rows.map((r, i) => `${cx(i)},${yCost(r.Cost || 0)}`); + g += ``; + rows.forEach((r, i) => { + g += `${esc(fmtMonth(r.Month))}\n${fmtMoneyFull(r.Cost)}`; + }); + // x labels + g += xAxisMonthLabels(rows, cx, H); + const legend = legendHtml([ + { label: "Token volume", color: PALETTE[2] }, + { label: "AI effective cost", color: PALETTE[3] }, + ]); + return svgEl(W, H, g, "AI token volume and cost trend") + legend; +} + +function anomalyChart(rows) { + // rows: [{Day, Cost, Flag, Baseline}] + // Flattened to match lineChart/tokenTrendChart's aspect ratio (was 280) so + // this full-width daily chart doesn't read as taller/heavier than the other + // trend charts across tabs — daily granularity needs horizontal, not + // vertical, resolution. + const W = 760, H = 200; + const m = { l: 56, r: 18, t: 16, b: 34 }; + const iw = W - m.l - m.r, ih = H - m.t - m.b; + if (!rows || rows.length === 0) return emptyChart(W, H, "Daily anomaly detection — no data"); + const max = Math.max(...rows.map((r) => Math.max(r.Cost || 0, r.Baseline || 0)), 1) * 1.12; + const n = rows.length; + const x = (i) => m.l + (n === 1 ? iw / 2 : (i / (n - 1)) * iw); + const y = (v) => m.t + ih - (v / max) * ih; + let g = ""; + g += yAxisGrid(m, W, ih, max, 4, fmtMoney); + // month x labels + let lastMonth = ""; + rows.forEach((r, i) => { + const mo = String(r.Day).slice(0, 7); + if (mo !== lastMonth) { + lastMonth = mo; + g += `${esc(fmtMonth(mo))}`; + } + }); + // baseline (dashed) + cost line + const base = rows.map((r, i) => `${x(i)},${y(r.Baseline || 0)}`).join(" L"); + g += ``; + const cost = rows.map((r, i) => `${x(i)},${y(r.Cost || 0)}`).join(" L"); + g += ``; + // anomaly markers + rows.forEach((r, i) => { + if (r.Flag !== 0) { + const up = r.Flag > 0; + g += `${esc(String(r.Day).slice(0, 10))}\n${fmtMoneyFull(r.Cost)} (${up ? "spike" : "drop"})\nbaseline ${fmtMoneyFull(r.Baseline)}`; + } + }); + const legend = legendHtml([ + { label: "Daily effective cost", color: PALETTE[0] }, + { label: "Expected baseline", color: "var(--muted)" }, + { label: "Spike", color: PALETTE[4] }, + { label: "Drop", color: PALETTE[1] }, + ]); + return svgEl(W, H, g, "Daily anomaly detection — cost vs expected baseline") + legend; +} + +function momBars(rows) { + // rows: [{Month, EffChangePct}] — diverging bars (cost up = red, down = green) + const W = 760, H = 240; + const m = { l: 44, r: 14, t: 14, b: 34 }; + const iw = W - m.l - m.r, ih = H - m.t - m.b; + const data = (rows || []).filter((r) => isFinite(r.EffChangePct)); + if (data.length === 0) return emptyChart(W, H, "Month-over-month effective cost change — no data"); + const maxAbs = Math.max(...data.map((r) => Math.abs(r.EffChangePct)), 5); + const n = data.length; + const y0 = m.t + ih / 2; // zero line + const cx = (i) => m.l + ((i + 0.5) / n) * iw; + const bw = Math.max(5, (iw / n) * 0.6); + const yScale = (v) => (v / maxAbs) * (ih / 2); + let g = ``; + data.forEach((r, i) => { + const v = r.EffChangePct; + const h = Math.abs(yScale(v)); + const yTop = v >= 0 ? y0 - h : y0; + const color = v > 0 ? PALETTE[4] : PALETTE[1]; + g += `${esc(fmtMonth(r.Month))}\n${v > 0 ? "+" : ""}${v.toFixed(1)}%`; + }); + g += xAxisMonthLabels(data, cx, H); + return svgEl(W, H, g, "Month-over-month effective cost change"); +} + +function forecastChart(rows, splitMonth) { + // rows: [{Month, Actual, Forecast}] — actual solid up to splitMonth, forecast dashed onward + const W = 760, H = 280; + const m = { l: 56, r: 18, t: 16, b: 34 }; + const iw = W - m.l - m.r, ih = H - m.t - m.b; + if (!rows || rows.length === 0) return emptyChart(W, H, "Cost forecast — no data"); + const max = Math.max(...rows.map((r) => Math.max(r.Actual || 0, r.Forecast || 0)), 1) * 1.12; + const n = rows.length; + const x = (i) => m.l + (n === 1 ? iw / 2 : (i / (n - 1)) * iw); + const y = (v) => m.t + ih - (v / max) * ih; + let g = ""; + g += yAxisGrid(m, W, ih, max, 4, fmtMoney); + const splitIdx = rows.findIndex((r) => r.Month >= splitMonth); + const sIdx = splitIdx < 0 ? n - 1 : splitIdx; + // shaded forecast region + g += ``; + // actual line up to split + const actualPts = rows.slice(0, sIdx + 1).map((r, i) => `${x(i)},${y(r.Actual || 0)}`); + if (actualPts.length > 1) g += ``; + // forecast line from split onward + const fcPts = rows.slice(sIdx).map((r, i) => `${x(sIdx + i)},${y(r.Forecast || 0)}`); + if (fcPts.length > 1) g += ``; + // x labels + g += xAxisMonthLabels(rows, x, H); + rows.forEach((r, i) => { + const isFc = i >= sIdx; + g += `${esc(fmtMonth(r.Month))}\n${isFc ? "forecast " + fmtMoneyFull(r.Forecast) : "actual " + fmtMoneyFull(r.Actual)}`; + }); + const legend = legendHtml([ + { label: "Actual", color: PALETTE[0] }, + { label: "Forecast", color: PALETTE[3] }, + ]); + return svgEl(W, H, g, "Cost forecast — actual vs projected") + legend; +} + +/* --------------------------------------------------------------- KPI calc */ + +function deriveKpis(d) { + const s = d.summary?.[0] || {}; + const list = s.List || 0, eff = s.Effective || 0, contracted = s.Contracted || 0, billed = s.Billed || 0; + const savings = list - eff; + const esr = list > 0 ? savings / list : 0; + const negotiated = list - contracted; + const commitment = contracted - eff; + + const tagMap = Object.fromEntries((d.tagged || []).map((r) => [r._t, r.Cost || 0])); + const tagged = tagMap.Tagged || 0, untagged = tagMap.Untagged || 0; + const tagTotal = tagged + untagged; + const untaggedPct = tagTotal > 0 ? untagged / tagTotal : 0; + + const priceMap = Object.fromEntries((d.pricing || []).map((r) => [r.PricingCategory, r.Cost || 0])); + const committed = priceMap.Committed || 0; + const priceTotal = Object.values(priceMap).reduce((a, b) => a + b, 0); + const coverage = priceTotal > 0 ? committed / priceTotal : 0; + + const trend = d.trend || []; + let mom = null, lastMonthVal = null, lastMonthLabel = null; + if (trend.length >= 1) { + const last = trend[trend.length - 1]; + lastMonthVal = last.Effective || 0; + lastMonthLabel = fmtMonth(last.Month); + if (trend.length >= 2) { + const prev = trend[trend.length - 2].Effective || 0; + mom = prev > 0 ? (lastMonthVal - prev) / prev : null; + } + } + + return { + billed, eff, list, contracted, savings, esr, negotiated, commitment, + tagged, untagged, untaggedPct, committed, coverage, + resources: s.Resources || 0, services: s.Services || 0, + subscriptions: s.Subscriptions || 0, regions: s.Regions || 0, + mom, lastMonthVal, lastMonthLabel, + }; +} + +function kpiThreshold(pct, greenMax, amberMax) { + if (pct < greenMax) return "threshold-green"; + if (pct < amberMax) return "threshold-amber"; + return "threshold-red"; +} + +const VALID_TABS = ["overview", "allocation", "rate", "usage", "anomaly", "tokenomics"]; + +function switchTab(tabId, opts = {}) { + 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"); + }); + if (!opts.skipHash) { + const url = new URL(location.href); + url.hash = `tab=${tabId}`; + history.pushState({ tab: tabId }, "", url); + } + load(); +} + +function tabFromHash() { + const m = /tab=([a-z]+)/.exec(location.hash); + return m && VALID_TABS.includes(m[1]) ? m[1] : null; +} + +/* --------------------------------------------------------- triage strip */ + +function buildTriageTile(title, count, cue, tabId) { + const isTeaser = count === null; + const cls = isTeaser ? "is-teaser" : count === 0 ? "threshold-green" : count <= 4 ? "threshold-amber" : "threshold-red"; + const badge = isTeaser ? "Not loaded" : count === 0 ? "Good" : count <= 4 ? "Review" : "Urgent"; + const display = isTeaser ? "—" : count === 0 ? "None" : fmtInt(count); + return ``; +} + +function renderTriageStrip(d) { + // Anomalies: reuse anomaly tab cache when loaded (use same cache key for consistency) + const anomPayload = state.cache["anomaly"]?.[cacheKey()]; + const daily = anomPayload?.data?.daily || []; + const anomCount = anomPayload ? daily.filter((r) => r.Flag !== 0).length : null; + const anomCue = anomCount === null ? "Visit Anomalies & forecast tab to load" + : anomCount === 0 ? "No anomalies detected" + : "Review flagged cost days"; + + // Overspend: months in trend where effective cost rose >20% vs prior month + const trend = d.trend || []; + let overspendCount = 0; + for (let i = 1; i < trend.length; i++) { + const prev = trend[i - 1].Effective || 0; + const curr = trend[i].Effective || 0; + if (prev > 0 && curr > prev * 1.20) overspendCount++; + } + const overspendCue = overspendCount === 0 + ? "Spend within expected range" + : `${overspendCount} month${overspendCount === 1 ? "" : "s"} with >20% spike`; + + // Savings opportunities: underutilized commitments from rate tab cache when loaded + const ratePayload = state.cache["rate"]?.[cacheKey()]; + const byCommitment = ratePayload?.data?.byCommitment || []; + const savingsCount = ratePayload ? byCommitment.filter((r) => (r.Unused || 0) > 0).length : null; + const savingsCue = savingsCount === null ? "Visit Rate optimization tab to load" + : savingsCount === 0 ? "Commitments fully utilized" + : "Underutilized commitments found"; + + return `
+ ${buildTriageTile("Anomalies", anomCount, anomCue, "anomaly")} + ${buildTriageTile("Overspend", overspendCount, overspendCue, "usage")} + ${buildTriageTile("Savings Opportunities", savingsCount, savingsCue, "rate")} +
`; +} + +function isPartialMonth() { + const now = new Date(); + return now.getDate() < new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate(); +} + +const KPI_TIPS = { + "Untagged cost": "% of spend on resources missing tags. Target: <10% · Review: <25% · Urgent: ≥25%. Tagging enables accurate showback and chargeback.", + "Commitment waste": "% of RI/savings-plan spend on unused capacity. Target: <10% · Review: <20% · Urgent: ≥20%. Idle commitments erode net savings.", + "Effective savings rate": "Negotiated + commitment savings as % of list price. Higher = better. Enterprise customers typically target ≥15–20%.", + "Commitment coverage": "Compute spend covered by RIs or savings plans. Target: ≥60% for steady workloads. Higher coverage → lower effective rate.", + "Compute coverage": "On-demand core-hours offset by commitments. Target: ≥60%. Tracks whether savings plan scope is sufficient.", + "MACC burn rate": "Microsoft Azure Consumption Commitment utilization. Target: ≥90% to avoid forfeiting unused balance at term end.", + "Anomaly days": "Days where daily cost deviated significantly from the expected baseline (STL decomposition). Review flagged dates for unexpected spend.", + "Hourly cost / core": "Compute effective cost per core-hour actually consumed this period — the real, paid-for unit rate.", + "Effective cost / core": "Compute effective cost per core-hour, including unused commitment waste spread across usage — the fully-loaded unit cost if that waste is charged back.", + "Unpredicted variance": "Net effective cost variance between actual spend and the anomaly baseline on flagged days (FinOps KPI: Total Unpredicted Variance of Spend). Positive = spent more than expected.", + "Anomaly detection rate": "Effective cost on anomaly-flagged days as % of total effective spend (FinOps KPI: Anomaly Cost %). The day-count ratio shown alongside is a separate reference stat, not the derivation of this percentage.", + "Last month change": "Month-over-month % change in effective cost vs. the prior month. Watch for spikes or drops that don't match expected seasonality.", + "Forecast next month": "Projected effective cost for next month using time-series decomposition (FinOps KPI: Cost Forecasting). Based on historical trend + seasonality, not a guarantee.", + "Visibility delay": "Median (P50) delay between when cost was incurred and when it appeared in the FinOps hub (FinOps KPI: Cost Visibility Delay). On local/demo data without a live Cost Management connector, a large delay is expected.", + "Tag policy compliance": "% of effective cost on resources with all required tag keys present and non-empty (FinOps KPI: Tagging Policy Compliance).", + "Subscriptions": "Distinct subscriptions (billing accounts) with cost activity in the selected period.", + "Allocated cost": "Effective cost with allocation evidence — a cost center, owner, or ownership tag — the complement of Unallocated cost.", +}; + +function kpiCard(label, value, meta, accent, thresholdClass, tier) { + // Hierarchy tier is now explicitly assigned by each tab's render*() call + // site (via the 6th `tier` argument) rather than an incomplete global + // label allow-list, so every tab consciously designates its own hero + // metric. `accent` is kept for call-site compatibility but unused. + const hierarchyClass = tier === "primary" ? "kpi--primary" : tier === "reference" ? "kpi--reference" : ""; + + // Combine threshold and hierarchy classes + const classArray = [thresholdClass, hierarchyClass].filter(Boolean); + const cls = classArray.length > 0 ? ` ${classArray.join(" ")}` : ""; + + const tip = KPI_TIPS[label]; + const tipHtml = tip ? ` ` : ""; + + return `
+
${esc(label)}${tipHtml}
+
${value}
+
${meta}
+
`; +} + +/* ---------------------------------------------------------------- render */ + +function panelHtml(id, span, title, sub, body) { + const subHtml = sub ? `

${sub}

` : ""; + return `
+

${title}

${subHtml}
+
${body}
+
`; +} + +function openKqlDialog(panelId) { + _kqlPanelId = panelId; + 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), "primary"), + // supporting KPIs + kpiCard("Effective cost", fmtMoney(k.eff), `Billed ${fmtMoney(k.billed)}`, PALETTE[0]), + kpiCard("Total savings", fmtMoney(k.savings), + `${fmtPct(k.esr)} effective savings rate`, PALETTE[1]), + // reference KPIs + kpiCard("Commitment coverage", fmtPct(k.coverage), + `${fmtMoney(k.committed)} of compute spend`, PALETTE[5], undefined, "reference"), + // supporting KPIs + kpiCard("Tracked resources", fmtInt(k.resources), + `${fmtInt(k.services)} services · ${fmtInt(k.subscriptions)} subs · ${fmtInt(k.regions)} regions`, PALETTE[2]), + kpiCard("Latest month", k.lastMonthVal == null ? "—" : fmtMoney(k.lastMonthVal), + k.mom == null ? (k.lastMonthLabel ? `${esc(k.lastMonthLabel)}${partialHtml}` : (isPartialMonth() ? `partial month` : "")) : `${momTxt} vs prior · ${esc(k.lastMonthLabel)}${partialHtml}`, PALETTE[4]), + // macc-consumption-vs-commitment — MACC burn rate. Demote to reference + // tier when unconfigured (N/A) so an empty card doesn't take full + // primary-grid visual weight. + kpiCard("MACC burn rate", + maccRow.CommitmentAmount > 0 ? fmtPct(maccRow.CommitmentBurnPercent / 100) : "N/A", + maccRow.CommitmentAmount > 0 + ? `${fmtMoney(maccRow.ConsumptionAmount)} of ${fmtMoney(maccRow.CommitmentAmount)} committed` + : "No Microsoft Azure Consumption Commitment data", + PALETTE[7], undefined, maccRow.CommitmentAmount > 0 ? undefined : "reference"), + ].join(""); + + const d = p.data; + const html = ` + ${renderTriageStrip(d)} +
${kpis}
+ +

Understand usage & cost

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

Optimize usage & cost

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

Quantify business value

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

No AI token data

+

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

+

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

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

AI token economics

Token Consumption Metrics KPI
+
+ ${panelHtml("token-trend", 12, "Token volume & AI cost trend", "Monthly token consumption (bars) and effective AI cost (line).", tokenTrendChart(d.trend))} + ${panelHtml("token-by-model", 6, "AI cost by model", "Effective cost per model family.", + hbar((d.models || []).map((m) => ({ Model: m.Model, Cost: m.Cost })), "Model", "Cost", { label: "AI cost by model" }))} + ${panelHtml("token-direction", 6, "Token direction mix", "Input vs cached input vs output — by token volume.", + donut(dirSlices, { + centerBig: fmtTokens(k.tokens), centerSmall: "tokens", + valueFmt: (s) => `${fmtTokens(s.value)} · ${fmtMoney(s.cost)}`, + label: "Token direction mix", + }))} +
+ +

Model efficiency

Rate & usage optimization
+
+ ${panelHtml("token-model-table", 12, "Cost per 1M tokens by model", "Unit economics for model selection — sorted by effective cost.", tokenModelTable(d.models, k.eff))} +
+ +

AI cost allocation

Showback & chargeback
+
+ ${panelHtml("token-by-app", 12, "AI cost by application", "Azure OpenAI effective cost and token volume by application, team, environment, and cost center.", aiByAppTable(d.byApplication))} +
+ `; +} + +function tokenModelTable(models, totalCost) { + const rows = (models || []).filter((m) => (m.Tokens || 0) > 0); + if (rows.length === 0) return `

No token data in range.

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

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

`; + const totalCost = data.reduce((a, r) => a + (r.EffectiveCost || 0), 0); + const untaggedCount = data.filter((r) => !r.Application).length; + const callout = untaggedCount === data.length + ? `
100% of AI cost (${fmtMoney(totalCost)}) is untagged — no application-level chargeback is currently possible. Tag Azure OpenAI resources with an application tag to enable it.
` + : ""; + return callout + ` + + ${data.map((r, i) => { + const share = totalCost > 0 ? (r.EffectiveCost || 0) / totalCost : 0; + const isUnknown = !r.Application; + const color = PALETTE[i % PALETTE.length]; + return ` + + + + + + + + + `; + }).join("")} +
ApplicationTeamEnvironmentCost centerTokensEffective cost$/1M tokens% of AI
${swatchHtml(color, isUnknown)}${esc(r.Application || "(untagged)")}${esc(r.Team || "—")}${esc(r.Environment || "—")}${esc(r.CostCenter || "—")}${fmtTokens(r.TokenCount)}${fmtMoney(r.EffectiveCost)}${fmtPerM(r.CostPer1KTokens)}${fmtPct(share)}
`; +} + +/* --------------------------------------------- anomalies & forecast render */ + +function renderAnomaly(p) { + const content = el("content"); + if (!p) return; + if (p.error) return renderError(p); + if (p.empty) { + content.innerHTML = `

No cost data

The Hub database has no rows yet.

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

Cost anomalies

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

Trend & forecast

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

No cost data

The Hub database has no rows yet.

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

Usage & unit economics

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

No cost data

The Hub database has no rows yet.

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

Rate optimization

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

Commitment transactions

Rate optimization · Commitment purchasing
+
+ ${panelHtml("rate-commit-score", 6, "Commitment utilization score", "Per-commitment utilization (used vs potential) from the formal CUS KPI.", commitUtilTable(d.commitmentUtilScore))} + ${panelHtml("rate-top-txns", 6, "Top commitment transactions", "Largest RI and savings plan purchases by billed cost. Effective cost is $0 by design — amortization credits the cost to the months the commitment is consumed, not the purchase month.", topCommitTxnTable(d.topCommitmentTxns))} +
+ `; +} + +function commitUtilTable(rows) { + const data = (rows || []).filter((r) => r.CommitmentDiscountName !== '(Grand Total)' && (r.Potential || 0) > 0); + if (data.length === 0) return `

No commitment data in range.

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

No commitment transactions in range.

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

No cost data

The Hub database has no rows yet.

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

Cost allocation

Allocation capability
+
+ ${panelHtml("alloc-hierarchy", 8, "Cost by financial hierarchy", "Org → project → environment (from resource tags), with share of total.", hierTable)} + ${panelHtml("alloc-tagging", 4, "Tagging coverage", "Tagged vs untagged effective cost.", + donut([ + { label: "Tagged", value: total - c.Untagged, color: PALETTE[1] }, + { label: "Untagged", value: c.Untagged, color: UNKNOWN_COLOR, isUnknown: true }, + ], { centerBig: fmtPct(1 - untaggedPct), centerSmall: "tagged", label: "Tagging coverage" }))} + ${panelHtml("alloc-tag-keys", 6, "Cost by tag key", `Effective cost touched by each governance tag.${tagKeyNote}`, hbar(d.tagKeys, "k", "Cost", { filterDim: null, label: "Cost by tag key" }))} + ${panelHtml("alloc-by-subscription", 6, "Cost by subscription", "Spend per billing scope for showback.", hbar(d.bySubscription, "SubAccountName", "Cost", { label: "Cost by subscription" }))} +
+ `; +} + +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(); + + // Restore tab from URL hash (bookmarking / back-forward support), or + // normalize the hash to reflect the default tab so the URL is always + // shareable. + const initialTab = tabFromHash(); + if (initialTab && initialTab !== state.tab) { + switchTab(initialTab, { skipHash: true }); + history.replaceState({ tab: initialTab }, "", `#tab=${initialTab}`); + } else { + history.replaceState({ tab: state.tab }, "", `#tab=${state.tab}`); + load(); + } + + window.addEventListener("popstate", () => { + const tab = tabFromHash() || "overview"; + if (tab !== state.tab) switchTab(tab, { skipHash: true }); + }); +} + +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 d66a5e7cf..5e01dff59 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: 07/07/2026 +ms.date: 07/08/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -52,6 +52,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** @@ -76,6 +79,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..e48acdc78 --- /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: 07/08/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..df84d7bae --- /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: 07/08/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..711344a98 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: 07/08/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..6e6bd39a6 --- /dev/null +++ b/src/powershell/Private/Invoke-FinOpsHubLocalCommand.ps1 @@ -0,0 +1,83 @@ +# 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. + + .PARAMETER TimeoutSec + Optional. Maximum number of seconds to wait for a response. Default = 0 (wait indefinitely). +#> +function Invoke-FinOpsHubLocalCommand +{ + [Diagnostics.CodeAnalysis.SuppressMessage("PSAvoidUsingEmptyCatchBlock", "", Justification="Not all failures have a JSON error body (for example, a connection failure); ignore parse errors and fall through to rethrow the original exception.")] + [CmdletBinding()] + param + ( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ClusterUri, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Database, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Command, + + [Parameter()] + [ValidateSet('mgmt', 'query')] + [string] + $Endpoint = 'mgmt', + + [Parameter()] + [ValidateRange(0, [int]::MaxValue)] + [int] + $TimeoutSec = 0 + ) + + $uri = '{0}/v1/rest/{1}' -f $ClusterUri.TrimEnd('/'), $Endpoint + $body = @{ db = $Database; csl = $Command } | ConvertTo-Json -Compress + + try + { + return Invoke-RestMethod -Uri $uri -Method 'Post' -ContentType 'application/json' -Body $body -TimeoutSec $TimeoutSec -ErrorAction 'Stop' + } + catch + { + # Kusto returns a structured JSON error body. Surface its message/code when present; + # otherwise rethrow the original exception (for example, a connection failure) as-is. + $content = $null + try + { + $content = $_.ErrorDetails.Message | ConvertFrom-Json -Depth 10 + } + catch {} + + if ($content.error) + { + throw ($script:LocalizedData.Common_ErrorResponse -f $content.error.message, $content.error.code) + } + + throw + } +} diff --git a/src/powershell/Public/Initialize-FinOpsHubLocal.ps1 b/src/powershell/Public/Initialize-FinOpsHubLocal.ps1 new file mode 100644 index 000000000..15656192d --- /dev/null +++ b/src/powershell/Public/Initialize-FinOpsHubLocal.ps1 @@ -0,0 +1,189 @@ +# 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. + + .PARAMETER TimeoutSec + Optional. Maximum number of seconds to wait for each emulator request or asset download. Default = 0 (wait indefinitely). + + .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()] + [ValidateNotNullOrEmpty()] + [string] + $ClusterUri = 'http://localhost:8082', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $ReleaseUri = 'https://github.com/microsoft/finops-toolkit/releases/latest/download', + + [Parameter()] + [ValidateRange(1, [int]::MaxValue)] + [int] + $RawRetentionInDays = 90, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $OpenDataPath = '/data/export/open-data', + + [Parameter()] + [switch] + $SkipOpenData, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $Destination = [System.IO.Path]::GetTempPath(), + + [Parameter()] + [ValidateRange(0, [int]::MaxValue)] + [int] + $TimeoutSec = 0 + ) + + $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' -TimeoutSec $TimeoutSec + } + 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 + try + { + $null = Invoke-WebRequest -Uri "$($ReleaseUri.TrimEnd('/'))/$assetName" -OutFile $filePath -TimeoutSec $TimeoutSec -Verbose:$false -ErrorAction 'Stop' + } + catch + { + throw ($script:LocalizedData.HubLocal_Initialize_DownloadFailed -f $assetName, $ReleaseUri) + } + + $scripts[$assetName] = Get-Content -Path $filePath -Raw + if ([string]::IsNullOrWhiteSpace($scripts[$assetName])) + { + throw ($script:LocalizedData.HubLocal_Initialize_AssetEmpty -f $assetName, $ReleaseUri) + } + } + + # 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 -TimeoutSec $TimeoutSec + } + } + + # 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 -TimeoutSec $TimeoutSec + } + + # 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'] -TimeoutSec $TimeoutSec + } + + # 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 -TimeoutSec $TimeoutSec + $empty = @($openDataTables | Where-Object { + [int64](Invoke-FinOpsHubLocalCommand -ClusterUri $ClusterUri -Database 'Ingestion' -Command "$_ | count" -Endpoint 'query' -TimeoutSec $TimeoutSec).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 + } + } + } + } + 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/Tests/Unit/DocsLinks.Tests.ps1 b/src/powershell/Tests/Unit/DocsLinks.Tests.ps1 index e25c3ecbd..9334db2b7 100644 --- a/src/powershell/Tests/Unit/DocsLinks.Tests.ps1 +++ b/src/powershell/Tests/Unit/DocsLinks.Tests.ps1 @@ -345,31 +345,38 @@ Describe 'Documentation links' { Context 'docs: Internal relative links' { - It 'Should resolve to an existing file: : []()' -ForEach $jekyllInternalLinks { - $sourceDir = Split-Path $SourceFile -Parent + if ($jekyllInternalLinks.Count -gt 0) { + It 'Should resolve to an existing file: : []()' -ForEach $jekyllInternalLinks { + $sourceDir = Split-Path $SourceFile -Parent + + if ([string]::IsNullOrEmpty($PathPart)) + { + Set-ItResult -Skipped -Because 'anchor-only link' + return + } - if ([string]::IsNullOrEmpty($PathPart)) - { - Set-ItResult -Skipped -Because 'anchor-only link' - return + $resolvedPath = Join-Path $sourceDir $PathPart | Resolve-Path -ErrorAction SilentlyContinue + $resolvedPath | Should -Not -BeNullOrEmpty -Because "link target '$PathPart' in ${SourceRel}:${LineNumber} should resolve to an existing file" } - $resolvedPath = Join-Path $sourceDir $PathPart | Resolve-Path -ErrorAction SilentlyContinue - $resolvedPath | Should -Not -BeNullOrEmpty -Because "link target '$PathPart' in ${SourceRel}:${LineNumber} should resolve to an existing file" - } + It 'Should have a valid anchor: : []()' -ForEach ($jekyllInternalLinks | Where-Object { $_.AnchorPart }) { + $sourceDir = Split-Path $SourceFile -Parent + $resolvedPath = Join-Path $sourceDir $PathPart - It 'Should have a valid anchor: : []()' -ForEach ($jekyllInternalLinks | Where-Object { $_.AnchorPart }) { - $sourceDir = Split-Path $SourceFile -Parent - $resolvedPath = Join-Path $sourceDir $PathPart + if (-not (Test-Path $resolvedPath)) + { + Set-ItResult -Skipped -Because 'target file does not exist (covered by file resolution test)' + return + } - if (-not (Test-Path $resolvedPath)) - { - Set-ItResult -Skipped -Because 'target file does not exist (covered by file resolution test)' - return + $anchors = Get-FileAnchors $resolvedPath + $anchors | Should -Contain $AnchorPart -Because "anchor '#$AnchorPart' should match a heading or HTML anchor in $(Split-Path $resolvedPath -Leaf) (link in ${SourceRel}:${LineNumber})" + } + } + else { + It 'Should not contain internal relative links to validate' { + $jekyllInternalLinks | Should -BeNullOrEmpty -Because 'the Jekyll docs site currently has no internal .md-to-.md relative links (navigation uses root-relative permalinks)' } - - $anchors = Get-FileAnchors $resolvedPath - $anchors | Should -Contain $AnchorPart -Because "anchor '#$AnchorPart' should match a heading or HTML anchor in $(Split-Path $resolvedPath -Leaf) (link in ${SourceRel}:${LineNumber})" } } @@ -431,18 +438,27 @@ Describe 'Documentation links' { $resolvedPath | Should -Not -BeNullOrEmpty -Because "link target '$PathPart' in ${SourceRel}:${LineNumber} should resolve to an existing file" } - It 'Should have a valid anchor: : []()' -ForEach ($rootInternalLinks | Where-Object { $_.AnchorPart }) { - $sourceDir = Split-Path $SourceFile -Parent - $resolvedPath = Join-Path $sourceDir $PathPart + $rootInternalLinksWithAnchor = @($rootInternalLinks | Where-Object { $_.AnchorPart }) - if (-not (Test-Path $resolvedPath)) - { - Set-ItResult -Skipped -Because 'target file does not exist (covered by file resolution test)' - return - } + if ($rootInternalLinksWithAnchor.Count -gt 0) { + It 'Should have a valid anchor: : []()' -ForEach $rootInternalLinksWithAnchor { + $sourceDir = Split-Path $SourceFile -Parent + $resolvedPath = Join-Path $sourceDir $PathPart - $anchors = Get-FileAnchors $resolvedPath - $anchors | Should -Contain $AnchorPart -Because "anchor '#$AnchorPart' should match a heading or HTML anchor in $(Split-Path $resolvedPath -Leaf) (link in ${SourceRel}:${LineNumber})" + if (-not (Test-Path $resolvedPath)) + { + Set-ItResult -Skipped -Because 'target file does not exist (covered by file resolution test)' + return + } + + $anchors = Get-FileAnchors $resolvedPath + $anchors | Should -Contain $AnchorPart -Because "anchor '#$AnchorPart' should match a heading or HTML anchor in $(Split-Path $resolvedPath -Leaf) (link in ${SourceRel}:${LineNumber})" + } + } + else { + It 'Should not contain internal links with anchors to validate' { + $rootInternalLinksWithAnchor | Should -BeNullOrEmpty -Because 'no internal root markdown links currently include an anchor fragment' + } } } diff --git a/src/powershell/Tests/Unit/Initialize-FinOpsHubLocal.Tests.ps1 b/src/powershell/Tests/Unit/Initialize-FinOpsHubLocal.Tests.ps1 new file mode 100644 index 000000000..c6e29d62f --- /dev/null +++ b/src/powershell/Tests/Unit/Initialize-FinOpsHubLocal.Tests.ps1 @@ -0,0 +1,245 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +InModuleScope 'FinOpsToolkit' { + Describe 'Initialize-FinOpsHubLocal' { + BeforeAll { + function newQueryResult + { + param + ( + [Parameter(Mandatory = $true)] + [int] + $Count + ) + + # Shape matches the real Kusto REST response the function reads: + # (Invoke-FinOpsHubLocalCommand ...).Tables[0].Rows[0][0] + return @{ Tables = @(@{ Rows = @(, @($Count)) }) } + } + + $clusterUri = 'http://localhost:8082' + $destination = 'TestDrive:\hub-local' + + Mock -CommandName 'New-Directory' + Mock -CommandName 'Start-Sleep' + Mock -CommandName 'Invoke-WebRequest' + Mock -CommandName 'Get-Content' -MockWith { + if ($Path -like '*opendata*') + { + return 'opendata script $$openDataPath$$' + } + elseif ($Path -like '*Hub.kql') + { + return 'hub script content' + } + + return 'ingestion script $$rawRetentionInDays$$ days' + } + + # Default: every command call succeeds with an empty response. + Mock -CommandName 'Invoke-FinOpsHubLocalCommand' -MockWith { return @{} } + + # Every open data table count check reports rows present (no retry needed). + Mock -CommandName 'Invoke-FinOpsHubLocalCommand' -ParameterFilter { $Endpoint -eq 'query' } -MockWith { + return (newQueryResult -Count 5) + } + } + + Context 'Happy path' { + It 'Should verify reachability, then create the Ingestion and Hub databases' { + # Act + Initialize-FinOpsHubLocal -ClusterUri $clusterUri -Destination $destination + + # Assert + Should -Invoke -CommandName 'Invoke-FinOpsHubLocalCommand' -Times 1 -ParameterFilter { + $Database -eq 'NetDefaultDB' -and $Command -eq '.show version' + } + Should -Invoke -CommandName 'Invoke-FinOpsHubLocalCommand' -Times 1 -ParameterFilter { + $Database -eq 'NetDefaultDB' -and $Command -like '*create database Ingestion*' + } + Should -Invoke -CommandName 'Invoke-FinOpsHubLocalCommand' -Times 1 -ParameterFilter { + $Database -eq 'NetDefaultDB' -and $Command -like '*create database Hub*' + } + } + + It 'Should apply the Ingestion and Hub setup scripts' { + # Act + Initialize-FinOpsHubLocal -ClusterUri $clusterUri -Destination $destination + + # Assert + Should -Invoke -CommandName 'Invoke-FinOpsHubLocalCommand' -Times 1 -ParameterFilter { + $Database -eq 'Ingestion' -and $Command -like '*ingestion script*' + } + Should -Invoke -CommandName 'Invoke-FinOpsHubLocalCommand' -Times 1 -ParameterFilter { + $Database -eq 'Hub' -and $Command -eq 'hub script content' + } + } + + It 'Should download the ingestion, hub, and open data setup scripts' { + # Act + Initialize-FinOpsHubLocal -ClusterUri $clusterUri -Destination $destination + + # Assert + Should -Invoke -CommandName 'Invoke-WebRequest' -Times 3 + Should -Invoke -CommandName 'Invoke-WebRequest' -Times 1 -ParameterFilter { $Uri -like '*finops-hub-fabric-setup-Ingestion.kql' } + Should -Invoke -CommandName 'Invoke-WebRequest' -Times 1 -ParameterFilter { $Uri -like '*finops-hub-fabric-setup-Hub.kql' } + Should -Invoke -CommandName 'Invoke-WebRequest' -Times 1 -ParameterFilter { $Uri -like '*finops-hub-local-opendata.kql' } + } + + It 'Should replace the raw retention placeholder before applying the Ingestion setup script' { + # Act + Initialize-FinOpsHubLocal -ClusterUri $clusterUri -RawRetentionInDays 30 -Destination $destination + + # Assert + Should -Invoke -CommandName 'Invoke-FinOpsHubLocalCommand' -Times 1 -ParameterFilter { + $Database -eq 'Ingestion' -and $Command -eq 'ingestion script 30 days' + } + } + + It 'Should replace the open data path placeholder before loading open data' { + # Act + Initialize-FinOpsHubLocal -ClusterUri $clusterUri -OpenDataPath '/custom/path' -Destination $destination + + # Assert + Should -Invoke -CommandName 'Invoke-FinOpsHubLocalCommand' -Times 1 -ParameterFilter { + $Database -eq 'Ingestion' -and $Command -eq 'opendata script /custom/path' + } + } + + It 'Should thread the requested timeout through every emulator request and download' { + # Act + Initialize-FinOpsHubLocal -ClusterUri $clusterUri -TimeoutSec 45 -Destination $destination + + # Assert + Should -Invoke -CommandName 'Invoke-FinOpsHubLocalCommand' -Times 0 -ParameterFilter { $TimeoutSec -ne 45 } + Should -Invoke -CommandName 'Invoke-WebRequest' -Times 0 -ParameterFilter { $TimeoutSec -ne 45 } + } + + It 'Should skip downloading and loading open data when requested' { + # Act + Initialize-FinOpsHubLocal -ClusterUri $clusterUri -SkipOpenData -Destination $destination + + # Assert + Should -Invoke -CommandName 'Invoke-WebRequest' -Times 0 -ParameterFilter { $Uri -like '*opendata*' } + Should -Invoke -CommandName 'Invoke-FinOpsHubLocalCommand' -Times 0 -ParameterFilter { $Endpoint -eq 'query' } + Should -Invoke -CommandName 'Invoke-FinOpsHubLocalCommand' -Times 0 -ParameterFilter { $Command -like '*opendata script*' } + } + } + + Context 'Emulator unreachable' { + It 'Should throw a clear error naming the cluster URI and stop before downloading anything' { + # Arrange + Mock -CommandName 'Invoke-FinOpsHubLocalCommand' -MockWith { throw 'connection refused' } -ParameterFilter { + $Database -eq 'NetDefaultDB' -and $Command -eq '.show version' + } + + # Act / Assert + { Initialize-FinOpsHubLocal -ClusterUri 'http://localhost:1' -Destination $destination } | + Should -Throw "*Could not reach the Kusto emulator at 'http://localhost:1'*" + Should -Invoke -CommandName 'Invoke-WebRequest' -Times 0 + } + } + + Context 'Download failures' { + It 'Should throw a clear error naming the asset and release URI' { + # Arrange + Mock -CommandName 'Invoke-WebRequest' -MockWith { throw 'network error' } -ParameterFilter { + $Uri -like '*finops-hub-fabric-setup-Ingestion.kql' + } + + # Act / Assert + { Initialize-FinOpsHubLocal -ClusterUri $clusterUri -Destination $destination } | + Should -Throw "*Could not download asset 'finops-hub-fabric-setup-Ingestion.kql'*" + } + } + + Context 'Empty downloaded assets' { + It 'Should throw a clear error when a downloaded asset is empty' { + # Arrange + Mock -CommandName 'Get-Content' -MockWith { return '' } + + # Act / Assert + { Initialize-FinOpsHubLocal -ClusterUri $clusterUri -Destination $destination } | + Should -Throw "*Downloaded asset 'finops-hub-fabric-setup-Ingestion.kql'*was empty*" + } + } + + Context 'Open data retry logic' { + It 'Should retry loading open data until every table reports rows' { + # Arrange + $script:queryCallCount = 0 + Mock -CommandName 'Invoke-FinOpsHubLocalCommand' -ParameterFilter { $Endpoint -eq 'query' } -MockWith { + $script:queryCallCount++ + # First attempt's 4 table checks report empty; second attempt's report filled. + if ($script:queryCallCount -le 4) + { + return (newQueryResult -Count 0) + } + + return (newQueryResult -Count 5) + } + + # Act + Initialize-FinOpsHubLocal -ClusterUri $clusterUri -Destination $destination + + # Assert + Should -Invoke -CommandName 'Invoke-FinOpsHubLocalCommand' -Times 2 -ParameterFilter { + $Database -eq 'Ingestion' -and $Command -like '*opendata script*' + } + Should -Invoke -CommandName 'Start-Sleep' -Times 1 + } + + It 'Should throw after the maximum number of attempts if the tables remain empty' { + # Arrange + Mock -CommandName 'Invoke-FinOpsHubLocalCommand' -ParameterFilter { $Endpoint -eq 'query' } -MockWith { + return (newQueryResult -Count 0) + } + + # Act / Assert + { Initialize-FinOpsHubLocal -ClusterUri $clusterUri -Destination $destination } | + Should -Throw '*Open data tables were still empty after 5 attempts*' + Should -Invoke -CommandName 'Start-Sleep' -Times 4 + } + } + + Context 'ShouldProcess (-WhatIf)' { + It 'Should not create databases, apply setup scripts, or load open data' { + # Act + Initialize-FinOpsHubLocal -ClusterUri $clusterUri -Destination $destination -WhatIf + + # Assert -- only the reachability check runs; every ShouldProcess-gated call is skipped. + Should -Invoke -CommandName 'Invoke-FinOpsHubLocalCommand' -Times 1 + Should -Invoke -CommandName 'Invoke-FinOpsHubLocalCommand' -Times 1 -ParameterFilter { $Command -eq '.show version' } + } + } + + Context 'Parameter validation' { + It 'Should require a non-empty ClusterUri' { + { Initialize-FinOpsHubLocal -ClusterUri '' } | Should -Throw + } + + It 'Should require a non-empty ReleaseUri' { + { Initialize-FinOpsHubLocal -ReleaseUri '' } | Should -Throw + } + + It 'Should reject a RawRetentionInDays of 0' { + { Initialize-FinOpsHubLocal -RawRetentionInDays 0 } | Should -Throw + } + + It 'Should require a non-empty OpenDataPath' { + { Initialize-FinOpsHubLocal -OpenDataPath '' } | Should -Throw + } + + It 'Should require a non-empty Destination' { + { Initialize-FinOpsHubLocal -Destination '' } | Should -Throw + } + + It 'Should reject a negative TimeoutSec' { + { Initialize-FinOpsHubLocal -TimeoutSec -1 } | Should -Throw + } + } + } +} diff --git a/src/powershell/Tests/Unit/Invoke-FinOpsHubLocalCommand.Tests.ps1 b/src/powershell/Tests/Unit/Invoke-FinOpsHubLocalCommand.Tests.ps1 new file mode 100644 index 000000000..0bae244d0 --- /dev/null +++ b/src/powershell/Tests/Unit/Invoke-FinOpsHubLocalCommand.Tests.ps1 @@ -0,0 +1,112 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +InModuleScope FinOpsToolkit { + Describe 'Invoke-FinOpsHubLocalCommand' { + BeforeAll { + function newKustoErrorRecord($code, $message) + { + $body = @{ error = @{ code = $code; message = $message } } | ConvertTo-Json -Compress + $exception = New-Object System.Exception('Response status code does not indicate success') + $errorRecord = New-Object System.Management.Automation.ErrorRecord( + $exception, 'WebCmdletWebResponseException', [System.Management.Automation.ErrorCategory]::InvalidOperation, $null + ) + $errorRecord.ErrorDetails = [System.Management.Automation.ErrorDetails]::new($body) + return $errorRecord + } + } + + Context 'Successful call' { + It 'Should post to the mgmt endpoint by default and return the response' { + # Arrange + Mock -CommandName 'Invoke-RestMethod' -MockWith { return @{ Tables = @() } } + + # Act + $result = Invoke-FinOpsHubLocalCommand -ClusterUri 'http://localhost:8082' -Database 'Hub' -Command '.show version' + + # Assert + $result | Should -Not -BeNullOrEmpty + Should -Invoke -CommandName 'Invoke-RestMethod' -Times 1 -ParameterFilter { + $Uri -eq 'http://localhost:8082/v1/rest/mgmt' -and + $Method -eq 'Post' -and + $TimeoutSec -eq 0 + } + } + + It 'Should post to the query endpoint when requested' { + # Arrange + Mock -CommandName 'Invoke-RestMethod' -MockWith { return @{ Tables = @() } } + + # Act + Invoke-FinOpsHubLocalCommand -ClusterUri 'http://localhost:8082' -Database 'Hub' -Command 'Costs_v1_2 | count' -Endpoint 'query' + + # Assert + Should -Invoke -CommandName 'Invoke-RestMethod' -Times 1 -ParameterFilter { $Uri -eq 'http://localhost:8082/v1/rest/query' } + } + + It 'Should trim a trailing slash from the cluster URI' { + # Arrange + Mock -CommandName 'Invoke-RestMethod' -MockWith { return @{ Tables = @() } } + + # Act + Invoke-FinOpsHubLocalCommand -ClusterUri 'http://localhost:8082/' -Database 'Hub' -Command '.show version' + + # Assert + Should -Invoke -CommandName 'Invoke-RestMethod' -Times 1 -ParameterFilter { $Uri -eq 'http://localhost:8082/v1/rest/mgmt' } + } + + It 'Should pass the requested timeout through to Invoke-RestMethod' { + # Arrange + Mock -CommandName 'Invoke-RestMethod' -MockWith { return @{ Tables = @() } } + + # Act + Invoke-FinOpsHubLocalCommand -ClusterUri 'http://localhost:8082' -Database 'Hub' -Command '.show version' -TimeoutSec 45 + + # Assert + Should -Invoke -CommandName 'Invoke-RestMethod' -Times 1 -ParameterFilter { $TimeoutSec -eq 45 } + } + } + + Context 'Structured Kusto errors' { + It 'Should surface the Kusto error message and code' { + # Arrange + Mock -CommandName 'Invoke-RestMethod' -MockWith { throw (newKustoErrorRecord 'BadRequest_SyntaxError' 'Query could not be parsed') } + + # Act / Assert + { Invoke-FinOpsHubLocalCommand -ClusterUri 'http://localhost:8082' -Database 'Hub' -Command 'invalid |||' } | + Should -Throw '*Query could not be parsed*BadRequest_SyntaxError*' + } + } + + Context 'Unstructured errors' { + It 'Should rethrow the original exception when there is no JSON error body' { + # Arrange + Mock -CommandName 'Invoke-RestMethod' -MockWith { throw 'Unable to connect to the remote server' } + + # Act / Assert + { Invoke-FinOpsHubLocalCommand -ClusterUri 'http://localhost:8082' -Database 'Hub' -Command '.show version' } | + Should -Throw 'Unable to connect to the remote server' + } + } + + Context 'Parameter validation' { + It 'Should require a non-empty ClusterUri' { + { Invoke-FinOpsHubLocalCommand -ClusterUri '' -Database 'Hub' -Command '.show version' } | Should -Throw + } + + It 'Should require a non-empty Database' { + { Invoke-FinOpsHubLocalCommand -ClusterUri 'http://localhost:8082' -Database '' -Command '.show version' } | Should -Throw + } + + It 'Should require a non-empty Command' { + { Invoke-FinOpsHubLocalCommand -ClusterUri 'http://localhost:8082' -Database 'Hub' -Command '' } | Should -Throw + } + + It 'Should reject a negative TimeoutSec' { + { Invoke-FinOpsHubLocalCommand -ClusterUri 'http://localhost:8082' -Database 'Hub' -Command '.show version' -TimeoutSec -1 } | Should -Throw + } + } + } +} diff --git a/src/powershell/Tests/Unit/New-Directory.Tests.ps1 b/src/powershell/Tests/Unit/New-Directory.Tests.ps1 index 0f7f5c3ad..95e3d79aa 100644 --- a/src/powershell/Tests/Unit/New-Directory.Tests.ps1 +++ b/src/powershell/Tests/Unit/New-Directory.Tests.ps1 @@ -4,93 +4,6 @@ & "$PSScriptRoot/../Initialize-Tests.ps1" InModuleScope 'FinOpsToolkit' { - BeforeAll { - function New-MockReleaseObject - { - param - ( - [Parameter(Mandatory = $true)] - [hashtable[]] - $Releases, - - [Parameter()] - [switch] - $GitHub - ) - - $output = @() - foreach ($hashtable in $Releases) - { - # Duplicate Files as "assets" to mock GitHub requests - $hashtable['assets'] = $hashtable.Files - $output += New-Object -TypeName 'psobject' -Property $hashtable - } - - if ($GitHub) - { - $output = ConvertTo-Json -InputObject $output -Depth 10 - } - - return $output - } - - function New-MockRelease - { - [OutputType([hashtable])] - [CmdletBinding()] - param - ( - [Parameter(Mandatory = $true)] - [string] - $Name, - - [Parameter(Mandatory = $true)] - [string] - $Version, - - [Parameter()] - [bool] - $PreRelease = $false, - - [Parameter()] - $Assets = @() - ) - - return @{ - Name = $Name - Version = $Version - tag_name = "v$Version" # For mocking GitHub requests - PreRelease = $PreRelease - Files = @($Assets) - } - } - - function New-MockAsset - { - [OutputType([hashtable])] - [CmdletBinding()] - param - ( - [Parameter(Mandatory = $true)] - [string] - $Name, - - [Parameter(Mandatory = $true)] - [string] - $Url - ) - - return @{ - Name = $Name - Url = $Url - browser_download_url = $Url # For mocking GitHub requests - } - } - - $previewVersion = '1.0.0-alpha.01' - $releaseVersion = '1.0.0' - } - Describe 'New-Directory' { BeforeAll { Mock -CommandName 'New-Item' diff --git a/src/powershell/en-US/FinOpsToolkit.strings.psd1 b/src/powershell/en-US/FinOpsToolkit.strings.psd1 index bf003b56b..addcebe00 100644 --- a/src/powershell/en-US/FinOpsToolkit.strings.psd1 +++ b/src/powershell/en-US/FinOpsToolkit.strings.psd1 @@ -16,6 +16,11 @@ ConvertFrom-StringData -StringData @' Hub_Remove_Failed = FinOps hub could not be deleted. {0}. Hub_Remove_NotFound = FinOps hub '{0}' not found. + HubLocal_Initialize_AssetEmpty = Downloaded asset '{0}' from '{1}' was empty. The release may be incomplete or the URI may not point to a valid FinOps toolkit release. + HubLocal_Initialize_DownloadFailed = Could not download asset '{0}' from '{1}'. Check the release URI and network connectivity. + 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)