diff --git a/.changeset/fiery-planets-think.md b/.changeset/fiery-planets-think.md new file mode 100644 index 0000000..503be54 --- /dev/null +++ b/.changeset/fiery-planets-think.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": minor +--- + +feat(scripts): sketch Edge Script statistics command diff --git a/AGENTS.md b/AGENTS.md index 86963ad..2456e5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,6 +165,8 @@ bunny-cli/ │ │ │ └── commands.ts # createHostnamesCommands(): add/ssl/list/remove factory parameterized by a pull-zone resolver │ │ ├── logger.ts # Chalk-based structured logger │ │ ├── manifest.ts # .bunny/ context file resolution (load, save, resolveManifestId) +│ │ ├── stats.ts # Shared stats rendering: sumChart(), renderBarChart(), formatBucketLabel() (UTC date labels), BAR_WIDTH (used by dns/zone/stats + scripts/stats) +│ │ ├── stats.test.ts # Tests for stats helpers │ │ ├── types.ts # GlobalArgs, OutputFormat, and shared type definitions │ │ ├── ui.ts # readPassword(), confirm(), spinner() wrappers │ │ └── version.ts # VERSION constant from package.json @@ -309,9 +311,11 @@ bunny-cli/ │ │ ├── deploy.ts # Deploy code to an Edge Script (publishes by default) │ │ ├── docs.ts # Open Edge Script documentation in browser │ │ ├── init.ts # Scaffold a new Edge Script project from a template (calls `createScript`) +│ │ ├── interactive.ts # resolveScriptInteractive(): explicit ID → linked manifest → picker (offers to link; skipped for JSON output) │ │ ├── link.ts # Link directory to a remote Edge Script (.bunny/script.json) │ │ ├── list.ts # List all Edge Scripts (Standalone + Middleware) │ │ ├── show.ts # Show Edge Script details + hostnames (supports manifest fallback) +│ │ ├── stats.ts # Show Edge Script usage statistics (requests/CPU/cost totals + per-bucket bar chart with friendly UTC date labels; uses core/stats.ts) │ │ ├── deployments/ │ │ │ ├── index.ts # defineNamespace("deployments", ...) │ │ │ └── list.ts # List deployments for an Edge Script @@ -918,7 +922,9 @@ bunny │ │ └── pull [id] [--force] Pull env vars to .env file │ ├── link [--id] Link directory to a remote Edge Script │ ├── list (alias: ls) List all Edge Scripts -│ └── show [id] Show Edge Script details (uses linked script if omitted) +│ ├── show [id] Show Edge Script details (uses linked script if omitted) +│ └── stats [id] [--from] [--to] [--hourly] [--link] +│ Show usage statistics (requests/CPU/cost totals + bar chart; defaults to last 30 days). No ID → linked script → interactive picker (offers to link; --no-link skips). JSON output skips the picker and errors. ├── docs Open bunny.net documentation in browser ├── open [--print] Open bunny.net dashboard in browser (or print URL) ├── --profile, -p Profile to use (default: "default") diff --git a/packages/cli/README.md b/packages/cli/README.md index 995f6f6..41a3eaf 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -592,6 +592,29 @@ bunny scripts show bunny scripts show ``` +#### `bunny scripts stats` + +Show usage statistics for an Edge Script — request, CPU, and cost totals over the period, plus a per-bucket requests-served bar chart in text mode (buckets are labelled with friendly UTC dates, e.g. `May 19, 2026`, or date + time with `--hourly`). Defaults to the last 30 days. + +When no ID is given, the command resolves the linked script from `.bunny/script.json`. If there is no link either, it prompts you to pick a script and offers to link the directory for next time. In `--output json` mode the picker is skipped and the command errors instead — pass an ID or run `bunny scripts link` in CI. + +```bash +bunny scripts stats +bunny scripts stats 12345 --from 2026-05-01 --to 2026-05-31 +bunny scripts stats 12345 --hourly +bunny scripts stats 12345 --output json + +# Pick interactively without being asked to link (e.g. one-off checks) +bunny scripts stats --no-link +``` + +| Flag | Description | +| ---------- | ---------------------------------------------------------------------------------- | +| `--from` | Start date (YYYY-MM-DD); defaults to 30 days ago | +| `--to` | End date (YYYY-MM-DD); defaults to today | +| `--hourly` | Group statistics by hour instead of by day | +| `--link` | After an interactive pick, link the directory (use `--no-link` to skip the prompt) | + #### `bunny scripts delete` Delete an Edge Script. Uses the linked script if no ID is provided. Requires double confirmation (or `--force` to skip). diff --git a/packages/cli/src/commands/dns/zone/stats.ts b/packages/cli/src/commands/dns/zone/stats.ts index 7bc105f..7308c29 100644 --- a/packages/cli/src/commands/dns/zone/stats.ts +++ b/packages/cli/src/commands/dns/zone/stats.ts @@ -1,11 +1,10 @@ import { createCoreClient } from "@bunny.net/openapi-client"; -import chalk from "chalk"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; -import { bunny } from "../../../core/colors.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { formatKeyValue, formatTable } from "../../../core/format.ts"; import { logger } from "../../../core/logger.ts"; +import { renderBarChart, sumChart } from "../../../core/stats.ts"; import { spinner } from "../../../core/ui.ts"; import { resolveZoneInteractive } from "../interactive.ts"; import { queryTypeLabel } from "../query-types.ts"; @@ -16,33 +15,6 @@ interface StatsArgs { to?: string; } -const BAR_WIDTH = 24; - -/** Sum the values of a date-keyed chart map. */ -function sumChart(chart: { [key: string]: number } | null | undefined): number { - return Object.values(chart ?? {}).reduce((acc, n) => acc + n, 0); -} - -/** Render a horizontal bar chart of query counts per record type. */ -function renderTypeChart(byType: [string, number][]): string { - const max = Math.max(...byType.map(([, n]) => n), 1); - const labelWidth = Math.max(...byType.map(([t]) => t.length)); - const numWidth = Math.max( - ...byType.map(([, n]) => n.toLocaleString().length), - ); - return byType - .map(([type, count]) => { - const filled = - count > 0 ? Math.max(1, Math.round((count / max) * BAR_WIDTH)) : 0; - const bar = - bunny("█".repeat(filled)) + chalk.gray("░".repeat(BAR_WIDTH - filled)); - const label = type.padEnd(labelWidth); - const value = count.toLocaleString().padStart(numWidth); - return ` ${label} ${bar} ${value}`; - }) - .join("\n"); -} - export const dnsStatsCommand = defineCommand({ command: "stats [domain]", describe: "Show DNS query statistics for a zone.", @@ -118,7 +90,7 @@ export const dnsStatsCommand = defineCommand({ logger.log(""); logger.dim(" Queries by type"); if (output === "text") { - logger.log(renderTypeChart(byType)); + logger.log(renderBarChart(byType)); } else { logger.log( formatTable( diff --git a/packages/cli/src/commands/scripts/index.ts b/packages/cli/src/commands/scripts/index.ts index 1811db3..e74f973 100644 --- a/packages/cli/src/commands/scripts/index.ts +++ b/packages/cli/src/commands/scripts/index.ts @@ -10,6 +10,7 @@ import { scriptsInitCommand } from "./init.ts"; import { scriptsLinkCommand } from "./link.ts"; import { scriptsListCommand } from "./list.ts"; import { scriptsShowCommand } from "./show.ts"; +import { scriptsStatsCommand } from "./stats.ts"; export const scriptsNamespace = defineNamespace( "scripts", @@ -26,5 +27,6 @@ export const scriptsNamespace = defineNamespace( scriptsLinkCommand, scriptsListCommand, scriptsShowCommand, + scriptsStatsCommand, ], ); diff --git a/packages/cli/src/commands/scripts/interactive.ts b/packages/cli/src/commands/scripts/interactive.ts new file mode 100644 index 0000000..d57f314 --- /dev/null +++ b/packages/cli/src/commands/scripts/interactive.ts @@ -0,0 +1,96 @@ +import type { createComputeClient } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/compute.d.ts"; +import prompts from "prompts"; +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { loadManifest, saveManifest } from "../../core/manifest.ts"; +import type { OutputFormat } from "../../core/types.ts"; +import { confirm, spinner } from "../../core/ui.ts"; +import { fetchScript, fetchScripts } from "./api.ts"; +import { SCRIPT_MANIFEST } from "./constants.ts"; + +type ComputeClient = ReturnType; +type EdgeScript = components["schemas"]["EdgeScriptModel"]; + +interface ResolveResult { + script: EdgeScript; + /** True only when chosen via the interactive picker, so linking is worth offering. */ + picked: boolean; +} + +/** + * Resolve an Edge Script from an explicit ID, the linked manifest, or an + * interactive picker. The caller decides whether to offer linking afterwards + * (see `maybeLinkScript`) so it can run once the command's own output is shown. + * + * In non-interactive output modes (`--output json`) the picker is skipped and + * a UserError points the caller at `bunny scripts link`. + */ +export async function resolveScriptInteractive( + client: ComputeClient, + id: number | undefined, + opts: { output: OutputFormat }, +): Promise { + const linkedId = id ?? loadManifest(SCRIPT_MANIFEST).id; + if (linkedId) { + const spin = spinner("Fetching Edge Script..."); + spin.start(); + try { + return { script: await fetchScript(client, linkedId), picked: false }; + } finally { + spin.stop(); + } + } + + if (opts.output === "json") { + throw new UserError( + "No script ID provided and no linked script found.", + "Run `bunny scripts link` or pass an ID explicitly.", + ); + } + + const spin = spinner("Fetching Edge Scripts..."); + spin.start(); + let scripts: EdgeScript[]; + try { + scripts = await fetchScripts(client); + } finally { + spin.stop(); + } + + if (scripts.length === 0) { + throw new UserError( + "No Edge Scripts found in your account.", + "Create one with `bunny scripts init`.", + ); + } + + const { selected } = await prompts({ + type: "select", + name: "selected", + message: "Select a script:", + choices: scripts.map((s) => ({ title: `${s.Name} (${s.Id})`, value: s })), + }); + if (!selected) throw new UserError("A script is required."); + + return { script: selected, picked: true }; +} + +/** Offer to link the directory to a picked script: `link` forces the choice, otherwise prompt. */ +export async function maybeLinkScript( + script: EdgeScript, + link: boolean | undefined, +): Promise { + const shouldLink = + link !== undefined + ? link + : await confirm(`Link this directory to ${script.Name}?`); + if (!shouldLink) return; + + saveManifest(SCRIPT_MANIFEST, { + id: script.Id, + name: script.Name ?? undefined, + scriptType: script.ScriptType, + }); + logger.success(`Linked to ${script.Name} (${script.Id}).`); +} diff --git a/packages/cli/src/commands/scripts/stats.ts b/packages/cli/src/commands/scripts/stats.ts new file mode 100644 index 0000000..0bfcf27 --- /dev/null +++ b/packages/cli/src/commands/scripts/stats.ts @@ -0,0 +1,170 @@ +import { createComputeClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { formatKeyValue, formatTable } from "../../core/format.ts"; +import { logger } from "../../core/logger.ts"; +import { formatBucketLabel, renderBarChart } from "../../core/stats.ts"; +import { spinner } from "../../core/ui.ts"; +import { maybeLinkScript, resolveScriptInteractive } from "./interactive.ts"; + +interface StatsArgs { + id?: number; + from?: string; + to?: string; + hourly?: boolean; + link?: boolean; +} + +/** + * Show usage statistics for an Edge Script. + * + * Displays request, CPU, and cost totals over the period, plus a per-bucket + * requests-served bar chart. When no ID is given it falls back to the linked + * script from the local manifest, then to an interactive picker (offering to + * link the directory for next time). + * + * @example + * ```bash + * # Stats for the linked script, or pick one interactively (last 30 days) + * bunny scripts stats + * + * # Stats for a specific script over a date range + * bunny scripts stats 12345 --from 2026-05-01 --to 2026-05-31 + * + * # Hourly grouping, JSON output + * bunny scripts stats 12345 --hourly --output json + * ``` + */ +export const scriptsStatsCommand = defineCommand({ + command: "stats [id]", + describe: "Show usage statistics for an Edge Script.", + examples: [ + ["$0 scripts stats", "Stats for the linked script (last 30 days)"], + [ + "$0 scripts stats 12345 --from 2026-05-01 --to 2026-05-31", + "Stats over a date range", + ], + ["$0 scripts stats 12345 --hourly", "Hourly grouping"], + ], + + builder: (yargs) => + yargs + .positional("id", { + type: "number", + describe: "Edge Script ID (uses linked script if omitted)", + }) + .option("from", { + type: "string", + describe: "Start date (YYYY-MM-DD); defaults to 30 days ago", + }) + .option("to", { + type: "string", + describe: "End date (YYYY-MM-DD); defaults to today", + }) + .option("hourly", { + type: "boolean", + describe: "Group statistics by hour instead of by day", + }) + .option("link", { + type: "boolean", + describe: + "Link the directory to the picked script (use --no-link to skip the prompt)", + }), + + handler: async ({ + id: rawId, + from, + to, + hourly, + link, + profile, + output, + verbose, + apiKey, + }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createComputeClient(clientOptions(config, verbose)); + + const { script, picked } = await resolveScriptInteractive(client, rawId, { + output, + }); + const id = script.Id as number; + + const spin = spinner("Fetching statistics..."); + spin.start(); + + const { data } = await client.GET("/compute/script/{id}/statistics", { + params: { + path: { id }, + query: { dateFrom: from, dateTo: to, hourly }, + }, + }); + + spin.stop(); + + if (output === "json") { + logger.log(JSON.stringify(data ?? {}, null, 2)); + return; + } + + const period = + from || to ? `${from ?? "…"} → ${to ?? "today"}` : "last 30 days"; + logger.log( + formatKeyValue( + [ + { key: "Script", value: script.Name ?? String(id) }, + { key: "Period", value: period }, + { + key: "Total Requests", + value: (data?.TotalRequestsServed ?? 0).toLocaleString(), + }, + { + key: "Total CPU", + value: `${(data?.TotalCpuUsed ?? 0).toLocaleString()}ms`, + }, + { + key: "Avg CPU / Execution", + value: `${(data?.AverageCpuTimePerExecution ?? 0).toFixed(2)}ms`, + }, + { + key: "Total Cost", + value: `$${(data?.TotalMonthlyCost ?? 0).toFixed(2)}`, + }, + ], + output, + ), + ); + + const requests = Object.entries(data?.RequestsServedChart ?? {}).sort( + (a, b) => a[0].localeCompare(b[0]), + ); + if (requests.length > 0) { + logger.log(""); + logger.dim(" Requests served"); + if (output === "text") { + logger.log( + renderBarChart( + requests.map(([bucket, count]) => [ + formatBucketLabel(bucket, hourly), + count, + ]), + ), + ); + } else { + logger.log( + formatTable( + ["Bucket", "Requests"], + requests.map(([bucket, count]) => [bucket, String(count)]), + output, + ), + ); + } + } + + if (picked) { + logger.log(""); + await maybeLinkScript(script, link); + } + }, +}); diff --git a/packages/cli/src/core/stats.test.ts b/packages/cli/src/core/stats.test.ts new file mode 100644 index 0000000..ee3c872 --- /dev/null +++ b/packages/cli/src/core/stats.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { + BAR_WIDTH, + formatBucketLabel, + renderBarChart, + sumChart, +} from "./stats.ts"; + +// --- sumChart --- + +describe("sumChart", () => { + test("sums chart values", () => { + expect(sumChart({ "2026-05-01": 3, "2026-05-02": 7 })).toBe(10); + }); + + test("returns 0 for null/undefined", () => { + expect(sumChart(null)).toBe(0); + expect(sumChart(undefined)).toBe(0); + }); + + test("returns 0 for an empty chart", () => { + expect(sumChart({})).toBe(0); + }); +}); + +// --- formatBucketLabel --- + +describe("formatBucketLabel", () => { + test("formats a daily UTC bucket as a friendly date", () => { + expect(formatBucketLabel("2026-05-19T00:00:00Z")).toBe("May 19, 2026"); + }); + + test("renders in UTC regardless of local timezone (no day shift)", () => { + // Midnight UTC must stay on the same calendar day, not roll back west of UTC. + expect(formatBucketLabel("2026-02-03T00:00:00Z")).toBe("Feb 3, 2026"); + }); + + test("includes the UTC time when hourly", () => { + expect(formatBucketLabel("2026-05-19T14:00:00Z", true)).toBe( + "May 19, 2026 14:00", + ); + }); + + test("returns the raw value when unparseable", () => { + expect(formatBucketLabel("not-a-date")).toBe("not-a-date"); + }); +}); + +// --- renderBarChart --- + +describe("renderBarChart", () => { + test("renders one line per row with label and value", () => { + const lines = renderBarChart([ + ["A", 10], + ["BB", 5], + ]).split("\n"); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain("A"); + expect(lines[0]).toContain("10"); + expect(lines[1]).toContain("BB"); + expect(lines[1]).toContain("5"); + }); + + test("the max value fills the full bar width", () => { + const line = renderBarChart([["X", 100]]); + expect([...line].filter((c) => c === "█")).toHaveLength(BAR_WIDTH); + }); + + test("a zero value renders no filled glyphs", () => { + const line = renderBarChart([ + ["hit", 100], + ["zero", 0], + ]).split("\n")[1]; + expect(line).not.toContain("█"); + }); +}); diff --git a/packages/cli/src/core/stats.ts b/packages/cli/src/core/stats.ts new file mode 100644 index 0000000..d5aef33 --- /dev/null +++ b/packages/cli/src/core/stats.ts @@ -0,0 +1,51 @@ +import chalk from "chalk"; +import { bunny } from "./colors.ts"; + +export const BAR_WIDTH = 24; + +/** Sum the values of a date-keyed chart map. */ +export function sumChart( + chart: { [key: string]: number } | null | undefined, +): number { + return Object.values(chart ?? {}).reduce((acc, n) => acc + n, 0); +} + +/** Format a UTC chart-bucket timestamp as "Feb 3, 2026" (daily) or "Feb 3, 2026 14:00" (hourly); raw value if unparseable. */ +export function formatBucketLabel(bucket: string, hourly = false): string { + const date = new Date(bucket); + if (Number.isNaN(date.getTime())) return bucket; + + const day = date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }); + if (!hourly) return day; + + const time = date.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "UTC", + }); + return `${day} ${time}`; +} + +/** Render a horizontal bar chart from a list of [label, value] pairs. */ +export function renderBarChart(rows: [string, number][]): string { + const max = Math.max(...rows.map(([, n]) => n), 1); + const labelWidth = Math.max(...rows.map(([l]) => l.length)); + const numWidth = Math.max(...rows.map(([, n]) => n.toLocaleString().length)); + return rows + .map(([label, value]) => { + const filled = + value > 0 ? Math.max(1, Math.round((value / max) * BAR_WIDTH)) : 0; + const bar = + bunny("█".repeat(filled)) + chalk.gray("░".repeat(BAR_WIDTH - filled)); + const name = label.padEnd(labelWidth); + const num = value.toLocaleString().padStart(numWidth); + return ` ${name} ${bar} ${num}`; + }) + .join("\n"); +}