Skip to content

Commit 0acd7f8

Browse files
committed
feat(pii): detector-models table with inline toggles in middleware UI
Replace the default-detector model chooser on the PII/Filtering tab with a table of every token-classify detector model (NER and pattern). Each row has a "default detector" toggle (persisted to pii_default_detectors via settings), an Edit link to the model config, and the table offers an "Add detector model" action seeded from the secret-filter template. Detectors named as defaults but not loaded are shown as "not loaded". The per-model state table's PII column becomes an inline toggle that PATCHes pii.enabled for that model. buildPIIStatus now returns detector_models so the UI can render the table. Assisted-by: claude-code:claude-opus-4-8 [Claude Code]
1 parent 8eb77f5 commit 0acd7f8

5 files changed

Lines changed: 297 additions & 69 deletions

File tree

core/http/react-ui/e2e/middleware-page.spec.js

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,16 @@ const MOCK_STATUS = {
1414
{ name: 'claude-strict', backend: 'cloud-proxy', enabled: true, explicit: true, default_for_backend: true, detectors: ['privacy-filter-multilingual'] },
1515
],
1616
recent_event_count: 2,
17-
// Instance-wide default detector (Default PII policy editor).
17+
// Instance-wide default detector set (managed by the Detector models
18+
// table's per-row Default toggle).
1819
default_detectors: ['global-ner-default'],
20+
// The token_classify "filter" models themselves: one NER, one in-process
21+
// pattern matcher, plus an orphan default that names a model not loaded.
22+
detector_models: [
23+
{ name: 'privacy-filter-multilingual', backend: 'llama-cpp', type: 'ner', default: false },
24+
{ name: 'secret-filter', backend: 'pattern', type: 'pattern', default: false },
25+
{ name: 'global-ner-default', backend: '', type: 'unknown', default: true, missing: true },
26+
],
1927
},
2028
router: {
2129
configured: true,
@@ -122,6 +130,10 @@ test.describe('Middleware page — admin in no-auth mode', () => {
122130
await page.route('**/api/settings', (route) =>
123131
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true }) })
124132
)
133+
// The per-model PII toggle PATCHes the model config (pii.enabled).
134+
await page.route('**/api/models/config-json/**', (route) =>
135+
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true }) })
136+
)
125137
})
126138

127139
test('Filtering tab renders per-model state and referenced detectors', async ({ page }) => {
@@ -138,18 +150,38 @@ test.describe('Middleware page — admin in no-auth mode', () => {
138150
await expect(page.getByText(/cloud-proxy/).first()).toBeVisible()
139151
})
140152

141-
test('Filtering tab shows the instance-wide Default PII policy editor', async ({ page }) => {
153+
test('Filtering tab lists detector models with type badges and a default toggle', async ({ page }) => {
142154
await page.goto('/app/middleware')
143155

144-
// The default-policy card and its detector picker render.
145-
await expect(page.getByText('Default PII policy')).toBeVisible()
146-
await expect(page.getByText('Default detector model(s)')).toBeVisible()
156+
// The Detector models card renders every token_classify filter model.
157+
await expect(page.getByText('Detector models')).toBeVisible()
158+
const nerRow = page.locator('tr').filter({ hasText: 'privacy-filter-multilingual' }).first()
159+
await expect(nerRow).toContainText(/NER/i)
160+
const patternRow = page.locator('tr').filter({ hasText: 'secret-filter' }).first()
161+
await expect(patternRow).toContainText(/pattern/i)
162+
163+
// The NER detector is not (yet) a default — its toggle is unchecked.
164+
// (The underlying checkbox is 0×0 by design, so we click the label wrapper.)
165+
const nerToggle = nerRow.locator('label.toggle')
166+
await expect(nerToggle.locator('input[type="checkbox"]')).not.toBeChecked()
167+
168+
// Toggling it on persists the new default set via POST /api/settings.
169+
const saved = page.waitForRequest(req =>
170+
req.url().includes('/api/settings') && req.method() === 'POST')
171+
await nerToggle.click()
172+
const req = await saved
173+
const body = JSON.parse(req.postData() || '{}')
174+
expect(body.pii_default_detectors).toContain('privacy-filter-multilingual')
175+
})
176+
177+
test('Filtering tab surfaces an orphan default detector that is not loaded', async ({ page }) => {
178+
await page.goto('/app/middleware')
147179

148-
// The configured default detector renders as a removable chip (not a
149-
// stack of input rows) with its own remove control.
150-
const chip = page.locator('span').filter({ hasText: 'global-ner-default' }).first()
151-
await expect(chip).toBeVisible()
152-
await expect(chip.getByRole('button', { name: /Remove global-ner-default/i })).toBeVisible()
180+
// global-ner-default names a model that is not loaded, but it is in the
181+
// default set — it must still appear (toggled on) so admins can remove it.
182+
const orphanRow = page.locator('tr').filter({ hasText: 'global-ner-default' }).first()
183+
await expect(orphanRow).toContainText(/not loaded/i)
184+
await expect(orphanRow.locator('label.toggle input[type="checkbox"]')).toBeChecked()
153185
})
154186

155187
test('Filtering tab flags an enabled model with no detector as a no-op', async ({ page }) => {
@@ -166,6 +198,25 @@ test.describe('Middleware page — admin in no-auth mode', () => {
166198
await expect(okRow).not.toContainText(/no-op/i)
167199
})
168200

201+
test('Filtering tab PII column toggles a model\'s pii.enabled via PATCH', async ({ page }) => {
202+
await page.goto('/app/middleware')
203+
204+
// qwen-7b is OFF (enabled:false) — its PII toggle reads unchecked.
205+
const row = page.locator('tr').filter({ hasText: 'qwen-7b' }).first()
206+
const toggle = row.locator('label.toggle')
207+
await expect(toggle.locator('input[type="checkbox"]')).not.toBeChecked()
208+
209+
// Toggling on PATCHes the model config with an explicit pii.enabled:true,
210+
// scoped to that model (no other field is sent — the server deep-merges).
211+
const patched = page.waitForRequest(req =>
212+
req.url().includes('/api/models/config-json/') && req.method() === 'PATCH')
213+
await toggle.click()
214+
const req = await patched
215+
expect(decodeURIComponent(req.url())).toContain('qwen-7b')
216+
const body = JSON.parse(req.postData() || '{}')
217+
expect(body.pii.enabled).toBe(true)
218+
})
219+
169220
test('Routing tab renders configured routers and recent decisions', async ({ page }) => {
170221
await page.goto('/app/middleware')
171222
await page.getByRole('button', { name: /Routing/i }).click()

core/http/react-ui/src/pages/Middleware.jsx

Lines changed: 161 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@ import { useState, useEffect, useCallback, useRef, useMemo, Fragment } from 'rea
22
import { useOutletContext, Link, useNavigate, useLocation, useSearchParams } from 'react-router-dom'
33
import { apiUrl } from '../utils/basePath'
44
import { fromState } from '../utils/editorNav'
5-
import { settingsApi } from '../utils/api'
5+
import { settingsApi, modelsApi } from '../utils/api'
66
import LoadingSpinner from '../components/LoadingSpinner'
7-
import ModelMultiSelect from '../components/ModelMultiSelect'
8-
import { CAP_TOKEN_CLASSIFY } from '../utils/capabilities'
7+
import Toggle from '../components/Toggle'
98

109
// Middleware admin page. Three tabs:
1110
// - Filtering: per-model resolved PII state + per-model detector list
@@ -174,6 +173,28 @@ export default function Middleware() {
174173

175174
function FilteringTab({ status, addToast, onChanged }) {
176175
const location = useLocation()
176+
// Rows mid-save, so just that model's toggle disables while the PATCH
177+
// round-trips (and the 5s background poll re-syncs the resolved state).
178+
const [piiBusy, setPiiBusy] = useState(() => new Set())
179+
180+
// Toggling the PII column writes an explicit pii.enabled to the model YAML
181+
// via PATCH /api/models/config-json/:name (a deep-merge that preserves
182+
// pii.detectors and every other field). This makes the resolved state
183+
// explicit: a cloud-proxy model shown ON by backend default becomes
184+
// pii.enabled:true; toggling it OFF writes pii.enabled:false.
185+
const togglePII = async (name, on) => {
186+
setPiiBusy(prev => new Set(prev).add(name))
187+
try {
188+
await modelsApi.patchConfig(name, { pii: { enabled: on } })
189+
addToast?.(on ? `PII filtering enabled for ${name}` : `PII filtering disabled for ${name}`, 'success')
190+
onChanged?.()
191+
} catch (err) {
192+
addToast?.(`Failed to update ${name}: ${err.message}`, 'error')
193+
} finally {
194+
setPiiBusy(prev => { const n = new Set(prev); n.delete(name); return n })
195+
}
196+
}
197+
177198
if (!status?.pii) return null
178199
const pii = status.pii
179200

@@ -192,15 +213,15 @@ function FilteringTab({ status, addToast, onChanged }) {
192213
</div>
193214
</div>
194215

195-
{/* Instance-wide default policy */}
196-
<DefaultPIIPolicy pii={pii} addToast={addToast} onChanged={onChanged} />
216+
{/* Detector models + instance-wide default policy (per-row toggle) */}
217+
<DetectorModels pii={pii} addToast={addToast} onChanged={onChanged} />
197218

198219
{/* Per-model resolved state */}
199220
<div className="card" style={{ padding: 'var(--spacing-md)' }}>
200221
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-sm)' }}>
201222
<span style={{ fontSize: '0.875rem', fontWeight: 600 }}>Per-model state</span>
202223
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
203-
Edit the model YAML to change these.
224+
Toggle PII inline; edit a row for detectors and policy.
204225
</span>
205226
</div>
206227
<div className="table-container">
@@ -209,7 +230,7 @@ function FilteringTab({ status, addToast, onChanged }) {
209230
<tr>
210231
<th>Model</th>
211232
<th style={{ width: 120 }}>Backend</th>
212-
<th style={{ width: 80 }}>PII</th>
233+
<th style={{ width: 120 }}>PII</th>
213234
<th style={{ width: 110 }}>Source</th>
214235
<th>Detectors</th>
215236
<th style={{ width: 80 }}>Edit</th>
@@ -221,15 +242,21 @@ function FilteringTab({ status, addToast, onChanged }) {
221242
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem' }}>{m.name}</td>
222243
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>{m.backend || '—'}</td>
223244
<td>
224-
{enabledBadge(m.enabled)}
225-
{m.enabled && (!m.detectors || m.detectors.length === 0) && (
226-
<span
227-
title="Enabled but no detector resolved — nothing is scanned. Set a default detector below or add pii.detectors to the model."
228-
style={{ marginLeft: 6, fontSize: '0.6875rem', fontWeight: 600, color: 'var(--color-warning)', whiteSpace: 'nowrap', cursor: 'help' }}
229-
>
230-
<i className="fas fa-triangle-exclamation" style={{ marginRight: 3 }} />no-op
231-
</span>
232-
)}
245+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
246+
<Toggle
247+
checked={!!m.enabled}
248+
disabled={piiBusy.has(m.name)}
249+
onChange={(v) => togglePII(m.name, v)}
250+
/>
251+
{m.enabled && (!m.detectors || m.detectors.length === 0) && (
252+
<span
253+
title="Enabled but no detector resolved — nothing is scanned. Toggle a detector's Default on above, or add pii.detectors to the model."
254+
style={{ fontSize: '0.6875rem', fontWeight: 600, color: 'var(--color-warning)', whiteSpace: 'nowrap', cursor: 'help' }}
255+
>
256+
<i className="fas fa-triangle-exclamation" style={{ marginRight: 3 }} />no-op
257+
</span>
258+
)}
259+
</span>
233260
</td>
234261
<td style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
235262
{m.explicit ? 'YAML' : (m.default_for_backend ? 'backend default' : 'default off')}
@@ -267,57 +294,142 @@ function FilteringTab({ status, addToast, onChanged }) {
267294
)
268295
}
269296

270-
// DefaultPIIPolicy edits the instance-wide default detector (RuntimeSettings,
271-
// saved via POST /api/settings): a fallback applied to any PII-enabled model
272-
// that names none of its own — chiefly cloud-proxy / MITM models, which are
273-
// PII-enabled by default but carry no detector list. Mirrors ProxyTab's
274-
// read-from-status / save-via-settingsApi / re-sync-when-not-dirty pattern.
275-
function DefaultPIIPolicy({ pii, addToast, onChanged }) {
276-
const serverDetectors = useMemo(() => pii.default_detectors || [], [pii.default_detectors])
277-
278-
const [detectors, setDetectors] = useState(serverDetectors)
279-
const [saving, setSaving] = useState(false)
280-
281-
const sameSet = (a, b) => a.length === b.length && [...a].sort().join('|') === [...b].sort().join('|')
282-
const dirty = !sameSet(detectors, serverDetectors)
297+
// detectorTypeBadge labels a detector model by how it matches: a neural NER
298+
// token-classifier vs an in-process restricted-regex pattern matcher. `unknown`
299+
// is a default that names a model no longer loaded.
300+
function detectorTypeBadge(type) {
301+
const map = {
302+
ner: { label: 'NER', color: 'var(--color-primary)' },
303+
pattern: { label: 'pattern', color: 'var(--color-data-2, var(--color-warning))' },
304+
unknown: { label: 'not loaded', color: 'var(--color-text-muted)' },
305+
}
306+
const t = map[type] || map.unknown
307+
return (
308+
<span style={{
309+
display: 'inline-block',
310+
padding: '2px 8px',
311+
fontSize: '0.6875rem',
312+
fontWeight: 600,
313+
borderRadius: 'var(--radius-sm)',
314+
background: t.color,
315+
color: 'white',
316+
fontFamily: 'var(--font-mono)',
317+
textTransform: 'uppercase',
318+
}}>
319+
{t.label}
320+
</span>
321+
)
322+
}
283323

284-
// Re-sync from the server only when the user has no pending edits to clobber.
285-
useEffect(() => {
286-
if (dirty) return
287-
setDetectors(serverDetectors)
288-
// eslint-disable-next-line react-hooks/exhaustive-deps
289-
}, [serverDetectors])
324+
// DetectorModels lists the token_classify "filter" models (NER + in-process
325+
// pattern matchers) and, via a per-row toggle, manages the instance-wide
326+
// default detector set (RuntimeSettings.pii_default_detectors, saved via POST
327+
// /api/settings). A detector toggled on is applied to any PII-enabled model
328+
// that names none of its own — chiefly cloud-proxy / MITM models, which are
329+
// PII-enabled by default but carry no detector list. Per-model `pii.detectors`
330+
// always overrides. This replaces the old model-multiselect chooser: the table
331+
// shows every available detector, so admins toggle defaults instead of retyping
332+
// names, and link straight to each detector's config to edit its policy.
333+
function DetectorModels({ pii, addToast, onChanged }) {
334+
const navigate = useNavigate()
335+
const location = useLocation()
336+
const rows = useMemo(() => pii.detector_models || [], [pii.detector_models])
337+
// Names currently in the default set; the toggle adds/removes against this.
338+
const defaults = useMemo(() => pii.default_detectors || [], [pii.default_detectors])
339+
// Track which rows are mid-save to disable just that toggle (optimistic).
340+
const [busy, setBusy] = useState(() => new Set())
290341

291-
const save = async () => {
292-
setSaving(true)
342+
const toggleDefault = async (name, on) => {
343+
const next = on
344+
? [...new Set([...defaults, name])]
345+
: defaults.filter(d => d !== name)
346+
setBusy(prev => new Set(prev).add(name))
293347
try {
294-
const body = await settingsApi.save({ pii_default_detectors: detectors })
348+
const body = await settingsApi.save({ pii_default_detectors: next })
295349
if (body && body.success === false) throw new Error(body.error || 'unknown error')
296-
addToast?.('Default PII policy updated', 'success')
350+
addToast?.(on ? `${name} added to default detectors` : `${name} removed from default detectors`, 'success')
297351
onChanged?.()
298352
} catch (err) {
299353
addToast?.(`Failed to save: ${err.message}`, 'error')
300354
} finally {
301-
setSaving(false)
355+
setBusy(prev => { const n = new Set(prev); n.delete(name); return n })
302356
}
303357
}
304358

305359
return (
306360
<div className="card" style={{ padding: 'var(--spacing-md)', marginBottom: 'var(--spacing-md)' }}>
307-
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-sm)' }}>
308-
<span style={{ fontSize: '0.875rem', fontWeight: 600 }}>Default PII policy</span>
309-
<button className="btn btn-primary btn-sm" onClick={save} disabled={!dirty || saving}>
310-
<i className={`fas ${saving ? 'fa-spinner fa-spin' : 'fa-floppy-disk'}`} style={{ marginRight: 4 }} />
311-
Save
361+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-sm)', gap: 'var(--spacing-sm)', flexWrap: 'wrap' }}>
362+
<span style={{ fontSize: '0.875rem', fontWeight: 600 }}>Detector models</span>
363+
<button
364+
className="btn btn-secondary btn-sm"
365+
onClick={() => navigate('/app/model-editor?template=secret-filter', { state: fromState(location, 'Middleware') })}
366+
title="Add a NER or pattern detector model"
367+
>
368+
<i className="fas fa-plus" /> Add detector model
312369
</button>
313370
</div>
314371
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-sm)' }}>
315-
Cloud-proxy / MITM models are PII-enabled by default; pick a default detector here so their requests are actually scanned. Applied to any PII-enabled model that names none of its own. Per-model config always overrides.
372+
These token_classify models do the scanning. Toggle <strong>Default</strong> on to apply a
373+
detector to any PII-enabled model that names none of its own (chiefly cloud-proxy / MITM models).
374+
Per-model <code>pii.detectors</code> always overrides. Edit a detector to change which entities it
375+
flags and what action it takes.
316376
</div>
317377

318-
<div>
319-
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 600, marginBottom: 4 }}>Default detector model(s)</label>
320-
<ModelMultiSelect value={detectors} onChange={setDetectors} capability={CAP_TOKEN_CLASSIFY} placeholder="Select default detector model..." />
378+
<div className="table-container">
379+
<table className="table">
380+
<thead>
381+
<tr>
382+
<th>Detector model</th>
383+
<th style={{ width: 110 }}>Type</th>
384+
<th style={{ width: 120 }}>Backend</th>
385+
<th style={{ width: 110 }}>Default</th>
386+
<th style={{ width: 80 }}>Edit</th>
387+
</tr>
388+
</thead>
389+
<tbody>
390+
{rows.map(d => (
391+
<tr key={d.name}>
392+
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem', fontWeight: 600 }}>
393+
{d.missing
394+
? <span title="This default detector names a model that is not loaded.">{d.name}</span>
395+
: <Link to={`/app/model-editor/${encodeURIComponent(d.name)}`} state={fromState(location, 'Middleware')} title={`Edit ${d.name}.yaml`}>{d.name}</Link>}
396+
</td>
397+
<td>{detectorTypeBadge(d.type)}</td>
398+
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>{d.backend || '—'}</td>
399+
<td>
400+
<Toggle
401+
checked={!!d.default}
402+
disabled={busy.has(d.name)}
403+
onChange={(v) => toggleDefault(d.name, v)}
404+
/>
405+
</td>
406+
<td>
407+
{d.missing ? (
408+
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}></span>
409+
) : (
410+
<Link
411+
to={`/app/model-editor/${encodeURIComponent(d.name)}`}
412+
state={fromState(location, 'Middleware')}
413+
className="btn btn-secondary btn-sm"
414+
style={{ fontSize: '0.6875rem', padding: '2px 8px' }}
415+
title={`Edit ${d.name}.yaml`}
416+
>
417+
<i className="fas fa-pen-to-square" /> Edit
418+
</Link>
419+
)}
420+
</td>
421+
</tr>
422+
))}
423+
{rows.length === 0 && (
424+
<tr>
425+
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-muted)', padding: 'var(--spacing-md)' }}>
426+
No detector models loaded. Add one with the button above (a token_classify NER model
427+
or a built-in secret pattern model).
428+
</td>
429+
</tr>
430+
)}
431+
</tbody>
432+
</table>
321433
</div>
322434
</div>
323435
)

0 commit comments

Comments
 (0)