diff --git a/index.html b/index.html index 1a61f19..0828826 100644 --- a/index.html +++ b/index.html @@ -4,8 +4,14 @@ AZAION + + + - +
diff --git a/src/api/endpoints.test.ts b/src/api/endpoints.test.ts index 41aa38a..1402a4a 100644 --- a/src/api/endpoints.test.ts +++ b/src/api/endpoints.test.ts @@ -55,6 +55,26 @@ describe('AZ-486 endpoints — wire-contract URLs', () => { // Assert expect(endpoints.admin.class(42)).toBe('/api/admin/classes/42') }) + + it('admin.aiSettings', () => { + // Assert + expect(endpoints.admin.aiSettings()).toBe('/api/admin/ai-settings') + }) + + it('admin.gpsSettings', () => { + // Assert + expect(endpoints.admin.gpsSettings()).toBe('/api/admin/gps-settings') + }) + + it('admin.gpsPing', () => { + // Assert + expect(endpoints.admin.gpsPing()).toBe('/api/admin/gps-settings/ping') + }) + + it('admin.gpsReconnect', () => { + // Assert + expect(endpoints.admin.gpsReconnect()).toBe('/api/admin/gps-settings/reconnect') + }) }) describe('AC-1: annotations', () => { diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index 04fdbae..43fd6d7 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -33,6 +33,11 @@ export const endpoints = { // DetectionClass.id is `number` in the type system; widened to accept // string for forward-compat if the backend switches the column to UUID. class: (id: string | number) => `/api/admin/classes/${id}`, + // v2 admin page — mocked via MSW until the backend lands the endpoints. + aiSettings: () => '/api/admin/ai-settings', + gpsSettings: () => '/api/admin/gps-settings', + gpsPing: () => '/api/admin/gps-settings/ping', + gpsReconnect: () => '/api/admin/gps-settings/reconnect', }, annotations: { classes: () => '/api/annotations/classes', diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 807ec8d..c8b5ef0 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -3,17 +3,15 @@ import { useTranslation } from 'react-i18next' import { useAuth } from '../auth' import { useFlight } from './FlightContext' import { useState, useRef, useEffect } from 'react' -import HelpModal from './HelpModal' import type { Flight } from '../types' export default function Header() { - const { t, i18n } = useTranslation() + const { t } = useTranslation() const { user, logout, hasPermission } = useAuth() const { flights, selectedFlight, selectFlight } = useFlight() const navigate = useNavigate() const [showDropdown, setShowDropdown] = useState(false) const [filter, setFilter] = useState('') - const [showHelp, setShowHelp] = useState(false) const dropdownRef = useRef(null) useEffect(() => { @@ -39,25 +37,56 @@ export default function Header() { { to: '/admin', label: t('nav.admin'), perm: 'ADM' }, ] - const toggleLang = () => { - i18n.changeLanguage(i18n.language === 'en' ? 'ua' : 'en') - } - return ( -
- AZAION +
+ + AZAION + + + //
{showDropdown && ( -
+
setFilter(e.target.value)} @@ -68,66 +97,97 @@ export default function Header() { ))} {filtered.length === 0 && ( -
No flights
+
No flights
)}
)}
-
) } diff --git a/src/features/admin/AdminPage.tsx b/src/features/admin/AdminPage.tsx index 533ab70..3e5ed95 100644 --- a/src/features/admin/AdminPage.tsx +++ b/src/features/admin/AdminPage.tsx @@ -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 = { + Plane: 'var(--accent-blue)', + Copter: 'var(--accent-green)', + FixedWing: 'var(--accent-amber)', +} +const TYPE_LETTERS: Record = { + Plane: 'P', Copter: 'C', FixedWing: 'F', +} +const TYPE_LEGEND_KEY: Record = { + Plane: 'legendPlane', Copter: 'legendCopter', FixedWing: 'legendFixedW', +} + +function PencilIcon() { + return ( + + + + + ) +} +function CloseIcon() { + return ( + + + + + ) +} +function StarIcon({ filled }: { filled: boolean }) { + return ( + + + + ) +} + +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([]) const [aircrafts, setAircrafts] = useState([]) - const [users, setUsers] = useState([]) - const [newClass, setNewClass] = useState({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 }) - const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'Annotator' }) - const [deactivateId, setDeactivateId] = useState(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(null) - const [editForm, setEditForm] = useState({ name: '', shortName: '', color: '#FF0000', maxSizeM: 0 }) + const [editForm, setEditForm] = useState(NEW_CLASS_DEFAULTS) const [editError, setEditError] = useState(null) const [editSaving, setEditSaving] = useState(false) + const [aircraftModalOpen, setAircraftModalOpen] = useState(false) + const [aircraftDraft, setAircraftDraft] = useState(NEW_AIRCRAFT_DEFAULTS) + const [aircraftSaving, setAircraftSaving] = useState(false) + const [aircraftError, setAircraftError] = useState(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(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(endpoints.annotations.classes()).then(setClasses).catch(() => {}) api.get(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {}) - api.get(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(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(endpoints.annotations.classes()) - setClasses(updated) + if (editingId === ADDING_ID) { + const created = await api.post(endpoints.admin.classes(), editForm) + setClasses(prev => [...prev, created]) + } else { + const updated = await api.patch(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) => { - 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(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 ( -
- {/* Detection classes */} -
-

{t('admin.classes.title')}

-
- - - - - - - +
+ + {/* ===== LEFT: DETECTION CLASSES (340px) ===== */} +
#NameColor
+ + + + + + - {classes.map(c => c.id === editingId ? ( - - - - + {editingId === ADDING_ID && ( + void handleSaveClass()} + onCancel={handleCancelEdit} + onKeyDown={handleEditKeyDown} + saving={editSaving} + errorMessage={editError ? t(`admin.classes.${editError}`) : null} + placeholderName="Name" + /> + )} + {filteredClasses.map(c => c.id === editingId ? ( + void handleSaveClass()} + onCancel={handleCancelEdit} + onKeyDown={handleEditKeyDown} + saving={editSaving} + errorMessage={editError ? t(`admin.classes.${editError}`) : null} + /> ) : ( - - - - - + + + + ))}
#{t('admin.classes.colName')}{t('admin.classes.colHex')}{t('admin.classes.colOps')}
{c.id} -
- 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" - /> - 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" - /> - setEditForm(p => ({ ...p, color: e.target.value }))} - className="w-7 h-6 border-0 bg-transparent cursor-pointer" - /> - 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" - /> - - -
- {editError && ( -
- {t(`admin.classes.${editError}`)} -
- )} -
{c.id}{c.name} - - +
{c.id}{c.name} + + + +
-
- 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" /> - setNewClass(p => ({ ...p, color: e.target.value }))} className="w-8 h-7 border-0 bg-transparent cursor-pointer" /> - -
-
+ - {/* Center: AI + GPS settings */} -
-
-

{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')} +
-
- - + +
+ + +
+
+ +
{t('admin.aiEngine.framesHint')}
+ ai.setDraft({ ...ai.draft, framesToRecognize: v })} + /> +
+ +
+ +
{t('admin.aiEngine.minSecondsHint')}
+ ai.setDraft({ ...ai.draft, minSecondsBetween: v })} + /> +
+ +
+ +
{t('admin.aiEngine.minConfidenceHint')}
+ ai.setDraft({ ...ai.draft, minConfidence: v })} + /> +
+
+ +
+
+ + {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 && ( +
+ {ai.error} +
+ )}
-
- - -
-
-
-
-

{t('admin.gpsSettings')}

-
-
- - + {/* GPS DEVICE LINK */} +
+
+
+
{t('admin.gpsDevice.title')}
+
{t('admin.gpsDevice.subtitle')}
+
+
+ {t('admin.gpsDevice.socket')} + + {gps.telemetry?.socket ?? FALLBACK} + + + + {t('admin.gpsDevice.connected')} + +
-
- - -
-
- - -
- -
-
- {/* Users */} -
-

{t('admin.users')}

-
- - - - - - - - - - - - {users.map(u => ( - - - - - - - - ))} - -
NameEmailRoleStatus
{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 => ( + + ))} +
+
+ +
+
+ + {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 && ( +
+ {gps.error} +
+ )}
-
+
- {/* Aircrafts sidebar */} -
-

{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 => ( + + ))} +
+
+ +
+
+ + +
+
+ + setAircraftDraft(p => ({ ...p, maxMinutes: Number(e.target.value) }))} + style={{ textAlign: 'right' }} + aria-label={t('admin.aircrafts.fieldMaxMinutes')} + /> +
+
+ + + + {aircraftError && ( +
+ {t(`admin.aircrafts.${aircraftError}`)} +
+ )} +
+ ) } 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 && ( + + + +
+ {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 ( +
+
+ +
+ {title} + +
+ +
{children}
+ + {footer && ( +
+ {footer} +
+ )} +
+
+ ) +} 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 ( +
+ { + const raw = e.target.value + const parsed = raw === '' ? 0 : Number(raw) + onChange(Number.isFinite(parsed) ? parsed : 0) + }} + style={{ textAlign: 'right', width: 88 }} + /> +
+ + +
+ {suffix} +
+ ) +} 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() + 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(() => {