Skip to content

Commit 2c4074f

Browse files
committed
feat(060): settings page UX — cross-section search, posture summary, dirty/discard
Make the settings page genuinely useful for finding and reviewing options: - Cross-section search box: type to surface matching settings from every section (Security/General/Advanced/Teams) in one editable list. - Security posture summary: at-a-glance colour-coded chips (quarantine, MCP auth, Docker isolation, secret scan, code exec, read-only, reveal headers), flagging anything that warrants review. - Per-field dirty highlight (left accent + ● badge) and a per-section Discard button to revert unsaved changes. Relates to Spec 060.
1 parent 92bb43a commit 2c4074f

3 files changed

Lines changed: 103 additions & 17 deletions

File tree

frontend/src/components/settings/SettingField.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
<template>
22
<div
3-
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 py-3 border-b border-base-200 last:border-0"
3+
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 py-3 px-2 -mx-2 border-b border-base-200 last:border-0 rounded transition-colors"
4+
:class="dirty ? 'bg-warning/5 border-l-2 border-l-warning' : 'border-l-2 border-l-transparent'"
45
:data-test="`setting-row-${field.key}`"
56
>
67
<!-- Label + help + badges -->
78
<div class="sm:max-w-md">
89
<div class="flex items-center gap-2 flex-wrap">
910
<span class="font-medium">{{ field.label }}</span>
11+
<span v-if="dirty" class="badge badge-warning badge-xs" title="Unsaved change">●</span>
1012
<span v-if="field.restart" class="badge badge-warning badge-xs gap-1" title="Requires restart">
1113
restart
1214
</span>
@@ -110,7 +112,7 @@
110112
import { ref, computed } from 'vue'
111113
import type { SettingField } from '@/views/settings/fields'
112114
113-
const props = defineProps<{ field: SettingField; modelValue: any }>()
115+
const props = defineProps<{ field: SettingField; modelValue: any; dirty?: boolean }>()
114116
const emit = defineEmits<{ (e: 'update:modelValue', v: any): void }>()
115117
116118
const showSecret = ref(false)

frontend/src/components/settings/SettingsSection.vue

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
:key="f.key"
66
:field="f"
77
:model-value="getPath(working, f.key)"
8+
:dirty="!!dirty[f.key]"
89
@update:model-value="onChange(f, $event)"
910
/>
11+
<p v-if="!fields.length" class="text-sm text-base-content/50 py-4">No settings match your search.</p>
1012

1113
<!-- per-section action bar -->
1214
<div class="flex items-center justify-between pt-2">
@@ -22,15 +24,26 @@
2224
{{ dirtyKeys.length }} unsaved change{{ dirtyKeys.length > 1 ? 's' : '' }}
2325
</span>
2426
</div>
25-
<button
26-
class="btn btn-primary btn-sm"
27-
:disabled="!dirtyKeys.length || saving || hasInvalid"
28-
:data-test="`settings-apply-${sectionId}`"
29-
@click="attemptSave"
30-
>
31-
<span v-if="saving" class="loading loading-spinner loading-xs"></span>
32-
Save changes
33-
</button>
27+
<div class="flex items-center gap-2">
28+
<button
29+
v-if="dirtyKeys.length"
30+
class="btn btn-ghost btn-sm"
31+
:disabled="saving"
32+
:data-test="`settings-discard-${sectionId}`"
33+
@click="discard"
34+
>
35+
Discard
36+
</button>
37+
<button
38+
class="btn btn-primary btn-sm"
39+
:disabled="!dirtyKeys.length || saving || hasInvalid"
40+
:data-test="`settings-apply-${sectionId}`"
41+
@click="attemptSave"
42+
>
43+
<span v-if="saving" class="loading loading-spinner loading-xs"></span>
44+
Save changes
45+
</button>
46+
</div>
3447
</div>
3548

