diff --git a/frontend/src/components/ServerCard.vue b/frontend/src/components/ServerCard.vue index 7ebeed7d..69ddc2e8 100644 --- a/frontend/src/components/ServerCard.vue +++ b/frontend/src/components/ServerCard.vue @@ -35,13 +35,13 @@ {{ blockedToolCount }} disabled -
+
{{ quarantineToolCount }} pending approval
-
+
{{ server.tool_list_token_size.toLocaleString() }} tokens
@@ -106,7 +106,9 @@ {{ server.last_error }}
- +
@@ -114,6 +116,25 @@ Server is quarantined
+ +
+ + + + {{ toolQuarantineSummary }} + + Review + +
+
@@ -408,6 +429,34 @@ const blockedToolCount = computed(() => { return q.blocked_count ?? 0 }) +// Human-readable summary for the tool-quarantine banner. Differentiates +// fully-quarantined (every tool needs approval) from partially-quarantined, +// and surfaces "changed" tools separately because they indicate a rug-pull +// rather than a first-time review. +const toolQuarantineSummary = computed(() => { + const q = props.server.quarantine + if (!q) return '' + const pending = q.pending_count ?? 0 + const changed = q.changed_count ?? 0 + const total = pending + changed + if (total === 0) return '' + const toolCount = props.server.tool_count ?? 0 + const noun = (n: number) => (n === 1 ? 'tool' : 'tools') + if (changed > 0 && pending > 0) { + return `${pending} ${noun(pending)} pending, ${changed} changed — approval needed` + } + if (changed > 0) { + return `${changed} ${noun(changed)} changed since approval — re-review needed` + } + if (toolCount > 0 && pending === toolCount) { + return `All ${pending} ${noun(pending)} pending security approval` + } + if (toolCount > 0) { + return `${pending} of ${toolCount} ${noun(toolCount)} pending security approval` + } + return `${pending} ${noun(pending)} pending security approval` +}) + // Security scan badge (Spec 039) const securityScanStatus = computed(() => { return props.server.security_scan?.status || 'not_scanned' diff --git a/frontend/src/components/__tests__/ServerCard.test.ts b/frontend/src/components/__tests__/ServerCard.test.ts index 4092dc56..770ff950 100644 --- a/frontend/src/components/__tests__/ServerCard.test.ts +++ b/frontend/src/components/__tests__/ServerCard.test.ts @@ -61,4 +61,106 @@ describe('ServerCard', () => { expect(wrapper.text()).toContain('disabled-server') expect(wrapper.text()).toContain('Disabled') }) + + it('shows tool-quarantine banner with Review link when tools are pending and server is not quarantined', () => { + const server = { + name: 'partial-server', + protocol: 'stdio' as const, + enabled: true, + connected: true, + quarantined: false, + tool_count: 10, + quarantine: { pending_count: 3, changed_count: 0, blocked_count: 0 } + } + + const wrapper = mount(ServerCard, { + props: { server }, + global: { plugins: [pinia, router] } + }) + + expect(wrapper.text()).toContain('3 of 10 tools pending security approval') + const review = wrapper.find('a.btn-warning') + expect(review.exists()).toBe(true) + expect(review.attributes('href')).toBe('/servers/partial-server?tab=tools') + // The server-level "Server is quarantined" banner must NOT render here + expect(wrapper.text()).not.toContain('Server is quarantined') + }) + + it('says "All N pending" when every tool is pending', () => { + const server = { + name: 'fully-pending', + protocol: 'stdio' as const, + enabled: true, + connected: true, + quarantined: false, + tool_count: 4, + quarantine: { pending_count: 4, changed_count: 0, blocked_count: 0 } + } + + const wrapper = mount(ServerCard, { + props: { server }, + global: { plugins: [pinia, router] } + }) + + expect(wrapper.text()).toContain('All 4 tools pending security approval') + }) + + it('flags rug-pull-style changed tools separately', () => { + const server = { + name: 'rugpull', + protocol: 'stdio' as const, + enabled: true, + connected: true, + quarantined: false, + tool_count: 5, + quarantine: { pending_count: 0, changed_count: 2, blocked_count: 0 } + } + + const wrapper = mount(ServerCard, { + props: { server }, + global: { plugins: [pinia, router] } + }) + + expect(wrapper.text()).toContain('2 tools changed since approval') + }) + + it('does not double up: server-level banner wins over tool-level banner', () => { + const server = { + name: 'srv-quarantined', + protocol: 'stdio' as const, + enabled: true, + connected: false, + quarantined: true, + tool_count: 4, + quarantine: { pending_count: 4, changed_count: 0, blocked_count: 0 } + } + + const wrapper = mount(ServerCard, { + props: { server }, + global: { plugins: [pinia, router] } + }) + + expect(wrapper.text()).toContain('Server is quarantined') + expect(wrapper.text()).not.toContain('pending security approval') + }) + + it('shows both disabled and pending counts when both apply', () => { + const server = { + name: 'mixed', + protocol: 'stdio' as const, + enabled: true, + connected: true, + quarantined: false, + tool_count: 10, + quarantine: { pending_count: 2, changed_count: 0, blocked_count: 3 } + } + + const wrapper = mount(ServerCard, { + props: { server }, + global: { plugins: [pinia, router] } + }) + + expect(wrapper.text()).toContain('3 disabled') + expect(wrapper.text()).toContain('2 pending approval') + }) }) \ No newline at end of file