mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 13:41:12 +00:00
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:
+626
-229
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user