-
Notifications
You must be signed in to change notification settings - Fork 227
docs: run FinOps hubs on your own hardware #2187
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
MSBrett
wants to merge
64
commits into
dev
Choose a base branch
from
features/ftklocal
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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)
a01cebe
Merge T-5000.1+T-5000.2: ftklocal two-DB foundation into features/ftkβ¦
f6108e8
fix(finops-hub-local): make kql defaults to Hub; split KQL_DB/PARITY_DB
5bbf5c3
Merge T-5000.2 fix: make kql defaults to Hub (KQL_DB/PARITY_DB split)
0fa2a96
docs(finops-hub-local): export-config + manual staging contract (T-50β¦
98ebb14
Merge T-5000.4: export-config + staging contract into features/ftklocal
a4f6106
feat(finops-hub-local): local settings.json + config-container decisiβ¦
01e5e42
Merge T-5000.10: settings.json + config-container decision into featuβ¦
1f90c55
feat(finops-hub-local): add localized ADX dashboard artifact (T-5000.5)
f554584
merge(T-5000.5): integrate localized ADX dashboard into features/ftklβ¦
a5342c3
merge(features/ftklocal): sync .8 worktree to resolve notes/ references
bb422ad
feat(finops-hub-local): proactive backfill chunking + fail-fast doublβ¦
abf9d0d
merge(features/ftklocal): sync .3 worktree (brings in .5 dashboard)
8be8346
chore(finops-hub-local): genericize EXPORT_DIR example path (avoid /Uβ¦
e3b13ee
feat(agent-skills): add finops-hub-local skill + fix ftk.ps1 catalog β¦
e264089
fix(finops-hub-local): guard git fallback in ftk.ps1 Get-FtkRepo
2d8a2ff
merge(T-5000.8): integrate finops-hub-local agent skill + ftk.ps1 catβ¦
1614eb1
Merge branch 'features/ftklocal' into features/ftklocal-t5000.3
20fd3d3
refactor(finops-hub-local): gate chunked backfill on row count alone β¦
a9471b6
merge(T-5000.3): integrate ingest reliability (row-gated chunking, doβ¦
f574559
docs(finops-hub-local): README + adapter/parity notes; drop stale croβ¦
e474163
merge(T-5000.7): integrate finops-hub-local docs + ftk.ps1 cross-db sβ¦
7a615f7
docs(finops-hub-local): measured performance + limits (T-5000.6)
2fe9be7
merge(T-5000.6): integrate measured performance + limits doc
5ec214c
docs(finops-hub-local): correct ADX dashboard connectivity (UAT-measuβ¦
801981e
docs(finops-hub-local): Windows-primary + macOS install/deploy guidesβ¦
69912ba
style(finops-hub-local): apply Prettier formatting to docs
91899bd
chore(finops-hub-local): pin .md to LF in .gitattributes
c4a3908
docs(finops-hub-local): add 'build by hand' runbook
41abdd4
refactor(finops-hub-local): load each schema bundle in one call
ed1f4ca
fix(finops-hub-local): correct the by-hand ingest steps (update policβ¦
a961d73
docs(finops-hub-local): align README by-hand summary with the update-β¦
421b9a7
fix(finops-hub-local): add .build.config to keep runtime data out of β¦
5278e2f
Revert "refactor(finops-hub-local): load each schema bundle in one call"
e016dc3
fix(finops-hub-local): fix single-element-array unwrap in ingest backβ¦
4ef6457
docs(hubs): add Run hubs locally guide; remove finops-hub-local template
bce7f65
docs: clarify local hub prerequisites
MSBrett 96bd183
docs: improve local hub ingestion guidance
MSBrett 7aee803
docs: broaden local hub positioning
MSBrett 7e951a8
docs: remove unused local ingest helper
MSBrett a32f12b
docs: update mslearn dates
MSBrett 3fc373f
docs: add ftklocal agent skill
MSBrett 195869d
feat(finops-hub): build-generated local open-data loader, Initialize-β¦
MSBrett 66b66be
Merge remote-tracking branch 'origin/dev' into features/ftklocal
MSBrett b3c40a2
feat(hubs): add ftk-local-dashboard canvas extension
a133f66
feat(dashboard): add KPI interpretive annotations (T-5001.1)
13b389c
feat(dashboard): add triage framing to Overview tab (T-5001.4)
435bc00
fix(dashboard): correct Overspend tab target and null guards in triagβ¦
7f627ca
feat(dashboard): elevate source line to diagnostic rail (T-5001.2)
807c02c
feat(dashboard): add open KQL escape hatch per chart (T-5001.5)
4ebc90e
fix(dashboard): wire Run button to chart re-render and add HTTP errorβ¦
4e23bce
feat(dashboard): introduce visual hierarchy between primary and referβ¦
db3531f
fix(dashboard): fix KPI ordering, grid span, and border stacking (T-5β¦
64dddf7
fix(dashboard): fix reference KPI shadow cascade and remove dead fontβ¦
2fa1846
fix(dashboard): reset row count on error state and fix orphaned separβ¦
db01317
feat(dashboard): add /api/kql endpoint, refactor error handling (T-50β¦
bce4ee4
fix(dashboard): fix tainted format string in logError (CodeQL js/tainβ¦
b60441d
feat(dashboard): add CUS, MACC, top-txns, AI-by-app from INDEX.md catβ¦
c5a23e1
feat(canvas): add interactive click-to-filter to dashboard
c215872
chore: add PRODUCT.md and ignore impeccable cache
b1b6654
feat(dashboard): impeccable polish β P1-P3 design fixes
6f1366c
feat(dashboard): harden β abort races, error boundary, a11y, null guards
ef634f4
feat(dashboard): P1+P2 UX polish β KPI tooltips, triage callouts, ariβ¦
bfd2d81
fix(docs): mount kustodata volume in docker run so databases survive β¦
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
|
|
||
| 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())); | ||
| } | ||
| }, | ||
| }), | ||
| ], | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.