-
Notifications
You must be signed in to change notification settings - Fork 435
Add usage reporting and continuation-aware logs windows to the dashboard canvas #42226
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
Changes from all commits
06443e5
7bceacc
ad9190c
b303dbc
2b59e00
12d2cd2
f4aa159
5224f6f
9a80515
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { execFile } from "node:child_process"; | ||
| import { constants as fsConstants } from "node:fs"; | ||
| import { access } from "node:fs/promises"; | ||
| import { join } from "node:path"; | ||
|
|
||
| function execp(bin, args, cwd) { | ||
| return new Promise((resolve, reject) => { | ||
| execFile( | ||
| bin, | ||
| args, | ||
| { | ||
| cwd, | ||
| env: { ...process.env, NO_COLOR: "1", GH_NO_UPDATE_NOTIFIER: "1" }, | ||
| maxBuffer: 10 * 1024 * 1024, | ||
| }, | ||
| (err, stdout, stderr) => { | ||
| if (err) reject(Object.assign(err, { stderr: stderr ?? "" })); | ||
| else resolve(stdout); | ||
| } | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| export function createGhAwRunner({ getWorkspacePath }) { | ||
| return async function runGhAw(args) { | ||
| const cwd = getWorkspacePath(); | ||
| const isWin = process.platform === "win32"; | ||
| const devBin = join(cwd, isWin ? "gh-aw.exe" : "gh-aw"); | ||
| try { | ||
| await access(devBin, fsConstants.X_OK); | ||
| return await execp(devBin, args, cwd); | ||
| } catch { | ||
| return await execp("gh", ["aw", ...args], cwd); | ||
| } | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| export const CACHE_TTL_MS = 60_000; | ||
| export const DEFAULT_LOG_TIMEOUT_MINUTES = 1; | ||
| export const DEFAULT_REPORT_WINDOW_ID = "7d"; | ||
| export const DEFAULT_RUN_COUNT = 100; | ||
| export const MAX_LOG_CONTINUATIONS = 6; | ||
| export const REPORT_WINDOWS = { | ||
| "3d": { id: "3d", label: "3 days", startDate: "-3d", days: 3 }, | ||
| "7d": { id: "7d", label: "7 days", startDate: "-1w", days: 7 }, | ||
| "1mo": { id: "1mo", label: "1 month", startDate: "-1mo", days: 30 }, | ||
| }; | ||
|
|
||
| export function getReportWindow(windowId) { | ||
| return REPORT_WINDOWS[windowId] ?? REPORT_WINDOWS[DEFAULT_REPORT_WINDOW_ID]; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| import { CACHE_TTL_MS, DEFAULT_LOG_TIMEOUT_MINUTES, MAX_LOG_CONTINUATIONS } from "./dashboard-config.mjs"; | ||
| import { buildLogsArgs, continuationToLogsOptions, logsArgsToOptions, logsCommandUsesJSON, mergeRuns, normalizeLogsCommandArgs, normalizeLogsOptions, parseGhAwArgs } from "./dashboard-logs.mjs"; | ||
| import { applyForecastToUsageSummary, buildUsageSummary, forecastDaysForWindow } from "./usage-forecast.mjs"; | ||
|
|
||
| export function createDashboardDataAccess({ runGhAw, cacheTTL = CACHE_TTL_MS }) { | ||
| const cache = new Map(); | ||
|
|
||
| function getCached(key) { | ||
| const entry = cache.get(key); | ||
| return entry && Date.now() < entry.expiresAt ? entry.data : null; | ||
| } | ||
|
|
||
| function setCached(key, data) { | ||
| cache.set(key, { data, expiresAt: Date.now() + cacheTTL }); | ||
| } | ||
|
|
||
| async function getDefinitions() { | ||
| const hit = getCached("definitions"); | ||
| if (hit) return hit; | ||
| const raw = await runGhAw(["status", "--json"]); | ||
| const data = JSON.parse(raw); | ||
| setCached("definitions", data); | ||
| return data; | ||
| } | ||
|
|
||
| async function getExperiments() { | ||
| const hit = getCached("experiments"); | ||
| if (hit) return hit; | ||
| const raw = await runGhAw(["experiments", "list", "--json"]); | ||
| const data = JSON.parse(raw); | ||
| const experiments = Array.isArray(data) ? data : []; | ||
| setCached("experiments", experiments); | ||
| return experiments; | ||
| } | ||
|
|
||
| async function fetchLogsBatches(initialOptions, initialArgs = null) { | ||
| let current = initialOptions; | ||
| let logsFetches = 0; | ||
| let runs = []; | ||
| let continuation = null; | ||
| let summary = null; | ||
| let firstBatch = null; | ||
|
|
||
| while (current && logsFetches < MAX_LOG_CONTINUATIONS) { | ||
| const raw = await runGhAw(logsFetches === 0 && initialArgs ? initialArgs : buildLogsArgs(current)); | ||
| let data; | ||
| try { | ||
| data = JSON.parse(raw); | ||
| } catch (error) { | ||
| throw new Error(`Failed to parse logs batch ${logsFetches + 1}: ${error.message}`); | ||
| } | ||
| if (!firstBatch) { | ||
| firstBatch = data; | ||
| } | ||
| runs = mergeRuns(runs, Array.isArray(data?.runs) ? data.runs : []); | ||
| continuation = data?.continuation ?? null; | ||
| summary = data?.summary ?? summary; | ||
| logsFetches += 1; | ||
|
|
||
| if (!continuation) { | ||
| break; | ||
| } | ||
|
|
||
| current = continuationToLogsOptions(continuation, current); | ||
| } | ||
|
|
||
| return { | ||
| firstBatch, | ||
| runs, | ||
| summary, | ||
| logsFetches, | ||
| partial: Boolean(continuation), | ||
| continuation, | ||
| }; | ||
| } | ||
|
|
||
| async function getLogsData(options = {}) { | ||
| const normalized = normalizeLogsOptions(options); | ||
| const key = `logs:${JSON.stringify({ | ||
| window: normalized.window.id, | ||
| count: normalized.count, | ||
| timeout: normalized.timeout, | ||
| startDate: normalized.startDate, | ||
| endDate: normalized.endDate, | ||
| beforeRunID: normalized.beforeRunID, | ||
| afterRunID: normalized.afterRunID, | ||
| workflowName: normalized.workflowName, | ||
| engine: normalized.engine, | ||
| branch: normalized.branch, | ||
| artifacts: normalized.artifacts, | ||
| })}`; | ||
| const hit = getCached(key); | ||
| if (hit) return hit; | ||
|
|
||
| const logsResult = await fetchLogsBatches(normalized); | ||
|
|
||
| const result = { | ||
| runs: logsResult.runs, | ||
| summary: logsResult.summary, | ||
| window: normalized.window, | ||
| timeout: normalized.timeout, | ||
| logsFetches: logsResult.logsFetches, | ||
| partial: logsResult.partial, | ||
| continuation: logsResult.continuation, | ||
| }; | ||
| setCached(key, result); | ||
| return result; | ||
| } | ||
|
|
||
| async function getForecastData(workflowIDs, window, timeout) { | ||
| if (!Array.isArray(workflowIDs) || workflowIDs.length === 0) { | ||
| return []; | ||
| } | ||
|
|
||
| const args = ["forecast", "--json", "--period", "month", "--days", String(forecastDaysForWindow(window)), "--timeout", String(timeout), ...workflowIDs]; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All unique workflow IDs are spread directly into the CLI argument list with no upper bound ( Consider either:
@copilot please address this. |
||
| const raw = await runGhAw(args); | ||
| let data; | ||
| try { | ||
| data = JSON.parse(raw); | ||
| } catch (error) { | ||
| const snippet = String(raw ?? "") | ||
| .replace(/\s+/g, " ") | ||
| .slice(0, 200); | ||
| throw new Error(`Failed to parse forecast output: ${error.message}${snippet ? ` (output: ${snippet})` : ""}`); | ||
| } | ||
| return Array.isArray(data?.workflows) ? data.workflows : []; | ||
| } | ||
|
|
||
| async function getRuns(options = {}) { | ||
| return getLogsData(options); | ||
| } | ||
|
|
||
| async function getUsage(options = {}) { | ||
| const normalized = normalizeLogsOptions(options); | ||
| const key = `usage:${JSON.stringify({ | ||
| window: normalized.window.id, | ||
| count: normalized.count, | ||
| timeout: normalized.timeout, | ||
| })}`; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/zoom-out] The 💡 Fix: mirror the complete key from `getLogsData`const key = `usage:${JSON.stringify({
window: normalized.window.id,
count: normalized.count,
timeout: normalized.timeout,
startDate: normalized.startDate,
endDate: normalized.endDate,
workflowName: normalized.workflowName,
engine: normalized.engine,
branch: normalized.branch,
artifacts: normalized.artifacts,
})}`;Alternatively, since @copilot please address this.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Consider extending the key to include all fields that influence the underlying logs fetch, mirroring the const key = `usage:${JSON.stringify({
window: normalized.window.id,
count: normalized.count,
timeout: normalized.timeout,
startDate: normalized.startDate,
workflowName: normalized.workflowName,
engine: normalized.engine,
branch: normalized.branch,
})}`;@copilot please address this.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Incomplete cache key lets 💡 Suggested fixMirror the key construction from const key = `usage:${JSON.stringify({
window: normalized.window.id,
count: normalized.count,
timeout: normalized.timeout,
startDate: normalized.startDate,
endDate: normalized.endDate,
beforeRunID: normalized.beforeRunID,
afterRunID: normalized.afterRunID,
workflowName: normalized.workflowName,
engine: normalized.engine,
branch: normalized.branch,
artifacts: normalized.artifacts,
})}`;Current callers only pass |
||
| const hit = getCached(key); | ||
| if (hit) return hit; | ||
|
|
||
| const logsData = await getLogsData(normalized); | ||
| const usageItems = buildUsageSummary(logsData.runs, logsData.window); | ||
| const workflowIDs = usageItems.map(item => item.workflow_id).filter(Boolean); | ||
| const forecastWorkflows = await getForecastData(workflowIDs, logsData.window, logsData.timeout); | ||
| const result = { | ||
| items: applyForecastToUsageSummary(usageItems, forecastWorkflows), | ||
| window: logsData.window, | ||
| timeout: logsData.timeout, | ||
| logsFetches: logsData.logsFetches, | ||
| partial: logsData.partial, | ||
| continuation: logsData.continuation, | ||
| total_runs: logsData.runs.length, | ||
| forecast_history_days: forecastDaysForWindow(logsData.window), | ||
| }; | ||
| setCached(key, result); | ||
| return result; | ||
| } | ||
|
|
||
| async function execCommand(rawCmd, options = {}) { | ||
| const args = parseGhAwArgs(rawCmd); | ||
| if (!args) { | ||
| return { command: rawCmd, output: "Only 'gh aw <subcommand>' commands are supported.", error: true }; | ||
| } | ||
|
|
||
| try { | ||
| if (args[0] === "logs" && logsCommandUsesJSON(args)) { | ||
| const commandArgs = normalizeLogsCommandArgs(args, options.window, options.timeout ?? DEFAULT_LOG_TIMEOUT_MINUTES); | ||
| const logsOptions = logsArgsToOptions(commandArgs, { window: options.window, timeout: options.timeout }); | ||
| const logsResult = await fetchLogsBatches(logsOptions, commandArgs); | ||
|
|
||
| return { | ||
| command: `gh aw ${commandArgs.join(" ")}`, | ||
| output: JSON.stringify( | ||
| { | ||
| ...(logsResult.firstBatch ?? {}), | ||
| runs: logsResult.runs, | ||
| partial: logsResult.partial, | ||
| logs_fetches: logsResult.logsFetches, | ||
| continuation: logsResult.continuation, | ||
| }, | ||
| null, | ||
| 2 | ||
| ), | ||
| }; | ||
| } | ||
|
|
||
| const output = await runGhAw(args); | ||
| return { command: rawCmd, output }; | ||
| } catch (err) { | ||
| return { command: rawCmd, output: err.stderr || err.message, error: true }; | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| clearCache: () => cache.clear(), | ||
| execCommand, | ||
| getDefinitions, | ||
| getExperiments, | ||
| getRuns, | ||
| getUsage, | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/tdd] The error path in
fetchLogsBatches— when a mid-continuation batch returns unparseable JSON — has no test. If the CLI emits a warning line before the JSON, or returns a rate-limit error message, the thrownErrorwill surface as a 500 with a raw message rather than something actionable.💡 Add a parse-failure test and consider a better error surface
Test: