@@ -2,10 +2,9 @@ import { useState, useEffect, useCallback, useRef, useMemo, Fragment } from 'rea
22import { useOutletContext , Link , useNavigate , useLocation , useSearchParams } from 'react-router-dom'
33import { apiUrl } from '../utils/basePath'
44import { fromState } from '../utils/editorNav'
5- import { settingsApi } from '../utils/api'
5+ import { settingsApi , modelsApi } from '../utils/api'
66import 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
175174function 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