Skip to content

Commit be57406

Browse files
feat(webui): profile switcher in header (Profiles v2 T4, MCP-3243) (#765)
* feat(webui): profile switcher in header (Profiles v2 T4, MCP-3243) Add a Web UI profile switcher that consumes the REST /api/v1/profiles surface shipped in MCP-3241: - GET /api/v1/profiles — configured profiles with effective servers + tool count - GET/PUT /api/v1/profiles/active — read/set the server-level default active profile (empty slug = all servers) New pieces: - ProfileSummary/ActiveProfileResponse types + api.getProfiles/getActiveProfile/ setActiveProfile - useProfilesStore pinia store (fetch, setActive, activeLabel/isAllServers) - ProfileSwitcher.vue dropdown in TopHeader showing each profile's server membership + tool count, active badge, and a zero-profile empty state - vitest coverage for the component + api methods Refs MCP-3243 Co-Authored-By: Paperclip <noreply@paperclip.ing> * docs(qa): MCP-3243 profile switcher Playwright verification report 5/5 Playwright scenarios green against a live core with two configured profiles; self-contained HTML report + ordered screenshots + the spec. Refs MCP-3243 Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent 1dec5aa commit be57406

14 files changed

Lines changed: 569 additions & 2 deletions

File tree

105 KB
Loading
116 KB
Loading
124 KB
Loading
133 KB
Loading
124 KB
Loading

docs/qa/mcp-3243-profile-switcher/report.html

Lines changed: 29 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
const BASE = 'http://127.0.0.1:18243';
4+
const KEY = 'test-mcp3243-key';
5+
const SHOTS = '/tmp/uitest-mcp3243/shots';
6+
7+
test.beforeEach(async ({ page }) => {
8+
// Suppress the first-run onboarding wizard modal (its backdrop would
9+
// intercept clicks on the header switcher).
10+
await page.request.post(`${BASE}/api/v1/onboarding/mark`, {
11+
headers: { 'X-API-Key': KEY, 'Content-Type': 'application/json' },
12+
data: { engaged: true },
13+
});
14+
// Reset default to "all servers" so each run starts clean.
15+
await page.request.put(`${BASE}/api/v1/profiles/active`, {
16+
headers: { 'X-API-Key': KEY, 'Content-Type': 'application/json' },
17+
data: { profile: '' },
18+
});
19+
// Seed the API key into localStorage before the SPA boots so the very first
20+
// render never races into an auth-error modal.
21+
await page.addInitScript((k) => { try { localStorage.setItem('mcpproxy-api-key', k); } catch {} }, KEY);
22+
await page.goto(`${BASE}/?apikey=${KEY}`);
23+
await page.waitForLoadState('domcontentloaded');
24+
await expect(page.locator('[data-test="profile-switcher-button"]')).toBeVisible();
25+
});
26+
27+
test('1 default shows All servers', async ({ page }) => {
28+
await expect(page.locator('[data-test="profile-switcher-active"]')).toHaveText('All servers');
29+
await page.screenshot({ path: `${SHOTS}/01-default-all-servers.png` });
30+
});
31+
32+
test('2 open menu lists profiles with server + tool counts', async ({ page }) => {
33+
await page.locator('[data-test="profile-switcher-button"]').click();
34+
await expect(page.locator('[data-test="profile-switcher-menu"]')).toBeVisible();
35+
const dev = page.locator('[data-test="profile-option-dev"]');
36+
await expect(dev).toContainText('dev');
37+
await expect(dev).toContainText('2 servers');
38+
await expect(page.locator('[data-test="profile-option-solo"]')).toContainText('1 server');
39+
// All-servers active badge present by default.
40+
await expect(page.locator('[data-test="profile-option-all"] [data-test="profile-active-badge"]')).toBeVisible();
41+
await page.screenshot({ path: `${SHOTS}/02-menu-open.png` });
42+
});
43+
44+
test('3 select dev updates label + badge', async ({ page }) => {
45+
await page.locator('[data-test="profile-switcher-button"]').click();
46+
await page.locator('[data-test="profile-option-dev"]').click();
47+
await expect(page.locator('[data-test="profile-switcher-active"]')).toHaveText('dev');
48+
await page.screenshot({ path: `${SHOTS}/03-selected-dev.png` });
49+
// Reopen — badge moved to dev.
50+
await page.locator('[data-test="profile-switcher-button"]').click();
51+
await expect(page.locator('[data-test="profile-active-badge-dev"]')).toBeVisible();
52+
await page.screenshot({ path: `${SHOTS}/04-dev-active-in-menu.png` });
53+
});
54+
55+
test('5 clear back to All servers', async ({ page }) => {
56+
await page.locator('[data-test="profile-switcher-button"]').click();
57+
await page.locator('[data-test="profile-option-dev"]').click();
58+
await expect(page.locator('[data-test="profile-switcher-active"]')).toHaveText('dev');
59+
await page.locator('[data-test="profile-switcher-button"]').click();
60+
await page.locator('[data-test="profile-option-all"]').click();
61+
await expect(page.locator('[data-test="profile-switcher-active"]')).toHaveText('All servers');
62+
await page.screenshot({ path: `${SHOTS}/05-cleared.png` });
63+
});
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<template>
2+
<!-- Profiles v2 (MCP-3243 / T4): default active-profile switcher. Mirrors the
3+
MCP-endpoints dropdown affordance in the header. -->
4+
<div class="relative" data-test="profile-switcher">
5+
<button
6+
type="button"
7+
data-test="profile-switcher-button"
8+
class="flex items-center space-x-2 px-3 py-2 bg-base-200 rounded-lg cursor-pointer hover:bg-base-300 transition-colors text-sm"
9+
:title="buttonTitle"
10+
@click="toggle"
11+
>
12+
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
13+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
14+
</svg>
15+
<span class="text-xs opacity-60">Profile:</span>
16+
<span class="font-medium truncate max-w-[10rem]" data-test="profile-switcher-active">{{ activeLabel }}</span>
17+
<svg
18+
class="w-3 h-3 opacity-60 transition-transform"
19+
:class="{ 'rotate-180': open }"
20+
fill="none"
21+
stroke="currentColor"
22+
viewBox="0 0 24 24"
23+
>
24+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
25+
</svg>
26+
</button>
27+
28+
<div
29+
v-if="open"
30+
class="absolute right-0 top-full mt-2 p-2 shadow-lg bg-base-100 rounded-box w-80 border border-base-300 z-50"
31+
data-test="profile-switcher-menu"
32+
>
33+
<div class="text-xs font-semibold opacity-60 mb-1 px-2 pt-1">Active profile</div>
34+
35+
<!-- "All servers" / zero-config default — always selectable to clear. -->
36+
<button
37+
type="button"
38+
data-test="profile-option-all"
39+
class="w-full text-left px-2 py-2 rounded hover:bg-base-200 flex items-center justify-between group"
40+
:class="{ 'bg-base-200': isAllServers }"
41+
@click="select('')"
42+
>
43+
<div class="min-w-0">
44+
<div class="flex items-center space-x-2">
45+
<span class="font-medium">All servers</span>
46+
<span v-if="isAllServers" class="badge badge-xs badge-primary" data-test="profile-active-badge">active</span>
47+
</div>
48+
<div class="text-xs opacity-50 mt-0.5">No profile filter — every enabled server is in scope</div>
49+
</div>
50+
<svg v-if="isAllServers" class="w-4 h-4 text-success shrink-0 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
51+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
52+
</svg>
53+
</button>
54+
55+
<div v-if="profilesStore.hasProfiles" class="divider my-1 opacity-40" />
56+
57+
<!-- Empty / zero-profile state. -->
58+
<div
59+
v-if="!profilesStore.hasProfiles && !profilesStore.loading"
60+
class="px-2 py-3 text-xs opacity-60"
61+
data-test="profile-switcher-empty"
62+
>
63+
No profiles configured. Add a <code class="font-mono">profiles</code> block to your config to scope tool
64+
discovery to a subset of servers.
65+
</div>
66+
67+
<div v-if="profilesStore.loading && !profilesStore.loaded" class="px-2 py-3 text-xs opacity-60" data-test="profile-switcher-loading">
68+
Loading profiles…
69+
</div>
70+
71+
<!-- Configured profiles. -->
72+
<button
73+
v-for="p in profilesStore.profiles"
74+
:key="p.name"
75+
type="button"
76+
:data-test="`profile-option-${p.name}`"
77+
class="w-full text-left px-2 py-2 rounded hover:bg-base-200 flex items-center justify-between group"
78+
:class="{ 'bg-base-200': p.name === profilesStore.activeProfile }"
79+
@click="select(p.name)"
80+
>
81+
<div class="min-w-0">
82+
<div class="flex items-center space-x-2">
83+
<span class="font-medium truncate">{{ p.name }}</span>
84+
<span
85+
v-if="p.name === profilesStore.activeProfile"
86+
class="badge badge-xs badge-primary"
87+
:data-test="`profile-active-badge-${p.name}`"
88+
>active</span>
89+
</div>
90+
<div class="text-xs opacity-50 mt-0.5">
91+
{{ p.servers.length }} {{ p.servers.length === 1 ? 'server' : 'servers' }}
92+
· {{ p.tool_count }} {{ p.tool_count === 1 ? 'tool' : 'tools' }}
93+
</div>
94+
</div>
95+
<svg
96+
v-if="p.name === profilesStore.activeProfile"
97+
class="w-4 h-4 text-success shrink-0 ml-2"
98+
fill="none"
99+
stroke="currentColor"
100+
viewBox="0 0 24 24"
101+
>
102+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
103+
</svg>
104+
</button>
105+
106+
<div
107+
v-if="profilesStore.error"
108+
class="px-2 py-2 text-xs text-error"
109+
data-test="profile-switcher-error"
110+
>
111+
{{ profilesStore.error }}
112+
</div>
113+
</div>
114+
115+
<!-- Click-outside overlay -->
116+
<div v-if="open" class="fixed inset-0 z-40" @click="open = false" />
117+
</div>
118+
</template>
119+
120+
<script setup lang="ts">
121+
import { ref, computed, onMounted } from 'vue'
122+
import { useProfilesStore } from '@/stores/profiles'
123+
124+
const profilesStore = useProfilesStore()
125+
126+
const open = ref(false)
127+
const activeLabel = computed(() => profilesStore.activeLabel)
128+
const isAllServers = computed(() => profilesStore.isAllServers)
129+
const buttonTitle = computed(() =>
130+
isAllServers.value
131+
? 'Active profile: All servers (no filter)'
132+
: `Active profile: ${profilesStore.activeProfile}`
133+
)
134+
135+
function toggle() {
136+
open.value = !open.value
137+
// Refresh on open so tool counts / membership reflect the latest index.
138+
if (open.value) {
139+
void profilesStore.fetchProfiles()
140+
}
141+
}
142+
143+
async function select(name: string) {
144+
// No-op if already selected — just close.
145+
if (name === profilesStore.activeProfile) {
146+
open.value = false
147+
return
148+
}
149+
await profilesStore.setActive(name)
150+
open.value = false
151+
}
152+
153+
onMounted(() => {
154+
void profilesStore.fetchProfiles()
155+
})
156+
</script>

frontend/src/components/TopHeader.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@
4444

4545
<!-- Right: Stats + Proxy Info -->
4646
<div class="hidden md:flex items-center space-x-3 shrink-0">
47+
<!-- Profile switcher (Profiles v2 / MCP-3243) -->
48+
<ProfileSwitcher />
49+
4750
<!-- Servers -->
4851
<div class="flex items-center space-x-2 px-3 py-2 bg-base-200 rounded-lg text-sm">
4952
<div
@@ -137,6 +140,7 @@ import { useSystemStore } from '@/stores/system'
137140
import { useServersStore } from '@/stores/servers'
138141
import { useAuthStore } from '@/stores/auth'
139142
import AddServerModal from './AddServerModal.vue'
143+
import ProfileSwitcher from './ProfileSwitcher.vue'
140144
141145
const router = useRouter()
142146
const systemStore = useSystemStore()

frontend/src/services/__tests__/api.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,43 @@ describe('APIService', () => {
4444
expect(result.success).toBe(false)
4545
expect(result.error).toContain('HTTP 404')
4646
})
47-
})
47+
48+
// Profiles v2 (MCP-3243 / T4): the switcher consumes the REST surface from
49+
// MCP-3241.
50+
it('getProfiles GETs /api/v1/profiles', async () => {
51+
const mockResponse = {
52+
success: true,
53+
data: { profiles: [{ name: 'dev', servers: ['github'], tool_count: 3 }] }
54+
}
55+
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => mockResponse })
56+
57+
const result = await apiService.getProfiles()
58+
expect(global.fetch).toHaveBeenCalledWith('/api/v1/profiles', expect.any(Object))
59+
expect(result).toEqual(mockResponse)
60+
})
61+
62+
it('setActiveProfile PUTs the slug to /api/v1/profiles/active', async () => {
63+
const mockResponse = { success: true, data: { active_profile: 'dev' } }
64+
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => mockResponse })
65+
66+
const result = await apiService.setActiveProfile('dev')
67+
expect(global.fetch).toHaveBeenCalledWith(
68+
'/api/v1/profiles/active',
69+
expect.objectContaining({ method: 'PUT', body: JSON.stringify({ profile: 'dev' }) })
70+
)
71+
expect(result).toEqual(mockResponse)
72+
})
73+
74+
it('setActiveProfile sends an empty slug to clear the active profile', async () => {
75+
;(global.fetch as any).mockResolvedValueOnce({
76+
ok: true,
77+
json: async () => ({ success: true, data: { active_profile: '' } })
78+
})
79+
80+
await apiService.setActiveProfile('')
81+
expect(global.fetch).toHaveBeenCalledWith(
82+
'/api/v1/profiles/active',
83+
expect.objectContaining({ method: 'PUT', body: JSON.stringify({ profile: '' }) })
84+
)
85+
})
86+
})

0 commit comments

Comments
 (0)