-
-
{t('admin.aiSettings')}
-
-
-
-
+ {/* ===== CENTER ===== */}
+
+
+
+ {/* AI RECOGNITION ENGINE */}
+
+
-
+
-
+
+ {t('admin.aiEngine.title')}
+ {t('admin.aiEngine.subtitle')}
+
+ {t('admin.aiEngine.model')}
+
+ {ai.telemetry ? `${ai.telemetry.model} · ${ai.telemetry.checkpoint}` : FALLBACK}
+
+ {t('admin.aiEngine.loaded')}
+
-
-
+
+
-
+
+
+
-
+
+
+
+
+ ai.setDraft({ ...ai.draft, framesToRecognize: v })}
+ />
+
+
+ {t('admin.aiEngine.framesHint')}
+
+
+ ai.setDraft({ ...ai.draft, minSecondsBetween: v })}
+ />
+
+
+ {t('admin.aiEngine.minSecondsHint')}
+
+
+ ai.setDraft({ ...ai.draft, minConfidence: v })}
+ />
+
+ {t('admin.aiEngine.minConfidenceHint')}
+
+
+ {ai.error && (
+
+
+ {t('admin.aiEngine.lastRun')}{' '}
+
+ {formatRunTime(ai.telemetry?.lastRunAt ?? null)}
+
+
+
+ {t('admin.aiEngine.frames')}{' '}
+
+ {ai.telemetry ? ai.telemetry.frames.toLocaleString() : FALLBACK}
+
+
+
+ {t('admin.aiEngine.avgConf')}{' '}
+
+ {ai.telemetry ? `${ai.telemetry.avgConfidence.toFixed(1)}%` : FALLBACK}
+
+
+
+
+
+
+
+
+ {ai.error}
+
+ )}
-
-
-
-
-
{t('admin.gpsSettings')}
-
-
-
-
+ {/* GPS DEVICE LINK */}
+
- {/* Users */}
-
+
-
+
-
+
+ {t('admin.gpsDevice.title')}
+ {t('admin.gpsDevice.subtitle')}
+
+ {t('admin.gpsDevice.socket')}
+
+ {gps.telemetry?.socket ?? FALLBACK}
+
+
+
+ {t('admin.gpsDevice.connected')}
+
+
-
-
-
-
-
-
-
-
-
-
+
- {/* Aircrafts sidebar */}
- {t('admin.users')}
-
-
-
-
-
- | Name | -Role | -Status | -- | |
|---|---|---|---|---|
| {u.name} | -{u.email} | -{u.role} | -- - {u.isActive ? 'Active' : 'Inactive'} - - | -- {u.isActive && ( - - )} - | -
- 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" />
- 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" />
- 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" />
-
-
+
+
+
+
+
+
+
+
+
+
+ {t('admin.gpsDevice.addressHint')}
+ gps.setDraft({ ...gps.draft, address: e.target.value })}
+ aria-label={t('admin.gpsDevice.address')}
+ />
+
+
+
+ {t('admin.gpsDevice.portHint')}
+ gps.setDraft({ ...gps.draft, port: Number(e.target.value) })}
+ style={{ textAlign: 'right' }}
+ aria-label={t('admin.gpsDevice.port')}
+ />
+
+
+
+
+ {t('admin.gpsDevice.protocolHint')}
+
+ {PROTOCOLS.map(p => (
+
+ ))}
+
+
+
+ {gps.error && (
+
+
+ {t('admin.gpsDevice.fix')}{' '}
+
+ {gps.telemetry ? `${gps.telemetry.fix} · ${gps.telemetry.satellites} SAT` : FALLBACK}
+
+
+
+ {t('admin.gpsDevice.hdop')}{' '}
+
+ {gps.telemetry ? gps.telemetry.hdop.toFixed(2) : FALLBACK}
+
+
+
+ {t('admin.gpsDevice.lastPkt')}{' '}
+
+ {gps.telemetry ? `+${gps.telemetry.lastPacketMs}ms` : FALLBACK}
+
+
+
+
+
+
+
+
+
+ {gps.error}
+
+ )}
-
+
+
+ >
+ }
+ >
+
+
)
}
diff --git a/src/features/admin/ClassEditRow.tsx b/src/features/admin/ClassEditRow.tsx
new file mode 100644
index 0000000..da603e2
--- /dev/null
+++ b/src/features/admin/ClassEditRow.tsx
@@ -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) => 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 (
+
+ )
+}
+function CloseIcon() {
+ return (
+
+ )
+}
+
+export function ClassEditRow({
+ idCell, rowId, form, onChange, onSave, onCancel, onKeyDown,
+ saving, errorMessage, placeholderName,
+}: ClassEditRowProps) {
+ const { t } = useTranslation()
+ const colorInputRef = useRef(null)
+
+ return (
+
+
+ {idCell}
+
+ 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')}
+ />
+
+
+
+ onChange({ ...form, color: e.target.value })}
+ style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
+ tabIndex={-1}
+ />
+
+
+
+
+
+
+
+
+ {errorMessage && (
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/features/admin/Modal.tsx b/src/features/admin/Modal.tsx
new file mode 100644
index 0000000..de3318a
--- /dev/null
+++ b/src/features/admin/Modal.tsx
@@ -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) => {
+ if (e.target === e.currentTarget) onClose()
+ }
+ const onPanelKey = (e: KeyboardEvent) => {
+ // 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 (
+ )
+ 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( )
+ 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( )
+ 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( )
+ 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)
+ })
+})
diff --git a/src/features/admin/__tests__/AdminPage.aircrafts.test.tsx b/src/features/admin/__tests__/AdminPage.aircrafts.test.tsx
new file mode 100644
index 0000000..04a14c6
--- /dev/null
+++ b/src/features/admin/__tests__/AdminPage.aircrafts.test.tsx
@@ -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( )
+ 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( )
+ 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)
+ })
+})
diff --git a/src/features/admin/__tests__/AdminPage.gps-settings.test.tsx b/src/features/admin/__tests__/AdminPage.gps-settings.test.tsx
new file mode 100644
index 0000000..239fd2b
--- /dev/null
+++ b/src/features/admin/__tests__/AdminPage.gps-settings.test.tsx
@@ -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( )
+ 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( )
+ 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( )
+ 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))
+ })
+})
diff --git a/src/features/admin/useAiSettings.ts b/src/features/admin/useAiSettings.ts
new file mode 100644
index 0000000..0f21817
--- /dev/null
+++ b/src/features/admin/useAiSettings.ts
@@ -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(FACTORY_AI_SETTINGS)
+ const [persisted, setPersisted] = useState(FACTORY_AI_SETTINGS)
+ const [telemetry, setTelemetry] = useState(null)
+ const [status, setStatus] = useState('idle')
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ let cancelled = false
+ setStatus('loading')
+ api.get(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(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
+}
diff --git a/src/features/admin/useGpsSettings.ts b/src/features/admin/useGpsSettings.ts
new file mode 100644
index 0000000..d959d68
--- /dev/null
+++ b/src/features/admin/useGpsSettings.ts
@@ -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(FACTORY_GPS_SETTINGS)
+ const [persisted, setPersisted] = useState(FACTORY_GPS_SETTINGS)
+ const [telemetry, setTelemetry] = useState(null)
+ const [status, setStatus] = useState('idle')
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ let cancelled = false
+ setStatus('loading')
+ api.get(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(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(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
+}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index d9f9716..b577bd8 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -2,7 +2,7 @@
"nav": {
"flights": "Flights",
"annotations": "Annotations",
- "dataset": "Dataset Explorer",
+ "dataset": "Dataset",
"admin": "Admin",
"settings": "Settings",
"logout": "Logout"
@@ -116,19 +116,73 @@
"title": "Admin",
"classes": {
"title": "Detection Classes",
+ "search": "Search class…",
+ "add": "+ ADD",
+ "colName": "Name",
+ "colHex": "Hex",
+ "colOps": "Ops",
"edit": "Edit",
+ "delete": "Delete",
"save": "Save",
"cancel": "Cancel",
"nameRequired": "Name is required",
"maxSizeMustBePositive": "Max size must be a positive number",
"updateFailed": "Update failed. Please try again."
},
- "aiSettings": "AI Recognition Settings",
- "gpsSettings": "GPS Device Settings",
- "aircrafts": "Default Aircrafts",
- "users": "User Management",
- "addUser": "Add User",
- "deactivate": "Deactivate"
+ "aiEngine": {
+ "title": "AI Recognition Engine",
+ "subtitle": "Detection model runtime parameters. Applied per-flight, hot-reloaded.",
+ "framesToRecognize": "Frames To Recognize",
+ "framesHint": "Number of consecutive frames the model averages before emitting a detection.",
+ "minSeconds": "Min Seconds Between",
+ "minSecondsHint": "Cooldown gap between successive inference calls on the same video stream.",
+ "minConfidence": "Min Confidence",
+ "minConfidenceHint": "Detections below this threshold are discarded before reaching the canvas.",
+ "reset": "RESET",
+ "apply": "APPLY",
+ "lastRun": "LAST RUN",
+ "frames": "FRAMES",
+ "avgConf": "AVG CONF",
+ "model": "MODEL",
+ "loaded": "LOADED",
+ "unitFR": "FR",
+ "unitSec": "SEC"
+ },
+ "gpsDevice": {
+ "title": "GPS Device Link",
+ "subtitle": "Ground-station receiver feeding the GPS-Denied correction pipeline.",
+ "address": "Device Address",
+ "addressHint": "IPv4 endpoint or hostname of the GPS receiver bridge.",
+ "port": "Device Port",
+ "portHint": "UDP port the receiver streams NMEA sentences on.",
+ "protocol": "Protocol",
+ "protocolHint": "Wire format negotiated with the receiver. Switch only when the device is offline.",
+ "ping": "PING",
+ "reconnect": "RECONNECT",
+ "apply": "APPLY",
+ "connected": "CONNECTED",
+ "fix": "FIX",
+ "hdop": "HDOP",
+ "lastPkt": "LAST PKT",
+ "socket": "SOCKET"
+ },
+ "aircrafts": {
+ "title": "Default Aircrafts",
+ "legendPlane": "PLANE",
+ "legendCopter": "COPTER",
+ "legendFixedW": "FIXED-W",
+ "add": "+ ADD AIRCRAFT",
+ "addTitle": "Add Aircraft",
+ "setDefault": "Set default",
+ "default": "Default",
+ "fieldModel": "Model",
+ "fieldType": "Type",
+ "fieldResolution": "Resolution",
+ "fieldMaxMinutes": "Max minutes",
+ "fieldDefault": "Set as default",
+ "modelRequired": "Model is required",
+ "saveFailed": "Save failed. Please try again."
+ }
},
"settings": {
"title": "Settings",
diff --git a/src/i18n/ua.json b/src/i18n/ua.json
index 2a0aee9..a587f27 100644
--- a/src/i18n/ua.json
+++ b/src/i18n/ua.json
@@ -116,19 +116,73 @@
"title": "Адмін",
"classes": {
"title": "Класи детекцій",
+ "search": "Пошук класу…",
+ "add": "+ ДОДАТИ",
+ "colName": "Назва",
+ "colHex": "Hex",
+ "colOps": "Дії",
"edit": "Редагувати",
+ "delete": "Видалити",
"save": "Зберегти",
"cancel": "Скасувати",
"nameRequired": "Назва обов'язкова",
"maxSizeMustBePositive": "Максимальний розмір має бути додатнім числом",
"updateFailed": "Не вдалося оновити. Спробуйте ще раз."
},
- "aiSettings": "AI Налаштування",
- "gpsSettings": "GPS Пристрій",
- "aircrafts": "Літальні апарати",
- "users": "Користувачі",
- "addUser": "Додати користувача",
- "deactivate": "Деактивувати"
+ "aiEngine": {
+ "title": "AI Розпізнавання",
+ "subtitle": "Параметри роботи моделі. Застосовуються до польоту, гаряче перезавантаження.",
+ "framesToRecognize": "Кадрів для розпізнавання",
+ "framesHint": "Кількість послідовних кадрів, які модель усереднює перед видачею детекції.",
+ "minSeconds": "Мін секунд між",
+ "minSecondsHint": "Інтервал між послідовними викликами розпізнавання на одному відеопотоці.",
+ "minConfidence": "Мін впевненість",
+ "minConfidenceHint": "Детекції нижче порогу відкидаються до відображення на канві.",
+ "reset": "СКИНУТИ",
+ "apply": "ЗАСТОСУВАТИ",
+ "lastRun": "ОСТАННІЙ ЗАПУСК",
+ "frames": "КАДРИ",
+ "avgConf": "СЕРЕДНЯ",
+ "model": "МОДЕЛЬ",
+ "loaded": "ЗАВАНТАЖЕНО",
+ "unitFR": "КАДР",
+ "unitSec": "СЕК"
+ },
+ "gpsDevice": {
+ "title": "GPS Пристрій",
+ "subtitle": "Наземний приймач, який живить конвеєр корекції GPS-Denied.",
+ "address": "Адреса пристрою",
+ "addressHint": "IPv4 точка або hostname моста GPS-приймача.",
+ "port": "Порт пристрою",
+ "portHint": "UDP-порт, на якому приймач транслює NMEA-повідомлення.",
+ "protocol": "Протокол",
+ "protocolHint": "Wire-формат узгоджений з приймачем. Перемикайте лише коли пристрій офлайн.",
+ "ping": "PING",
+ "reconnect": "ПЕРЕПІД'ЄДНАТИ",
+ "apply": "ЗАСТОСУВАТИ",
+ "connected": "З'ЄДНАНО",
+ "fix": "FIX",
+ "hdop": "HDOP",
+ "lastPkt": "ОСТ. ПАКЕТ",
+ "socket": "СОКЕТ"
+ },
+ "aircrafts": {
+ "title": "Літальні апарати",
+ "legendPlane": "ЛІТАК",
+ "legendCopter": "КОПТЕР",
+ "legendFixedW": "FIXED-W",
+ "add": "+ ДОДАТИ АПАРАТ",
+ "addTitle": "Додати апарат",
+ "setDefault": "Встановити за замовч.",
+ "default": "За замовч.",
+ "fieldModel": "Модель",
+ "fieldType": "Тип",
+ "fieldResolution": "Роздільність",
+ "fieldMaxMinutes": "Макс. хвилин",
+ "fieldDefault": "За замовчуванням",
+ "modelRequired": "Модель обов'язкова",
+ "saveFailed": "Не вдалося зберегти. Спробуйте ще раз."
+ }
},
"settings": {
"title": "Налаштування",
diff --git a/src/index.css b/src/index.css
index 0a66657..8e22dc1 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,31 +1,368 @@
@import "tailwindcss";
+/* Fonts are loaded via in index.html so they
+ resolve before first paint (no FOUT). Don't re-import via @import here. */
@theme {
- --color-az-bg: #1e1e1e;
- --color-az-panel: #2b2b2b;
- --color-az-header: #343a40;
- --color-az-border: #495057;
- --color-az-muted: #6c757d;
- --color-az-text: #adb5bd;
- --color-az-orange: #fd7e14;
- --color-az-blue: #228be6;
- --color-az-red: #fa5252;
- --color-az-green: #40c057;
+ /* v2 — AZAION design system. v1 az-* names below are aliases so legacy
+ pages still render until they're migrated to v2 utilities. */
+ --color-surface-0: #0A0D10;
+ --color-surface-1: #13171C;
+ --color-surface-2: #1A1F26;
+ --color-surface-input: #0A0D10;
+ --color-border-hair: #252B34;
+ --color-border-raised: #3B4451;
+ --color-text-primary: #E8ECF1;
+ --color-text-secondary: #9AA4B2;
+ --color-text-muted: #5B6573;
+ --color-accent-amber: #FF9D3D;
+ --color-accent-cyan: #36D6C5;
+ --color-accent-red: #FF4756;
+ --color-accent-green: #3DDC84;
+ --color-accent-blue: #4E9EFF;
+
+ /* legacy v1 aliases — mapped to v2 vars so unmigrated pages stay readable. */
+ --color-az-bg: #0A0D10;
+ --color-az-panel: #13171C;
+ --color-az-header: #13171C;
+ --color-az-border: #252B34;
+ --color-az-muted: #5B6573;
+ --color-az-text: #E8ECF1;
+ --color-az-orange: #FF9D3D;
+ --color-az-blue: #4E9EFF;
+ --color-az-red: #FF4756;
+ --color-az-green: #3DDC84;
+}
+
+:root {
+ --surface-0: #0A0D10;
+ --surface-1: #13171C;
+ --surface-2: #1A1F26;
+ --surface-input: #0A0D10;
+ --border-hair: #252B34;
+ --border-raised: #3B4451;
+ --text-primary: #E8ECF1;
+ --text-secondary: #9AA4B2;
+ --text-muted: #5B6573;
+ --accent-amber: #FF9D3D;
+ --accent-cyan: #36D6C5;
+ --accent-red: #FF4756;
+ --accent-green: #3DDC84;
+ --accent-blue: #4E9EFF;
+}
+
+html, body {
+ background: var(--surface-0);
+ color: var(--text-primary);
}
body {
margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ font-family: 'IBM Plex Sans', system-ui, sans-serif;
+ font-size: 13px;
+ line-height: 1.5;
+ font-feature-settings: "ss01", "cv11";
}
-::-webkit-scrollbar {
- width: 6px;
- height: 6px;
+.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
+.tnum { font-variant-numeric: tabular-nums; }
+
+.micro {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 10px;
+ line-height: 1.4;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--text-secondary);
}
-::-webkit-scrollbar-track {
- background: var(--color-az-bg);
+
+.sect-head {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 11px;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--accent-amber);
}
-::-webkit-scrollbar-thumb {
- background: var(--color-az-border);
- border-radius: 3px;
+
+.hint { font-size: 11px; color: var(--text-muted); line-height: 1.45; }
+
+/* Corner brackets */
+.bracket { position: relative; }
+.bracket::before, .bracket::after,
+.bracket > .br::before, .bracket > .br::after {
+ content: ''; position: absolute; width: 8px; height: 8px;
+ border-color: var(--accent-amber); border-style: solid; border-width: 0;
+ pointer-events: none;
}
+.bracket::before { top: -1px; left: -1px; border-top-width: 1px; border-left-width: 1px; }
+.bracket::after { top: -1px; right: -1px; border-top-width: 1px; border-right-width: 1px; }
+.bracket > .br::before { bottom: -1px; left: -1px; border-bottom-width: 1px; border-left-width: 1px; }
+.bracket > .br::after { bottom: -1px; right: -1px; border-bottom-width: 1px; border-right-width: 1px; }
+
+/* Subtle grid backdrop */
+.grid-bg {
+ background-image:
+ linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
+ background-size: 60px 60px;
+}
+
+/* Inputs */
+.inp {
+ background: var(--surface-input);
+ border: 1px solid var(--border-hair);
+ border-radius: 2px;
+ height: 32px;
+ padding: 6px 10px;
+ font: 12px 'IBM Plex Sans', system-ui, sans-serif;
+ color: var(--text-primary);
+ outline: none;
+ width: 100%;
+}
+.inp:focus { border-color: var(--accent-amber); box-shadow: 0 0 0 1px var(--accent-amber); }
+.inp::placeholder { color: var(--text-muted); }
+.inp-mono { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; }
+
+/* Hide native number-input spinner arrows — custom ▲▼ steppers replace them. */
+.inp[type="number"]::-webkit-inner-spin-button,
+.inp[type="number"]::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+.inp[type="number"] { -moz-appearance: textfield; appearance: textfield; }
+
+/* Checkbox — v2 dark theme, amber check.
+ Layout-stable: flex (not inline-flex) so the baseline of the wrapping
+ label doesn't shift when the input gains focus or toggles. The checkmark
+ is a background-image SVG so there is no pseudo-element being added /
+ removed (which can briefly affect intrinsic size in some browsers). */
+.checkbox-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ line-height: 16px;
+ color: var(--text-primary);
+ cursor: pointer;
+ user-select: none;
+}
+.checkbox {
+ appearance: none;
+ -webkit-appearance: none;
+ box-sizing: border-box;
+ width: 16px;
+ height: 16px;
+ flex: none;
+ margin: 0;
+ padding: 0;
+ background: var(--surface-input) no-repeat center center;
+ background-size: 10px 10px;
+ border: 1px solid var(--border-raised);
+ border-radius: 2px;
+ cursor: pointer;
+ transition: border-color .1s, background-color .1s, box-shadow .1s;
+ outline: none;
+}
+.checkbox:hover { border-color: var(--accent-amber); }
+.checkbox:focus-visible {
+ border-color: var(--accent-amber);
+ box-shadow: 0 0 0 1px var(--accent-amber);
+}
+.checkbox:checked {
+ background-color: var(--accent-amber);
+ border-color: var(--accent-amber);
+ background-image: url("data:image/svg+xml;utf8,");
+}
+.checkbox:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex; align-items: center; gap: 6px;
+ height: 28px; padding: 0 12px;
+ font: 600 11px 'JetBrains Mono', monospace;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ border-radius: 2px;
+ border: 1px solid transparent;
+ cursor: pointer;
+ transition: background-color .12s, color .12s, border-color .12s;
+ white-space: nowrap;
+}
+.btn:disabled { opacity: 0.5; cursor: not-allowed; }
+.btn-primary {
+ background: var(--accent-amber);
+ color: #0A0D10;
+ border-color: var(--accent-amber);
+}
+.btn-primary:hover:not(:disabled) { filter: brightness(1.08); }
+.btn-secondary {
+ background: transparent;
+ color: var(--accent-amber);
+ border-color: var(--accent-amber);
+}
+.btn-secondary:hover:not(:disabled) { background: rgba(255,157,61,.12); }
+.btn-ghost {
+ background: transparent;
+ color: var(--text-secondary);
+ border-color: var(--border-hair);
+}
+.btn-ghost:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-raised); }
+.btn-danger {
+ background: var(--accent-red);
+ color: #0A0D10;
+ border-color: var(--accent-red);
+}
+
+/* Icon button */
+.ibtn {
+ display: inline-flex; align-items: center; justify-content: center;
+ width: 24px; height: 24px;
+ border: 1px solid transparent;
+ border-radius: 2px;
+ color: var(--text-muted);
+ background: transparent;
+ cursor: pointer;
+ transition: color .1s, background .1s, border-color .1s;
+}
+.ibtn:hover { color: var(--text-primary); background: var(--surface-2); border-color: var(--border-hair); }
+.ibtn:disabled { opacity: 0.4; cursor: not-allowed; }
+.ibtn.danger:hover { color: var(--accent-red); border-color: var(--accent-red); background: rgba(255,71,86,.08); }
+.ibtn.edit:hover { color: var(--accent-amber); border-color: var(--accent-amber); background: rgba(255,157,61,.08); }
+.ibtn.cyan:hover { color: var(--accent-cyan); border-color: var(--accent-cyan); background: rgba(54,214,197,.08); }
+
+/* Header-scoped icon buttons override the smaller in-table variant */
+header .ibtn {
+ width: 28px; height: 28px;
+ border: 1px solid var(--border-hair);
+ color: var(--text-secondary);
+}
+header .ibtn:hover { background: var(--surface-2); color: var(--text-primary); border-color: var(--border-raised); }
+header .ibtn.active { color: var(--accent-amber); border-color: var(--accent-amber); background: rgba(255,157,61,0.08); }
+header .ibtn.danger:hover { color: var(--accent-red); border-color: var(--accent-red); background: rgba(255,71,86,0.08); }
+
+/* Pills */
+.pill {
+ display: inline-flex; align-items: center; gap: 6px;
+ height: 18px; padding: 0 8px;
+ font: 600 10px 'JetBrains Mono', monospace;
+ letter-spacing: 0.10em;
+ text-transform: uppercase;
+ border: 1px solid currentColor;
+ border-radius: 2px;
+ background: transparent;
+}
+.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
+.pill-green { color: var(--accent-green); }
+.pill-red { color: var(--accent-red); }
+.pill-cyan { color: var(--accent-cyan); }
+.pill-amber { color: var(--accent-amber); }
+.pill-blue { color: var(--accent-blue); }
+.pill-muted { color: var(--text-muted); }
+
+/* Chip (role chips, type chips — solid filled, denser) */
+.chip {
+ display: inline-flex; align-items: center; justify-content: center;
+ height: 18px; min-width: 60px; padding: 0 8px;
+ font: 600 10px 'JetBrains Mono', monospace;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ border-radius: 2px;
+}
+.chip-admin { background: rgba(255,157,61,.16); color: var(--accent-amber); border: 1px solid rgba(255,157,61,.35); }
+.chip-operator { background: rgba(78,158,255,.14); color: var(--accent-blue); border: 1px solid rgba(78,158,255,.35); }
+.chip-viewer { background: rgba(154,164,178,.10); color: var(--text-secondary); border: 1px solid var(--border-hair); }
+
+/* Type squares (P / C / F) */
+.type-sq {
+ display: inline-flex; align-items: center; justify-content: center;
+ width: 16px; height: 16px;
+ border-radius: 2px;
+ font: 700 9px 'JetBrains Mono', monospace;
+ color: #0A0D10;
+ flex: none;
+}
+
+/* Color swatch */
+.swatch {
+ display: inline-block; width: 12px; height: 12px;
+ border: 1px solid rgba(255,255,255,0.18);
+ border-radius: 1px;
+ flex: none;
+}
+
+/* Segmented control */
+.seg { display: inline-flex; border: 1px solid var(--border-hair); border-radius: 2px; overflow: hidden; }
+.seg-btn {
+ height: 30px; padding: 0 14px;
+ font: 600 10px 'JetBrains Mono', monospace;
+ letter-spacing: 0.10em;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+ background: var(--surface-input);
+ border-right: 1px solid var(--border-hair);
+ cursor: pointer;
+ transition: background .1s, color .1s;
+}
+.seg-btn:last-child { border-right: 0; }
+.seg-btn:hover { color: var(--text-primary); }
+.seg-btn.active {
+ background: var(--accent-amber);
+ color: #0A0D10;
+}
+
+/* Header bar tabs */
+.tab {
+ display: inline-flex; align-items: center;
+ height: 48px; padding: 0 14px;
+ font: 500 12px/1 'JetBrains Mono', monospace;
+ letter-spacing: 0.10em; text-transform: uppercase;
+ color: var(--text-secondary);
+ border-bottom: 2px solid transparent;
+ text-decoration: none;
+ cursor: pointer;
+}
+.tab:hover { color: var(--text-primary); }
+.tab.active { color: var(--text-primary); border-bottom-color: var(--accent-amber); font-weight: 500; }
+
+/* Table rows */
+.row-hover:hover { background: var(--surface-2); }
+
+/* Card panel base */
+.panel {
+ background: var(--surface-1);
+ border: 1px solid var(--border-hair);
+ border-radius: 2px;
+}
+
+/* Star button */
+.star { color: var(--accent-amber); }
+.star-off { color: var(--text-muted); }
+
+/* Pulse for live dot */
+@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
+.live { animation: pulse 1.6s ease-in-out infinite; }
+
+/* Reveal-on-hover */
+.row-hover .reveal { opacity: 0; transition: opacity .12s; }
+.row-hover:hover .reveal { opacity: 1; }
+
+/* select matching inp */
+select.inp {
+ appearance: none;
+ -webkit-appearance: none;
+ background-image:
+ linear-gradient(45deg, transparent 50%, var(--text-secondary) 50%),
+ linear-gradient(135deg, var(--text-secondary) 50%, transparent 50%);
+ background-position: calc(100% - 14px) 14px, calc(100% - 9px) 14px;
+ background-size: 5px 5px, 5px 5px;
+ background-repeat: no-repeat;
+ padding-right: 28px;
+}
+
+/* Scrollbar */
+::-webkit-scrollbar { width: 8px; height: 8px; }
+::-webkit-scrollbar-track { background: var(--surface-0); }
+::-webkit-scrollbar-thumb { background: #1f2630; border-radius: 2px; }
+::-webkit-scrollbar-thumb:hover { background: #2a323e; }
diff --git a/src/types/index.ts b/src/types/index.ts
index 91685b6..8991c42 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -69,8 +69,51 @@ export interface Flight {
export interface Aircraft {
id: string
model: string
- type: 'Plane' | 'Copter'
+ type: 'Plane' | 'Copter' | 'FixedWing'
isDefault: boolean
+ resolution?: string
+ maxMinutes?: number
+}
+
+export interface AiRecognitionSettings {
+ framesToRecognize: number
+ minSecondsBetween: number
+ minConfidence: number
+}
+
+export interface AiRecognitionTelemetry {
+ model: string
+ checkpoint: string
+ lastRunAt: string | null
+ frames: number
+ avgConfidence: number
+}
+
+export interface AiRecognitionResponse {
+ settings: AiRecognitionSettings
+ telemetry: AiRecognitionTelemetry
+}
+
+export type GpsProtocol = 'NMEA' | 'UBX' | 'MAVLINK'
+
+export interface GpsDeviceSettings {
+ address: string
+ port: number
+ protocol: GpsProtocol
+}
+
+export interface GpsDeviceTelemetry {
+ socket: string
+ connected: boolean
+ fix: '2D' | '3D' | 'NO_FIX'
+ satellites: number
+ hdop: number
+ lastPacketMs: number
+}
+
+export interface GpsDeviceResponse {
+ settings: GpsDeviceSettings
+ telemetry: GpsDeviceTelemetry
}
export interface Waypoint {
diff --git a/tests/admin_class_edit.test.tsx b/tests/admin_class_edit.test.tsx
index 7c8af84..79e9480 100644
--- a/tests/admin_class_edit.test.tsx
+++ b/tests/admin_class_edit.test.tsx
@@ -105,14 +105,12 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
// Act
await clickEdit('1')
- // Assert — form is visible inside row 1.
+ // Assert — name input is visible inside row 1 (v2 minimal edit:
+ // only the name is editable inline; shortName/color/maxSizeM are
+ // preserved in form state and sent on save).
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
expect(nameInput).toBeInTheDocument()
- const shortInput = within(row1).getByDisplayValue('a') as HTMLInputElement
- expect(shortInput).toBeInTheDocument()
- const maxSize = within(row1).getByDisplayValue('7') as HTMLInputElement
- expect(maxSize).toBeInTheDocument()
// Assert — row 2 stays read-only: the row still shows the plain text name.
const row2 = getRow('2')
@@ -246,31 +244,17 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
// Act
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
- // Assert — no PATCH; error alert rendered.
+ // Assert — no PATCH; error alert rendered (v2 renders the alert in
+ // a sibling tr below the edit row, not inside row1 itself).
expect(patchCalls.length).toBe(0)
- const alert = within(row1).getByRole('alert')
+ const alert = screen.getByRole('alert')
expect(alert.textContent ?? '').toMatch(/name is required|назва обов/i)
})
- it('non-positive maxSizeM → no PATCH; maxSizeMustBePositive error visible', async () => {
- // Arrange
- const patchCalls = capturePatchCalls()
- renderWithProviders( )
- await screen.findByText('class-a')
- await clickEdit('1')
- const row1 = getRow('1')
- const maxInput = within(row1).getByDisplayValue('7') as HTMLInputElement
- await userEvent.clear(maxInput)
- await userEvent.type(maxInput, '0')
-
- // Act
- await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
-
- // Assert — no PATCH; error alert rendered.
- expect(patchCalls.length).toBe(0)
- const alert = within(row1).getByRole('alert')
- expect(alert.textContent ?? '').toMatch(/positive|додатнім/i)
- })
+ // The maxSizeM field is no longer editable inline in v2 (mockup shows
+ // name-only). The original "non-positive maxSizeM" validation test is
+ // removed — the constraint is now enforced by a separate edit-class
+ // flow (not yet built) rather than inline.
})
describe('AC-6: backend error is surfaced inline', () => {
@@ -299,10 +283,11 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
// Act
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
- // Assert — PATCH happened, error rendered, form still open, no alert().
+ // Assert — PATCH happened, error rendered (in a sibling tr), form
+ // still open, no alert().
await waitFor(() => expect(patchCount).toBe(1))
const row1After = getRow('1')
- const alert = await within(row1After).findByRole('alert')
+ const alert = await screen.findByRole('alert')
expect(alert.textContent ?? '').toMatch(/update failed|не вдалося оновити/i)
expect(within(row1After).getByDisplayValue('will-fail')).toBeInTheDocument()
expect(alertCalls).toBe(0)
@@ -317,7 +302,7 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
// Arrange — capture POST; second GET returns 3 classes.
const postCalls: { body: unknown }[] = []
let getCount = 0
- const NEW_CLASS: DetectionClass = { id: 3, name: 'fresh', shortName: '', color: '#FF0000', maxSizeM: 7, photoMode: 0 }
+ const NEW_CLASS: DetectionClass = { id: 3, name: 'fresh', shortName: '', color: '#FF9D3D', maxSizeM: 7, photoMode: 0 }
server.use(
http.post('/api/admin/classes', async ({ request }) => {
postCalls.push({ body: await request.json() })
@@ -332,13 +317,15 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
renderWithProviders( )
await screen.findByText('class-a')
- // Act — scope to the classes table panel (both the class-add row and
- // the user-add row use placeholder="Name" + a `+` button; disambiguate
- // by walking up from the class-a cell to the enclosing panel).
- const classesPanel = (getRow('1').closest('table') as HTMLElement).parentElement as HTMLElement
- const addNameInput = within(classesPanel).getByPlaceholderText('Name') as HTMLInputElement
- await userEvent.type(addNameInput, 'fresh')
- await userEvent.click(within(classesPanel).getByRole('button', { name: '+' }))
+ // Act — v2 layout: click the top "+ ADD" button to open an inline
+ // add-row at the top of the table, type the name, click the save
+ // (cyan checkmark, aria-label "Save") icon button.
+ const classesPanel = getRow('1').closest('aside') as HTMLElement
+ await userEvent.click(within(classesPanel).getByRole('button', { name: /^\+ add$|^\+ додати$/i }))
+ const addRow = within(classesPanel).getByText('+', { selector: 'td' }).closest('tr') as HTMLElement
+ const nameInput = within(addRow).getByPlaceholderText('Name') as HTMLInputElement
+ await userEvent.type(nameInput, 'fresh')
+ await userEvent.click(within(addRow).getByRole('button', { name: /^save$|^зберегти$/i }))
// Assert
await waitFor(() => expect(postCalls.length).toBe(1))
diff --git a/tests/fixtures/seed_aircraft.ts b/tests/fixtures/seed_aircraft.ts
index 1a43370..0a9ebcd 100644
--- a/tests/fixtures/seed_aircraft.ts
+++ b/tests/fixtures/seed_aircraft.ts
@@ -1,8 +1,11 @@
import type { Aircraft } from '../../src/types'
-// Three aircraft with one default, per `seed_aircraft` in test-data.md.
+// Six aircraft matching the v2 admin mockup. AC-001 is the default.
export const seedAircraft: Aircraft[] = [
- { id: 'aircraft-1', model: 'Bayraktar TB2', type: 'Plane', isDefault: true },
- { id: 'aircraft-2', model: 'DJI Mavic 3', type: 'Copter', isDefault: false },
- { id: 'aircraft-3', model: 'Leleka-100', type: 'Plane', isDefault: false },
+ { id: 'AC-001', model: 'DJI Mavic 3', type: 'Copter', isDefault: true, resolution: '4K', maxMinutes: 46 },
+ { id: 'AC-002', model: 'Matrice 300 RTK', type: 'Copter', isDefault: false, resolution: '4K', maxMinutes: 55 },
+ { id: 'AC-003', model: 'Leleka-100', type: 'FixedWing', isDefault: false, resolution: 'HD', maxMinutes: 180 },
+ { id: 'AC-004', model: 'Fixed Wing Scout', type: 'Plane', isDefault: false, resolution: '1080P', maxMinutes: 95 },
+ { id: 'AC-005', model: 'Autel EVO II Pro', type: 'Copter', isDefault: false, resolution: '6K', maxMinutes: 40 },
+ { id: 'AC-006', model: 'PD-2 Recon', type: 'FixedWing', isDefault: false, resolution: 'HD', maxMinutes: 600 },
]
diff --git a/tests/fixtures/seed_flights.ts b/tests/fixtures/seed_flights.ts
index 6eacebc..1ca04da 100644
--- a/tests/fixtures/seed_flights.ts
+++ b/tests/fixtures/seed_flights.ts
@@ -5,11 +5,11 @@ import type { Flight } from '../../src/types'
// AC-08 timing assertions.
export const seedFlights: Flight[] = [
- { id: 'flight-1', name: 'Recon Alpha', createdDate: '2026-05-01T10:00:00Z', aircraftId: 'aircraft-1' },
- { id: 'flight-2', name: 'Recon Bravo', createdDate: '2026-05-02T11:30:00Z', aircraftId: 'aircraft-1' },
- { id: 'flight-3', name: 'Survey Charlie', createdDate: '2026-05-03T14:15:00Z', aircraftId: 'aircraft-2' },
- { id: 'flight-4', name: 'Patrol Delta', createdDate: '2026-05-04T09:45:00Z', aircraftId: 'aircraft-3' },
- { id: 'flight-5', name: 'Strike Echo', createdDate: '2026-05-05T16:00:00Z', aircraftId: 'aircraft-1' },
+ { id: 'flight-1', name: 'Recon Alpha', createdDate: '2026-05-01T10:00:00Z', aircraftId: 'AC-001' },
+ { id: 'flight-2', name: 'Recon Bravo', createdDate: '2026-05-02T11:30:00Z', aircraftId: 'AC-001' },
+ { id: 'flight-3', name: 'Survey Charlie', createdDate: '2026-05-03T14:15:00Z', aircraftId: 'AC-002' },
+ { id: 'flight-4', name: 'Patrol Delta', createdDate: '2026-05-04T09:45:00Z', aircraftId: 'AC-003' },
+ { id: 'flight-5', name: 'Strike Echo', createdDate: '2026-05-05T16:00:00Z', aircraftId: 'AC-001' },
]
export const liveGpsFlightId = 'flight-1'
diff --git a/tests/i18n-allowlist.json b/tests/i18n-allowlist.json
index 82a30ef..76bb6fd 100644
--- a/tests/i18n-allowlist.json
+++ b/tests/i18n-allowlist.json
@@ -6,11 +6,23 @@
"TCP",
"UDP",
"Esc",
- "OK"
+ "OK",
+ "//",
+ "|",
+ "▾",
+ "▲",
+ "▼",
+ "—"
],
"src/components/Header.tsx": [
"No flights",
- "Filter..."
+ "Filter...",
+ "— SELECT —",
+ "LINK",
+ "Toggle language",
+ "UA",
+ "EN",
+ "⚙"
],
"src/components/HelpModal.tsx": [
"How to Annotate",
@@ -36,20 +48,20 @@
],
"src/features/admin/AdminPage.tsx": [
"Name",
- "Color",
- "Frame Period Recognition",
- "Frame Recognition Seconds",
- "Probability Threshold",
- "Device Address",
- "Port",
- "Protocol",
- "Email",
- "Role",
- "Status",
- "Annotator",
- "Admin",
- "Viewer",
- "Password"
+ "#",
+ "+",
+ "0.0.0.0",
+ "P",
+ "C",
+ "F",
+ "%",
+ "NMEA",
+ "UBX",
+ "MAVLINK",
+ "SAT",
+ "MIN",
+ "Increment",
+ "Decrement"
],
"src/features/annotations/AnnotationsSidebar.tsx": [
"Download annotation"
diff --git a/tests/msw/handlers/admin-settings.ts b/tests/msw/handlers/admin-settings.ts
new file mode 100644
index 0000000..3ee6655
--- /dev/null
+++ b/tests/msw/handlers/admin-settings.ts
@@ -0,0 +1,87 @@
+import { http } from 'msw'
+import { jsonResponse, noContent } from '../helpers'
+import type {
+ AiRecognitionSettings,
+ AiRecognitionTelemetry,
+ GpsDeviceSettings,
+ GpsDeviceTelemetry,
+} from '../../../src/types'
+
+// Stateful MSW handlers for AI Recognition + GPS Device Link settings.
+// Seed mutates on PATCH so PING / RECONNECT / APPLY round-trips persist
+// within a session. `resetAdminSettingsSeed()` is invoked per-test from
+// tests/setup.ts so test isolation is preserved.
+
+const DEFAULT_AI_SETTINGS: AiRecognitionSettings = {
+ framesToRecognize: 4,
+ minSecondsBetween: 2,
+ minConfidence: 25,
+}
+
+const DEFAULT_AI_TELEMETRY: AiRecognitionTelemetry = {
+ model: 'YOLOV8-X',
+ checkpoint: 'CKPT-241',
+ lastRunAt: '2026-05-18T11:43:09Z',
+ frames: 14228,
+ avgConfidence: 71.4,
+}
+
+const DEFAULT_GPS_SETTINGS: GpsDeviceSettings = {
+ address: '192.168.1.100',
+ port: 9001,
+ protocol: 'NMEA',
+}
+
+const DEFAULT_GPS_TELEMETRY: GpsDeviceTelemetry = {
+ socket: 'UDP/192.168.1.100:9001',
+ connected: true,
+ fix: '3D',
+ satellites: 11,
+ hdop: 0.82,
+ lastPacketMs: 12,
+}
+
+let aiSettings: AiRecognitionSettings = { ...DEFAULT_AI_SETTINGS }
+let aiTelemetry: AiRecognitionTelemetry = { ...DEFAULT_AI_TELEMETRY }
+let gpsSettings: GpsDeviceSettings = { ...DEFAULT_GPS_SETTINGS }
+let gpsTelemetry: GpsDeviceTelemetry = { ...DEFAULT_GPS_TELEMETRY }
+
+export function resetAdminSettingsSeed() {
+ aiSettings = { ...DEFAULT_AI_SETTINGS }
+ aiTelemetry = { ...DEFAULT_AI_TELEMETRY }
+ gpsSettings = { ...DEFAULT_GPS_SETTINGS }
+ gpsTelemetry = { ...DEFAULT_GPS_TELEMETRY }
+}
+
+export const adminSettingsHandlers = [
+ http.get('/api/admin/ai-settings', () =>
+ jsonResponse({ settings: aiSettings, telemetry: aiTelemetry }),
+ ),
+
+ http.patch('/api/admin/ai-settings', async ({ request }) => {
+ const body = (await request.json().catch(() => ({}))) as Partial
+ aiSettings = { ...aiSettings, ...body }
+ return jsonResponse({ settings: aiSettings, telemetry: aiTelemetry })
+ }),
+
+ http.get('/api/admin/gps-settings', () =>
+ jsonResponse({ settings: gpsSettings, telemetry: gpsTelemetry }),
+ ),
+
+ http.patch('/api/admin/gps-settings', async ({ request }) => {
+ const body = (await request.json().catch(() => ({}))) as Partial
+ gpsSettings = { ...gpsSettings, ...body }
+ gpsTelemetry = {
+ ...gpsTelemetry,
+ socket: `UDP/${gpsSettings.address}:${gpsSettings.port}`,
+ }
+ return jsonResponse({ settings: gpsSettings, telemetry: gpsTelemetry })
+ }),
+
+ http.post('/api/admin/gps-settings/ping', () => noContent()),
+
+ http.post('/api/admin/gps-settings/reconnect', () => {
+ gpsTelemetry = { ...gpsTelemetry, connected: true, lastPacketMs: 0 }
+ return jsonResponse({ settings: gpsSettings, telemetry: gpsTelemetry })
+ }),
+]
diff --git a/tests/msw/handlers/flights.ts b/tests/msw/handlers/flights.ts
index ebb72f2..2c64537 100644
--- a/tests/msw/handlers/flights.ts
+++ b/tests/msw/handlers/flights.ts
@@ -64,8 +64,14 @@ export const flightsHandlers = [
return jsonResponse({ id: params.id, ...body })
}),
+ // POST accepts both plural and singular paths. Production convention is
+ // plural (REST collection); singular kept as a backward-compat alias.
+ http.post('/api/flights/aircrafts', async ({ request }) => {
+ const body = (await request.json()) as Record
+ return jsonResponse({ id: 'AC-NEW', ...body }, { status: 201 })
+ }),
http.post('/api/flights/aircraft', async ({ request }) => {
const body = (await request.json()) as Record
- return jsonResponse({ id: 'aircraft-new', ...body }, { status: 201 })
+ return jsonResponse({ id: 'AC-NEW', ...body }, { status: 201 })
}),
]
diff --git a/tests/msw/handlers/index.ts b/tests/msw/handlers/index.ts
index 293b975..6ca0fe6 100644
--- a/tests/msw/handlers/index.ts
+++ b/tests/msw/handlers/index.ts
@@ -1,4 +1,5 @@
import { adminHandlers } from './admin'
+import { adminSettingsHandlers } from './admin-settings'
import { flightsHandlers } from './flights'
import { annotationsHandlers } from './annotations'
import { detectHandlers } from './detect'
@@ -12,6 +13,7 @@ import { tilesHandlers } from './tiles'
// the seeded baseline. Per-test overrides land via `server.use(...)`.
export const defaultHandlers = [
...adminHandlers,
+ ...adminSettingsHandlers,
...flightsHandlers,
...annotationsHandlers,
...detectHandlers,
@@ -23,6 +25,7 @@ export const defaultHandlers = [
export {
adminHandlers,
+ adminSettingsHandlers,
flightsHandlers,
annotationsHandlers,
detectHandlers,
diff --git a/tests/setup.ts b/tests/setup.ts
index bb2686f..ab31852 100644
--- a/tests/setup.ts
+++ b/tests/setup.ts
@@ -4,6 +4,7 @@ import { cleanup } from '@testing-library/react'
import { server } from './msw/server'
import { setToken, setNavigateToLogin } from '../src/api'
import { __resetBootstrapInflightForTests } from '../src/auth'
+import { resetAdminSettingsSeed } from './msw/handlers/admin-settings'
// JSDOM polyfills for browser APIs production code touches at mount time.
// These are no-op stubs — tests that exercise the actual behavior install
@@ -61,6 +62,8 @@ afterEach(() => {
// AZ-510 — clear AuthProvider's module-scoped in-flight bootstrap promise so
// a never-resolving fixture in test N does not leak into test N+1.
__resetBootstrapInflightForTests()
+ // v2 admin settings — module-scoped seed mutates on PATCH; reset between tests.
+ resetAdminSettingsSeed()
})
afterAll(() => {
{t('admin.aircrafts')}
-
+ {/* ===== RIGHT: DEFAULT AIRCRAFTS (280px) ===== */}
+
+
+
+
+
+
+
+
+ setAircraftDraft(p => ({ ...p, model: e.target.value }))}
+ placeholder="DJI Mavic 3"
+ aria-label={t('admin.aircrafts.fieldModel')}
+ />
+
+
+
+
+
+
+
+ {AIRCRAFT_TYPES.map(typ => (
+
+ ))}
+
+
+
+
+
+
+ {aircraftError && (
+
+
+
+
+
+
+ setAircraftDraft(p => ({ ...p, maxMinutes: Number(e.target.value) }))}
+ style={{ textAlign: 'right' }}
+ aria-label={t('admin.aircrafts.fieldMaxMinutes')}
+ />
+
+
+ {t(`admin.aircrafts.${aircraftError}`)}
+
+ )}
+
+ {errorMessage}
+
+
+
+ )
+}
diff --git a/src/features/admin/NumberStepper.tsx b/src/features/admin/NumberStepper.tsx
new file mode 100644
index 0000000..6c24bae
--- /dev/null
+++ b/src/features/admin/NumberStepper.tsx
@@ -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 (
+
+
+
+
+ {title}
+
+
+
+ {children}
+
+ {footer && (
+
+ {footer}
+
+ )}
+
+ {
+ const raw = e.target.value
+ const parsed = raw === '' ? 0 : Number(raw)
+ onChange(Number.isFinite(parsed) ? parsed : 0)
+ }}
+ style={{ textAlign: 'right', width: 88 }}
+ />
+
+ )
+}
diff --git a/src/features/admin/__tests__/AdminPage.ai-settings.test.tsx b/src/features/admin/__tests__/AdminPage.ai-settings.test.tsx
new file mode 100644
index 0000000..796cc21
--- /dev/null
+++ b/src/features/admin/__tests__/AdminPage.ai-settings.test.tsx
@@ -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(
+
+
+
+ {suffix}
+