+59
-0
@@ -123,2 +123,61 @@ import { ApiClient, ApiError, requireProject } from '../api.js'; | ||
| // next release, so depending on them here would be a scheduled breakage. | ||
| // Bytes → human units, one decimal above KiB. Local because the metrics payload is the only | ||
| // bytes-denominated read in this file (fmtMib serves the MiB-denominated resize path). | ||
| export function fmtBytes(n) { | ||
| if (n < 1024) | ||
| return `${n} B`; | ||
| const units = ['KiB', 'MiB', 'GiB', 'TiB']; | ||
| let v = n / 1024; | ||
| let i = 0; | ||
| while (v >= 1024 && i < units.length - 1) { | ||
| v /= 1024; | ||
| i++; | ||
| } | ||
| return `${v.toFixed(1)} ${units[i]}`; | ||
| } | ||
| // Human-readable stats lines from GET /database/metrics. Pure seam for tests. "—" for anything | ||
| // unmeasured (old platform, suspended instance, no cache traffic yet) — never a fake 0: the | ||
| // platform omits cacheHitRatio and sends max 0 in exactly those cases. | ||
| export function dbStatsLines(group, body) { | ||
| const c = body?.connections ?? {}; | ||
| const max = typeof c.max === 'number' && c.max > 0 ? c.max : null; | ||
| const total = typeof c.total === 'number' ? c.total : null; | ||
| const conn = total === null ? '—' | ||
| : (max === null ? String(total) : `${total} / ${max}`) | ||
| + (typeof c.active === 'number' && max !== null ? ` (${c.active} active)` : ''); | ||
| const ratio = body?.cacheHitRatio; | ||
| const cache = typeof ratio === 'number' ? `${(ratio * 100).toFixed(1)}%` : '—'; | ||
| const size = typeof body?.dbSizeBytes === 'number' ? fmtBytes(body.dbSizeBytes) : '—'; | ||
| const bits = [ | ||
| typeof body?.state === 'string' ? body.state : null, | ||
| typeof body?.serverVersion === 'string' && body.serverVersion ? `PG ${body.serverVersion}` : null, | ||
| ].filter(Boolean); | ||
| const state = bits.length ? ` (${bits.join(' · ')})` : ''; | ||
| return [ | ||
| `postgres ${group}${state}`, | ||
| ` connections ${conn}`, | ||
| ` cache hit ${cache}`, | ||
| ` size ${size}`, | ||
| ]; | ||
| } | ||
| // Point-in-time stats snapshot for a postgres service: connections vs the server's ceiling, cache | ||
| // hit rate, database size. Read-only. insta-db-backed: a suspended instance answers from the | ||
| // provider's control plane (shown as "(suspended)" with structural zeros), never dialed. | ||
| // Neon-backed: the platform reads over a direct SQL connection, so a one-shot call may wake a | ||
| // suspended endpoint — acceptable for an explicit command, which is why nothing here polls. | ||
| export async function dbStats(opts) { | ||
| const api = await ApiClient.load(); | ||
| const p = await requireProject(); | ||
| const qs = new URLSearchParams(); | ||
| const branch = opts.branch ?? p.branch; | ||
| if (branch) | ||
| qs.set('branch', branch); | ||
| if (opts.group) | ||
| qs.set('group', opts.group); | ||
| const res = await api.rawRequest('GET', `/projects/${p.projectId}/database/metrics${qs.toString() ? `?${qs}` : ''}`); | ||
| if (opts.json) | ||
| return printJson(res.body); | ||
| for (const line of dbStatsLines(opts.group ?? 'default', res.body)) | ||
| info(line); | ||
| } | ||
| export function dbVolumeLines(group, body) { | ||
@@ -125,0 +184,0 @@ const gib = typeof body?.volumeGib === 'number' ? `${body.volumeGib}Gi` : (typeof body?.volumeSize === 'string' ? body.volumeSize : undefined); |
@@ -11,2 +11,54 @@ import { ApiClient, requireProject } from '../api.js'; | ||
| } | ||
| // One printed series line. Pure seam so the formatting is testable without a backend. | ||
| // | ||
| // Byte-rate series (compute's egress/ingress) arrive as raw bytes per second, which is unreadable at | ||
| // real traffic volumes — 20480031 bytes/s is 20 MB/s. Percent and vCPU units are already | ||
| // human-sized, so only bytes and byte rates get scaled. | ||
| export function metricLine(s) { | ||
| const last = s.points?.[s.points.length - 1]; | ||
| const value = last ? formatMetricValue(last[1], s.unit) : 'n/a'; | ||
| const unit = s.unit && !isScaled(s.unit) ? ` (${s.unit})` : ''; | ||
| return `${s.name}${unit}: ${value} [${s.points?.length ?? 0} points]`; | ||
| } | ||
| // The platform's unit strings are the contract here (`bytes` for memory/storage, `bytes/s` for | ||
| // egress/ingress — src/adapters/fly.ts and insta-db.ts). Matching them loosely is deliberate: an | ||
| // unrecognised unit fails SILENTLY back to the raw 8-digit number this scaling exists to fix, so a | ||
| // casing or spacing change on the platform side must not be enough to regress it. | ||
| function scaleOf(unit) { | ||
| const u = unit?.trim().toLowerCase(); | ||
| if (u === 'bytes' || u === 'byte' || u === 'b') | ||
| return 'bytes'; | ||
| if (u === 'bytes/s' || u === 'byte/s' || u === 'b/s' || u === 'bytes/sec') | ||
| return 'bytes/s'; | ||
| return undefined; | ||
| } | ||
| function isScaled(unit) { | ||
| return scaleOf(unit) !== undefined; | ||
| } | ||
| function formatMetricValue(v, unit) { | ||
| if (!Number.isFinite(v)) | ||
| return 'n/a'; | ||
| const scale = scaleOf(unit); | ||
| // Traffic scales by 1000 because egress is BILLED per decimal GB (`bytes / 1e9`, platform | ||
| // src/adapters/fly.ts) — a 1024-based "GB/s" would sit ~7% off the invoice. Memory and storage | ||
| // stay binary: those ceilings are provisioned in GiB. Same split as the console. | ||
| if (scale === 'bytes') | ||
| return humanBytes(v, 1024); | ||
| if (scale === 'bytes/s') | ||
| return `${humanBytes(v, 1000)}/s`; | ||
| return String(v); | ||
| } | ||
| function humanBytes(v, base) { | ||
| const units = ['B', 'KB', 'MB', 'GB', 'TB']; | ||
| let value = v; | ||
| let i = 0; | ||
| while (Math.abs(value) >= base && i < units.length - 1) { | ||
| value /= base; | ||
| i += 1; | ||
| } | ||
| // Sub-KB values keep their integer form (`512 B`, not `512.0 B`), but a RATE is fractional — | ||
| // PromQL rate() of a byte counter yields things like 342.857142, which must not print in full. | ||
| const shown = i === 0 && Number.isInteger(value) ? String(value) : value.toFixed(1); | ||
| return `${shown} ${units[i]}`; | ||
| } | ||
| // insta metrics <db|compute> [group] | ||
@@ -23,6 +75,4 @@ export async function metrics(component, group, opts) { | ||
| return info('(no series)'); | ||
| for (const s of res.series) { | ||
| const last = s.points?.[s.points.length - 1]; | ||
| info(`${s.name}${s.unit ? ` (${s.unit})` : ''}: ${last ? last[1] : 'n/a'} [${s.points?.length ?? 0} points]`); | ||
| } | ||
| for (const s of res.series) | ||
| info(metricLine(s)); | ||
| } | ||
@@ -29,0 +79,0 @@ // Customer-facing name for each internal billing dimension (the platform stores RAM as `ram`). |
+3
-0
@@ -183,2 +183,5 @@ #!/usr/bin/env node | ||
| .action(guard((o) => dbCmd.dbLimits(o))); | ||
| db.command('stats').description("Postgres stats snapshot: connections vs the server's max (active count), cache hit rate, database size. insta-db-backed services answer without waking a suspended instance") | ||
| .option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)') | ||
| .action(guard((o) => dbCmd.dbStats(o))); | ||
| db.command('always-on <mode>').description('Set a postgres service always-on (mode: on|off). on = instance stays warm, no cold starts; off = default scale-to-zero (idle instance suspends; first connection cold-starts). insta-db-backed services only') | ||
@@ -185,0 +188,0 @@ .option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)') |
+1
-1
| { | ||
| "name": "insta", | ||
| "version": "0.0.28", | ||
| "version": "0.0.29", | ||
| "type": "module", | ||
@@ -5,0 +5,0 @@ "description": "InstaCloud CLI — a thin client of the platform control-plane API.", |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
203741
2.81%3473
3.33%