diff --git a/src/composables/agent-config/__tests__/fixtures.ts b/src/composables/agent-config/__tests__/fixtures.ts new file mode 100644 index 00000000..33dac8db --- /dev/null +++ b/src/composables/agent-config/__tests__/fixtures.ts @@ -0,0 +1,432 @@ +// Fixtures for agent remote configuration (LLD U0.6). Shapes are exactly what +// useAgentConfigApi returns, i.e. AFTER the camelcase interceptor: envelope keys camelCase, +// config documents (overlay/base/effective/remote-config) verbatim snake_case. +// +// Test data for the specs (the `?fixtures` demo mode was removed, R90). + +import type { + AgentConfigRevision, + AgentConfigRevisionSummary, + AgentInstanceDetail, + AgentInstanceSummary, + ConfigDoc, + ConfigErrorBody, + ConfigPreview, + InstancesMeta, + OverlayDoc, + RemoteConfigDoc, +} from '@/types/agent-config'; +import { clone, mergePatch } from '@/utils/agent-config/merge-patch'; + +export const FIXTURE_AGENT_ID = '5b1c8a2e-0000-4000-8000-00000000a9e1'; + +export const SSH_SOURCE = + 'ghcr.io/compliance-framework/plugin-local-ssh:v1.2.0'; +export const SSH_POLICIES = + 'ghcr.io/compliance-framework/plugin-local-ssh-policies:v1.0.0'; +export const UBUNTU_SOURCE = + 'ghcr.io/compliance-framework/plugin-ubuntu-packages:v0.4.0'; +export const UBUNTU_POLICIES = + 'ghcr.io/compliance-framework/plugin-ubuntu-policies:v0.2.0'; +/** The policy bundle overlay r7 swaps local-ssh to. */ +export const SSH_POLICIES_NEXT = + 'ghcr.io/compliance-framework/plugin-local-ssh-policies:v1.1.0'; + +export const instanceIds = { + a: '0f5e2c1a-0000-4000-8000-00000000000a', + b: '0f5e2c1a-0000-4000-8000-00000000000b', + c: '0f5e2c1a-0000-4000-8000-00000000000c', + d: '0f5e2c1a-0000-4000-8000-00000000000d', + e: '0f5e2c1a-0000-4000-8000-00000000000e', + f: '0f5e2c1a-0000-4000-8000-00000000000f', + g: '0f5e2c1a-0000-4000-8000-000000000010', +} as const; + +export const remoteConfigSafe: RemoteConfigDoc = { + mode: 'apply_safe', + poll_interval: '60s', + trusted_sources: ['ghcr.io/compliance-framework/*'], + overridable_config_flags: ['local-ssh:port', 'timeout'], + allow_local_sources: false, +}; + +export const baseConfig: ConfigDoc = { + daemon: true, + verbosity: 0, + api: { + url: 'https://ccf.example.com', + auth: { client_id: 'agent-ssh-client' }, + }, + remote_config: remoteConfigSafe, + agent_evidence: { + enabled: true, + emit_on_run_completion: true, + interval: '1h', + }, + plugins: { + 'local-ssh': { + source: SSH_SOURCE, + schedule: '*/5 * * * *', + policies: [SSH_POLICIES], + config: { + host: 'localhost', + port: '22', + password: '${env:SSH_PASSWORD}', + }, + labels: { env: 'prod' }, + policy_data: { max_auth_tries: 4 }, + }, + 'ubuntu-packages': { + source: UBUNTU_SOURCE, + schedule: '0 * * * *', + policies: [UBUNTU_POLICIES], + protocol_version: 2, + }, + }, +}; + +export const overlayRev7: OverlayDoc = { + verbosity: 1, + plugins: { + 'local-ssh': { + schedule: '*/15 * * * *', + policies: [SSH_POLICIES_NEXT], + config: { port: '2222' }, + policy_data: { max_auth_tries: 3 }, + }, + }, +}; + +export const overlayRev6: OverlayDoc = { + verbosity: 1, + plugins: { 'local-ssh': { schedule: '*/15 * * * *' } }, +}; + +export const configRev0: AgentConfigRevision = { + agentId: FIXTURE_AGENT_ID, + revision: 0, + overlay: {}, + overlaySize: 2, + comment: null, + createdBy: null, + createdAt: null, + revertOf: null, +}; + +export const configRev7: AgentConfigRevision = { + agentId: FIXTURE_AGENT_ID, + revision: 7, + overlay: overlayRev7, + overlaySize: JSON.stringify(overlayRev7).length, + comment: 'tighten ssh', + createdBy: 'alice@example.com', + createdAt: '2026-09-29T08:00:00Z', + revertOf: null, +}; + +export const configRev6: AgentConfigRevision = { + agentId: FIXTURE_AGENT_ID, + revision: 6, + overlay: overlayRev6, + overlaySize: JSON.stringify(overlayRev6).length, + comment: 'slower ssh schedule', + createdBy: 'bob@example.com', + createdAt: '2026-09-25T08:00:00Z', + revertOf: null, +}; + +function summary( + id: string, + hostname: string, + over: Partial, +): AgentInstanceSummary { + return { + instanceId: id, + hostname, + agentVersion: 'v0.9.0', + mode: 'apply_safe', + firstSeenAt: '2026-09-01T00:00:00Z', + lastSeenAt: '2026-09-30T09:59:00Z', + reportedAt: '2026-09-30T09:58:00Z', + stale: false, + appliedRevision: 7, + attemptedRevision: 7, + status: 'applied', + reason: null, + error: null, + syncStatus: 'in-sync', + effectiveDigest: 'sha256:1111', + heartbeatConfigRevision: 7, + reportStale: false, + remoteConfig: remoteConfigSafe, + unsafe: [], + daemon: true, + truncated: false, + warnings: [], + ...over, + }; +} + +export const instancesMixed: { + items: AgentInstanceSummary[]; + meta: InstancesMeta; +} = { + items: [ + summary(instanceIds.a, 'ip-a', { + warnings: [ + { + path: '/plugins/nightly-audit/schedule', + code: 'invalid-value', + message: 'invalid cron schedule "every night"; the plugin is skipped', + }, + ], + }), + summary(instanceIds.b, 'ip-b', { + status: 'rejected', + reason: 'unsafe-changes', + appliedRevision: 6, + attemptedRevision: 7, + syncStatus: 'out-of-sync', + heartbeatConfigRevision: 6, + unsafe: [ + { + path: '/plugins/local-ssh/config/password', + safety: 'unsafe', + reason: 'config-not-overridable', + }, + ], + }), + summary(instanceIds.c, 'ip-c', { + mode: 'report', + status: 'not-applicable', + syncStatus: 'not-applicable', + appliedRevision: 0, + attemptedRevision: null, + heartbeatConfigRevision: 0, + remoteConfig: { ...remoteConfigSafe, mode: 'report' }, + }), + summary(instanceIds.f, 'ip-f', { + status: 'pending', + appliedRevision: 6, + attemptedRevision: 6, + syncStatus: 'out-of-sync', + heartbeatConfigRevision: 6, + }), + summary(instanceIds.g, 'ip-g', { daemon: false, truncated: true }), + summary(instanceIds.d, 'ip-d', { + mode: 'apply_all', + stale: true, + lastSeenAt: '2026-09-28T09:00:00Z', + reportedAt: '2026-09-28T09:00:00Z', + remoteConfig: { + ...remoteConfigSafe, + mode: 'apply_all', + allow_local_sources: true, + }, + }), + summary(instanceIds.e, 'ip-e', { + mode: '', + status: 'unknown', + syncStatus: 'unknown', + reportedAt: null, + appliedRevision: null, + attemptedRevision: null, + heartbeatConfigRevision: 0, + effectiveDigest: null, + remoteConfig: null, + }), + ], + meta: { + desiredRevision: 7, + page: 1, + limit: 25, + total: 7, + totalPages: 1, + counts: { + total: 7, + fresh: 6, + stale: 1, + inSync: 3, + outOfSync: 2, + pending: 1, + rejected: 1, + failed: 0, + unknown: 1, + }, + }, +}; + +function redact(doc: ConfigDoc): ConfigDoc { + const out = clone(doc); + if (out.api?.auth) delete out.api.auth.client_secret; + return out; +} + +export function detailFor( + s: AgentInstanceSummary, + overlay: OverlayDoc, +): AgentInstanceDetail { + if (s.reportedAt == null) { + return { ...s, base: null, effective: null }; + } + const base = clone(baseConfig); + base.remote_config = clone(s.remoteConfig ?? remoteConfigSafe); + return { + ...s, + base, + effective: redact(mergePatch(base, overlay)), + }; +} + +export const instanceDetailA: AgentInstanceDetail = detailFor( + instancesMixed.items[0], + overlayRev7, +); + +export const previewMixed: ConfigPreview = { + desiredRevision: 7, + standalone: false, + overlayErrors: [ + { + path: '/plugins/local-ssh/labels/team', + code: 'invalid-type', + message: 'must be a string', + }, + ], + instances: [ + { + instanceId: instanceIds.a, + hostname: 'ip-a', + mode: 'apply_safe', + stale: false, + validated: true, + effective: mergePatch(baseConfig, overlayRev7), + errors: [], + warnings: [ + { + path: '/plugins/nightly-audit/schedule', + code: 'cron', + message: 'invalid cron schedule in the host file', + }, + ], + changes: [ + { + path: '/plugins/local-ssh/schedule', + safety: 'safe', + reason: 'logging', + }, + { + path: '/plugins/local-ssh/config/port', + safety: 'safe', + reason: 'overridable-config-flag', + }, + { + path: '/plugins/local-ssh/source', + safety: 'unsafe', + reason: 'untrusted-source', + value: 'docker.io/evil/ssh:latest', + }, + ], + willApply: false, + willApplyReason: 'unsafe-changes', + }, + { + instanceId: instanceIds.d, + hostname: 'ip-d', + mode: 'apply_all', + stale: true, + validated: true, + effective: mergePatch(baseConfig, overlayRev7), + errors: [ + { + path: '/plugins/local-ssh/schedule', + code: 'cron', + message: 'invalid cron schedule', + }, + ], + warnings: [], + changes: [], + willApply: false, + willApplyReason: 'invalid-config', + }, + { + instanceId: instanceIds.c, + hostname: 'ip-c', + mode: 'report', + stale: false, + validated: false, + effective: mergePatch(baseConfig, overlayRev7), + errors: [ + { + path: '/plugins/local-ssh/policies/0', + code: 'source', + message: 'policy source is empty', + }, + ], + warnings: [], + changes: [], + willApply: false, + willApplyReason: 'mode-report', + }, + ], +}; + +export const previewStandalone: ConfigPreview = { + desiredRevision: 0, + standalone: true, + overlayErrors: [], + instances: [], +}; + +export const revisionsPage1: { + items: AgentConfigRevisionSummary[]; + total: number; + totalPages: number; +} = { + items: [7, 6, 5, 4, 3, 2, 1].map((rev) => ({ + agentId: FIXTURE_AGENT_ID, + revision: rev, + overlaySize: 100 + rev, + comment: rev === 5 ? null : `change ${rev}`, + createdBy: rev % 2 ? 'alice@example.com' : 'bob@example.com', + createdAt: `2026-09-${String(20 + rev).padStart(2, '0')}T08:00:00Z`, + revertOf: rev === 4 ? 2 : null, + })), + total: 7, + totalPages: 1, +}; + +// ---- Raw (uncamelized) error bodies, as the API sends them ---- + +export const error409: { errors: ConfigErrorBody } = { + errors: { body: 'configuration revision conflict', 'current-revision': 8 }, +}; + +export const error422: { errors: ConfigErrorBody } = { + errors: { + body: 'configuration overlay is invalid', + overlay: [ + { path: '/api', code: 'locked-key', message: 'api is set locally only' }, + ], + instances: [ + { + 'instance-id': instanceIds.a, + hostname: 'ip-a', + errors: [ + { + path: '/plugins/x/schedule', + code: 'cron', + message: 'invalid cron', + }, + ], + warnings: [ + { path: '/plugins/y/schedule', code: 'cron', message: 'file cron' }, + ], + }, + ], + }, +}; + +export const error428: { errors: ConfigErrorBody } = { + errors: { body: 'If-Match header with the current revision is required' }, +}; diff --git a/src/config/tooltips.ts b/src/config/tooltips.ts index 984e0efb..9506cbea 100644 --- a/src/config/tooltips.ts +++ b/src/config/tooltips.ts @@ -53,6 +53,12 @@ export const TOOLTIPS = { // Agent configuration 'agents.config.field.forbidden': 'Set on the agent host; can never be changed remotely', + 'agents.config.instance.oneShot': + 'Runs once and exits; pruned 24 h after it was last seen', + 'agents.config.instance.truncated': + "The agent's report exceeded the size limit, so its local file was dropped: the File view and the checks against this host's file are unavailable", + 'agents.config.instance.fileWarnings': + "Problems in this agent's local file (tolerated; the affected plugins are skipped)", // Add more tooltips here as needed // 'feature.name': 'Tooltip text here', diff --git a/src/utils/agent-config/__tests__/instance-status.spec.ts b/src/utils/agent-config/__tests__/instance-status.spec.ts new file mode 100644 index 00000000..16c3c909 --- /dev/null +++ b/src/utils/agent-config/__tests__/instance-status.spec.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest'; +import type { AgentInstanceSummary } from '@/types/agent-config'; +import { instancesMixed } from '@/composables/agent-config/__tests__/fixtures'; +import { deriveInstanceState, summarizeSync } from '../instance-status'; + +const base = instancesMixed.items[0]; +const inst = (over: Partial): AgentInstanceSummary => ({ + ...base, + warnings: [], + ...over, +}); + +describe('deriveInstanceState (U1.3 table)', () => { + it.each<[string, Partial, string, string]>([ + ['row 1 unknown', { status: 'unknown' }, 'not-reported', 'No report'], + [ + 'row 1 no report', + { reportedAt: null, status: 'applied' }, + 'not-reported', + 'No report', + ], + [ + 'row 2 not-applicable', + { status: 'not-applicable' }, + 'report-only', + 'Report only', + ], + [ + 'row 2 report mode', + { mode: 'report', status: 'applied' }, + 'report-only', + 'Report only', + ], + [ + 'row 3', + { status: 'rejected', reason: 'unsafe-changes' }, + 'rejected-unsafe', + 'Rejected r7: needs apply_all', + ], + [ + 'row 4', + { status: 'rejected', reason: 'forbidden-changes' }, + 'rejected-forbidden', + 'Rejected r7: forbidden change', + ], + [ + 'row 5', + { status: 'rejected', reason: 'invalid-config' }, + 'rejected-invalid', + 'Rejected r7', + ], + [ + 'row 6', + { status: 'failed', reason: 'download-failed' }, + 'failed', + 'Failed r7', + ], + [ + 'row 7', + { status: 'pending', appliedRevision: 6 }, + 'pending', + 'Pending r6 → r7', + ], + [ + 'row 7 null applied', + { status: 'pending', appliedRevision: null }, + 'pending', + 'Pending r0 → r7', + ], + [ + 'row 8', + { status: 'applied', syncStatus: 'in-sync' }, + 'in-sync', + 'In sync', + ], + [ + 'row 9', + { status: 'applied', syncStatus: 'out-of-sync', appliedRevision: 5 }, + 'pending', + 'Pending r5 → r7', + ], + [ + 'row 10', + { status: 'applied', syncStatus: 'unknown' }, + 'unknown', + 'Unknown', + ], + ])('%s', (_, over, state, chip) => { + const s = deriveInstanceState(inst(over), 7); + expect(s.state).toBe(state); + expect(s.chipLabel).toBe(chip); + }); + + it('marks rows 3–6 attempted before the desired revision as "(older revision)"', () => { + const s = deriveInstanceState( + inst({ status: 'failed', attemptedRevision: 5 }), + 7, + ); + expect(s.chipLabel).toBe('Failed r5 (older revision)'); + expect(s.olderRevision).toBe(true); + expect(s.problem).toBe(true); + const cur = deriveInstanceState( + inst({ status: 'failed', attemptedRevision: 7 }), + 7, + ); + expect(cur.olderRevision).toBe(false); + }); + + it('adds the extra badges', () => { + const s = deriveInstanceState( + inst({ + daemon: false, + truncated: true, + reportStale: true, + warnings: [ + { + path: '/plugins/a/schedule', + message: 'bad', + code: 'invalid-value', + }, + ], + }), + 7, + ); + expect(s.badges.map((b) => b.key)).toEqual([ + 'one-shot', + 'truncated', + 'report-stale', + 'file-warnings', + ]); + // A truncated report has no base (the host's file was dropped), never missing bundles. + expect(s.badges[1].tooltip).toContain('local file was dropped'); + expect(s.badges[3].label).toBe('1 file warning'); + expect(deriveInstanceState(inst({}), 7).badges).toEqual([]); + }); +}); + +describe('summarizeSync', () => { + it('counts fresh apply-mode instances and problems (fixture instancesMixed)', () => { + const states = instancesMixed.items.map((i) => deriveInstanceState(i, 7)); + const s = summarizeSync(instancesMixed.items, states); + // fresh apply-mode: a, b, f, g (d is stale, c report, e unknown mode '') + expect(s.expected).toBe(4); + expect(s.inSync).toBe(2); + expect(s.reportOnly).toBe(1); + expect(s.notReported).toBe(1); + expect(s.stale).toBe(1); + expect(s.problems.map((p) => p.hostname)).toEqual(['ip-b']); + expect(s.total).toBe(7); + expect(s.partial).toBe(false); + }); + + it('a complete list keeps the row numbers; counts only supply the total', () => { + const states = instancesMixed.items.map((i) => deriveInstanceState(i, 7)); + const s = summarizeSync( + instancesMixed.items, + states, + instancesMixed.meta.counts, + ); + // As without counts: fresh apply-mode a, b, f, g; the stale d is not counted in sync. + expect([s.inSync, s.expected, s.stale, s.notReported]).toEqual([ + 2, 4, 1, 1, + ]); + expect(s.total).toBe(7); + expect(s.reportOnly).toBe(1); + expect(s.partial).toBe(false); + }); + + it('a stale instance is never counted as in sync (complete list with counts)', () => { + const inst = { ...instancesMixed.items[0], stale: true, warnings: [] }; + const states = [deriveInstanceState(inst, 7)]; + const counts = { + total: 1, + fresh: 0, + stale: 1, + inSync: 1, + outOfSync: 0, + pending: 0, + rejected: 0, + failed: 0, + unknown: 0, + }; + const s = summarizeSync([inst], states, counts); + expect([s.inSync, s.expected, s.stale]).toEqual([0, 0, 1]); + expect(s.partial).toBe(false); + }); + + it('marks the summary partial when fewer rows are loaded than counts.total', () => { + const rows = instancesMixed.items.slice(0, 2); + const states = rows.map((i) => deriveInstanceState(i, 7)); + const counts = { ...instancesMixed.meta.counts, total: 120, inSync: 90 }; + const s = summarizeSync(rows, states, counts); + expect(s.total).toBe(120); + // Fleet-wide, from the API's counts (stale instances included). + expect(s.inSync).toBe(90); + expect(s.expected).toBe(92); + expect(s.stale).toBe(1); + expect(s.loaded).toBe(2); + expect(s.partial).toBe(true); + // Row-only numbers are not shown as fleet-wide. + expect(s.reportOnly).toBeNull(); + expect(s.problems.map((p) => p.hostname)).toEqual(['ip-b']); + }); +}); diff --git a/src/utils/agent-config/instance-status.ts b/src/utils/agent-config/instance-status.ts new file mode 100644 index 00000000..f6cf3823 --- /dev/null +++ b/src/utils/agent-config/instance-status.ts @@ -0,0 +1,247 @@ +// Reported instance state → UI state (LLD U1.3). Uses the server's `status`, `syncStatus` +// and `stale` (R10, R14); there is no clock logic here. + +import type { + AgentInstanceSummary, + InstanceCounts, +} from '@/types/agent-config'; +import { TOOLTIPS } from '@/config/tooltips'; + +export type InstanceUiStateName = + | 'not-reported' + | 'report-only' + | 'rejected-unsafe' + | 'rejected-forbidden' + | 'rejected-invalid' + | 'failed' + | 'pending' + | 'in-sync' + | 'unknown'; + +export type ChipSeverity = 'success' | 'warn' | 'danger' | 'secondary'; + +export interface InstanceBadge { + key: 'one-shot' | 'truncated' | 'report-stale' | 'file-warnings'; + label: string; + tooltip?: string; + severity: ChipSeverity; +} + +export interface InstanceUiState { + instanceId: string; + state: InstanceUiStateName; + chipLabel: string; + severity: ChipSeverity; + /** True for the rejected/failed rows (3–6). */ + problem: boolean; + /** Rows 3–6 when the agent has not tried the latest revision yet. */ + olderRevision: boolean; + stale: boolean; + badges: InstanceBadge[]; +} + +const PROBLEM_STATES: InstanceUiStateName[] = [ + 'rejected-unsafe', + 'rejected-forbidden', + 'rejected-invalid', + 'failed', +]; + +export function isProblemState(state: InstanceUiStateName): boolean { + return PROBLEM_STATES.includes(state); +} + +function baseState( + inst: AgentInstanceSummary, + desired: number, +): Pick { + const attempted = inst.attemptedRevision ?? '?'; + const pending = { + state: 'pending' as const, + chipLabel: `Pending r${inst.appliedRevision ?? 0} → r${desired}`, + severity: 'warn' as const, + }; + if (inst.status === 'unknown' || inst.reportedAt == null) { + return { + state: 'not-reported', + chipLabel: 'No report', + severity: 'secondary', + }; + } + if (inst.status === 'not-applicable' || inst.mode === 'report') { + return { + state: 'report-only', + chipLabel: 'Report only', + severity: 'secondary', + }; + } + if (inst.status === 'rejected' && inst.reason === 'unsafe-changes') { + return { + state: 'rejected-unsafe', + chipLabel: `Rejected r${attempted}: needs apply_all`, + severity: 'danger', + }; + } + if (inst.status === 'rejected' && inst.reason === 'forbidden-changes') { + return { + state: 'rejected-forbidden', + chipLabel: `Rejected r${attempted}: forbidden change`, + severity: 'danger', + }; + } + if (inst.status === 'rejected') { + return { + state: 'rejected-invalid', + chipLabel: `Rejected r${attempted}`, + severity: 'danger', + }; + } + if (inst.status === 'failed') { + return { + state: 'failed', + chipLabel: `Failed r${attempted}`, + severity: 'danger', + }; + } + if (inst.status === 'pending') return pending; + if (inst.status === 'applied' && inst.syncStatus === 'in-sync') { + return { state: 'in-sync', chipLabel: 'In sync', severity: 'success' }; + } + if (inst.status === 'applied' && inst.syncStatus === 'out-of-sync') + return pending; + return { state: 'unknown', chipLabel: 'Unknown', severity: 'secondary' }; +} + +export function deriveInstanceState( + inst: AgentInstanceSummary, + desiredRevision: number, +): InstanceUiState { + const base = baseState(inst, desiredRevision); + const problem = isProblemState(base.state); + const olderRevision = + problem && + inst.attemptedRevision != null && + inst.attemptedRevision < desiredRevision; + const badges: InstanceBadge[] = []; + if (inst.daemon === false) { + badges.push({ + key: 'one-shot', + label: 'One-shot run', + tooltip: TOOLTIPS['agents.config.instance.oneShot'], + severity: 'secondary', + }); + } + if (inst.truncated) { + badges.push({ + key: 'truncated', + label: 'Report truncated', + tooltip: TOOLTIPS['agents.config.instance.truncated'], + severity: 'warn', + }); + } + if (inst.reportStale) { + badges.push({ + key: 'report-stale', + label: 'Report older than last heartbeat', + severity: 'secondary', + }); + } + const warnings = inst.warnings?.length ?? 0; + if (warnings > 0) { + badges.push({ + key: 'file-warnings', + label: `${warnings} file warning${warnings === 1 ? '' : 's'}`, + tooltip: TOOLTIPS['agents.config.instance.fileWarnings'], + severity: 'warn', + }); + } + return { + instanceId: inst.instanceId, + ...base, + chipLabel: olderRevision + ? `${base.chipLabel} (older revision)` + : base.chipLabel, + problem, + olderRevision, + stale: inst.stale, + badges, + }; +} + +export interface SyncSummary { + /** Every instance of the agent: meta.counts.total when known, else the loaded rows. */ + total: number; + inSync: number; + /** + * Instances whose sync applies: non-stale apply-mode instances (R14 "fresh"). When `partial`, + * the API's counts instead: in sync + out of sync, stale instances included. + */ + expected: number; + /** From the rows only: null when they are not every instance (`partial`). */ + reportOnly: number | null; + notReported: number; + stale: number; + /** Rows 3–6 on non-stale loaded instances. */ + problems: { instanceId: string; hostname: string | null; label: string }[]; + /** Fewer rows are loaded than the agent has (the list is paginated): problems cover `loaded`. */ + partial: boolean; + loaded: number; +} + +/** + * The header's sync summary, from the rows. When the rows are only part of the fleet + * (`partial`: fewer than `counts.total`), the fleet-wide numbers come from the API's `counts` + * instead, which include stale instances (the header says so); report-only is then unknown and + * the problem chips cover the loaded rows. + */ +export function summarizeSync( + instances: AgentInstanceSummary[], + states: InstanceUiState[], + counts?: InstanceCounts | null, +): SyncSummary { + const summary: SyncSummary = { + total: instances.length, + inSync: 0, + expected: 0, + reportOnly: 0, + notReported: 0, + stale: 0, + problems: [], + partial: false, + loaded: instances.length, + }; + let reportOnly = 0; + instances.forEach((inst, i) => { + const st = states[i]; + if (inst.stale) { + summary.stale++; + return; + } + if (inst.mode === 'apply_safe' || inst.mode === 'apply_all') { + summary.expected++; + if (st.state === 'in-sync') summary.inSync++; + } + if (st.state === 'report-only') reportOnly++; + if (st.state === 'not-reported') summary.notReported++; + if (st.problem) { + summary.problems.push({ + instanceId: inst.instanceId, + hostname: inst.hostname, + label: st.chipLabel, + }); + } + }); + summary.reportOnly = reportOnly; + if (counts) { + summary.total = counts.total; + summary.partial = instances.length < counts.total; + } + if (counts && summary.partial) { + summary.inSync = counts.inSync; + summary.expected = counts.inSync + counts.outOfSync; + summary.stale = counts.stale; + summary.notReported = counts.unknown ?? summary.notReported; + summary.reportOnly = null; + } + return summary; +}