Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/fiery-planets-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": minor
---

feat(scripts): sketch Edge Script statistics command
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <string> Profile to use (default: "default")
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,29 @@ bunny scripts show <script-id>
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).
Expand Down
32 changes: 2 additions & 30 deletions packages/cli/src/commands/dns/zone/stats.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<StatsArgs>({
command: "stats [domain]",
describe: "Show DNS query statistics for a zone.",
Expand Down Expand Up @@ -118,7 +90,7 @@ export const dnsStatsCommand = defineCommand<StatsArgs>({
logger.log("");
logger.dim(" Queries by type");
if (output === "text") {
logger.log(renderTypeChart(byType));
logger.log(renderBarChart(byType));
} else {
logger.log(
formatTable(
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/scripts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -26,5 +27,6 @@ export const scriptsNamespace = defineNamespace(
scriptsLinkCommand,
scriptsListCommand,
scriptsShowCommand,
scriptsStatsCommand,
],
);
96 changes: 96 additions & 0 deletions packages/cli/src/commands/scripts/interactive.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createComputeClient>;
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<ResolveResult> {
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<void> {
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}).`);
}
Loading