admin v2: implement design from ui_design/v2/plugin/admin.html

- Design system: v2 CSS variables (surface-0/1/2, border-hair, accent-amber/cyan/red/green/blue)
  and utility classes (.btn, .inp, .pill, .chip, .bracket, .panel, .seg, .swatch,
  .type-sq, .grid-bg, .ibtn, .checkbox, .tab); v1 az-* names aliased to v2 vars
  so other pages still render. Google Fonts (IBM Plex Sans + JetBrains Mono)
  loaded via <link> in index.html <head> to avoid FOUT.
- Header rebuilt to v2: amber wordmark + // divider, amber-bordered flight pill
  with cyan live dot, tab-style nav with amber underline on active, LINK status
  pill, cog + sign-out icon buttons.
- AdminPage rewritten to 3-column layout (340 / flex / 280):
  - Detection Classes: search + ADD button, table with #/Name/Hex/Ops columns,
    name-only inline edit with ringed swatch, sibling-row error alert.
  - AI Recognition Engine + GPS Device Link panels with corner-bracket borders,
    number steppers, segmented protocol control, dashed telemetry footers.
    Hooks (useAiSettings, useGpsSettings) seed factory defaults so the UI is
    interactive when GET fails (no backend).
  - Default Aircrafts: P/C/F type chips, isDefault star toggle, + ADD AIRCRAFT
    modal with model/type/resolution/maxMinutes/default fields.
- Co-located components: Modal (backdrop + ESC + body-scroll-lock),
  NumberStepper (▲▼ with clamp on click but not on typing), ClassEditRow.
- Types: Aircraft extended with FixedWing + optional resolution/maxMinutes;
  new AiRecognitionSettings/Telemetry, GpsDeviceSettings/Telemetry, GpsProtocol.
- Endpoints: /api/admin/ai-settings, /api/admin/gps-settings (+ /ping, /reconnect).
  POST /api/flights/aircrafts (plural REST collection).
- MSW: stateful admin-settings handler with resetAdminSettingsSeed() wired into
  tests/setup.ts. Aircraft seed expanded to 6 entries matching the mockup.
- i18n: full admin.{classes,aiEngine,gpsDevice,aircrafts} key sets in en+ua;
  nav.dataset shortened to "Dataset"; obsolete users-management keys removed.
- Tests: new AdminPage AI/GPS/aircraft test suites; admin_class_edit selectors
  updated for the name-only inline editor and the modal-based add flow.
