Skip to content

Commit 035161e

Browse files
committed
fix(ui/store): clear missing server fields on merge (#438)
Reported by Jason Landbridge (issue #438): after clicking "Approve all tools" on a server, navigating to the Servers overview page kept showing the "5 pending approval" quarantine badge even though everything was approved. A hard refresh fixed it. Root cause is a generalisation of the previously-special-cased last_error bug: The Pinia stores/servers.ts mergeServers helper updates each existing server in place via Object.assign(existingServer, incomingServer). The backend's enrichServersWithQuarantineStats (internal/httpapi/server.go) only sets `Quarantine` when `pending > 0 || changed > 0`; once all tools are approved the field disappears from the JSON entirely. Object.assign adds and overwrites — it never deletes. The stale `quarantine: { pending_count: 5 }` therefore survived on the in-place reactive object and ServerCard's `server.quarantine?.pending_count` kept rendering the badge until a full page reload replaced the array. The author had already special-cased `last_error` for this exact reason. The same pattern affects every conditional optional field — `quarantine`, `oauth_status`, `token_expires_at`, `user_logged_out`, `health`, `security_scan`, `diagnostic`, `error_code`, etc. — and Jason flagged that he sees similar staleness "in several areas of the UI." Fix: replace the one-field special case with a general key-sync. For each key on the existing server that is absent on the incoming server, delete it before Object.assign. This treats the API response as authoritative (which it is) and works for every conditional field without enumerating them. The existing object reference is still preserved, so v-memo on ServerCard keeps its stable identity and the list does not visually rerender. Tests in frontend/tests/unit/servers-store.spec.ts: * drops the stale quarantine field when the backend stops emitting it * drops last_error on recovery (regression coverage for the original special case the new code subsumes) * drops oauth_status / token_expires_at / user_logged_out together * preserves the existing object reference across merges (identity stability for v-memo) * sanity: still updates and adds present fields 8 tests, all passing (5 new + 3 existing). Note: a complementary backend change — emit `servers.changed` SSE on tool approval — is in a separate PR so a Servers/overview page open in another tab also reactively updates without depending on the Approve flow's explicit fetchServers() call. This PR is the primary fix; the SSE one is the cross-tab improvement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f9c8d0e commit 035161e

2 files changed

Lines changed: 147 additions & 15 deletions

File tree

frontend/src/stores/servers.ts

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,23 +47,26 @@ export const useServersStore = defineStore('servers', () => {
4747
const existingServer = existingMap.get(incomingServer.name)
4848

4949
if (existingServer) {
50-
// Update existing server in-place (preserves object reference)
51-
// Only update properties that have changed
52-
let hasChanges = false
53-
54-
// IMPORTANT: Clear last_error if not present in incoming server
55-
// Object.assign won't clear properties that are missing from source
56-
if (!('last_error' in incomingServer) && existingServer.last_error) {
57-
delete existingServer.last_error
58-
hasChanges = true
50+
// Update existing server in-place (preserves object reference so
51+
// ServerCard's v-memo / consumers' computeds keep their identity).
52+
//
53+
// CRITICAL: the API response is authoritative — any field absent on
54+
// the incoming server has been cleared on the backend and must be
55+
// dropped from the existing reactive object. Object.assign alone
56+
// would leak a stale `quarantine: { pending_count: 5 }` (and other
57+
// conditionally-emitted fields) after the user approves all tools,
58+
// because the backend stops emitting the field when its count hits
59+
// zero (see internal/httpapi/server.go enrichServersWithQuarantineStats).
60+
// This was the root cause of issue #438.
61+
const existingKeys = Object.keys(existingServer) as (keyof Server)[]
62+
const incomingKeys = new Set(Object.keys(incomingServer))
63+
for (const key of existingKeys) {
64+
if (!incomingKeys.has(key)) {
65+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
66+
delete (existingServer as any)[key]
67+
}
5968
}
60-
6169
Object.assign(existingServer, incomingServer)
62-
hasChanges = true
63-
64-
if (hasChanges) {
65-
console.log(`Server ${existingServer.name} updated with changes`)
66-
}
6770

6871
result.push(existingServer)
6972
} else {

frontend/tests/unit/servers-store.spec.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,135 @@ vi.mock('@/services/api', () => ({
1111
},
1212
}))
1313

14+
describe('useServersStore — mergeServers field-clearing (issue #438)', () => {
15+
beforeEach(() => {
16+
setActivePinia(createPinia())
17+
vi.clearAllMocks()
18+
})
19+
20+
function mkServer(overrides: Record<string, unknown> = {}) {
21+
return {
22+
name: 'srv',
23+
protocol: 'http' as const,
24+
enabled: true,
25+
quarantined: false,
26+
connected: true,
27+
connecting: false,
28+
tool_count: 4,
29+
...overrides,
30+
}
31+
}
32+
33+
it('drops the stale `quarantine` field when the backend stops emitting it', async () => {
34+
// The backend's enrichServersWithQuarantineStats sets `Quarantine` only
35+
// when pending > 0 || changed > 0. After "Approve all tools" the field
36+
// disappears from the JSON entirely; without a clear-on-merge path the
37+
// old `pending_count: 5` would survive on the in-place reactive object
38+
// and ServerCard would keep rendering the "5 pending approval" badge.
39+
;(api.getServers as any).mockResolvedValueOnce({
40+
success: true,
41+
data: { servers: [mkServer({ quarantine: { pending_count: 5, changed_count: 0 } })] },
42+
})
43+
44+
const store = useServersStore()
45+
await store.fetchServers()
46+
expect(store.servers[0].quarantine).toEqual({ pending_count: 5, changed_count: 0 })
47+
48+
;(api.getServers as any).mockResolvedValueOnce({
49+
success: true,
50+
data: { servers: [mkServer()] }, // no quarantine field at all
51+
})
52+
53+
await store.fetchServers()
54+
expect(store.servers[0].quarantine).toBeUndefined()
55+
})
56+
57+
it('drops `last_error` on recovery (regression coverage for the original special case)', async () => {
58+
;(api.getServers as any).mockResolvedValueOnce({
59+
success: true,
60+
data: { servers: [mkServer({ last_error: 'connection refused' })] },
61+
})
62+
63+
const store = useServersStore()
64+
await store.fetchServers()
65+
expect(store.servers[0].last_error).toBe('connection refused')
66+
67+
;(api.getServers as any).mockResolvedValueOnce({
68+
success: true,
69+
data: { servers: [mkServer()] },
70+
})
71+
72+
await store.fetchServers()
73+
expect(store.servers[0].last_error).toBeUndefined()
74+
})
75+
76+
it('drops `oauth_status`, `token_expires_at`, and `user_logged_out` when no longer present', async () => {
77+
;(api.getServers as any).mockResolvedValueOnce({
78+
success: true,
79+
data: {
80+
servers: [
81+
mkServer({
82+
oauth_status: 'expired',
83+
token_expires_at: '2026-04-30T00:00:00Z',
84+
user_logged_out: true,
85+
}),
86+
],
87+
},
88+
})
89+
const store = useServersStore()
90+
await store.fetchServers()
91+
92+
;(api.getServers as any).mockResolvedValueOnce({
93+
success: true,
94+
data: { servers: [mkServer()] },
95+
})
96+
await store.fetchServers()
97+
98+
expect(store.servers[0].oauth_status).toBeUndefined()
99+
expect(store.servers[0].token_expires_at).toBeUndefined()
100+
expect(store.servers[0].user_logged_out).toBeUndefined()
101+
})
102+
103+
it('preserves the existing object reference across merges (identity stability for v-memo)', async () => {
104+
;(api.getServers as any).mockResolvedValueOnce({
105+
success: true,
106+
data: { servers: [mkServer({ quarantine: { pending_count: 2, changed_count: 0 } })] },
107+
})
108+
const store = useServersStore()
109+
await store.fetchServers()
110+
const ref1 = store.servers[0]
111+
112+
;(api.getServers as any).mockResolvedValueOnce({
113+
success: true,
114+
data: { servers: [mkServer()] },
115+
})
116+
await store.fetchServers()
117+
const ref2 = store.servers[0]
118+
119+
// Same reactive object — v-memo on ServerCard still sees stable identity.
120+
expect(ref2).toBe(ref1)
121+
expect(ref2.quarantine).toBeUndefined()
122+
})
123+
124+
it('still updates and adds present fields (sanity: the clear logic does not break normal merges)', async () => {
125+
;(api.getServers as any).mockResolvedValueOnce({
126+
success: true,
127+
data: { servers: [mkServer({ tool_count: 1 })] },
128+
})
129+
const store = useServersStore()
130+
await store.fetchServers()
131+
132+
;(api.getServers as any).mockResolvedValueOnce({
133+
success: true,
134+
data: { servers: [mkServer({ tool_count: 9, last_error: 'oops' })] },
135+
})
136+
await store.fetchServers()
137+
138+
expect(store.servers[0].tool_count).toBe(9)
139+
expect(store.servers[0].last_error).toBe('oops')
140+
})
141+
})
142+
14143
describe('useServersStore — securityApproveServer (F-04)', () => {
15144
beforeEach(() => {
16145
setActivePinia(createPinia())

0 commit comments

Comments
 (0)