diff --git a/scripts/README.md b/scripts/README.md index 70f402e2..cef727ce 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -945,6 +945,120 @@ JSON output shape: --- +## Subscriber health dashboard + +### Purpose + +Aggregate per-subscriber health for the entire subscriber set. Output JSON +fields are aligned 1:1 with the on-chain `SubscriptionHealth` struct returned +by `get_subscription_health`: + +| Field | Type | Description | +| --- | --- | --- | +| `active` | `bool` | Subscription is active | +| `charge_due` | `bool` | Billing interval elapsed, charge is due | +| `within_grace` | `bool` | Subscriber is in the grace window | +| `has_sufficient_allowance` | `bool` | Token allowance covers the subscription amount | +| `is_paused` | `bool` | Subscription is paused | +| `trial_active` | `bool` | Trial period is active | +| `daily_limit_set` | `bool` | A daily spending cap has been configured | + +Plus an `address` identity field and ops-specific extensions: `amount`, +`allowance`, `token`, `ttl_remaining`, `expiring_ttl`, `requires_restore`. + +### Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | All subscribers healthy (none paused, no charge due, allowance sufficient) | +| `1` | Any subscriber unhealthy (paused, charge due, no allowance, requires restore) | +| `2` | Hard failure (RPC error, fixture parse error, script crash) | + +### Usage + +```bash +# Live RPC scan +CONTRACT_ID=C... tsx subscriber-health-dashboard.ts + +# Table output +CONTRACT_ID=C... tsx subscriber-health-dashboard.ts --format table + +# Fixture-driven run (no RPC needed) +tsx subscriber-health-dashboard.ts --fixtures data/healthy-subscribers.json +# → exit 0 (all healthy) + +ntsx subscriber-health-dashboard.ts --fixtures data/unhealthy-subscribers.json +# → exit 1 (at least one unhealthy) + +# Write per-subscriber CSV +CONTRACT_ID=C... tsx subscriber-health-dashboard.ts --detail subscribers.csv +``` + +### JSON output shape + +```json +{ + "status": "healthy|unhealthy", + "summary": { + "total": 3, + "total_active": 3, + "total_healthy": 3, + "total_unhealthy": 0, + "paused_count": 0, + "charge_due_count": 0, + "grace_period_active_count": 1, + "no_allowance_count": 0, + "trial_active_count": 1, + "daily_limit_set_count": 0, + "expiring_ttl_count": 0, + "requires_restore_count": 0, + "total_indexed": 3, + "scanned": 3 + }, + "subscribers": [ + { + "address": "GAZ...", + "active": true, + "charge_due": false, + "within_grace": false, + "has_sufficient_allowance": true, + "is_paused": false, + "trial_active": false, + "daily_limit_set": false, + "amount": "10000000", + "allowance": "50000000", + "token": "CBDE...", + "ttl_remaining": 1000000, + "expiring_ttl": false, + "requires_restore": false + } + ] +} +``` + +### Fixture files + +Fixture files in `data/` enable testing without a live RPC connection: + +| File | Description | Expected exit | +| --- | --- | --- | +| `data/healthy-subscribers.json` | All subscribers healthy | `0` | +| `data/unhealthy-subscribers.json` | Mixed health (paused + no allowance + charge due) | `1` | + +### Environment variables + +| Variable | Default | Description | +| --- | --- | --- | +| `CONTRACT_ID` | — | Required (or use `--fixtures`) | +| `RPC_URL` | `https://soroban-testnet.stellar.org` | Soroban RPC endpoint | +| `NETWORK_PASSPHRASE` | `Networks.TESTNET` | Stellar network passphrase | +| `PAGE_SIZE` | `50` | Subscriber page size (max 50) | +| `LEDGER_ENTRY_BATCH` | `100` | getLedgerEntries batch size (max 200) | +| `EXPIRING_TTL_LEDGERS` | `500000` | TTL threshold for expiring entries | +| `PROGRESS` | `1` | Set `0` to suppress progress output | + +--- + ## Environment variable reference All scripts read configuration from environment variables. The full set used diff --git a/scripts/data/healthy-subscribers.json b/scripts/data/healthy-subscribers.json new file mode 100644 index 00000000..14e79a6c --- /dev/null +++ b/scripts/data/healthy-subscribers.json @@ -0,0 +1,53 @@ +{ + "description": "Fixture: all subscribers healthy — expected exit code 0", + "subscribers": [ + { + "address": "GAZXP2EFNQJ3VXCR3QAOH3JZKGN3QHXYDQMAUIYNXFMCMRQD7GQZMBWP", + "active": true, + "charge_due": false, + "within_grace": false, + "has_sufficient_allowance": true, + "is_paused": false, + "trial_active": false, + "daily_limit_set": false, + "amount": "10000000", + "allowance": "50000000", + "token": "CBDEFU5CIXZHY7WVMJW63PEEY3DS7EFQVMPUHKGZC5HWAAQH4X5V6ZZM", + "ttl_remaining": 1000000, + "expiring_ttl": false, + "requires_restore": false + }, + { + "address": "GBZV2CMX5Y3TAXKRG3VKVZEZJFRYX6ZZQRAXYNSY5DJL2QGXABY6ENXN", + "active": true, + "charge_due": false, + "within_grace": true, + "has_sufficient_allowance": true, + "is_paused": false, + "trial_active": false, + "daily_limit_set": false, + "amount": "5000000", + "allowance": "20000000", + "token": "CBDEFU5CIXZHY7WVMJW63PEEY3DS7EFQVMPUHKGZC5HWAAQH4X5V6ZZM", + "ttl_remaining": 1000000, + "expiring_ttl": false, + "requires_restore": false + }, + { + "address": "GCNEPJUCLTDY3FQYAS6KW6OMJGX5ON4IZTTXD26EZSWYBP4I4VM7WKBX", + "active": true, + "charge_due": false, + "within_grace": false, + "has_sufficient_allowance": true, + "is_paused": false, + "trial_active": true, + "daily_limit_set": false, + "amount": "2500000", + "allowance": "10000000", + "token": "CBDEFU5CIXZHY7WVMJW63PEEY3DS7EFQVMPUHKGZC5HWAAQH4X5V6ZZM", + "ttl_remaining": 1000000, + "expiring_ttl": false, + "requires_restore": false + } + ] +} diff --git a/scripts/data/unhealthy-subscribers.json b/scripts/data/unhealthy-subscribers.json new file mode 100644 index 00000000..e5aaa6d6 --- /dev/null +++ b/scripts/data/unhealthy-subscribers.json @@ -0,0 +1,69 @@ +{ + "description": "Fixture: mixed health — at least one unhealthy subscriber (paused + no allowance + charge due) — expected exit code 1", + "subscribers": [ + { + "address": "GAZXP2EFNQJ3VXCR3QAOH3JZKGN3QHXYDQMAUIYNXFMCMRQD7GQZMBWP", + "active": true, + "charge_due": false, + "within_grace": false, + "has_sufficient_allowance": true, + "is_paused": false, + "trial_active": false, + "daily_limit_set": false, + "amount": "10000000", + "allowance": "50000000", + "token": "CBDEFU5CIXZHY7WVMJW63PEEY3DS7EFQVMPUHKGZC5HWAAQH4X5V6ZZM", + "ttl_remaining": 1000000, + "expiring_ttl": false, + "requires_restore": false + }, + { + "address": "GBZV2CMX5Y3TAXKRG3VKVZEZJFRYX6ZZQRAXYNSY5DJL2QGXABY6ENXN", + "active": true, + "charge_due": false, + "within_grace": false, + "has_sufficient_allowance": false, + "is_paused": true, + "trial_active": false, + "daily_limit_set": false, + "amount": "5000000", + "allowance": "0", + "token": "CBDEFU5CIXZHY7WVMJW63PEEY3DS7EFQVMPUHKGZC5HWAAQH4X5V6ZZM", + "ttl_remaining": 1000000, + "expiring_ttl": false, + "requires_restore": false + }, + { + "address": "GCNEPJUCLTDY3FQYAS6KW6OMJGX5ON4IZTTXD26EZSWYBP4I4VM7WKBX", + "active": true, + "charge_due": true, + "within_grace": true, + "has_sufficient_allowance": true, + "is_paused": false, + "trial_active": false, + "daily_limit_set": true, + "amount": "2500000", + "allowance": "2500000", + "token": "CBDEFU5CIXZHY7WVMJW63PEEY3DS7EFQVMPUHKGZC5HWAAQH4X5V6ZZM", + "ttl_remaining": 50000, + "expiring_ttl": true, + "requires_restore": false + }, + { + "address": "GDQOIHCIB5TQYHLIPVHO6HLJUMRSNKRNPE3I52GWY5BGZQXQS6Z4WHS3", + "active": false, + "charge_due": false, + "within_grace": false, + "has_sufficient_allowance": false, + "is_paused": false, + "trial_active": false, + "daily_limit_set": false, + "amount": "10000000", + "allowance": "0", + "token": "CBDEFU5CIXZHY7WVMJW63PEEY3DS7EFQVMPUHKGZC5HWAAQH4X5V6ZZM", + "ttl_remaining": null, + "expiring_ttl": false, + "requires_restore": true + } + ] +} diff --git a/scripts/subscriber-health-dashboard.ts b/scripts/subscriber-health-dashboard.ts index 0ebbb716..4a54194b 100644 --- a/scripts/subscriber-health-dashboard.ts +++ b/scripts/subscriber-health-dashboard.ts @@ -6,10 +6,26 @@ * RPC batching limits), and produces a health summary covering low allowances, * grace windows, pauses, trials, and expiring TTLs. * + * Output JSON fields are aligned 1:1 with the on-chain SubscriptionHealth + * struct returned by `get_subscription_health`: + * + * active, charge_due, within_grace, has_sufficient_allowance, + * is_paused, trial_active, daily_limit_set + * + * Plus an `address` identity field and ops-specific extensions + * (ttl_remaining, expiring_ttl, requires_restore). + * + * Exit codes: + * 0 — all subscribers healthy (no unhealthy in aggregate) + * 1 — any subscriber unhealthy + * 2 — hard failure (RPC error, fixture parse error, script crash) + * * Usage: * CONTRACT_ID=C... npx tsx scripts/subscriber-health-dashboard.ts * CONTRACT_ID=C... npx tsx scripts/subscriber-health-dashboard.ts --format table * CONTRACT_ID=C... npx tsx scripts/subscriber-health-dashboard.ts --detail detail.csv + * CONTRACT_ID=C... npx tsx scripts/subscriber-health-dashboard.ts --fixtures data/healthy.json + * CONTRACT_ID=C... npx tsx scripts/subscriber-health-dashboard.ts --fixtures data/unhealthy.json * * Environment: * CONTRACT_ID, RPC_URL, NETWORK_PASSPHRASE @@ -22,7 +38,7 @@ * Expiring TTL = remaining liveUntil ledgers < EXPIRING_TTL_LEDGERS */ -import { writeFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { Contract, Networks, @@ -64,33 +80,65 @@ const EXPIRING_TTL_LEDGERS = const SHOW_PROGRESS = process.env.PROGRESS !== "0"; const SIM_SOURCE = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; +// ── Exit Codes ─────────────────────────────────────────────────────────────── + +const EXIT_HEALTHY = 0; +const EXIT_UNHEALTHY = 1; +const EXIT_HARD_FAILURE = 2; + // ── Types ──────────────────────────────────────────────────────────────────── +/** + * Per-subscriber health output. + * Fields are aligned with the on-chain SubscriptionHealth struct. + * Extra ops-specific fields are appended at the end. + */ +export interface SubscriberHealth { + // Identity + address: string; + + // On-chain SubscriptionHealth fields (1:1 alignment) + active: boolean; + charge_due: boolean; + within_grace: boolean; + has_sufficient_allowance: boolean; + is_paused: boolean; + trial_active: boolean; + daily_limit_set: boolean; + + // Ops extensions (not part of SubscriptionHealth, but useful for operators) + amount: string; + allowance: string; + token: string; + ttl_remaining: number | null; + expiring_ttl: boolean; + requires_restore: boolean; +} + +/** Aggregate summary for the full subscriber set. */ export interface HealthSummary { + total: number; total_active: number; - low_allowance_count: number; - grace_period_active_count: number; + total_healthy: number; + total_unhealthy: number; paused_count: number; + charge_due_count: number; + grace_period_active_count: number; + no_allowance_count: number; trial_active_count: number; + daily_limit_set_count: number; expiring_ttl_count: number; requires_restore_count: number; total_indexed: number; scanned: number; } -interface SubscriberDetail { - address: string; - active: boolean; - paused: boolean; - amount: string; - allowance: string; - low_allowance: boolean; - within_grace: boolean; - trial_active: boolean; - ttl_remaining: number | null; - expiring_ttl: boolean; - requires_restore: boolean; - token: string; +/** Top-level JSON output shape. */ +export interface DashboardOutput { + status: "healthy" | "unhealthy"; + summary: HealthSummary; + subscribers: SubscriberHealth[]; + fixture_source?: string; } // ── CLI ────────────────────────────────────────────────────────────────────── @@ -107,8 +155,14 @@ Usage: tsx scripts/subscriber-health-dashboard.ts [options] Options: --format json|table Output format (default: json) --detail Write per-subscriber detail CSV + --fixtures Load subscribers from a JSON fixture file instead of RPC --help, -h Show help +Exit Codes: + 0 All subscribers healthy + 1 Any subscriber unhealthy (paused, no allowance, charge due, etc.) + 2 Hard failure (RPC error, fixture parse error, etc.) + Environment: CONTRACT_ID, RPC_URL, NETWORK_PASSPHRASE, PAGE_SIZE, LEDGER_ENTRY_BATCH, EXPIRING_TTL_LEDGERS, PROGRESS @@ -210,7 +264,6 @@ async function fetchLedgerEntries( const resp = await server.getLedgerEntries(...keys); const entries = resp.entries ?? []; for (const entry of entries) { - // Match by decoding the key's address try { const lk = entry.key; const cd = lk.contractData(); @@ -236,17 +289,13 @@ async function fetchLedgerEntries( } } catch (err) { const msg = err instanceof Error ? err.message : String(err); - // If the whole batch fails, fall back to per-key and mark archived on errors. for (const user of slice) { try { const resp = await server.getLedgerEntries( subscriptionLedgerKey(user), ); const entry = resp.entries?.[0]; - if (!entry) { - // Missing may mean archived or never written — probe via simulation later. - continue; - } + if (!entry) continue; out.set(user, { found: true, archived: false, @@ -271,8 +320,6 @@ async function fetchLedgerEntries( } } - // Anything still missing after a successful batch: treat as potentially archived - // if we can confirm via get_subscription failure patterns later. void latestLedger; } @@ -337,21 +384,30 @@ async function getAllowance(owner: string, tokenId: string): Promise { } } +/** + * Call get_subscription_health and extract all 7 SubscriptionHealth fields. + */ async function getSubscriptionHealth(user: string): Promise<{ + active: boolean; + charge_due: boolean; within_grace: boolean; - trial_active: boolean; + has_sufficient_allowance: boolean; is_paused: boolean; - active: boolean; + trial_active: boolean; + daily_limit_set: boolean; } | null> { try { const retval = await simulate("get_subscription_health", addressVal(user)); if (!retval) return null; const native = scValToNative(retval) as Record; return { + active: Boolean(native.active), + charge_due: Boolean(native.charge_due), within_grace: Boolean(native.within_grace), - trial_active: Boolean(native.trial_active), + has_sufficient_allowance: Boolean(native.has_sufficient_allowance), is_paused: Boolean(native.is_paused), - active: Boolean(native.active), + trial_active: Boolean(native.trial_active), + daily_limit_set: Boolean(native.daily_limit_set), }; } catch { return null; @@ -372,7 +428,7 @@ function finishProgress(): void { export async function collectHealth(): Promise<{ summary: HealthSummary; - details: SubscriberDetail[]; + details: SubscriberHealth[]; }> { if (!CONTRACT_ID) throw new Error("CONTRACT_ID is required"); @@ -387,7 +443,7 @@ export async function collectHealth(): Promise<{ } const now = Math.floor(Date.now() / 1000); - const details: SubscriberDetail[] = []; + const details: SubscriberHealth[] = []; let scanned = 0; for (let offset = 0; offset < indexSize; offset += PAGE_SIZE) { @@ -413,7 +469,6 @@ export async function collectHealth(): Promise<{ let requiresRestore = meta.archived; if (!sub && !requiresRestore) { - // Fallback to simulation if ledger key encoding didn't match. try { const retval = await simulate( "get_subscription", @@ -432,33 +487,50 @@ export async function collectHealth(): Promise<{ details.push({ address, active: false, - paused: false, - amount: "0", - allowance: "0", - low_allowance: false, + charge_due: false, within_grace: false, + has_sufficient_allowance: false, + is_paused: false, trial_active: false, + daily_limit_set: false, + amount: "0", + allowance: "0", + token: "", ttl_remaining: null, expiring_ttl: false, requires_restore: requiresRestore, - token: "", }); continue; } const health = await getSubscriptionHealth(address); + const allowance = sub.active ? await getAllowance(address, sub.token) : 0n; const allowance = sub.active ? await getAllowance(address, sub.token) : 0n; const lowAllowance = sub.active && !sub.paused && allowance < sub.amount * 2n; + // Determine fields from on-chain health where possible, fall back to computation let withinGrace = health?.within_grace ?? false; if (!health && gracePeriod > 0 && sub.active && !sub.paused) { const next = sub.last_charged + sub.interval; withinGrace = now >= next && now <= next + gracePeriod; } + const trialActive = health?.trial_active ?? (sub.last_charged > now); + + // For has_sufficient_allowance: prefer on-chain health, fall back to local calculation + let hasSufficientAllowance = health?.has_sufficient_allowance ?? false; + if (health === null && sub.active) { + hasSufficientAllowance = allowance >= sub.amount; + } + + // For charge_due: prefer on-chain health, fall back to interval check + let chargeDue = health?.charge_due ?? false; + if (health === null && sub.active && !sub.paused) { + chargeDue = now >= sub.last_charged + sub.interval; + } const trialActive = health?.trial_active ?? sub.last_charged > now; const ttlRemaining = @@ -471,28 +543,39 @@ export async function collectHealth(): Promise<{ details.push({ address, active: sub.active, - paused: health?.is_paused ?? sub.paused, - amount: sub.amount.toString(), - allowance: allowance.toString(), - low_allowance: lowAllowance, + charge_due: chargeDue, within_grace: withinGrace, + has_sufficient_allowance: hasSufficientAllowance, + is_paused: health?.is_paused ?? sub.paused, trial_active: trialActive, + daily_limit_set: health?.daily_limit_set ?? false, + amount: sub.amount.toString(), + allowance: allowance.toString(), + token: sub.token, ttl_remaining: ttlRemaining, expiring_ttl: expiringTtl, requires_restore: requiresRestore, - token: sub.token, }); } } finishProgress(); + const unhealthy = details.filter( + (d) => d.is_paused || d.charge_due || !d.has_sufficient_allowance || d.requires_restore + ); + const summary: HealthSummary = { + total: details.length, total_active: details.filter((d) => d.active).length, - low_allowance_count: details.filter((d) => d.low_allowance).length, + total_healthy: details.length - unhealthy.length, + total_unhealthy: unhealthy.length, + paused_count: details.filter((d) => d.is_paused).length, + charge_due_count: details.filter((d) => d.charge_due).length, grace_period_active_count: details.filter((d) => d.within_grace).length, - paused_count: details.filter((d) => d.paused).length, + no_allowance_count: details.filter((d) => d.active && !d.has_sufficient_allowance).length, trial_active_count: details.filter((d) => d.trial_active).length, + daily_limit_set_count: details.filter((d) => d.daily_limit_set).length, expiring_ttl_count: details.filter((d) => d.expiring_ttl).length, requires_restore_count: details.filter((d) => d.requires_restore).length, total_indexed: indexSize, @@ -502,41 +585,155 @@ export async function collectHealth(): Promise<{ return { summary, details }; } -function printTable(summary: HealthSummary): void { +// ── Fixture loader ─────────────────────────────────────────────────────────── + +/** + * Load subscriber health data from a JSON fixture file. + * Fixture format: + * { + * "subscribers": [ + * { "address": "G...", "active": true, "charge_due": false, ... }, + * ... + * ] + * } + * Or a flat array of subscriber health objects. + */ +function loadFixtures(fixturePath: string): { summary: HealthSummary; details: SubscriberHealth[] } { + const raw = readFileSync(fixturePath, "utf-8"); + let entries: SubscriberHealth[]; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + entries = parsed; + } else if (parsed.subscribers && Array.isArray(parsed.subscribers)) { + entries = parsed.subscribers; + } else { + throw new Error("Fixture must be a JSON array or { \"subscribers\": [...] }"); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`Failed to parse fixture file: ${msg}`); + process.exit(EXIT_HARD_FAILURE); + } + + // Ensure all required fields with defaults + const details: SubscriberHealth[] = entries.map((e) => ({ + address: e.address ?? "", + active: e.active ?? false, + charge_due: e.charge_due ?? false, + within_grace: e.within_grace ?? false, + has_sufficient_allowance: e.has_sufficient_allowance ?? true, + is_paused: e.is_paused ?? false, + trial_active: e.trial_active ?? false, + daily_limit_set: e.daily_limit_set ?? false, + amount: e.amount ?? "0", + allowance: e.allowance ?? "0", + token: e.token ?? "", + ttl_remaining: e.ttl_remaining ?? null, + expiring_ttl: e.expiring_ttl ?? false, + requires_restore: e.requires_restore ?? false, + })); + + const unhealthy = details.filter( + (d) => d.is_paused || d.charge_due || !d.has_sufficient_allowance || d.requires_restore + ); + + const summary: HealthSummary = { + total: details.length, + total_active: details.filter((d) => d.active).length, + total_healthy: details.length - unhealthy.length, + total_unhealthy: unhealthy.length, + paused_count: details.filter((d) => d.is_paused).length, + charge_due_count: details.filter((d) => d.charge_due).length, + grace_period_active_count: details.filter((d) => d.within_grace).length, + no_allowance_count: details.filter((d) => d.active && !d.has_sufficient_allowance).length, + trial_active_count: details.filter((d) => d.trial_active).length, + daily_limit_set_count: details.filter((d) => d.daily_limit_set).length, + expiring_ttl_count: details.filter((d) => d.expiring_ttl).length, + requires_restore_count: details.filter((d) => d.requires_restore).length, + total_indexed: details.length, + scanned: details.length, + }; + + return { summary, details }; +} + +// ── Formatters ─────────────────────────────────────────────────────────────── + +function printTable(summary: HealthSummary, details: SubscriberHealth[]): void { + console.log(""); + console.log("── Aggregate Summary ──────────────────────────────────"); + console.log(""); const rows: [string, number][] = [ + ["total", summary.total], ["total_active", summary.total_active], - ["low_allowance_count", summary.low_allowance_count], - ["grace_period_active_count", summary.grace_period_active_count], + ["total_healthy", summary.total_healthy], + ["total_unhealthy", summary.total_unhealthy], ["paused_count", summary.paused_count], + ["charge_due_count", summary.charge_due_count], + ["grace_period_active_count", summary.grace_period_active_count], + ["no_allowance_count", summary.no_allowance_count], ["trial_active_count", summary.trial_active_count], + ["daily_limit_set_count", summary.daily_limit_set_count], ["expiring_ttl_count", summary.expiring_ttl_count], ["requires_restore_count", summary.requires_restore_count], ["total_indexed", summary.total_indexed], ["scanned", summary.scanned], ]; - console.log(""); console.log("Metric".padEnd(32) + "Value".padStart(12)); console.log("-".repeat(44)); for (const [k, v] of rows) { console.log(k.padEnd(32) + String(v).padStart(12)); } console.log(""); + console.log("── Per-Subscriber SubscriptionHealth ──────────────────"); + console.log(""); + const header = [ + "address".padEnd(50), + "active".padEnd(8), + "is_paused".padEnd(10), + "charge_due".padEnd(11), + "within_grace".padEnd(13), + "has_allowance".padEnd(14), + "trial".padEnd(8), + "daily_lim".padEnd(10), + ].join(" "); + console.log(header); + console.log("-".repeat(header.length)); + for (const d of details) { + const addr = d.address.length > 50 ? d.address.slice(0, 47) + "..." : d.address; + console.log( + [ + addr.padEnd(50), + (d.active ? "yes" : "no").padEnd(8), + (d.is_paused ? "yes" : "no").padEnd(10), + (d.charge_due ? "yes" : "no").padEnd(11), + (d.within_grace ? "yes" : "no").padEnd(13), + (d.has_sufficient_allowance ? "yes" : "no").padEnd(14), + (d.trial_active ? "yes" : "no").padEnd(8), + (d.daily_limit_set ? "yes" : "no").padEnd(10), + ].join(" ") + ); + } + console.log(""); } -function toCsv(details: SubscriberDetail[]): string { +function toCsv(details: SubscriberHealth[]): string { const header = [ "address", "active", - "paused", - "amount", - "allowance", - "low_allowance", + "is_paused", + "charge_due", "within_grace", + "has_sufficient_allowance", "trial_active", + "daily_limit_set", + "amount", + "allowance", + "token", "ttl_remaining", "expiring_ttl", "requires_restore", - "token", ]; const lines = [header.join(",")]; for (const d of details) { @@ -544,15 +741,19 @@ function toCsv(details: SubscriberDetail[]): string { [ d.address, d.active, - d.paused, - d.amount, - d.allowance, - d.low_allowance, + d.is_paused, + d.charge_due, d.within_grace, + d.has_sufficient_allowance, d.trial_active, + d.daily_limit_set, + d.amount, + d.allowance, + d.token, d.ttl_remaining ?? "", d.expiring_ttl, d.requires_restore, + ].join(",") d.token, ].join(","), ); @@ -566,17 +767,48 @@ async function main(): Promise { if (process.argv.includes("--help") || process.argv.includes("-h")) { showHelp(); } - if (!CONTRACT_ID) { - console.error("Error: CONTRACT_ID environment variable is required."); - showHelp(); + + const fixturesPath = getArg("--fixtures"); + + if (!CONTRACT_ID && !fixturesPath) { + console.error("Error: CONTRACT_ID environment variable is required (or use --fixtures)."); + process.exit(EXIT_HARD_FAILURE); } const format = (getArg("--format") ?? "json").toLowerCase(); const detailPath = getArg("--detail"); const started = Date.now(); - const { summary, details } = await collectHealth(); + + let result: { summary: HealthSummary; details: SubscriberHealth[] }; + let fixtureSource: string | undefined; + + if (fixturesPath) { + try { + result = loadFixtures(fixturesPath); + fixtureSource = fixturesPath; + } catch (err) { + console.error(`Failed to load fixtures: ${err instanceof Error ? err.message : err}`); + process.exit(EXIT_HARD_FAILURE); + } + } else { + try { + result = await collectHealth(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (JSON_OUTPUT) { + process.stdout.write( + JSON.stringify({ status: "error", error: msg }, null, 2) + "\n" + ); + } else { + console.error(`Fatal error: ${msg}`); + } + process.exit(EXIT_HARD_FAILURE); + } + } + const elapsedMs = Date.now() - started; + const { summary, details } = result; if (SHOW_PROGRESS) { console.error( @@ -584,29 +816,38 @@ async function main(): Promise { ); } - // Public acceptance shape (extra fields allowed for ops) - const publicSummary = { - total_active: summary.total_active, - low_allowance_count: summary.low_allowance_count, - grace_period_active_count: summary.grace_period_active_count, - paused_count: summary.paused_count, - trial_active_count: summary.trial_active_count, - expiring_ttl_count: summary.expiring_ttl_count, + // Determine aggregate status + const isHealthy = summary.total_unhealthy === 0; + + const output: DashboardOutput = { + status: isHealthy ? "healthy" : "unhealthy", + summary, + subscribers: details, }; + if (fixtureSource) { + output.fixture_source = fixtureSource; + } + if (format === "table") { - printTable(summary); + printTable(summary, details); } else { - console.log(JSON.stringify(publicSummary, null, 2)); + console.log(JSON.stringify(output, null, 2)); } if (detailPath) { writeFileSync(detailPath, toCsv(details), "utf8"); console.error(`Wrote detail CSV to ${detailPath} (${details.length} rows)`); } + + // Exit code: 0 = all healthy, 1 = any unhealthy + process.exit(isHealthy ? EXIT_HEALTHY : EXIT_UNHEALTHY); } +// JSON_OUTPUT flag for error path +const JSON_OUTPUT = !(getArg("--format") ?? "json").match(/^table$/i); + main().catch((err) => { console.error(`Fatal error: ${err instanceof Error ? err.message : err}`); - process.exit(1); + process.exit(EXIT_HARD_FAILURE); });