Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions e2e/web-ui-sweep/navigation-consistency.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,20 @@ test('Activity sessions rows open their calls (SC-009)', async ({ page }) => {
await page.waitForTimeout(500)
const row = page.locator('[data-test="sessions-row"]').first()
test.skip((await row.count()) === 0, 'no MCP sessions recorded on this instance')

// The row's "View Activity" link must land on the calls view scoped to that
// session, and the page's own REST request must carry the session filter.
const seen = collect(page, /^\/api\/v1\/activity$/)
await row.locator('[data-test="session-view-activity"]').click()
await expect(page).toHaveURL(/\/ui\/activity\?(?:[^#]*&)?session=[^&]+/)
const session = new URL(page.url()).searchParams.get('session')!
await page.waitForTimeout(1000)
expect(seen.length, 'sessions row: no activity REST request was issued').toBeGreaterThan(0)
// toRest() routes a `ws-` work session to work_session_id, a transport id to session_id.
const param = session.startsWith('ws-') ? 'work_session_id' : 'session_id'
for (const req of seen) {
expect(new URL(req).searchParams.get(param), `sessions row: ${req}`).toBe(session)
}
})

test('the status pill opens Servers and attention "See all" opens Home (Spec 109 FR-053)', async ({ page }) => {
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/components/ImportServers.vue
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ import { importSummary } from '@/utils/onboardingServersStep'
// showEmpty: the wizard owns its own empty and completion states, so it turns
// this one off; standalone use keeps the first-load empty line.
const props = withDefaults(defineProps<{ detected?: boolean; showEmpty?: boolean; showMessage?: boolean }>(), { detected: false, showEmpty: true, showMessage: true })
const emit = defineEmits<{ imported: [count: number] }>()
const emit = defineEmits<{ imported: [count: number, names?: string[]] }>()

const content = ref('')
const loading = ref(false)
Expand Down Expand Up @@ -177,6 +177,7 @@ async function importDetected() {
try {
let imported = 0
let renamed = 0
const importedNames: string[] = []
const skipped: Array<{ reason?: string }> = []
for (const source of detectedSources.value) {
const server_names = source.servers.filter(server => source.selected[server.name]).map(server => server.name)
Expand All @@ -189,12 +190,13 @@ async function importDetected() {
const response = await api.importServersFromPath({ path: source.path, format: source.format, server_names, rename: Object.keys(rename).length ? rename : undefined, skip_quarantine: !detectedQuarantine.value })
if (!response.success) throw new Error(response.error || `Could not import ${source.name}`)
imported += response.data?.summary?.imported ?? server_names.length
importedNames.push(...server_names.map(name => (rename as Record<string, string>)[name] ?? name))
skipped.push(...(response.data?.skipped ?? []))
}
detectedMessage.value = importSummary({ imported, renamed, skipped })
detectedImportedCount.value = imported
importedOnce.value = true
emit('imported', imported)
emit('imported', imported, importedNames)
await loadDetectedSources(false)
} catch (error) { detectedError.value = error instanceof Error ? error.message : 'Import failed' }
finally { detectedImporting.value = false }
Expand Down Expand Up @@ -264,7 +266,7 @@ async function handleImport() {
addError.value = resp.error || 'Import failed'
return
}
emit('imported', names.length)
emit('imported', resp.data?.summary?.imported ?? resp.data?.imported?.length ?? names.length, names)
} catch (e) {
addError.value = e instanceof Error ? e.message : 'Import failed'
} finally {
Expand Down
21 changes: 18 additions & 3 deletions frontend/src/components/OnboardingWizard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,7 @@ import ReviewQueueList from '@/components/ReviewQueueList.vue'
import TelemetryBanner from '@/components/TelemetryBanner.vue'
import { useDialogOpen } from '@/composables/useDialogOpen'
import { skipReasonLabel } from '@/utils/importSkipReason'
import { serversStepView, awaitingReviewSentence } from '@/utils/onboardingServersStep'
import { serversStepView, awaitingReviewSentence, countImportedStillQuarantined } from '@/utils/onboardingServersStep'
import type { ClientStatus, ActivityRecord, ConnectPreview, ImportedServer } from '@/types'

interface Props {
Expand Down Expand Up @@ -873,6 +873,9 @@ const hasUsableServer = computed(() => onboarding.hasUsableServer)
// Servers brought in by an import during THIS wizard open (reset in
// onOpened). Manual add is not an import and does not count here.
const importedThisSession = ref(0)
// Names of the servers an import of THIS session put into quarantine, so the
// "including the N you just imported" sentence counts only those still waiting.
const importedQuarantinedNames = ref<Set<string>>(new Set())
// Which body the Servers step shows: choose / review / imported / empty
// (fix-usertest-web T200). Pure rules live in utils/onboardingServersStep.ts.
const serversView = computed(() => serversStepView({
Expand All @@ -883,7 +886,10 @@ const serversView = computed(() => serversStepView({
}))
const awaitingReviewText = computed(() => awaitingReviewSentence(
quarantinedServersAwaitingReview.value.length,
Math.min(importedThisSession.value, quarantinedServersAwaitingReview.value.length),
countImportedStillQuarantined(
importedQuarantinedNames.value,
quarantinedServersAwaitingReview.value.map(server => server.name),
),
))

function selectionKey(path: string, name: string) {
Expand Down Expand Up @@ -1069,6 +1075,7 @@ async function onOpened() {
const requested = onboarding.consumeWizardInitialTab()
importSession.value++
importedThisSession.value = 0
importedQuarantinedNames.value = new Set()
serverAddedJustNow.value = false
connectMessage.value = ''
// Backup lines are session-scoped (Spec 078 US2): don't replay backup
Expand Down Expand Up @@ -1753,11 +1760,19 @@ async function onServerAdded() {
})
}

async function onSharedImport(count: number) {
async function onSharedImport(count: number, names: string[] = []) {
if (count === 0) return
importedThisSession.value += count
serverAddedJustNow.value = true
const quarantinedBefore = new Set(quarantinedServersAwaitingReview.value.map(server => server.name))
await Promise.all([fetchImportSources(), serversStore.fetchServers(), onboarding.fetchState()])
// Whatever newly entered quarantine because of this import is "just imported".
const next = new Set(importedQuarantinedNames.value)
for (const name of names) next.add(name)
for (const server of quarantinedServersAwaitingReview.value) {
if (!quarantinedBefore.has(server.name)) next.add(server.name)
}
importedQuarantinedNames.value = next
}

// `dismiss` is the onClose handler useDialogOpen calls for a NATIVE close
Expand Down
17 changes: 16 additions & 1 deletion frontend/src/components/TelemetryBanner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
</template>

<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed, onMounted, onBeforeUnmount } from 'vue'
import { RouterLink } from 'vue-router'
import { useOnboardingStore } from '@/stores/onboarding'
import { telemetryNoticeMode, telemetryOffLine } from '@/utils/telemetryState'
Expand Down Expand Up @@ -113,7 +113,22 @@ function dismiss() {
onboarding.dismissTelemetryNotice()
}

// The state is read on mount and again whenever the tab regains focus, so a
// change made elsewhere (config edit, Settings in another tab) is picked up
// without a full reload.
function refreshOnVisible() {
if (document.visibilityState === 'hidden') return
void onboarding.loadTelemetryState()
}

onMounted(() => {
void onboarding.loadTelemetryState()
document.addEventListener('visibilitychange', refreshOnVisible)
window.addEventListener('focus', refreshOnVisible)
})

onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', refreshOnVisible)
window.removeEventListener('focus', refreshOnVisible)
})
</script>
18 changes: 18 additions & 0 deletions frontend/src/stores/servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,21 @@ export const useServersStore = defineStore('servers', () => {
}
}

// Optimistic admin-state flip. The Health tile and badges read health.admin_state
// and health.status ahead of enabled/connected, so updating only the booleans
// left a stale "Online"/"Healthy" until the SSE refresh landed (#1466).
function applyOptimisticAdminState(server: Server, adminState: 'disabled' | 'quarantined') {
if (!server.health) return
server.health = {
...server.health,
admin_state: adminState,
status: adminState === 'disabled' ? 'disabled' : 'needs_review',
usable: false,
}
}

async function disableServer(serverName: string) {
const prevHealth = servers.value.find(s => s.name === serverName)?.health
try {
const server = servers.value.find(s => s.name === serverName)

Expand All @@ -214,6 +228,7 @@ export const useServersStore = defineStore('servers', () => {
server.enabled = false
server.connecting = false
server.connected = false
applyOptimisticAdminState(server, 'disabled')
}

const response = await api.disableServer(serverName)
Expand All @@ -224,6 +239,7 @@ export const useServersStore = defineStore('servers', () => {
// Revert optimistic update on error
if (server) {
server.enabled = true
server.health = prevHealth
}
throw new Error(response.error || 'Failed to disable server')
}
Expand All @@ -233,6 +249,7 @@ export const useServersStore = defineStore('servers', () => {
const server = servers.value.find(s => s.name === serverName)
if (server) {
server.enabled = true
server.health = prevHealth
}
throw error
}
Expand Down Expand Up @@ -311,6 +328,7 @@ export const useServersStore = defineStore('servers', () => {
const server = servers.value.find(s => s.name === serverName)
if (server) {
server.quarantined = true
applyOptimisticAdminState(server, 'quarantined')
}
return true
} else {
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/utils/onboardingServersStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ export function serversStepView(input: ServersStepInput): ServersStepView {
return 'empty'
}

/**
* How many of the servers imported this wizard session are still waiting in
* quarantine. An import that was not quarantined (or has since been approved)
* must not be reported as "including the N you just imported" (#1466).
*/
export function countImportedStillQuarantined(
importedNames: Iterable<string>,
quarantinedNames: Iterable<string>,
): number {
const quarantined = new Set(quarantinedNames)
let n = 0
for (const name of new Set(importedNames)) if (quarantined.has(name)) n++
return n
}

/** The one-sentence status under "Approve a server to finish this step.". */
export function awaitingReviewSentence(awaiting: number, justImported: number): string {
const plural = awaiting !== 1
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/views/Activity.vue
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@
type="button"
class="btn btn-sm btn-outline mt-2"
data-test="activity-empty-show-blocked"
@click="filterStatus = 'blocked'"
@click="showBlockedAttempts"
>
Show {{ blockedAttemptCount }} blocked attempt{{ blockedAttemptCount === 1 ? '' : 's' }}
</button>
Expand Down Expand Up @@ -2058,6 +2058,18 @@ const summaryParts = computed(() => compactSummaryParts(summary.value))
const BLOCKED_CALLS_TITLE =
'Blocked call attempts in the last 24 h, including calls a profile or token refused. Click to list them.'
const blockedAttemptCount = computed(() => summary.value?.blocked_count ?? 0)
// An explicit `type` filter overrides the view's types, so a filter that excludes
// `policy_decision` would leave the blocked status filter matching nothing.
function showBlockedAttempts() {
if (selectedTypes.value.length > 0 && !selectedTypes.value.includes('policy_decision')) {
// One URL write for both params: two back-to-back router.replace calls each
// start from the stale route.query and the second would drop the first.
// The route watcher hydrates both refs from the URL.
scopeQuery.set({ type: [...selectedTypes.value, 'policy_decision'].join(','), status: 'blocked' })
return
}
filterStatus.value = 'blocked'
}
const showBlockedOffer = computed(
() => activeView.value === 'calls' && !filterStatus.value && blockedAttemptCount.value > 0
)
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/views/Home.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
<!-- Usage summary strip: normally sits below the topology, but moves
above it when the attention list is empty (FR-051) so an otherwise-
calm landing page still opens on something live. -->
<UsageSummaryStrip v-if="authStore.principalKind !== 'tenant' && attentionStore.loaded && attentionStore.count === 0 && !showGettingStarted" data-test="home-usage-strip-top" />
<UsageSummaryStrip v-if="authStore.principalKind !== 'tenant' && attentionStore.loaded && serversStore.loaded && attentionStore.count === 0 && !showGettingStarted" data-test="home-usage-strip-top" />

<!-- Topology (moved from Dashboard.vue's Overview panel). Always shown —
Home no longer switches between an Overview and a Usage panel;
Expand Down Expand Up @@ -398,7 +398,7 @@
</div>
<!-- /Topology -->

<UsageSummaryStrip v-if="authStore.principalKind !== 'tenant' && attentionStore.loaded && attentionStore.count > 0" data-test="home-usage-strip-bottom" />
<UsageSummaryStrip v-if="authStore.principalKind !== 'tenant' && attentionStore.loaded && serversStore.loaded && attentionStore.count > 0" data-test="home-usage-strip-bottom" />

<!-- Modals -->
<OnboardingWizard :show="onboardingStore.wizardOpen" @close="onboardingStore.closeWizard" />
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/views/ServerDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2868,6 +2868,14 @@ function loadLogs() {

async function _loadLogsWithGen(gen: number) {
if (!server.value) return
// A disabled server has no running process and may have no log file: skip the
// request rather than surfacing a console error (#1466).
if (server.value.enabled === false) {
serverLogs.value = []
logsError.value = null
logsLoading.value = false
return
}

logsLoading.value = true
logsError.value = null
Expand All @@ -2877,6 +2885,9 @@ async function _loadLogsWithGen(gen: number) {
if (gen !== loadGeneration) return
if (response.success && response.data) {
serverLogs.value = response.data.logs || []
} else if (/\b404\b|not found/i.test(response.error || '')) {
// No log file yet: an empty state, not a fault.
serverLogs.value = []
} else {
logsError.value = response.error || 'Failed to load logs'
}
Expand Down
14 changes: 14 additions & 0 deletions frontend/tests/unit/activity-blocked-in-calls-view.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,20 @@ describe('Activity - blocked attempts in the Tool calls view (T168)', () => {
expect(wrapper.findAll('[data-test="activity-row"]')).toHaveLength(1)
})

it('the offer still lists refusals when an explicit type filter excludes policy_decision (#1466)', async () => {
mockState.rows = [REFUSAL]
mockState.summary = { period: '24h', total_count: 1, call_count: 0, blocked_count: 1 }
const { wrapper } = await mountActivityAt('/activity?view=calls&type=tool_call')

const button = wrapper.find('[data-test="activity-empty-show-blocked"]')
expect(button.exists()).toBe(true)
await button.trigger('click')
await flushPromises()
await flushPromises()
expect(wrapper.findAll('[data-test="activity-row"]')).toHaveLength(1)
expect(wrapper.findAll('[data-test="activity-row"]')[0].text()).toContain('write')
})

it('pluralises the offer for several blocked attempts', async () => {
mockState.rows = []
mockState.summary = { period: '24h', total_count: 2, call_count: 0, blocked_count: 2 }
Expand Down
10 changes: 10 additions & 0 deletions frontend/tests/unit/home-attention.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ describe('Home attention list (Spec 109 FR-001/FR-003)', () => {
expect(stripBottom.exists()).toBe(false)
})

it('holds the top usage strip back until the server list has loaded (#1466)', async () => {
attentionSpy.mockResolvedValue({ success: true, data: { count: 0, items: [] } })
const api = (await import('@/services/api')).default as unknown as { getServers: ReturnType<typeof vi.fn> }
// A fresh instance whose server list is still in flight: the strip must not
// flash only to be replaced by the getting-started card.
api.getServers.mockReturnValueOnce(new Promise(() => {}))
const wrapper = await mountHome()
expect(wrapper.find('[data-test="home-usage-strip-top"]').exists()).toBe(false)
})

it('lists items in the order the API returns them, with fix buttons routing to fix.target', async () => {
attentionSpy.mockResolvedValue({
success: true,
Expand Down
27 changes: 26 additions & 1 deletion frontend/tests/unit/import-servers-completion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe('ImportServers detected completion state', () => {
expect(message.text()).toBe('✓ 2 servers imported')
expect(message.text()).not.toContain('skipped')
expect(wrapper.find('[data-test="detected-import-empty"]').exists()).toBe(false)
expect(wrapper.emitted('imported')?.[0]).toEqual([2])
expect(wrapper.emitted('imported')?.[0]).toEqual([2, ['fetchy', 'thinker']])
})

it('renders nothing for the empty state when showEmpty is false and nothing is detected at mount', async () => {
Expand All @@ -91,3 +91,28 @@ describe('ImportServers detected completion state', () => {
expect(wrapper.find('[data-test="detected-import-message"]').text()).toBe('✓ 2 servers imported')
})
})

describe('ImportServers paste import count (#1466)', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('emits the number the backend actually imported, not the number selected', async () => {
;(api.importServersFromJSON as any)
.mockResolvedValueOnce({
success: true,
data: { imported: [{ name: 'a' }, { name: 'b' }] },
})
.mockResolvedValueOnce({
success: true,
data: { summary: { imported: 1 }, imported: [{ name: 'a' }], skipped: [{ name: 'b', reason: 'already_exists' }] },
})
const wrapper = mount(ImportServers, { props: { detected: false } })
await wrapper.find('[data-test="import-content-textarea"]').setValue('{"mcpServers":{}}')
await wrapper.find('[data-test="import-preview-button"]').trigger('click')
await flushPromises()
await wrapper.find('[data-test="import-confirm-button"]').trigger('click')
await flushPromises()
expect(wrapper.emitted('imported')?.[0]).toEqual([1, ['a', 'b']])
})
})
2 changes: 1 addition & 1 deletion frontend/tests/unit/import-servers-shared.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('ImportServers', () => {
await wrapper.find('[data-test="bulk-import-primary"]').trigger('click')
await flushPromises()
expect(api.importServersFromPath).toHaveBeenCalledWith(expect.objectContaining({ server_names: ['github'], skip_quarantine: false }))
expect(wrapper.emitted('imported')?.[0]).toEqual([1])
expect(wrapper.emitted('imported')?.[0]).toEqual([1, ['github']])
})

it('renames duplicate selected names and can bypass quarantine explicitly', async () => {
Expand Down
Loading
Loading