|
1 | | -import {Command} from '@oclif/core' |
| 1 | +import { Flags } from '@oclif/core'; |
| 2 | +import { routes } from '@powersync/management-types'; |
| 3 | +import { Document } from 'yaml'; |
2 | 4 |
|
3 | | -export default class FetchStatus extends Command { |
| 5 | +import { CloudInstanceCommand } from '../../command-types/CloudInstanceCommand.js'; |
| 6 | + |
| 7 | +type DiagnosticsResponse = routes.InstanceDiagnosticsResponse; |
| 8 | +type SyncRulesSection = NonNullable<DiagnosticsResponse['active_sync_rules']>; |
| 9 | + |
| 10 | +const INDENT = ' '; |
| 11 | +const BULLET = '•'; |
| 12 | + |
| 13 | +function pad(level: number): string { |
| 14 | + return INDENT.repeat(level); |
| 15 | +} |
| 16 | + |
| 17 | +/** Format a date value as "ISO (local)" for human output. Returns raw value if not parseable. */ |
| 18 | +function formatDate(value: string | number | undefined | null): string { |
| 19 | + if (value === undefined || value === null) return '—'; |
| 20 | + const date = new Date(value); |
| 21 | + if (Number.isNaN(date.getTime())) return String(value); |
| 22 | + return `${date.toISOString()} (${date.toLocaleString()})`; |
| 23 | +} |
| 24 | + |
| 25 | +function formatErrors(errors: Array<{ level: string; message: string; ts?: string }>, indentLevel: number): string { |
| 26 | + if (!errors?.length) return ''; |
| 27 | + const p = pad(indentLevel); |
| 28 | + return errors.map((e) => `${p}${BULLET} [${e.level}] ${e.message}${e.ts ? ` (${e.ts})` : ''}`).join('\n'); |
| 29 | +} |
| 30 | + |
| 31 | +function formatConnectionsSection(connections: DiagnosticsResponse['connections'], indentLevel: number): string { |
| 32 | + if (!connections?.length) return `${pad(indentLevel)}(no connections)\n`; |
| 33 | + const p = pad(indentLevel); |
| 34 | + const lines: string[] = []; |
| 35 | + for (const conn of connections) { |
| 36 | + const status = conn.connected ? 'connected' : 'disconnected'; |
| 37 | + lines.push(`${p}${BULLET} ${conn.id}`); |
| 38 | + lines.push(`${p} Postgres URI: ${conn.postgres_uri ?? '—'}`); |
| 39 | + lines.push(`${p} Status: ${status}`); |
| 40 | + if (conn.errors?.length) { |
| 41 | + lines.push(`${p} Errors:`); |
| 42 | + lines.push(formatErrors(conn.errors, indentLevel + 2)); |
| 43 | + } |
| 44 | + lines.push(''); |
| 45 | + } |
| 46 | + return lines.join('\n').trimEnd() + '\n'; |
| 47 | +} |
| 48 | + |
| 49 | +function formatSyncRulesSection(section: SyncRulesSection, indentLevel: number): string { |
| 50 | + const p = pad(indentLevel); |
| 51 | + const lines: string[] = []; |
| 52 | + |
| 53 | + if (section.errors?.length) { |
| 54 | + lines.push(`${p}Errors:`); |
| 55 | + lines.push(formatErrors(section.errors, indentLevel + 1)); |
| 56 | + lines.push(''); |
| 57 | + } |
| 58 | + |
| 59 | + if (section.connections?.length) { |
| 60 | + lines.push(`${p}Connections:`); |
| 61 | + for (const conn of section.connections) { |
| 62 | + lines.push(`${p} ${BULLET} ${conn.tag ?? conn.id} (slot: ${conn.slot_name ?? '—'})`); |
| 63 | + lines.push(`${p} Initial replication done: ${conn.initial_replication_done}`); |
| 64 | + if (conn.last_lsn != null) lines.push(`${p} Last LSN: ${conn.last_lsn}`); |
| 65 | + if (conn.last_keepalive_ts != null) lines.push(`${p} Last keepalive: ${formatDate(conn.last_keepalive_ts)}`); |
| 66 | + if (conn.last_checkpoint_ts != null) |
| 67 | + lines.push(`${p} Last checkpoint: ${formatDate(conn.last_checkpoint_ts)}`); |
| 68 | + if (conn.replication_lag_bytes != null) |
| 69 | + lines.push(`${p} Replication lag: ${conn.replication_lag_bytes} bytes`); |
| 70 | + if (conn.tables?.length) { |
| 71 | + lines.push(`${p} Tables:`); |
| 72 | + for (const table of conn.tables) { |
| 73 | + const name = `${table.schema}.${table.name}`; |
| 74 | + const repl = table.replication_id?.length ? table.replication_id.join(', ') : '—'; |
| 75 | + lines.push(`${p} - ${name} (replication_id: ${repl})`); |
| 76 | + if (table.data_queries != null) lines.push(`${p} data_queries: ${table.data_queries}`); |
| 77 | + if (table.parameter_queries != null) lines.push(`${p} parameter_queries: ${table.parameter_queries}`); |
| 78 | + if (table.errors?.length) lines.push(formatErrors(table.errors, indentLevel + 3)); |
| 79 | + } |
| 80 | + } |
| 81 | + } |
| 82 | + lines.push(''); |
| 83 | + } |
| 84 | + |
| 85 | + if (section.content != null && section.content !== '') { |
| 86 | + lines.push(`${p}Content:`); |
| 87 | + lines.push(`${p} ${section.content.split('\n').join(`\n${p} `)}`); |
| 88 | + } |
| 89 | + |
| 90 | + return lines.join('\n').trimEnd() || `${p}(no data)\n`; |
| 91 | +} |
| 92 | + |
| 93 | +function formatDiagnosticsHuman(diagnostics: DiagnosticsResponse): string { |
| 94 | + const sections: string[] = []; |
| 95 | + |
| 96 | + sections.push('═══ Connections ═══'); |
| 97 | + sections.push(formatConnectionsSection(diagnostics.connections ?? [], 0)); |
| 98 | + |
| 99 | + if (diagnostics.active_sync_rules != null) { |
| 100 | + sections.push('═══ Active Sync Rules ═══'); |
| 101 | + sections.push(formatSyncRulesSection(diagnostics.active_sync_rules, 0)); |
| 102 | + } |
| 103 | + |
| 104 | + if (diagnostics.deploying_sync_rules != null) { |
| 105 | + sections.push('═══ Deploying Sync Rules ═══'); |
| 106 | + sections.push(formatSyncRulesSection(diagnostics.deploying_sync_rules, 0)); |
| 107 | + } |
| 108 | + |
| 109 | + return sections.join('\n').trimEnd(); |
| 110 | +} |
| 111 | + |
| 112 | +// TODO self hosted support |
| 113 | +export default class FetchStatus extends CloudInstanceCommand { |
4 | 114 | static description = |
5 | | - 'Fetches diagnostics (connections, sync rules state, etc.). Routes to Management service (Cloud) or linked instance (self-hosted).' |
6 | | - static summary = 'Fetch diagnostics status for an instance.' |
| 115 | + 'Fetches diagnostics (connections, sync rules state, etc.). Routes to Management service (Cloud) or linked instance (self-hosted).'; |
| 116 | + static summary = 'Fetch diagnostics status for an instance.'; |
| 117 | + |
| 118 | + static flags = { |
| 119 | + ...CloudInstanceCommand.flags, |
| 120 | + output: Flags.string({ |
| 121 | + default: 'human', |
| 122 | + description: 'Output format: human-readable, json, or yaml.', |
| 123 | + options: ['human', 'json', 'yaml'] |
| 124 | + }) |
| 125 | + }; |
7 | 126 |
|
8 | 127 | async run(): Promise<void> { |
9 | | - this.log('fetch status: not yet implemented') |
| 128 | + const { flags } = await this.parse(FetchStatus); |
| 129 | + |
| 130 | + const { linked } = this.loadProject(flags, { |
| 131 | + configFileRequired: false, |
| 132 | + linkingIsRequired: true |
| 133 | + }); |
| 134 | + |
| 135 | + const client = await this.getClient(); |
| 136 | + |
| 137 | + const diagnostics = await client |
| 138 | + .getInstanceDiagnostics({ |
| 139 | + app_id: linked.project_id, |
| 140 | + org_id: linked.org_id, |
| 141 | + id: linked.instance_id |
| 142 | + }) |
| 143 | + .catch((error) => { |
| 144 | + this.error(`Failed to fetch diagnostics for instance ${linked.instance_id}: ${error}`, { exit: 1 }); |
| 145 | + }); |
| 146 | + |
| 147 | + if (flags.output === 'json') { |
| 148 | + this.log(JSON.stringify(diagnostics, null, 2)); |
| 149 | + return; |
| 150 | + } else if (flags.output === 'yaml') { |
| 151 | + this.log(new Document(diagnostics).toString()); |
| 152 | + return; |
| 153 | + } else { |
| 154 | + this.log(formatDiagnosticsHuman(diagnostics)); |
| 155 | + } |
10 | 156 | } |
11 | 157 | } |
0 commit comments