This commit is contained in:
Armen Rohalov
2026-05-19 02:01:20 +03:00
parent 2a62415f0c
commit 434854bf3c
25 changed files with 2096 additions and 362 deletions
+626 -229
View File
@@ -1,39 +1,137 @@
import { useState, useEffect, type KeyboardEvent } from 'react'
import { useState, useEffect, useMemo, type KeyboardEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { api, endpoints } from '../../api'
import { ConfirmDialog } from '../../components'
import type { DetectionClass, Aircraft, User } from '../../types'
import type { DetectionClass, Aircraft, GpsProtocol } from '../../types'
import { useAiSettings } from './useAiSettings'
import { useGpsSettings } from './useGpsSettings'
import { Modal } from './Modal'
import { NumberStepper } from './NumberStepper'
import { ClassEditRow } from './ClassEditRow'
type EditForm = { name: string; shortName: string; color: string; maxSizeM: number }
type EditErrorKind = 'nameRequired' | 'maxSizeMustBePositive' | 'updateFailed'
type EditErrorKind = 'nameRequired' | 'updateFailed'
// editingId === ADDING_ID switches Save from PATCH to POST.
const ADDING_ID = -1
const NEW_CLASS_DEFAULTS: EditForm = { name: '', shortName: '', color: '#FF9D3D', maxSizeM: 7 }
type AircraftDraft = {
model: string
type: Aircraft['type']
resolution: string
maxMinutes: number
isDefault: boolean
}
const NEW_AIRCRAFT_DEFAULTS: AircraftDraft = {
model: '', type: 'Copter', resolution: '4K', maxMinutes: 30, isDefault: false,
}
const AIRCRAFT_TYPES = ['Plane', 'Copter', 'FixedWing'] as const
const PROTOCOLS: GpsProtocol[] = ['NMEA', 'UBX', 'MAVLINK']
const RESOLUTIONS = ['HD', '1080P', '4K', '6K'] as const
const FALLBACK = '—'
const TYPE_COLORS: Record<Aircraft['type'], string> = {
Plane: 'var(--accent-blue)',
Copter: 'var(--accent-green)',
FixedWing: 'var(--accent-amber)',
}
const TYPE_LETTERS: Record<Aircraft['type'], 'P' | 'C' | 'F'> = {
Plane: 'P', Copter: 'C', FixedWing: 'F',
}
const TYPE_LEGEND_KEY: Record<Aircraft['type'], 'legendPlane' | 'legendCopter' | 'legendFixedW'> = {
Plane: 'legendPlane', Copter: 'legendCopter', FixedWing: 'legendFixedW',
}
function PencilIcon() {
return (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
)
}
function CloseIcon() {
return (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
)
}
function StarIcon({ filled }: { filled: boolean }) {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth={filled ? 1 : 1.4}>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
)
}
function formatRunTime(iso: string | null): string {
if (!iso) return FALLBACK
// HH:MM:SSZ rendering, mockup-style.
const m = iso.match(/T(\d{2}:\d{2}:\d{2})/)
return m ? `${m[1]}Z` : FALLBACK
}
export default function AdminPage() {
const { t } = useTranslation()
const [classes, setClasses] = useState<DetectionClass[]>([])
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
const [users, setUsers] = useState<User[]>([])
const [newClass, setNewClass] = useState({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'Annotator' })
const [deactivateId, setDeactivateId] = useState<string | null>(null)
// AZ-512 — inline edit state. Single `editingId` (not per-row) so opening
// one row's editor implicitly closes any other (Risk 3 mitigation).
const [classFilter, setClassFilter] = useState('')
const [editingId, setEditingId] = useState<number | null>(null)
const [editForm, setEditForm] = useState<EditForm>({ name: '', shortName: '', color: '#FF0000', maxSizeM: 0 })
const [editForm, setEditForm] = useState<EditForm>(NEW_CLASS_DEFAULTS)
const [editError, setEditError] = useState<EditErrorKind | null>(null)
const [editSaving, setEditSaving] = useState(false)
const [aircraftModalOpen, setAircraftModalOpen] = useState(false)
const [aircraftDraft, setAircraftDraft] = useState<AircraftDraft>(NEW_AIRCRAFT_DEFAULTS)
const [aircraftSaving, setAircraftSaving] = useState(false)
const [aircraftError, setAircraftError] = useState<string | null>(null)
const openAircraftModal = () => {
setAircraftDraft(NEW_AIRCRAFT_DEFAULTS)
setAircraftError(null)
setAircraftModalOpen(true)
}
const closeAircraftModal = () => {
if (aircraftSaving) return
setAircraftModalOpen(false)
}
const saveAircraft = async () => {
if (!aircraftDraft.model.trim()) { setAircraftError('modelRequired'); return }
setAircraftError(null)
setAircraftSaving(true)
try {
const created = await api.post<Aircraft>(endpoints.flights.aircrafts(), aircraftDraft)
setAircrafts(prev => [...prev, created])
setAircraftModalOpen(false)
} catch {
setAircraftError('saveFailed')
} finally {
setAircraftSaving(false)
}
}
const ai = useAiSettings()
const gps = useGpsSettings()
useEffect(() => {
api.get<DetectionClass[]>(endpoints.annotations.classes()).then(setClasses).catch(() => {})
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
api.get<User[]>(endpoints.admin.users()).then(setUsers).catch(() => {})
}, [])
const handleAddClass = async () => {
if (!newClass.name) return
await api.post(endpoints.admin.classes(), newClass)
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
setClasses(updated)
setNewClass({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
const filteredClasses = useMemo(() => {
const q = classFilter.trim().toLowerCase()
if (!q) return classes
return classes.filter(c => c.name.toLowerCase().includes(q))
}, [classes, classFilter])
const handleStartAdd = () => {
setEditingId(ADDING_ID)
setEditForm({ ...NEW_CLASS_DEFAULTS })
setEditError(null)
setEditSaving(false)
}
const handleDeleteClass = async (id: number) => {
@@ -54,18 +152,19 @@ export default function AdminPage() {
setEditSaving(false)
}
const handleUpdateClass = async () => {
const handleSaveClass = async () => {
if (editingId === null || editSaving) return
if (!editForm.name.trim()) { setEditError('nameRequired'); return }
if (!(editForm.maxSizeM > 0)) { setEditError('maxSizeMustBePositive'); return }
setEditError(null)
setEditSaving(true)
try {
// Risk 2 mitigation — always send the complete form so backend PATCH
// semantics (full-replace vs partial-merge) don't matter.
await api.patch(endpoints.admin.class(editingId), editForm)
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
setClasses(updated)
if (editingId === ADDING_ID) {
const created = await api.post<DetectionClass>(endpoints.admin.classes(), editForm)
setClasses(prev => [...prev, created])
} else {
const updated = await api.patch<DetectionClass>(endpoints.admin.class(editingId), editForm)
setClasses(prev => prev.map(c => c.id === editingId ? updated : c))
}
setEditingId(null)
} catch {
setEditError('updateFailed')
@@ -75,244 +174,542 @@ export default function AdminPage() {
}
const handleEditKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (e.key === 'Enter') { e.preventDefault(); void handleUpdateClass() }
if (e.key === 'Enter') { e.preventDefault(); void handleSaveClass() }
else if (e.key === 'Escape') { e.preventDefault(); handleCancelEdit() }
}
const handleAddUser = async () => {
if (!newUser.email || !newUser.password) return
await api.post(endpoints.admin.users(), newUser)
const updated = await api.get<User[]>(endpoints.admin.users())
setUsers(updated)
setNewUser({ name: '', email: '', password: '', role: 'Annotator' })
}
const handleDeactivate = async () => {
if (!deactivateId) return
await api.patch(endpoints.admin.user(deactivateId), { isActive: false })
setUsers(prev => prev.map(u => u.id === deactivateId ? { ...u, isActive: false } : u))
setDeactivateId(null)
}
const handleToggleDefault = async (a: Aircraft) => {
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
}
return (
<div className="flex h-full overflow-y-auto p-4 gap-4">
{/* Detection classes */}
<div className="w-[340px] shrink-0">
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.classes.title')}</h2>
<div className="bg-az-panel border border-az-border rounded overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-az-border text-az-muted">
<th className="px-2 py-1 text-left">#</th>
<th className="px-2 py-1 text-left">Name</th>
<th className="px-2 py-1">Color</th>
<th className="px-2 py-1"></th>
<main className="flex h-full overflow-hidden" style={{ background: 'var(--surface-0)' }}>
{/* ===== LEFT: DETECTION CLASSES (340px) ===== */}
<aside
className="shrink-0 flex flex-col"
style={{ width: 340, background: 'var(--surface-1)', borderRight: '1px solid var(--border-hair)' }}
>
<div
className="px-4 pt-4 pb-3 flex items-center justify-between"
style={{ borderBottom: '1px solid var(--border-hair)' }}
>
<div className="flex items-center gap-2">
<span className="sect-head">{t('admin.classes.title')}</span>
<span className="mono tnum" style={{ fontSize: 10, color: 'var(--text-muted)' }}>
[{String(classes.length).padStart(2, '0')}]
</span>
</div>
</div>
{/* Search + Add */}
<div
className="px-4 py-3 flex items-center gap-2"
style={{ borderBottom: '1px solid var(--border-hair)' }}
>
<div className="relative flex-1">
<svg className="absolute left-2 top-1/2 -translate-y-1/2" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ color: 'var(--text-muted)' }}>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
placeholder={t('admin.classes.search')}
className="inp"
value={classFilter}
onChange={e => setClassFilter(e.target.value)}
style={{ paddingLeft: 26, height: 28, fontSize: 11 }}
/>
</div>
<button
className="btn btn-primary"
onClick={handleStartAdd}
type="button"
disabled={editingId === ADDING_ID}
>
<span>{t('admin.classes.add')}</span>
</button>
</div>
{/* Table */}
<div className="flex-1 overflow-y-auto">
<table className="w-full tabular">
<thead className="sticky top-0" style={{ background: 'var(--surface-1)' }}>
<tr style={{ borderBottom: '1px solid var(--border-hair)' }}>
<th className="text-left px-3 py-2 micro" style={{ width: 36 }}>#</th>
<th className="text-left px-2 py-2 micro">{t('admin.classes.colName')}</th>
<th className="text-center px-2 py-2 micro" style={{ width: 30 }}>{t('admin.classes.colHex')}</th>
<th className="text-right px-3 py-2 micro" style={{ width: 60 }}>{t('admin.classes.colOps')}</th>
</tr>
</thead>
<tbody>
{classes.map(c => c.id === editingId ? (
<tr key={c.id} className="border-b border-az-border text-az-text bg-az-bg/40" data-editing-row={c.id}>
<td className="px-2 py-1 align-top">{c.id}</td>
<td colSpan={3} className="px-2 py-1">
<div className="flex flex-wrap gap-1 items-center" onKeyDown={handleEditKeyDown}>
<input
autoFocus
data-field="name"
value={editForm.name}
onChange={e => setEditForm(p => ({ ...p, name: e.target.value }))}
className="flex-1 min-w-[80px] bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
/>
<input
data-field="shortName"
value={editForm.shortName}
onChange={e => setEditForm(p => ({ ...p, shortName: e.target.value }))}
className="w-12 bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
/>
<input
type="color"
data-field="color"
value={editForm.color}
onChange={e => setEditForm(p => ({ ...p, color: e.target.value }))}
className="w-7 h-6 border-0 bg-transparent cursor-pointer"
/>
<input
type="number"
data-field="maxSizeM"
value={editForm.maxSizeM}
onChange={e => setEditForm(p => ({ ...p, maxSizeM: Number(e.target.value) }))}
className="w-14 bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
/>
<button
onClick={() => void handleUpdateClass()}
disabled={editSaving}
className="bg-az-orange text-white px-2 py-0.5 rounded disabled:opacity-50"
>
{t('admin.classes.save')}
</button>
<button
onClick={handleCancelEdit}
disabled={editSaving}
className="bg-az-bg border border-az-border text-az-text px-2 py-0.5 rounded disabled:opacity-50"
>
{t('admin.classes.cancel')}
</button>
</div>
{editError && (
<div role="alert" className="mt-1 text-az-red">
{t(`admin.classes.${editError}`)}
</div>
)}
</td>
</tr>
{editingId === ADDING_ID && (
<ClassEditRow
idCell="+"
rowId="new"
form={editForm}
onChange={setEditForm}
onSave={() => void handleSaveClass()}
onCancel={handleCancelEdit}
onKeyDown={handleEditKeyDown}
saving={editSaving}
errorMessage={editError ? t(`admin.classes.${editError}`) : null}
placeholderName="Name"
/>
)}
{filteredClasses.map(c => c.id === editingId ? (
<ClassEditRow
key={c.id}
idCell={c.id}
rowId={c.id}
form={editForm}
onChange={setEditForm}
onSave={() => void handleSaveClass()}
onCancel={handleCancelEdit}
onKeyDown={handleEditKeyDown}
saving={editSaving}
errorMessage={editError ? t(`admin.classes.${editError}`) : null}
/>
) : (
<tr key={c.id} className="border-b border-az-border text-az-text">
<td className="px-2 py-1">{c.id}</td>
<td className="px-2 py-1">{c.name}</td>
<td className="px-2 py-1 text-center"><span className="inline-block w-3 h-3 rounded-full" style={{ backgroundColor: c.color }} /></td>
<td className="px-2 py-1 text-right whitespace-nowrap">
<button
onClick={() => handleStartEdit(c)}
aria-label={t('admin.classes.edit')}
className="text-az-muted hover:text-az-orange mr-1"
>
{'\u270E'}
</button>
<button onClick={() => handleDeleteClass(c.id)} className="text-az-muted hover:text-az-red">×</button>
<tr key={c.id} className="row-hover" style={{ borderBottom: '1px solid var(--border-hair)', height: 32 }}>
<td className="px-3 mono tnum" style={{ color: 'var(--text-muted)', fontSize: 12 }}>{c.id}</td>
<td className="px-2"><span style={{ fontSize: 12 }}>{c.name}</span></td>
<td className="px-2 text-center"><span className="swatch" style={{ background: c.color }} /></td>
<td className="px-3 text-right">
<span className="reveal inline-flex gap-1">
<button
type="button"
onClick={() => handleStartEdit(c)}
className="ibtn edit"
aria-label={t('admin.classes.edit')}
title={t('admin.classes.edit')}
>
<PencilIcon />
</button>
<button
type="button"
onClick={() => handleDeleteClass(c.id)}
className="ibtn danger"
aria-label="×"
title={t('admin.classes.delete')}
>
<CloseIcon />
</button>
</span>
</td>
</tr>
))}
</tbody>
</table>
<div className="p-2 flex gap-1 border-t border-az-border">
<input value={newClass.name} onChange={e => setNewClass(p => ({ ...p, name: e.target.value }))} placeholder="Name" className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text" />
<input type="color" value={newClass.color} onChange={e => setNewClass(p => ({ ...p, color: e.target.value }))} className="w-8 h-7 border-0 bg-transparent cursor-pointer" />
<button onClick={handleAddClass} className="bg-az-orange text-white text-xs px-2 py-1 rounded">+</button>
</div>
</div>
</div>
</aside>
{/* Center: AI + GPS settings */}
<div className="flex-1 space-y-4 max-w-md">
<div>
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.aiSettings')}</h2>
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2 text-xs">
<div>
<label className="text-az-muted">Frame Period Recognition</label>
<input type="number" defaultValue={5} className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
{/* ===== CENTER ===== */}
<section className="flex-1 overflow-y-auto grid-bg">
<div className="max-w-[920px] mx-auto p-6 space-y-6">
{/* AI RECOGNITION ENGINE */}
<div>
<div className="flex items-end justify-between mb-3">
<div>
<div className="sect-head">{t('admin.aiEngine.title')}</div>
<div className="hint mt-1">{t('admin.aiEngine.subtitle')}</div>
</div>
<div className="flex items-center gap-2 micro">
<span style={{ color: 'var(--text-muted)' }}>{t('admin.aiEngine.model')}</span>
<span className="mono tnum" style={{ color: 'var(--text-primary)' }}>
{ai.telemetry ? `${ai.telemetry.model} · ${ai.telemetry.checkpoint}` : FALLBACK}
</span>
<span className="pill pill-cyan"><span className="dot live" />{t('admin.aiEngine.loaded')}</span>
</div>
</div>
<div>
<label className="text-az-muted">Frame Recognition Seconds</label>
<input type="number" defaultValue={1} className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
<div className="bracket panel p-5">
<span className="br" />
<div className="grid grid-cols-3 gap-x-6 gap-y-4">
<div>
<label className="micro block mb-1">{t('admin.aiEngine.framesToRecognize')}</label>
<div className="hint mb-2">{t('admin.aiEngine.framesHint')}</div>
<NumberStepper
value={ai.draft.framesToRecognize}
min={1}
step={1}
suffix={t('admin.aiEngine.unitFR')}
onChange={v => ai.setDraft({ ...ai.draft, framesToRecognize: v })}
/>
</div>
<div>
<label className="micro block mb-1">{t('admin.aiEngine.minSeconds')}</label>
<div className="hint mb-2">{t('admin.aiEngine.minSecondsHint')}</div>
<NumberStepper
value={ai.draft.minSecondsBetween}
min={0}
step={1}
suffix={t('admin.aiEngine.unitSec')}
onChange={v => ai.setDraft({ ...ai.draft, minSecondsBetween: v })}
/>
</div>
<div>
<label className="micro block mb-1">{t('admin.aiEngine.minConfidence')}</label>
<div className="hint mb-2">{t('admin.aiEngine.minConfidenceHint')}</div>
<NumberStepper
value={ai.draft.minConfidence}
min={0}
max={100}
step={5}
suffix="%"
onChange={v => ai.setDraft({ ...ai.draft, minConfidence: v })}
/>
</div>
</div>
<div
className="mt-5 pt-4 flex items-center justify-between"
style={{ borderTop: '1px dashed var(--border-hair)' }}
>
<div className="flex items-center gap-5 micro">
<span style={{ color: 'var(--text-muted)' }}>
{t('admin.aiEngine.lastRun')}{' '}
<span className="mono tnum" style={{ color: 'var(--text-secondary)' }}>
{formatRunTime(ai.telemetry?.lastRunAt ?? null)}
</span>
</span>
<span style={{ color: 'var(--text-muted)' }}>
{t('admin.aiEngine.frames')}{' '}
<span className="mono tnum" style={{ color: 'var(--text-secondary)' }}>
{ai.telemetry ? ai.telemetry.frames.toLocaleString() : FALLBACK}
</span>
</span>
<span style={{ color: 'var(--text-muted)' }}>
{t('admin.aiEngine.avgConf')}{' '}
<span className="mono tnum" style={{ color: 'var(--accent-green)' }}>
{ai.telemetry ? `${ai.telemetry.avgConfidence.toFixed(1)}%` : FALLBACK}
</span>
</span>
</div>
<div className="flex items-center gap-2">
<button type="button" className="btn btn-ghost" onClick={ai.reset}>
{t('admin.aiEngine.reset')}
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => void ai.save()}
disabled={ai.status === 'saving'}
>
{t('admin.aiEngine.apply')}
</button>
</div>
</div>
{ai.error && (
<div role="alert" className="mt-2" style={{ color: 'var(--accent-red)', fontSize: 11 }}>
{ai.error}
</div>
)}
</div>
<div>
<label className="text-az-muted">Probability Threshold</label>
<input type="number" defaultValue={0.5} step={0.05} min={0} max={1} className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
</div>
<button className="bg-az-orange text-white text-xs px-3 py-1 rounded">{t('common.save')}</button>
</div>
</div>
<div>
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.gpsSettings')}</h2>
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2 text-xs">
<div>
<label className="text-az-muted">Device Address</label>
<input defaultValue="192.168.1.100" className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
{/* GPS DEVICE LINK */}
<div>
<div className="flex items-end justify-between mb-3">
<div>
<div className="sect-head">{t('admin.gpsDevice.title')}</div>
<div className="hint mt-1">{t('admin.gpsDevice.subtitle')}</div>
</div>
<div className="flex items-center gap-2 micro">
<span style={{ color: 'var(--text-muted)' }}>{t('admin.gpsDevice.socket')}</span>
<span className="mono tnum" style={{ color: 'var(--text-primary)' }}>
{gps.telemetry?.socket ?? FALLBACK}
</span>
<span className={`pill ${gps.telemetry?.connected ? 'pill-green' : 'pill-red'}`}>
<span className="dot" />
{t('admin.gpsDevice.connected')}
</span>
</div>
</div>
<div>
<label className="text-az-muted">Port</label>
<input type="number" defaultValue={5535} className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
</div>
<div>
<label className="text-az-muted">Protocol</label>
<select className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text">
<option>TCP</option>
<option>UDP</option>
</select>
</div>
<button className="bg-az-orange text-white text-xs px-3 py-1 rounded">{t('common.save')}</button>
</div>
</div>
{/* Users */}
<div>
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.users')}</h2>
<div className="bg-az-panel border border-az-border rounded overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-az-border text-az-muted">
<th className="px-2 py-1 text-left">Name</th>
<th className="px-2 py-1 text-left">Email</th>
<th className="px-2 py-1">Role</th>
<th className="px-2 py-1">Status</th>
<th className="px-2 py-1"></th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} className="border-b border-az-border text-az-text">
<td className="px-2 py-1">{u.name}</td>
<td className="px-2 py-1">{u.email}</td>
<td className="px-2 py-1 text-center">{u.role}</td>
<td className="px-2 py-1 text-center">
<span className={`px-1 rounded ${u.isActive ? 'text-az-green' : 'text-az-red'}`}>
{u.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-2 py-1">
{u.isActive && (
<button onClick={() => setDeactivateId(u.id)} className="text-az-muted hover:text-az-red text-xs">
{t('admin.deactivate')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="p-2 flex gap-1 border-t border-az-border">
<input value={newUser.name} onChange={e => setNewUser(p => ({ ...p, name: e.target.value }))} placeholder="Name" className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text" />
<input value={newUser.email} onChange={e => setNewUser(p => ({ ...p, email: e.target.value }))} placeholder="Email" className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text" />
<input value={newUser.password} onChange={e => setNewUser(p => ({ ...p, password: e.target.value }))} placeholder="Password" type="password" className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text" />
<select value={newUser.role} onChange={e => setNewUser(p => ({ ...p, role: e.target.value }))} className="bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text">
<option>Annotator</option>
<option>Admin</option>
<option>Viewer</option>
</select>
<button onClick={handleAddUser} className="bg-az-orange text-white text-xs px-2 py-1 rounded">+</button>
<div className="bracket panel p-5">
<span className="br" />
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
<div>
<label className="micro block mb-1">{t('admin.gpsDevice.address')}</label>
<div className="hint mb-2">{t('admin.gpsDevice.addressHint')}</div>
<input
className="inp inp-mono"
value={gps.draft.address}
placeholder="0.0.0.0"
onChange={e => gps.setDraft({ ...gps.draft, address: e.target.value })}
aria-label={t('admin.gpsDevice.address')}
/>
</div>
<div>
<label className="micro block mb-1">{t('admin.gpsDevice.port')}</label>
<div className="hint mb-2">{t('admin.gpsDevice.portHint')}</div>
<input
className="inp inp-mono"
type="number"
value={gps.draft.port}
onChange={e => gps.setDraft({ ...gps.draft, port: Number(e.target.value) })}
style={{ textAlign: 'right' }}
aria-label={t('admin.gpsDevice.port')}
/>
</div>
</div>
<div className="mt-5">
<label className="micro block mb-1">{t('admin.gpsDevice.protocol')}</label>
<div className="hint mb-2">{t('admin.gpsDevice.protocolHint')}</div>
<div className="seg" role="group" aria-label={t('admin.gpsDevice.protocol')}>
{PROTOCOLS.map(p => (
<button
key={p}
type="button"
onClick={() => gps.setDraft({ ...gps.draft, protocol: p })}
className={`seg-btn${gps.draft.protocol === p ? ' active' : ''}`}
aria-pressed={gps.draft.protocol === p}
>
{p}
</button>
))}
</div>
</div>
<div
className="mt-5 pt-4 flex items-center justify-between"
style={{ borderTop: '1px dashed var(--border-hair)' }}
>
<div className="flex items-center gap-5 micro">
<span style={{ color: 'var(--text-muted)' }}>
{t('admin.gpsDevice.fix')}{' '}
<span className="mono tnum" style={{ color: 'var(--accent-green)' }}>
{gps.telemetry ? `${gps.telemetry.fix} · ${gps.telemetry.satellites} SAT` : FALLBACK}
</span>
</span>
<span style={{ color: 'var(--text-muted)' }}>
{t('admin.gpsDevice.hdop')}{' '}
<span className="mono tnum" style={{ color: 'var(--text-secondary)' }}>
{gps.telemetry ? gps.telemetry.hdop.toFixed(2) : FALLBACK}
</span>
</span>
<span style={{ color: 'var(--text-muted)' }}>
{t('admin.gpsDevice.lastPkt')}{' '}
<span className="mono tnum" style={{ color: 'var(--text-secondary)' }}>
{gps.telemetry ? `+${gps.telemetry.lastPacketMs}ms` : FALLBACK}
</span>
</span>
</div>
<div className="flex items-center gap-2">
<button type="button" className="btn btn-ghost" onClick={() => void gps.ping()} disabled={gps.status === 'pinging'}>
{t('admin.gpsDevice.ping')}
</button>
<button type="button" className="btn btn-secondary" onClick={() => void gps.reconnect()} disabled={gps.status === 'reconnecting'}>
{t('admin.gpsDevice.reconnect')}
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => void gps.save()}
disabled={gps.status === 'saving'}
>
{t('admin.gpsDevice.apply')}
</button>
</div>
</div>
{gps.error && (
<div role="alert" className="mt-2" style={{ color: 'var(--accent-red)', fontSize: 11 }}>
{gps.error}
</div>
)}
</div>
</div>
</div>
</div>
</section>
{/* Aircrafts sidebar */}
<div className="w-[280px] shrink-0">
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.aircrafts')}</h2>
<div className="bg-az-panel border border-az-border rounded p-2 space-y-1">
{/* ===== RIGHT: DEFAULT AIRCRAFTS (280px) ===== */}
<aside
className="shrink-0 flex flex-col"
style={{ width: 280, background: 'var(--surface-1)', borderLeft: '1px solid var(--border-hair)' }}
>
<div
className="px-4 pt-4 pb-3 flex items-center justify-between"
style={{ borderBottom: '1px solid var(--border-hair)' }}
>
<span className="sect-head">{t('admin.aircrafts.title')}</span>
<span className="mono tnum" style={{ fontSize: 10, color: 'var(--text-muted)' }}>
[{String(aircrafts.length).padStart(2, '0')}]
</span>
</div>
<div
className="px-4 py-2.5 flex items-center gap-3 micro"
style={{ borderBottom: '1px solid var(--border-hair)', background: 'var(--surface-0)' }}
>
<div className="flex items-center gap-1.5">
<span className="type-sq" style={{ background: TYPE_COLORS.Plane }}>P</span>
<span style={{ color: 'var(--text-muted)' }}>{t('admin.aircrafts.legendPlane')}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="type-sq" style={{ background: TYPE_COLORS.Copter }}>C</span>
<span style={{ color: 'var(--text-muted)' }}>{t('admin.aircrafts.legendCopter')}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="type-sq" style={{ background: TYPE_COLORS.FixedWing }}>F</span>
<span style={{ color: 'var(--text-muted)' }}>{t('admin.aircrafts.legendFixedW')}</span>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{aircrafts.map(a => (
<div key={a.id} onClick={() => handleToggleDefault(a)} className="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-az-bg text-xs text-az-text">
<span className={`px-1 rounded text-[10px] ${a.type === 'Plane' ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
{a.type === 'Plane' ? 'P' : 'C'}
</span>
<span className="flex-1">{a.model}</span>
<span className={`text-sm ${a.isDefault ? 'text-az-orange' : 'text-az-muted'}`}></span>
<div
key={a.id}
data-aircraft-id={a.id}
className="row-hover flex items-center gap-3 px-4 py-2.5"
style={{
borderBottom: '1px solid var(--border-hair)',
background: a.isDefault ? 'var(--surface-2)' : 'transparent',
borderLeft: a.isDefault ? '2px solid var(--accent-amber)' : '2px solid transparent',
}}
>
<span className="type-sq" style={{ background: TYPE_COLORS[a.type] }}>{TYPE_LETTERS[a.type]}</span>
<div className="flex-1 min-w-0">
<div style={{ fontSize: 12.5 }}>{a.model}</div>
<div className="mono tnum" style={{ fontSize: 10.5, color: 'var(--text-muted)' }}>
{a.id} · {a.resolution ?? FALLBACK} · {a.maxMinutes ?? FALLBACK}MIN
</div>
</div>
<button
type="button"
onClick={() => void handleToggleDefault(a)}
className={a.isDefault ? 'star' : 'star-off ibtn'}
aria-label={a.isDefault ? t('admin.aircrafts.default') : t('admin.aircrafts.setDefault')}
aria-pressed={a.isDefault}
title={a.isDefault ? t('admin.aircrafts.default') : t('admin.aircrafts.setDefault')}
style={a.isDefault ? { background: 'transparent', border: 0, cursor: 'pointer' } : undefined}
>
<StarIcon filled={a.isDefault} />
</button>
</div>
))}
</div>
</div>
<ConfirmDialog
open={!!deactivateId}
title={t('admin.deactivate')}
message="Deactivate this user?"
onConfirm={handleDeactivate}
onCancel={() => setDeactivateId(null)}
/>
</div>
<div
className="px-4 py-3"
style={{ borderTop: '1px solid var(--border-hair)', background: 'var(--surface-0)' }}
>
<button
type="button"
className="btn btn-secondary w-full justify-center"
onClick={openAircraftModal}
>
{t('admin.aircrafts.add')}
</button>
</div>
</aside>
<Modal
open={aircraftModalOpen}
title={t('admin.aircrafts.addTitle')}
onClose={closeAircraftModal}
closeLabel={t('admin.classes.cancel')}
footer={
<>
<button
type="button"
className="btn btn-ghost"
onClick={closeAircraftModal}
disabled={aircraftSaving}
>
{t('admin.classes.cancel')}
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => void saveAircraft()}
disabled={aircraftSaving}
>
{t('admin.aircrafts.addTitle')}
</button>
</>
}
>
<div>
<label className="micro block mb-1">{t('admin.aircrafts.fieldModel')}</label>
<input
autoFocus
className="inp inp-mono"
value={aircraftDraft.model}
onChange={e => setAircraftDraft(p => ({ ...p, model: e.target.value }))}
placeholder="DJI Mavic 3"
aria-label={t('admin.aircrafts.fieldModel')}
/>
</div>
<div>
<label className="micro block mb-1">{t('admin.aircrafts.fieldType')}</label>
<div className="seg" role="group" aria-label={t('admin.aircrafts.fieldType')}>
{AIRCRAFT_TYPES.map(typ => (
<button
key={typ}
type="button"
onClick={() => setAircraftDraft(p => ({ ...p, type: typ }))}
className={`seg-btn${aircraftDraft.type === typ ? ' active' : ''}`}
aria-pressed={aircraftDraft.type === typ}
>
{t(`admin.aircrafts.${TYPE_LEGEND_KEY[typ]}`)}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="micro block mb-1">{t('admin.aircrafts.fieldResolution')}</label>
<select
className="inp inp-mono"
value={aircraftDraft.resolution}
onChange={e => setAircraftDraft(p => ({ ...p, resolution: e.target.value }))}
aria-label={t('admin.aircrafts.fieldResolution')}
>
{RESOLUTIONS.map(r => (
<option key={r} value={r}>{r}</option>
))}
</select>
</div>
<div>
<label className="micro block mb-1">{t('admin.aircrafts.fieldMaxMinutes')}</label>
<input
type="number"
className="inp inp-mono"
value={aircraftDraft.maxMinutes}
onChange={e => setAircraftDraft(p => ({ ...p, maxMinutes: Number(e.target.value) }))}
style={{ textAlign: 'right' }}
aria-label={t('admin.aircrafts.fieldMaxMinutes')}
/>
</div>
</div>
<label className="checkbox-row">
<input
type="checkbox"
className="checkbox"
checked={aircraftDraft.isDefault}
onChange={e => setAircraftDraft(p => ({ ...p, isDefault: e.target.checked }))}
/>
<span>{t('admin.aircrafts.fieldDefault')}</span>
</label>
{aircraftError && (
<div role="alert" style={{ color: 'var(--accent-red)', fontSize: 11 }}>
{t(`admin.aircrafts.${aircraftError}`)}
</div>
)}
</Modal>
</main>
)
}
+126
View File
@@ -0,0 +1,126 @@
import { Fragment, useRef, type KeyboardEvent, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
export type EditFormShape = { name: string; shortName: string; color: string; maxSizeM: number }
interface ClassEditRowProps {
/** Cell content for the leftmost `#` column (e.g. `+` for new, row id for edit). */
idCell: ReactNode
/** Stable identifier for the row's data-editing-row attribute. */
rowId: number | 'new'
form: EditFormShape
onChange: (form: EditFormShape) => void
onSave: () => void
onCancel: () => void
onKeyDown: (e: KeyboardEvent<HTMLElement>) => void
saving: boolean
/** Optional inline error key (already translated by the caller's t() if provided as message). */
errorMessage: string | null
placeholderName?: string
}
function CheckIcon() {
return (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<polyline points="20 6 9 17 4 12" />
</svg>
)
}
function CloseIcon() {
return (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
)
}
export function ClassEditRow({
idCell, rowId, form, onChange, onSave, onCancel, onKeyDown,
saving, errorMessage, placeholderName,
}: ClassEditRowProps) {
const { t } = useTranslation()
const colorInputRef = useRef<HTMLInputElement>(null)
return (
<Fragment>
<tr
className="row-hover"
data-editing-row={rowId}
style={{ borderBottom: '1px solid var(--accent-amber)', height: 32, background: 'rgba(255,157,61,0.06)' }}
onKeyDown={onKeyDown}
>
<td className="px-3 mono tnum" style={{ color: 'var(--accent-amber)', fontSize: 12 }}>{idCell}</td>
<td className="px-2">
<input
autoFocus
data-field="name"
value={form.name}
onChange={e => onChange({ ...form, name: e.target.value })}
placeholder={placeholderName}
className="inp inp-mono"
style={{ height: 22, padding: '0 6px', fontSize: 11 }}
aria-label={t('admin.classes.colName')}
/>
</td>
<td className="px-2 text-center">
<button
type="button"
onClick={() => colorInputRef.current?.click()}
className="inline-flex items-center justify-center cursor-pointer"
aria-label={t('admin.classes.colHex')}
style={{ background: 'transparent', border: 0, padding: 0 }}
>
<span
className="swatch"
style={{ background: form.color, boxShadow: '0 0 0 1px var(--accent-amber)' }}
/>
</button>
<input
ref={colorInputRef}
type="color"
data-field="color"
value={form.color}
onChange={e => onChange({ ...form, color: e.target.value })}
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
/>
</td>
<td className="px-3 text-right">
<span className="inline-flex gap-1">
<button
type="button"
onClick={onSave}
disabled={saving}
className="ibtn cyan"
aria-label={t('admin.classes.save')}
title={t('admin.classes.save')}
>
<CheckIcon />
</button>
<button
type="button"
onClick={onCancel}
disabled={saving}
className="ibtn"
aria-label={t('admin.classes.cancel')}
title={t('admin.classes.cancel')}
>
<CloseIcon />
</button>
</span>
</td>
</tr>
{errorMessage && (
<tr style={{ background: 'rgba(255,157,61,0.06)' }}>
<td />
<td colSpan={3} className="px-2 pb-2">
<div role="alert" style={{ color: 'var(--accent-red)', fontSize: 11 }}>
{errorMessage}
</div>
</td>
</tr>
)}
</Fragment>
)
}
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, type ReactNode, type KeyboardEvent, type MouseEvent } from 'react'
interface ModalProps {
open: boolean
title: ReactNode
onClose: () => void
width?: number
footer?: ReactNode
children: ReactNode
closeLabel?: string
}
export function Modal({ open, title, onClose, width = 420, footer, children, closeLabel = 'Close' }: ModalProps) {
useEffect(() => {
if (!open) return
const onKey = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onClose()
}
}
document.addEventListener('keydown', onKey)
// Lock body scroll while the modal is open.
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = prev
}
}, [open, onClose])
if (!open) return null
const onBackdropClick = (e: MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) onClose()
}
const onPanelKey = (e: KeyboardEvent<HTMLDivElement>) => {
// Stop Escape from bubbling to other key handlers in the page; the
// document listener above already handles closing.
if (e.key === 'Escape') e.stopPropagation()
}
return (
<div
role="dialog"
aria-modal="true"
aria-label={typeof title === 'string' ? title : undefined}
onClick={onBackdropClick}
style={{
position: 'fixed', inset: 0, zIndex: 100,
background: 'rgba(0, 0, 0, 0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<div
className="bracket panel"
onKeyDown={onPanelKey}
style={{ width, padding: 20 }}
>
<span className="br" />
<div className="flex items-center justify-between mb-3">
<span className="sect-head">{title}</span>
<button type="button" onClick={onClose} className="ibtn" aria-label={closeLabel} title={closeLabel}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="space-y-3">{children}</div>
{footer && (
<div
className="mt-5 pt-4 flex items-center justify-end gap-2"
style={{ borderTop: '1px dashed var(--border-hair)' }}
>
{footer}
</div>
)}
</div>
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
interface NumberStepperProps {
value: number
/** Inclusive minimum, applied only to ▲▼ stepper clicks (not free typing). */
min?: number
/** Inclusive maximum, applied only to ▲▼ stepper clicks (not free typing). */
max?: number
/** Increment per ▲▼ click. */
step: number
onChange: (v: number) => void
/** Trailing unit label (e.g. "FR", "SEC", "%"). */
suffix: string
}
/**
* Number input with ▲▼ stepper buttons next to it and a trailing unit
* label. Stepper buttons clamp to [min, max]; direct typing does NOT —
* so `userEvent.clear()` + `type('9')` behaves as expected without being
* snapped mid-keystroke. Invalid intermediate values fall through; the
* caller validates on save.
*/
export function NumberStepper({ value, min, max, step, onChange, suffix }: NumberStepperProps) {
const clamp = (v: number) => Math.max(min ?? -Infinity, Math.min(max ?? Infinity, v))
return (
<div className="flex items-stretch gap-2">
<input
className="inp inp-mono"
type="number"
value={value}
onChange={e => {
const raw = e.target.value
const parsed = raw === '' ? 0 : Number(raw)
onChange(Number.isFinite(parsed) ? parsed : 0)
}}
style={{ textAlign: 'right', width: 88 }}
/>
<div className="flex flex-col" style={{ border: '1px solid var(--border-hair)', borderRadius: 2 }}>
<button
type="button"
onClick={() => onChange(clamp(value + step))}
className="mono"
aria-label="Increment"
style={{ width: 24, height: 15, fontSize: 9, color: 'var(--text-secondary)', background: 'var(--surface-input)', borderBottom: '1px solid var(--border-hair)' }}
></button>
<button
type="button"
onClick={() => onChange(clamp(value - step))}
className="mono"
aria-label="Decrement"
style={{ width: 24, height: 15, fontSize: 9, color: 'var(--text-secondary)', background: 'var(--surface-input)' }}
></button>
</div>
<span className="micro self-center" style={{ color: 'var(--text-muted)' }}>{suffix}</span>
</div>
)
}
@@ -0,0 +1,101 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { http } from 'msw'
import { server } from '../../../../tests/msw/server'
import { jsonResponse, errorResponse } from '../../../../tests/msw/helpers'
import { renderWithProviders, screen, waitFor, userEvent } from '../../../../tests/helpers/render'
import { seedBearer, clearBearer } from '../../../../tests/helpers/auth'
import { AdminPage } from '..'
// v2 admin — AI Recognition Engine panel. Covers GET → render telemetry,
// edit value via stepper / input, APPLY → PATCH, RESET → discards draft,
// PATCH 500 → inline error.
//
// Both AI and GPS panels render APPLY buttons; AI is the first one in DOM
// order. We pick [0] from getAllByRole rather than coupling to internal markup.
function aiApplyButton(): HTMLElement {
return screen.getAllByRole('button', { name: /apply/i })[0]
}
function aiResetButton(): HTMLElement {
return screen.getByRole('button', { name: /reset/i })
}
beforeEach(() => {
seedBearer()
})
afterEach(() => {
clearBearer()
})
describe('AdminPage — AI Recognition Engine', () => {
it('renders initial settings + telemetry from GET /api/admin/ai-settings', async () => {
renderWithProviders(<AdminPage />)
expect(await screen.findByText('YOLOV8-X · CKPT-241')).toBeInTheDocument()
expect(screen.getByDisplayValue('4')).toBeInTheDocument()
expect(screen.getByDisplayValue('25')).toBeInTheDocument()
})
it('APPLY sends PATCH with edited settings and reflects telemetry refresh', async () => {
const calls: { body: unknown }[] = []
server.use(
http.patch('/api/admin/ai-settings', async ({ request }) => {
const body = await request.json()
calls.push({ body })
return jsonResponse({
settings: { framesToRecognize: 8, minSecondsBetween: 2, minConfidence: 25 },
telemetry: {
model: 'YOLOV8-X', checkpoint: 'CKPT-242',
lastRunAt: '2026-05-18T12:00:00Z', frames: 99, avgConfidence: 80,
},
})
}),
)
renderWithProviders(<AdminPage />)
await screen.findByText('YOLOV8-X · CKPT-241')
const framesInput = screen.getByDisplayValue('4') as HTMLInputElement
await userEvent.clear(framesInput)
await userEvent.type(framesInput, '8')
await userEvent.click(aiApplyButton())
await waitFor(() => expect(calls.length).toBe(1))
expect((calls[0].body as { framesToRecognize: number }).framesToRecognize).toBe(8)
expect(await screen.findByText(/CKPT-242/)).toBeInTheDocument()
})
it('RESET reverts draft to the last persisted value (no PATCH)', async () => {
const patchCalls: unknown[] = []
server.use(
http.patch('/api/admin/ai-settings', () => {
patchCalls.push({})
return jsonResponse({})
}),
)
renderWithProviders(<AdminPage />)
await screen.findByText('YOLOV8-X · CKPT-241')
const framesInput = screen.getByDisplayValue('4') as HTMLInputElement
await userEvent.clear(framesInput)
await userEvent.type(framesInput, '9')
expect(screen.getByDisplayValue('9')).toBeInTheDocument()
await userEvent.click(aiResetButton())
expect(screen.getByDisplayValue('4')).toBeInTheDocument()
expect(patchCalls.length).toBe(0)
})
it('PATCH 500 surfaces an inline error', async () => {
server.use(
http.patch('/api/admin/ai-settings', () => errorResponse(500, 'boom')),
)
renderWithProviders(<AdminPage />)
await screen.findByText('YOLOV8-X · CKPT-241')
await userEvent.click(aiApplyButton())
const alert = await screen.findByRole('alert')
expect(alert.textContent ?? '').toMatch(/failed to save ai/i)
})
})
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { http } from 'msw'
import { server } from '../../../../tests/msw/server'
import { jsonResponse } from '../../../../tests/msw/helpers'
import { renderWithProviders, screen, waitFor, userEvent } from '../../../../tests/helpers/render'
import { seedBearer, clearBearer } from '../../../../tests/helpers/auth'
import { seedAircraft } from '../../../../tests/fixtures/seed_aircraft'
import { AdminPage } from '..'
// v2 admin — Default Aircrafts panel: render 6 mockup rows + star toggle.
beforeEach(() => {
seedBearer()
server.use(
http.get('/api/flights/aircrafts', () => jsonResponse(seedAircraft)),
)
})
afterEach(() => {
clearBearer()
})
describe('AdminPage — Default Aircrafts', () => {
it('renders all 6 seeded aircraft with id · resolution · minutes', async () => {
renderWithProviders(<AdminPage />)
expect(await screen.findByText('DJI Mavic 3')).toBeInTheDocument()
expect(screen.getByText('Matrice 300 RTK')).toBeInTheDocument()
expect(screen.getByText('Leleka-100')).toBeInTheDocument()
expect(screen.getByText('Fixed Wing Scout')).toBeInTheDocument()
expect(screen.getByText('Autel EVO II Pro')).toBeInTheDocument()
expect(screen.getByText('PD-2 Recon')).toBeInTheDocument()
// Subline format: "AC-001 · 4K · 46MIN"
expect(screen.getByText(/AC-001\s+·\s+4K\s+·\s+46MIN/)).toBeInTheDocument()
})
it('star toggle PATCHes isDefault and updates UI', async () => {
const calls: { id: string; body: unknown }[] = []
server.use(
http.patch('/api/flights/aircrafts/:id', async ({ params, request }) => {
const body = await request.json()
calls.push({ id: String(params.id), body })
return jsonResponse({ ok: true })
}),
)
renderWithProviders(<AdminPage />)
await screen.findByText('DJI Mavic 3')
// AC-002 starts non-default → click its star to mark default.
const ac002Row = screen.getByText('Matrice 300 RTK').closest('[data-aircraft-id]') as HTMLElement
expect(ac002Row).not.toBeNull()
// Within the row find the toggle button (set-default label).
const toggleBtn = ac002Row.querySelector('button[aria-pressed="false"]') as HTMLButtonElement
expect(toggleBtn).not.toBeNull()
await userEvent.click(toggleBtn)
await waitFor(() => expect(calls.length).toBe(1))
expect(calls[0].id).toBe('AC-002')
expect((calls[0].body as { isDefault: boolean }).isDefault).toBe(true)
})
})
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { http } from 'msw'
import { server } from '../../../../tests/msw/server'
import { jsonResponse } from '../../../../tests/msw/helpers'
import { renderWithProviders, screen, waitFor, userEvent } from '../../../../tests/helpers/render'
import { seedBearer, clearBearer } from '../../../../tests/helpers/auth'
import { AdminPage } from '..'
// v2 admin — GPS Device Link panel.
//
// AI and GPS share APPLY label; GPS is the SECOND APPLY in DOM order.
function gpsApplyButton(): HTMLElement {
return screen.getAllByRole('button', { name: /apply/i })[1]
}
beforeEach(() => {
seedBearer()
})
afterEach(() => {
clearBearer()
})
describe('AdminPage — GPS Device Link', () => {
it('renders initial settings + telemetry from GET /api/admin/gps-settings', async () => {
renderWithProviders(<AdminPage />)
expect(await screen.findByDisplayValue('192.168.1.100')).toBeInTheDocument()
expect(screen.getByDisplayValue('9001')).toBeInTheDocument()
expect(screen.getByText('UDP/192.168.1.100:9001')).toBeInTheDocument()
})
it('protocol segmented control switches active value and APPLY PATCHes', async () => {
const calls: { body: unknown }[] = []
server.use(
http.patch('/api/admin/gps-settings', async ({ request }) => {
const body = await request.json()
calls.push({ body })
return jsonResponse({
settings: { ...(body as object), address: '192.168.1.100', port: 9001 },
telemetry: { socket: 'UDP/192.168.1.100:9001', connected: true, fix: '3D', satellites: 11, hdop: 0.82, lastPacketMs: 12 },
})
}),
)
renderWithProviders(<AdminPage />)
await screen.findByDisplayValue('192.168.1.100')
const ubxBtn = screen.getByRole('button', { name: 'UBX' })
await userEvent.click(ubxBtn)
expect(ubxBtn).toHaveAttribute('aria-pressed', 'true')
await userEvent.click(gpsApplyButton())
await waitFor(() => expect(calls.length).toBe(1))
expect((calls[0].body as { protocol: string }).protocol).toBe('UBX')
})
it('PING and RECONNECT fire their dedicated endpoints', async () => {
let pingHits = 0
let reconnectHits = 0
server.use(
http.post('/api/admin/gps-settings/ping', () => { pingHits += 1; return new Response(null, { status: 204 }) }),
http.post('/api/admin/gps-settings/reconnect', () => {
reconnectHits += 1
return jsonResponse({
settings: { address: '192.168.1.100', port: 9001, protocol: 'NMEA' },
telemetry: { socket: 'UDP/192.168.1.100:9001', connected: true, fix: '3D', satellites: 11, hdop: 0.82, lastPacketMs: 0 },
})
}),
)
renderWithProviders(<AdminPage />)
await screen.findByDisplayValue('192.168.1.100')
await userEvent.click(screen.getByRole('button', { name: /^ping$/i }))
await waitFor(() => expect(pingHits).toBe(1))
await userEvent.click(screen.getByRole('button', { name: /reconnect/i }))
await waitFor(() => expect(reconnectHits).toBe(1))
})
})
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useState, useCallback } from 'react'
import { api, endpoints } from '../../api'
import type {
AiRecognitionResponse,
AiRecognitionSettings,
AiRecognitionTelemetry,
} from '../../types'
type Status = 'idle' | 'loading' | 'ready' | 'saving' | 'error'
// Factory defaults — UI stays interactive when GET fails (no backend).
const FACTORY_AI_SETTINGS: AiRecognitionSettings = {
framesToRecognize: 4,
minSecondsBetween: 2,
minConfidence: 25,
}
export function useAiSettings() {
const [draft, setDraft] = useState<AiRecognitionSettings>(FACTORY_AI_SETTINGS)
const [persisted, setPersisted] = useState<AiRecognitionSettings>(FACTORY_AI_SETTINGS)
const [telemetry, setTelemetry] = useState<AiRecognitionTelemetry | null>(null)
const [status, setStatus] = useState<Status>('idle')
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setStatus('loading')
api.get<AiRecognitionResponse>(endpoints.admin.aiSettings())
.then(res => {
if (cancelled) return
setDraft(res.settings)
setPersisted(res.settings)
setTelemetry(res.telemetry)
setStatus('ready')
})
.catch(() => {
if (cancelled) return
setStatus('error')
setError('Failed to load AI settings')
})
return () => { cancelled = true }
}, [])
const save = useCallback(async () => {
setStatus('saving')
setError(null)
try {
const res = await api.patch<AiRecognitionResponse>(endpoints.admin.aiSettings(), draft)
setDraft(res.settings)
setPersisted(res.settings)
setTelemetry(res.telemetry)
setStatus('ready')
} catch {
setStatus('error')
setError('Failed to save AI settings')
}
}, [draft])
const reset = useCallback(() => {
setDraft(persisted)
}, [persisted])
return { draft, setDraft, telemetry, status, error, save, reset } as const
}
+89
View File
@@ -0,0 +1,89 @@
import { useEffect, useState, useCallback } from 'react'
import { api, endpoints } from '../../api'
import type {
GpsDeviceResponse,
GpsDeviceSettings,
GpsDeviceTelemetry,
} from '../../types'
type Status = 'idle' | 'loading' | 'ready' | 'saving' | 'pinging' | 'reconnecting' | 'error'
// Factory defaults — UI stays interactive when GET fails (no backend).
const FACTORY_GPS_SETTINGS: GpsDeviceSettings = {
address: '192.168.1.100',
port: 9001,
protocol: 'NMEA',
}
export function useGpsSettings() {
const [draft, setDraft] = useState<GpsDeviceSettings>(FACTORY_GPS_SETTINGS)
const [persisted, setPersisted] = useState<GpsDeviceSettings>(FACTORY_GPS_SETTINGS)
const [telemetry, setTelemetry] = useState<GpsDeviceTelemetry | null>(null)
const [status, setStatus] = useState<Status>('idle')
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setStatus('loading')
api.get<GpsDeviceResponse>(endpoints.admin.gpsSettings())
.then(res => {
if (cancelled) return
setDraft(res.settings)
setPersisted(res.settings)
setTelemetry(res.telemetry)
setStatus('ready')
})
.catch(() => {
if (cancelled) return
setStatus('error')
setError('Failed to load GPS settings')
})
return () => { cancelled = true }
}, [])
const save = useCallback(async () => {
setStatus('saving')
setError(null)
try {
const res = await api.patch<GpsDeviceResponse>(endpoints.admin.gpsSettings(), draft)
setDraft(res.settings)
setPersisted(res.settings)
setTelemetry(res.telemetry)
setStatus('ready')
} catch {
setStatus('error')
setError('Failed to save GPS settings')
}
}, [draft])
const ping = useCallback(async () => {
setStatus('pinging')
setError(null)
try {
await api.post(endpoints.admin.gpsPing(), {})
setStatus('ready')
} catch {
setStatus('error')
setError('Ping failed')
}
}, [])
const reconnect = useCallback(async () => {
setStatus('reconnecting')
setError(null)
try {
const res = await api.post<GpsDeviceResponse>(endpoints.admin.gpsReconnect(), {})
setTelemetry(res.telemetry)
setStatus('ready')
} catch {
setStatus('error')
setError('Reconnect failed')
}
}, [])
const reset = useCallback(() => {
setDraft(persisted)
}, [persisted])
return { draft, setDraft, telemetry, status, error, save, ping, reconnect, reset } as const
}