feat(01-05): surface federation sync failures to the operator (FED-02)
A failed federation sync existed only as a `debug!` line on the node, so a peer that had not synced in days looked identical in the UI to one that synced a minute ago. Now the failure is persisted per peer and rendered. - `FederatedNode.last_sync_error` / `.last_sync_error_at` — the failure-side mirror of the existing `last_transport` / `last_transport_at` pair. - `federation::record_sync_result(data_dir, did, outcome)` — records the message on `Err`, CLEARS both fields on `Ok` so the badge disappears when the peer recovers. Runs under FEDERATION_STORE_LOCK via the `*_inner` load/save convention established by plan 01-01. An unknown DID is a silent Ok that writes nothing, so a peer removed mid-pass is never resurrected by an in-flight sync's error write. Skips the save entirely when nothing changed, keeping the steady state read-only rather than rewriting nodes.json (and contending for the lock) every 90s. - Message truncated to MAX_SYNC_ERROR_CHARS (256), counted in chars not bytes so truncation cannot split a UTF-8 sequence (T-01-18). - The 90s auto-sync loop calls it on both arms; the existing `debug!` line is kept — persisting is additive, not a replacement for logs. - `federation.list-nodes` emits both fields when set, omits them when unset. - NodeList renders a red SYNC badge beside the transport badge on both the trusted-node and peer rows, message + age in the `title` so the row stays single-line. Tests (written first, confirmed failing — 16 compile errors, E0425 on `record_sync_result` and E0609 on `last_sync_error`): - persists_error / success_clears_error / missing_did_is_noop / on_empty_store_is_noop / truncates_long_error - NodeList: badge present when set, ABSENT when unset (the guard against a badge that always renders), and present on an observer peer row. cargo test -p archipelago federation — 42 passed, 0 failed. vitest NodeList.test.ts — 4 passed. npm run build — green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
262998747e
commit
454388226c
@@ -58,6 +58,12 @@
|
||||
:class="transportBadge(node)!.cls"
|
||||
:title="transportBadge(node)!.title"
|
||||
>{{ transportBadge(node)!.label }}</span>
|
||||
<span
|
||||
v-if="syncErrorBadge(node)"
|
||||
data-testid="sync-error-badge"
|
||||
class="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0 truncate bg-red-500/20 text-red-300 ring-1 ring-red-400/40"
|
||||
:title="syncErrorBadge(node)!.title"
|
||||
>SYNC</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full shrink-0"
|
||||
@@ -121,6 +127,12 @@
|
||||
:class="transportBadge(node)!.cls"
|
||||
:title="transportBadge(node)!.title"
|
||||
>{{ transportBadge(node)!.label }}</span>
|
||||
<span
|
||||
v-if="syncErrorBadge(node)"
|
||||
data-testid="sync-error-badge"
|
||||
class="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0 truncate bg-red-500/20 text-red-300 ring-1 ring-red-400/40"
|
||||
:title="syncErrorBadge(node)!.title"
|
||||
>SYNC</span>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="trustBadgeClass(node.trust_level)">{{ node.trust_level }}</span>
|
||||
</div>
|
||||
@@ -195,4 +207,20 @@ function transportBadge(node: FederatedNode): { label: string; cls: string; titl
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// FED-02: the most recent sync attempt with this peer failed. Before this,
|
||||
// the failure existed only as a `debug!` line on the node, so a peer that
|
||||
// hadn't synced in days looked identical on this screen to one that synced a
|
||||
// minute ago. Returns null when the peer's last attempt succeeded — the
|
||||
// backend clears last_sync_error on success, so the badge disappears on
|
||||
// recovery rather than sticking around.
|
||||
//
|
||||
// The row must stay single-line, so the badge itself is a fixed short label
|
||||
// and the daemon's message plus when it happened go in the tooltip (the same
|
||||
// truncate + :title treatment the node-name span uses).
|
||||
function syncErrorBadge(node: FederatedNode): { title: string } | null {
|
||||
if (!node.last_sync_error) return null
|
||||
const age = node.last_sync_error_at ? timeAgo(node.last_sync_error_at) : 'unknown'
|
||||
return { title: `Last sync failed · ${age} · ${node.last_sync_error}` }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -29,4 +29,75 @@ describe('NodeList', () => {
|
||||
expect(wrapper.text()).toContain('Trusted Node')
|
||||
expect(wrapper.text()).not.toContain('Loading nodes...')
|
||||
})
|
||||
|
||||
// FED-02: a sync failure used to exist only as a debug! log line on the
|
||||
// node, so a peer that hadn't synced in days looked identical to one that
|
||||
// synced a minute ago. It must now be visible on the node's own row.
|
||||
it('shows a sync-error badge on a node whose last sync failed', () => {
|
||||
const failing: FederatedNode = {
|
||||
...trustedNode,
|
||||
last_sync_error: 'Failed to reach federated peer',
|
||||
last_sync_error_at: '2026-06-11T00:00:00Z',
|
||||
}
|
||||
const wrapper = mount(NodeList, {
|
||||
props: {
|
||||
nodes: [failing],
|
||||
loading: false,
|
||||
error: '',
|
||||
syncResults: [],
|
||||
dwnSyncDotClass: 'bg-green-400',
|
||||
cleaningNodes: false,
|
||||
},
|
||||
})
|
||||
|
||||
const badge = wrapper.find('[data-testid="sync-error-badge"]')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text()).toBe('SYNC')
|
||||
// The full message + when it happened live in the tooltip, so the row
|
||||
// stays single-line (the node-name truncate idiom).
|
||||
expect(badge.attributes('title')).toContain('Failed to reach federated peer')
|
||||
})
|
||||
|
||||
// The guard against a badge that always renders: a healthy peer (and a
|
||||
// peer that has recovered, since record_sync_result clears the field) must
|
||||
// show no sync-error badge at all.
|
||||
it('renders no sync-error badge when last_sync_error is unset', () => {
|
||||
const wrapper = mount(NodeList, {
|
||||
props: {
|
||||
nodes: [trustedNode],
|
||||
loading: false,
|
||||
error: '',
|
||||
syncResults: [],
|
||||
dwnSyncDotClass: 'bg-green-400',
|
||||
cleaningNodes: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="sync-error-badge"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
// Peers (Observer level) render in a separate column — the badge must be
|
||||
// on that row too, or a failing peer stays invisible.
|
||||
it('shows the sync-error badge on an observer peer row', () => {
|
||||
const peer: FederatedNode = {
|
||||
...trustedNode,
|
||||
did: 'did:key:z6MkPeerNode',
|
||||
trust_level: 'observer',
|
||||
name: 'Peer Node',
|
||||
last_sync_error: 'Peer returned 502 (via tor)',
|
||||
last_sync_error_at: '2026-06-11T00:00:00Z',
|
||||
}
|
||||
const wrapper = mount(NodeList, {
|
||||
props: {
|
||||
nodes: [peer],
|
||||
loading: false,
|
||||
error: '',
|
||||
syncResults: [],
|
||||
dwnSyncDotClass: 'bg-green-400',
|
||||
cleaningNodes: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="sync-error-badge"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,15 @@ export interface FederatedNode {
|
||||
last_transport?: 'fips' | 'tor' | 'mesh' | 'lan'
|
||||
/** RFC 3339 timestamp of last_transport. */
|
||||
last_transport_at?: string
|
||||
/**
|
||||
* Error from the most recent federation sync attempt with this peer, or
|
||||
* absent when the last attempt succeeded. Persisted per peer so a stale
|
||||
* peer is visibly different from a healthy one instead of the failure
|
||||
* living only in the node's debug log (FED-02).
|
||||
*/
|
||||
last_sync_error?: string
|
||||
/** RFC 3339 timestamp of last_sync_error. */
|
||||
last_sync_error_at?: string
|
||||
}
|
||||
|
||||
export interface DwnStatus {
|
||||
|
||||
Reference in New Issue
Block a user