3649
<!-- danger confirm -->
@@ -129,6 +142,15 @@ function dangerMessages(): string[] {
129142
return msgs
130143
}
131144
145+
function discard() {
146+
for (const key of dirtyKeys.value) {
147+
setPath(props.working, key, getPath(props.original, key))
148+
}
149+
dirty.value = {}
150+
lastResult.value = null
151+
error.value = ''
152+
}
153+
132154
function attemptSave() {
133155
const msgs = dangerMessages()
134156
if (msgs.length) {

frontend/src/views/Settings.vue

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,29 @@
88
Manage mcpproxy settings. Changes save instantly; a badge marks fields that need a restart.
99
</p>
1010
</div>
11-
<button class="btn btn-sm btn-outline" :disabled="loading" @click="loadConfig" data-test="settings-reload">
12-
<span v-if="loading" class="loading loading-spinner loading-xs"></span>
13-
<span v-else>Reload</span>
14-
</button>
11+
<div class="flex items-center gap-2">
12+
<label class="input input-bordered input-sm flex items-center gap-2 w-64">
13+
<span class="opacity-50">🔍</span>
14+
<input v-model="search" type="text" class="grow" placeholder="Search all settings…" data-test="settings-search" />
15+
<button v-if="search" class="opacity-50 hover:opacity-100" @click="search = ''" title="Clear">✕</button>
16+
</label>
17+
<button class="btn btn-sm btn-outline" :disabled="loading" @click="loadConfig" data-test="settings-reload">
18+
<span v-if="loading" class="loading loading-spinner loading-xs"></span>
19+
<span v-else>Reload</span>
20+
</button>
21+
</div>
22+
</div>
23+
24+
<!-- Search results (across all sections) -->
25+
<div v-if="loaded && search.trim()" class="card bg-base-100 shadow-md" data-test="settings-search-results">
26+
<div class="card-body">
27+
<h2 class="card-title text-lg">🔍 Search results <span class="text-sm font-normal text-base-content/60">({{ filteredFields.length }})</span></h2>
28+
<SettingsSection section-id="search" :fields="filteredFields" :working="state.working" :original="state.original" />
29+
</div>
1530
</div>
1631

1732
<!-- Tabs -->
18-
<div role="tablist" class="tabs tabs-bordered" data-test="settings-tabs">
33+
<div v-show="!search.trim()" role="tablist" class="tabs tabs-bordered" data-test="settings-tabs">
1934
<button
2035
v-for="t in tabs"
2136
:key="t.id"
@@ -33,12 +48,24 @@
3348
<span>{{ loadError }}</span>
3449
</div>
3550

36-
<template v-else-if="loaded">
51+
<template v-else-if="loaded && !search.trim()">
3752
<!-- Security & Access -->
3853
<div v-show="activeTab === 'security'" class="card bg-base-100 shadow-md">
3954
<div class="card-body">
4055
<h2 class="card-title text-lg">🔒 Security &amp; Access</h2>
4156
<p class="text-sm text-base-content/60 mb-2">The settings that most affect how exposed and protected your instance is.</p>
57+
<!-- posture summary -->
58+
<div class="flex flex-wrap gap-2 mb-4" data-test="settings-posture">
59+
<span
60+
v-for="p in posture"
61+
:key="p.label"
62+
class="badge gap-1"
63+
:class="p.good ? 'badge-success badge-outline' : 'badge-warning'"
64+
:title="p.good ? 'OK' : 'Review this'"
65+
>
66+
{{ p.label }}: {{ p.on ? 'on' : 'off' }}
67+
</span>
68+
</div>
4269
<SettingsSection section-id="security" :fields="securityFields" :working="state.working" :original="state.original" />
4370
</div>
4471
</div>
@@ -154,6 +181,41 @@ const activeTab = ref<string>('security')
154181
const state = reactive<{ working: any; original: any }>({ working: {}, original: {} })
155182
const hasTeams = computed(() => state.working && state.working.teams != null)
156183
184+
// cross-section search: type to find any setting across all tabs
185+
const search = ref('')
186+
const allFields = computed<SettingField[]>(() => [
187+
...securityFields,
188+
...generalFields,
189+
...advancedAccordions.flatMap((a) => a.fields),
190+
...(hasTeams.value ? teamsFields : []),
191+
])
192+
const filteredFields = computed<SettingField[]>(() => {
193+
const q = search.value.trim().toLowerCase()
194+
if (!q) return []
195+
return allFields.value.filter(
196+
(f) =>
197+
f.label.toLowerCase().includes(q) ||
198+
f.key.toLowerCase().includes(q) ||
199+
(f.help?.toLowerCase().includes(q) ?? false)
200+
)
201+
})
202+
203+
// at-a-glance security posture (computed from the working config)
204+
const posture = computed(() => {
205+
const w: any = state.working || {}
206+
const sdd = w.sensitive_data_detection?.enabled !== false
207+
const quarantine = w.quarantine_enabled !== false // default-on
208+
return [
209+
{ label: 'Quarantine', on: quarantine, good: quarantine },
210+
{ label: 'MCP auth', on: !!w.require_mcp_auth, good: !!w.require_mcp_auth },
211+
{ label: 'Docker isolation', on: !!(w.docker_isolation && w.docker_isolation.enabled), good: !!(w.docker_isolation && w.docker_isolation.enabled) },
212+
{ label: 'Secret scan', on: sdd, good: sdd },
213+
{ label: 'Code exec', on: !!w.enable_code_execution, good: !w.enable_code_execution },
214+
{ label: 'Read-only', on: !!w.read_only_mode, good: true },
215+
{ label: 'Reveal headers', on: !!w.reveal_secret_headers, good: !w.reveal_secret_headers },
216+
]
217+
})
218+
157219
const tabs = computed(() => {
158220
const base = [
159221
{ id: 'security', label: 'Security & Access', icon: '🔒' },

0 commit comments

Comments
 (0)