Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
2c7e32c
feat(finops-hub-local): two-DB ftklocal foundation (T-5000.1 + T-5000.2)
Jun 17, 2026
a01cebe
Merge T-5000.1+T-5000.2: ftklocal two-DB foundation into features/ftk…
Jun 17, 2026
f6108e8
fix(finops-hub-local): make kql defaults to Hub; split KQL_DB/PARITY_DB
Jun 17, 2026
5bbf5c3
Merge T-5000.2 fix: make kql defaults to Hub (KQL_DB/PARITY_DB split)
Jun 17, 2026
0fa2a96
docs(finops-hub-local): export-config + manual staging contract (T-50…
Jun 17, 2026
98ebb14
Merge T-5000.4: export-config + staging contract into features/ftklocal
Jun 17, 2026
a4f6106
feat(finops-hub-local): local settings.json + config-container decisi…
Jun 17, 2026
01e5e42
Merge T-5000.10: settings.json + config-container decision into featu…
Jun 17, 2026
1f90c55
feat(finops-hub-local): add localized ADX dashboard artifact (T-5000.5)
Jun 17, 2026
f554584
merge(T-5000.5): integrate localized ADX dashboard into features/ftkl…
Jun 17, 2026
a5342c3
merge(features/ftklocal): sync .8 worktree to resolve notes/ references
Jun 17, 2026
bb422ad
feat(finops-hub-local): proactive backfill chunking + fail-fast doubl…
Jun 17, 2026
abf9d0d
merge(features/ftklocal): sync .3 worktree (brings in .5 dashboard)
Jun 17, 2026
8be8346
chore(finops-hub-local): genericize EXPORT_DIR example path (avoid /U…
Jun 17, 2026
e3b13ee
feat(agent-skills): add finops-hub-local skill + fix ftk.ps1 catalog …
Jun 17, 2026
e264089
fix(finops-hub-local): guard git fallback in ftk.ps1 Get-FtkRepo
Jun 17, 2026
2d8a2ff
merge(T-5000.8): integrate finops-hub-local agent skill + ftk.ps1 cat…
Jun 17, 2026
1614eb1
Merge branch 'features/ftklocal' into features/ftklocal-t5000.3
Jun 17, 2026
20fd3d3
refactor(finops-hub-local): gate chunked backfill on row count alone …
Jun 18, 2026
a9471b6
merge(T-5000.3): integrate ingest reliability (row-gated chunking, do…
Jun 18, 2026
f574559
docs(finops-hub-local): README + adapter/parity notes; drop stale cro…
Jun 18, 2026
e474163
merge(T-5000.7): integrate finops-hub-local docs + ftk.ps1 cross-db s…
Jun 18, 2026
7a615f7
docs(finops-hub-local): measured performance + limits (T-5000.6)
Jun 18, 2026
2fe9be7
merge(T-5000.6): integrate measured performance + limits doc
Jun 18, 2026
5ec214c
docs(finops-hub-local): correct ADX dashboard connectivity (UAT-measu…
Jun 18, 2026
801981e
docs(finops-hub-local): Windows-primary + macOS install/deploy guides…
Jun 18, 2026
69912ba
style(finops-hub-local): apply Prettier formatting to docs
Jun 18, 2026
91899bd
chore(finops-hub-local): pin .md to LF in .gitattributes
Jun 18, 2026
c4a3908
docs(finops-hub-local): add 'build by hand' runbook
Jun 18, 2026
41abdd4
refactor(finops-hub-local): load each schema bundle in one call
Jun 18, 2026
ed1f4ca
fix(finops-hub-local): correct the by-hand ingest steps (update polic…
Jun 18, 2026
a961d73
docs(finops-hub-local): align README by-hand summary with the update-…
Jun 18, 2026
421b9a7
fix(finops-hub-local): add .build.config to keep runtime data out of …
Jun 18, 2026
5278e2f
Revert "refactor(finops-hub-local): load each schema bundle in one call"
Jun 18, 2026
e016dc3
fix(finops-hub-local): fix single-element-array unwrap in ingest back…
Jun 18, 2026
4ef6457
docs(hubs): add Run hubs locally guide; remove finops-hub-local template
Jun 18, 2026
bce7f65
docs: clarify local hub prerequisites
MSBrett Jun 18, 2026
96bd183
docs: improve local hub ingestion guidance
MSBrett Jun 19, 2026
7aee803
docs: broaden local hub positioning
MSBrett Jun 19, 2026
7e951a8
docs: remove unused local ingest helper
MSBrett Jun 19, 2026
a32f12b
docs: update mslearn dates
MSBrett Jun 19, 2026
3fc373f
docs: add ftklocal agent skill
MSBrett Jun 19, 2026
195869d
feat(finops-hub): build-generated local open-data loader, Initialize-…
MSBrett Jun 21, 2026
66b66be
Merge remote-tracking branch 'origin/dev' into features/ftklocal
MSBrett Jun 21, 2026
b3c40a2
feat(hubs): add ftk-local-dashboard canvas extension
Jun 25, 2026
a133f66
feat(dashboard): add KPI interpretive annotations (T-5001.1)
Jun 25, 2026
13b389c
feat(dashboard): add triage framing to Overview tab (T-5001.4)
Jun 25, 2026
435bc00
fix(dashboard): correct Overspend tab target and null guards in triag…
Jun 25, 2026
7f627ca
feat(dashboard): elevate source line to diagnostic rail (T-5001.2)
Jun 25, 2026
807c02c
feat(dashboard): add open KQL escape hatch per chart (T-5001.5)
Jun 25, 2026
4ebc90e
fix(dashboard): wire Run button to chart re-render and add HTTP error…
Jun 25, 2026
4e23bce
feat(dashboard): introduce visual hierarchy between primary and refer…
Jun 25, 2026
db3531f
fix(dashboard): fix KPI ordering, grid span, and border stacking (T-5…
Jun 25, 2026
64dddf7
fix(dashboard): fix reference KPI shadow cascade and remove dead font…
Jun 25, 2026
2fa1846
fix(dashboard): reset row count on error state and fix orphaned separ…
Jun 25, 2026
db01317
feat(dashboard): add /api/kql endpoint, refactor error handling (T-50…
Jun 25, 2026
bce4ee4
fix(dashboard): fix tainted format string in logError (CodeQL js/tain…
Jun 25, 2026
b60441d
feat(dashboard): add CUS, MACC, top-txns, AI-by-app from INDEX.md cat…
Jun 25, 2026
c5a23e1
feat(canvas): add interactive click-to-filter to dashboard
Jun 25, 2026
c215872
chore: add PRODUCT.md and ignore impeccable cache
Jun 25, 2026
b1b6654
feat(dashboard): impeccable polish β€” P1-P3 design fixes
Jun 25, 2026
6f1366c
feat(dashboard): harden β€” abort races, error boundary, a11y, null guards
Jun 25, 2026
ef634f4
feat(dashboard): P1+P2 UX polish β€” KPI tooltips, triage callouts, ari…
Jun 25, 2026
bfd2d81
fix(docs): mount kustodata volume in docker run so databases survive …
Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/extensions/ftk-local-dashboard/PRODUCT.md
Original file line number Diff line number Diff line change
@@ -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 `<title>` 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)`.
309 changes: 309 additions & 0 deletions .github/extensions/ftk-local-dashboard/extension.mjs
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
}

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()));
}
},
}),
],
});
Loading