diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index da62d14..f58bdf8 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,21 @@ +## v0.8.0-beta hotfix: Watch Mode UX + +### Motivation +Make Watch Mode usable in production by replacing placeholder target creation with full create/edit UX. + +### Changes +- Added Watch Target create form with watch-type specific fields and sensible defaults (`interval_minutes=60`, `is_active=true`). +- Added Watch Target edit mode with Save/Cancel and refresh of target list/detail after updates. +- Improved target detail panel with key scheduling/status fields and richer run history columns. +- Enforced clear viewer read-only UX hint and disabled create/edit/delete/run actions for viewers. + +### Testing +- backend unchanged for this hotfix. +- frontend: `npm run build` + +### Known Limitations +- Form validation remains primarily API-driven; frontend currently forwards server-side validation errors. + ## v0.8.0-beta: BGP Visibility Details ### Motivation diff --git a/frontend/src/components/WatchModeView.tsx b/frontend/src/components/WatchModeView.tsx index ce3750f..6117eff 100644 --- a/frontend/src/components/WatchModeView.tsx +++ b/frontend/src/components/WatchModeView.tsx @@ -1,14 +1,169 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { createWatchTarget, deleteWatchTarget, getWatchTargetRuns, listWatchTargets, runWatchTarget, updateWatchTarget } from '../api' import type { UserRole, WatchRun, WatchTarget } from '../types' +type WatchType = 'asn' | 'prefix' | 'bgp_visibility' | 'roa_preflight' +type WatchTargetForm = { + name: string + watch_type: WatchType + prefix: string + asn: string + origin_as: string + expected_origin_as: string + max_length: string + interval_minutes: string + is_active: boolean + change_case_id: string +} + +const emptyForm = (): WatchTargetForm => ({ + name: '', watch_type: 'prefix', prefix: '', asn: '', origin_as: '', expected_origin_as: '', max_length: '', interval_minutes: '60', is_active: true, change_case_id: '' +}) + +const parseOptionalNumber = (value: string): number | undefined => { + if (!value.trim()) return undefined + return Number(value) +} + +const buildPayload = (form: WatchTargetForm) => ({ + name: form.name.trim(), + watch_type: form.watch_type, + prefix: form.prefix.trim() || undefined, + asn: form.asn.trim() || undefined, + origin_as: form.origin_as.trim() || undefined, + expected_origin_as: form.expected_origin_as.trim() || undefined, + max_length: parseOptionalNumber(form.max_length), + interval_minutes: Number(form.interval_minutes), + is_active: form.is_active, + change_case_id: parseOptionalNumber(form.change_case_id) +}) + export function WatchModeView({ role }: { role: UserRole }) { const [targets, setTargets] = useState([]) const [selected, setSelected] = useState(null) const [runs, setRuns] = useState([]) + const [showCreate, setShowCreate] = useState(false) + const [createForm, setCreateForm] = useState(emptyForm) + const [editForm, setEditForm] = useState(null) + const [loading, setLoading] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const canEdit = role !== 'viewer' - const load = () => listWatchTargets().then(setTargets) - useEffect(() => { load() }, []) - useEffect(() => { if (selected) getWatchTargetRuns(selected.id).then(setRuns) }, [selected?.id]) - return

Watch Mode

{targets.map(t => )}
{selected &&
{selected.name}
{canEdit && }{canEdit && }{canEdit && }{runs.map(r=>)}
{r.created_at}{r.previous_status}{r.status}{String(r.changed)}
}
{canEdit && }
+ + const loadTargets = async (selectedId?: number) => { + setLoading(true) + setError(null) + try { + const data = await listWatchTargets() + setTargets(data) + const nextId = selectedId ?? selected?.id + if (nextId) setSelected(data.find((t) => t.id === nextId) ?? null) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load watch targets') + } finally { + setLoading(false) + } + } + + useEffect(() => { loadTargets() }, []) + useEffect(() => { + if (!selected) return + getWatchTargetRuns(selected.id).then(setRuns).catch((e) => setError(e instanceof Error ? e.message : 'Failed to load runs')) + }, [selected?.id]) + + const typeHints = useMemo(() => ({ + asn: ['asn'], + prefix: ['prefix', 'origin_as'], + bgp_visibility: ['prefix', 'expected_origin_as'], + roa_preflight: ['prefix', 'origin_as', 'max_length'] + }), []) + + const renderCommonFields = (form: WatchTargetForm, setForm: (next: WatchTargetForm) => void) => ( + <> + + + {(form.watch_type === 'prefix' || form.watch_type === 'bgp_visibility' || form.watch_type === 'roa_preflight') && } + {form.watch_type === 'asn' && } + {(form.watch_type === 'prefix' || form.watch_type === 'roa_preflight') && } + {form.watch_type === 'bgp_visibility' && } + {form.watch_type === 'roa_preflight' && } + + + +

Type fields: {typeHints[form.watch_type].join(', ')}

+ + ) + + const startEdit = () => { + if (!selected) return + setEditForm({ + name: selected.name, + watch_type: selected.watch_type as WatchType, + prefix: selected.prefix ?? '', + asn: selected.asn ?? '', + origin_as: selected.origin_as ?? '', + expected_origin_as: selected.expected_origin_as ?? '', + max_length: selected.max_length != null ? String(selected.max_length) : '', + interval_minutes: String(selected.interval_minutes), + is_active: selected.is_active, + change_case_id: selected.change_case_id != null ? String(selected.change_case_id) : '' + }) + } + + return
+

Watch Mode

+ {!canEdit &&
Viewer role: read-only access. Create/Edit/Delete/Run actions are disabled.
} + {loading &&
Loading targets…
} + {error &&
{error}
} + {success &&
{success}
} + +
+
+ {targets.map(t => )} +
+
{selected &&
+

{selected.name}

+
Watch Type: {selected.watch_type}
+
Resource: {selected.asn ?? selected.prefix ?? 'n/a'}
+
Interval: {selected.interval_minutes} min
+
Active: {String(selected.is_active)}
+
Last Status: {selected.last_status ?? 'n/a'}
+
Last Run: {selected.last_run_at ?? 'n/a'}
+
Next Run: {selected.next_run_at ?? 'n/a'}
+
Prefix: {selected.prefix ?? 'n/a'} | ASN: {selected.asn ?? 'n/a'} | Origin AS: {selected.origin_as ?? 'n/a'} | Expected Origin AS: {selected.expected_origin_as ?? 'n/a'} | Max Length: {selected.max_length ?? 'n/a'}
+ + {canEdit &&
+ + + +
} + + {editForm && canEdit &&
+

Edit Watch Target

+ {renderCommonFields(editForm, setEditForm)} +
+ + +
+
} + +

Runs History

+ {runs.map(r=>)}
created_atprevious_statusstatuschangedsummaryreport_id
{r.created_at}{r.previous_status ?? 'n/a'}{r.status}{String(r.changed)}{r.summary}{r.report_id ?? 'n/a'}
+
}
+
+ + {canEdit &&
+ + {showCreate &&
+

Create Watch Target

+ {renderCommonFields(createForm, setCreateForm)} +
+ + +
+
} +
} +
}