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

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