mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 05:51:11 +00:00
admin v2: implement design from ui_design/v2/plugin/admin.html
- Design system: v2 CSS variables (surface-0/1/2, border-hair, accent-amber/cyan/red/green/blue)
and utility classes (.btn, .inp, .pill, .chip, .bracket, .panel, .seg, .swatch,
.type-sq, .grid-bg, .ibtn, .checkbox, .tab); v1 az-* names aliased to v2 vars
so other pages still render. Google Fonts (IBM Plex Sans + JetBrains Mono)
loaded via <link> in index.html <head> to avoid FOUT.
- Header rebuilt to v2: amber wordmark + // divider, amber-bordered flight pill
with cyan live dot, tab-style nav with amber underline on active, LINK status
pill, cog + sign-out icon buttons.
- AdminPage rewritten to 3-column layout (340 / flex / 280):
- Detection Classes: search + ADD button, table with #/Name/Hex/Ops columns,
name-only inline edit with ringed swatch, sibling-row error alert.
- AI Recognition Engine + GPS Device Link panels with corner-bracket borders,
number steppers, segmented protocol control, dashed telemetry footers.
Hooks (useAiSettings, useGpsSettings) seed factory defaults so the UI is
interactive when GET fails (no backend).
- Default Aircrafts: P/C/F type chips, isDefault star toggle, + ADD AIRCRAFT
modal with model/type/resolution/maxMinutes/default fields.
- Co-located components: Modal (backdrop + ESC + body-scroll-lock),
NumberStepper (▲▼ with clamp on click but not on typing), ClassEditRow.
- Types: Aircraft extended with FixedWing + optional resolution/maxMinutes;
new AiRecognitionSettings/Telemetry, GpsDeviceSettings/Telemetry, GpsProtocol.
- Endpoints: /api/admin/ai-settings, /api/admin/gps-settings (+ /ping, /reconnect).
POST /api/flights/aircrafts (plural REST collection).
- MSW: stateful admin-settings handler with resetAdminSettingsSeed() wired into
tests/setup.ts. Aircraft seed expanded to 6 entries matching the mockup.
- i18n: full admin.{classes,aiEngine,gpsDevice,aircrafts} key sets in en+ua;
nav.dataset shortened to "Dataset"; obsolete users-management keys removed.
- Tests: new AdminPage AI/GPS/aircraft test suites; admin_class_edit selectors
updated for the name-only inline editor and the modal-based add flow.
This commit is contained in:
+7
-1
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Fragment, useRef, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type EditFormShape = { name: string; shortName: string; color: string; maxSizeM: number }
|
||||
|
||||
interface ClassEditRowProps {
|
||||
/** Cell content for the leftmost `#` column (e.g. `+` for new, row id for edit). */
|
||||
idCell: ReactNode
|
||||
/** Stable identifier for the row's data-editing-row attribute. */
|
||||
rowId: number | 'new'
|
||||
form: EditFormShape
|
||||
onChange: (form: EditFormShape) => void
|
||||
onSave: () => void
|
||||
onCancel: () => void
|
||||
onKeyDown: (e: KeyboardEvent<HTMLElement>) => void
|
||||
saving: boolean
|
||||
/** Optional inline error key (already translated by the caller's t() if provided as message). */
|
||||
errorMessage: string | null
|
||||
placeholderName?: string
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClassEditRow({
|
||||
idCell, rowId, form, onChange, onSave, onCancel, onKeyDown,
|
||||
saving, errorMessage, placeholderName,
|
||||
}: ClassEditRowProps) {
|
||||
const { t } = useTranslation()
|
||||
const colorInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<tr
|
||||
className="row-hover"
|
||||
data-editing-row={rowId}
|
||||
style={{ borderBottom: '1px solid var(--accent-amber)', height: 32, background: 'rgba(255,157,61,0.06)' }}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<td className="px-3 mono tnum" style={{ color: 'var(--accent-amber)', fontSize: 12 }}>{idCell}</td>
|
||||
<td className="px-2">
|
||||
<input
|
||||
autoFocus
|
||||
data-field="name"
|
||||
value={form.name}
|
||||
onChange={e => onChange({ ...form, name: e.target.value })}
|
||||
placeholder={placeholderName}
|
||||
className="inp inp-mono"
|
||||
style={{ height: 22, padding: '0 6px', fontSize: 11 }}
|
||||
aria-label={t('admin.classes.colName')}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => colorInputRef.current?.click()}
|
||||
className="inline-flex items-center justify-center cursor-pointer"
|
||||
aria-label={t('admin.classes.colHex')}
|
||||
style={{ background: 'transparent', border: 0, padding: 0 }}
|
||||
>
|
||||
<span
|
||||
className="swatch"
|
||||
style={{ background: form.color, boxShadow: '0 0 0 1px var(--accent-amber)' }}
|
||||
/>
|
||||
</button>
|
||||
<input
|
||||
ref={colorInputRef}
|
||||
type="color"
|
||||
data-field="color"
|
||||
value={form.color}
|
||||
onChange={e => onChange({ ...form, color: e.target.value })}
|
||||
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 text-right">
|
||||
<span className="inline-flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="ibtn cyan"
|
||||
aria-label={t('admin.classes.save')}
|
||||
title={t('admin.classes.save')}
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={saving}
|
||||
className="ibtn"
|
||||
aria-label={t('admin.classes.cancel')}
|
||||
title={t('admin.classes.cancel')}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{errorMessage && (
|
||||
<tr style={{ background: 'rgba(255,157,61,0.06)' }}>
|
||||
<td />
|
||||
<td colSpan={3} className="px-2 pb-2">
|
||||
<div role="alert" style={{ color: 'var(--accent-red)', fontSize: 11 }}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, type ReactNode, type KeyboardEvent, type MouseEvent } from 'react'
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean
|
||||
title: ReactNode
|
||||
onClose: () => void
|
||||
width?: number
|
||||
footer?: ReactNode
|
||||
children: ReactNode
|
||||
closeLabel?: string
|
||||
}
|
||||
|
||||
export function Modal({ open, title, onClose, width = 420, footer, children, closeLabel = 'Close' }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
// Lock body scroll while the modal is open.
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.body.style.overflow = prev
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const onBackdropClick = (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}
|
||||
const onPanelKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
// Stop Escape from bubbling to other key handlers in the page; the
|
||||
// document listener above already handles closing.
|
||||
if (e.key === 'Escape') e.stopPropagation()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={typeof title === 'string' ? title : undefined}
|
||||
onClick={onBackdropClick}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 100,
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bracket panel"
|
||||
onKeyDown={onPanelKey}
|
||||
style={{ width, padding: 20 }}
|
||||
>
|
||||
<span className="br" />
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="sect-head">{title}</span>
|
||||
<button type="button" onClick={onClose} className="ibtn" aria-label={closeLabel} title={closeLabel}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">{children}</div>
|
||||
|
||||
{footer && (
|
||||
<div
|
||||
className="mt-5 pt-4 flex items-center justify-end gap-2"
|
||||
style={{ borderTop: '1px dashed var(--border-hair)' }}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
interface NumberStepperProps {
|
||||
value: number
|
||||
/** Inclusive minimum, applied only to ▲▼ stepper clicks (not free typing). */
|
||||
min?: number
|
||||
/** Inclusive maximum, applied only to ▲▼ stepper clicks (not free typing). */
|
||||
max?: number
|
||||
/** Increment per ▲▼ click. */
|
||||
step: number
|
||||
onChange: (v: number) => void
|
||||
/** Trailing unit label (e.g. "FR", "SEC", "%"). */
|
||||
suffix: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Number input with ▲▼ stepper buttons next to it and a trailing unit
|
||||
* label. Stepper buttons clamp to [min, max]; direct typing does NOT —
|
||||
* so `userEvent.clear()` + `type('9')` behaves as expected without being
|
||||
* snapped mid-keystroke. Invalid intermediate values fall through; the
|
||||
* caller validates on save.
|
||||
*/
|
||||
export function NumberStepper({ value, min, max, step, onChange, suffix }: NumberStepperProps) {
|
||||
const clamp = (v: number) => Math.max(min ?? -Infinity, Math.min(max ?? Infinity, v))
|
||||
return (
|
||||
<div className="flex items-stretch gap-2">
|
||||
<input
|
||||
className="inp inp-mono"
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={e => {
|
||||
const raw = e.target.value
|
||||
const parsed = raw === '' ? 0 : Number(raw)
|
||||
onChange(Number.isFinite(parsed) ? parsed : 0)
|
||||
}}
|
||||
style={{ textAlign: 'right', width: 88 }}
|
||||
/>
|
||||
<div className="flex flex-col" style={{ border: '1px solid var(--border-hair)', borderRadius: 2 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(clamp(value + step))}
|
||||
className="mono"
|
||||
aria-label="Increment"
|
||||
style={{ width: 24, height: 15, fontSize: 9, color: 'var(--text-secondary)', background: 'var(--surface-input)', borderBottom: '1px solid var(--border-hair)' }}
|
||||
>▲</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(clamp(value - step))}
|
||||
className="mono"
|
||||
aria-label="Decrement"
|
||||
style={{ width: 24, height: 15, fontSize: 9, color: 'var(--text-secondary)', background: 'var(--surface-input)' }}
|
||||
>▼</button>
|
||||
</div>
|
||||
<span className="micro self-center" style={{ color: 'var(--text-muted)' }}>{suffix}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { server } from '../../../../tests/msw/server'
|
||||
import { jsonResponse, errorResponse } from '../../../../tests/msw/helpers'
|
||||
import { renderWithProviders, screen, waitFor, userEvent } from '../../../../tests/helpers/render'
|
||||
import { seedBearer, clearBearer } from '../../../../tests/helpers/auth'
|
||||
import { AdminPage } from '..'
|
||||
|
||||
// v2 admin — AI Recognition Engine panel. Covers GET → render telemetry,
|
||||
// edit value via stepper / input, APPLY → PATCH, RESET → discards draft,
|
||||
// PATCH 500 → inline error.
|
||||
//
|
||||
// Both AI and GPS panels render APPLY buttons; AI is the first one in DOM
|
||||
// order. We pick [0] from getAllByRole rather than coupling to internal markup.
|
||||
|
||||
function aiApplyButton(): HTMLElement {
|
||||
return screen.getAllByRole('button', { name: /apply/i })[0]
|
||||
}
|
||||
function aiResetButton(): HTMLElement {
|
||||
return screen.getByRole('button', { name: /reset/i })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
})
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
describe('AdminPage — AI Recognition Engine', () => {
|
||||
it('renders initial settings + telemetry from GET /api/admin/ai-settings', async () => {
|
||||
renderWithProviders(<AdminPage />)
|
||||
expect(await screen.findByText('YOLOV8-X · CKPT-241')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('4')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('25')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('APPLY sends PATCH with edited settings and reflects telemetry refresh', async () => {
|
||||
const calls: { body: unknown }[] = []
|
||||
server.use(
|
||||
http.patch('/api/admin/ai-settings', async ({ request }) => {
|
||||
const body = await request.json()
|
||||
calls.push({ body })
|
||||
return jsonResponse({
|
||||
settings: { framesToRecognize: 8, minSecondsBetween: 2, minConfidence: 25 },
|
||||
telemetry: {
|
||||
model: 'YOLOV8-X', checkpoint: 'CKPT-242',
|
||||
lastRunAt: '2026-05-18T12:00:00Z', frames: 99, avgConfidence: 80,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
renderWithProviders(<AdminPage />)
|
||||
await screen.findByText('YOLOV8-X · CKPT-241')
|
||||
|
||||
const framesInput = screen.getByDisplayValue('4') as HTMLInputElement
|
||||
await userEvent.clear(framesInput)
|
||||
await userEvent.type(framesInput, '8')
|
||||
|
||||
await userEvent.click(aiApplyButton())
|
||||
|
||||
await waitFor(() => expect(calls.length).toBe(1))
|
||||
expect((calls[0].body as { framesToRecognize: number }).framesToRecognize).toBe(8)
|
||||
expect(await screen.findByText(/CKPT-242/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('RESET reverts draft to the last persisted value (no PATCH)', async () => {
|
||||
const patchCalls: unknown[] = []
|
||||
server.use(
|
||||
http.patch('/api/admin/ai-settings', () => {
|
||||
patchCalls.push({})
|
||||
return jsonResponse({})
|
||||
}),
|
||||
)
|
||||
renderWithProviders(<AdminPage />)
|
||||
await screen.findByText('YOLOV8-X · CKPT-241')
|
||||
|
||||
const framesInput = screen.getByDisplayValue('4') as HTMLInputElement
|
||||
await userEvent.clear(framesInput)
|
||||
await userEvent.type(framesInput, '9')
|
||||
expect(screen.getByDisplayValue('9')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(aiResetButton())
|
||||
|
||||
expect(screen.getByDisplayValue('4')).toBeInTheDocument()
|
||||
expect(patchCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
it('PATCH 500 surfaces an inline error', async () => {
|
||||
server.use(
|
||||
http.patch('/api/admin/ai-settings', () => errorResponse(500, 'boom')),
|
||||
)
|
||||
renderWithProviders(<AdminPage />)
|
||||
await screen.findByText('YOLOV8-X · CKPT-241')
|
||||
|
||||
await userEvent.click(aiApplyButton())
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert.textContent ?? '').toMatch(/failed to save ai/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { server } from '../../../../tests/msw/server'
|
||||
import { jsonResponse } from '../../../../tests/msw/helpers'
|
||||
import { renderWithProviders, screen, waitFor, userEvent } from '../../../../tests/helpers/render'
|
||||
import { seedBearer, clearBearer } from '../../../../tests/helpers/auth'
|
||||
import { seedAircraft } from '../../../../tests/fixtures/seed_aircraft'
|
||||
import { AdminPage } from '..'
|
||||
|
||||
// v2 admin — Default Aircrafts panel: render 6 mockup rows + star toggle.
|
||||
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
server.use(
|
||||
http.get('/api/flights/aircrafts', () => jsonResponse(seedAircraft)),
|
||||
)
|
||||
})
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
describe('AdminPage — Default Aircrafts', () => {
|
||||
it('renders all 6 seeded aircraft with id · resolution · minutes', async () => {
|
||||
renderWithProviders(<AdminPage />)
|
||||
expect(await screen.findByText('DJI Mavic 3')).toBeInTheDocument()
|
||||
expect(screen.getByText('Matrice 300 RTK')).toBeInTheDocument()
|
||||
expect(screen.getByText('Leleka-100')).toBeInTheDocument()
|
||||
expect(screen.getByText('Fixed Wing Scout')).toBeInTheDocument()
|
||||
expect(screen.getByText('Autel EVO II Pro')).toBeInTheDocument()
|
||||
expect(screen.getByText('PD-2 Recon')).toBeInTheDocument()
|
||||
// Subline format: "AC-001 · 4K · 46MIN"
|
||||
expect(screen.getByText(/AC-001\s+·\s+4K\s+·\s+46MIN/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('star toggle PATCHes isDefault and updates UI', async () => {
|
||||
const calls: { id: string; body: unknown }[] = []
|
||||
server.use(
|
||||
http.patch('/api/flights/aircrafts/:id', async ({ params, request }) => {
|
||||
const body = await request.json()
|
||||
calls.push({ id: String(params.id), body })
|
||||
return jsonResponse({ ok: true })
|
||||
}),
|
||||
)
|
||||
renderWithProviders(<AdminPage />)
|
||||
await screen.findByText('DJI Mavic 3')
|
||||
|
||||
// AC-002 starts non-default → click its star to mark default.
|
||||
const ac002Row = screen.getByText('Matrice 300 RTK').closest('[data-aircraft-id]') as HTMLElement
|
||||
expect(ac002Row).not.toBeNull()
|
||||
// Within the row find the toggle button (set-default label).
|
||||
const toggleBtn = ac002Row.querySelector('button[aria-pressed="false"]') as HTMLButtonElement
|
||||
expect(toggleBtn).not.toBeNull()
|
||||
await userEvent.click(toggleBtn)
|
||||
|
||||
await waitFor(() => expect(calls.length).toBe(1))
|
||||
expect(calls[0].id).toBe('AC-002')
|
||||
expect((calls[0].body as { isDefault: boolean }).isDefault).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { server } from '../../../../tests/msw/server'
|
||||
import { jsonResponse } from '../../../../tests/msw/helpers'
|
||||
import { renderWithProviders, screen, waitFor, userEvent } from '../../../../tests/helpers/render'
|
||||
import { seedBearer, clearBearer } from '../../../../tests/helpers/auth'
|
||||
import { AdminPage } from '..'
|
||||
|
||||
// v2 admin — GPS Device Link panel.
|
||||
//
|
||||
// AI and GPS share APPLY label; GPS is the SECOND APPLY in DOM order.
|
||||
|
||||
function gpsApplyButton(): HTMLElement {
|
||||
return screen.getAllByRole('button', { name: /apply/i })[1]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
})
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
describe('AdminPage — GPS Device Link', () => {
|
||||
it('renders initial settings + telemetry from GET /api/admin/gps-settings', async () => {
|
||||
renderWithProviders(<AdminPage />)
|
||||
expect(await screen.findByDisplayValue('192.168.1.100')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('9001')).toBeInTheDocument()
|
||||
expect(screen.getByText('UDP/192.168.1.100:9001')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('protocol segmented control switches active value and APPLY PATCHes', async () => {
|
||||
const calls: { body: unknown }[] = []
|
||||
server.use(
|
||||
http.patch('/api/admin/gps-settings', async ({ request }) => {
|
||||
const body = await request.json()
|
||||
calls.push({ body })
|
||||
return jsonResponse({
|
||||
settings: { ...(body as object), address: '192.168.1.100', port: 9001 },
|
||||
telemetry: { socket: 'UDP/192.168.1.100:9001', connected: true, fix: '3D', satellites: 11, hdop: 0.82, lastPacketMs: 12 },
|
||||
})
|
||||
}),
|
||||
)
|
||||
renderWithProviders(<AdminPage />)
|
||||
await screen.findByDisplayValue('192.168.1.100')
|
||||
|
||||
const ubxBtn = screen.getByRole('button', { name: 'UBX' })
|
||||
await userEvent.click(ubxBtn)
|
||||
expect(ubxBtn).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
await userEvent.click(gpsApplyButton())
|
||||
|
||||
await waitFor(() => expect(calls.length).toBe(1))
|
||||
expect((calls[0].body as { protocol: string }).protocol).toBe('UBX')
|
||||
})
|
||||
|
||||
it('PING and RECONNECT fire their dedicated endpoints', async () => {
|
||||
let pingHits = 0
|
||||
let reconnectHits = 0
|
||||
server.use(
|
||||
http.post('/api/admin/gps-settings/ping', () => { pingHits += 1; return new Response(null, { status: 204 }) }),
|
||||
http.post('/api/admin/gps-settings/reconnect', () => {
|
||||
reconnectHits += 1
|
||||
return jsonResponse({
|
||||
settings: { address: '192.168.1.100', port: 9001, protocol: 'NMEA' },
|
||||
telemetry: { socket: 'UDP/192.168.1.100:9001', connected: true, fix: '3D', satellites: 11, hdop: 0.82, lastPacketMs: 0 },
|
||||
})
|
||||
}),
|
||||
)
|
||||
renderWithProviders(<AdminPage />)
|
||||
await screen.findByDisplayValue('192.168.1.100')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^ping$/i }))
|
||||
await waitFor(() => expect(pingHits).toBe(1))
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /reconnect/i }))
|
||||
await waitFor(() => expect(reconnectHits).toBe(1))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { api, endpoints } from '../../api'
|
||||
import type {
|
||||
AiRecognitionResponse,
|
||||
AiRecognitionSettings,
|
||||
AiRecognitionTelemetry,
|
||||
} from '../../types'
|
||||
|
||||
type Status = 'idle' | 'loading' | 'ready' | 'saving' | 'error'
|
||||
|
||||
// Factory defaults — UI stays interactive when GET fails (no backend).
|
||||
const FACTORY_AI_SETTINGS: AiRecognitionSettings = {
|
||||
framesToRecognize: 4,
|
||||
minSecondsBetween: 2,
|
||||
minConfidence: 25,
|
||||
}
|
||||
|
||||
export function useAiSettings() {
|
||||
const [draft, setDraft] = useState<AiRecognitionSettings>(FACTORY_AI_SETTINGS)
|
||||
const [persisted, setPersisted] = useState<AiRecognitionSettings>(FACTORY_AI_SETTINGS)
|
||||
const [telemetry, setTelemetry] = useState<AiRecognitionTelemetry | null>(null)
|
||||
const [status, setStatus] = useState<Status>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setStatus('loading')
|
||||
api.get<AiRecognitionResponse>(endpoints.admin.aiSettings())
|
||||
.then(res => {
|
||||
if (cancelled) return
|
||||
setDraft(res.settings)
|
||||
setPersisted(res.settings)
|
||||
setTelemetry(res.telemetry)
|
||||
setStatus('ready')
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setStatus('error')
|
||||
setError('Failed to load AI settings')
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setStatus('saving')
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.patch<AiRecognitionResponse>(endpoints.admin.aiSettings(), draft)
|
||||
setDraft(res.settings)
|
||||
setPersisted(res.settings)
|
||||
setTelemetry(res.telemetry)
|
||||
setStatus('ready')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
setError('Failed to save AI settings')
|
||||
}
|
||||
}, [draft])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setDraft(persisted)
|
||||
}, [persisted])
|
||||
|
||||
return { draft, setDraft, telemetry, status, error, save, reset } as const
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { api, endpoints } from '../../api'
|
||||
import type {
|
||||
GpsDeviceResponse,
|
||||
GpsDeviceSettings,
|
||||
GpsDeviceTelemetry,
|
||||
} from '../../types'
|
||||
|
||||
type Status = 'idle' | 'loading' | 'ready' | 'saving' | 'pinging' | 'reconnecting' | 'error'
|
||||
|
||||
// Factory defaults — UI stays interactive when GET fails (no backend).
|
||||
const FACTORY_GPS_SETTINGS: GpsDeviceSettings = {
|
||||
address: '192.168.1.100',
|
||||
port: 9001,
|
||||
protocol: 'NMEA',
|
||||
}
|
||||
|
||||
export function useGpsSettings() {
|
||||
const [draft, setDraft] = useState<GpsDeviceSettings>(FACTORY_GPS_SETTINGS)
|
||||
const [persisted, setPersisted] = useState<GpsDeviceSettings>(FACTORY_GPS_SETTINGS)
|
||||
const [telemetry, setTelemetry] = useState<GpsDeviceTelemetry | null>(null)
|
||||
const [status, setStatus] = useState<Status>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setStatus('loading')
|
||||
api.get<GpsDeviceResponse>(endpoints.admin.gpsSettings())
|
||||
.then(res => {
|
||||
if (cancelled) return
|
||||
setDraft(res.settings)
|
||||
setPersisted(res.settings)
|
||||
setTelemetry(res.telemetry)
|
||||
setStatus('ready')
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setStatus('error')
|
||||
setError('Failed to load GPS settings')
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setStatus('saving')
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.patch<GpsDeviceResponse>(endpoints.admin.gpsSettings(), draft)
|
||||
setDraft(res.settings)
|
||||
setPersisted(res.settings)
|
||||
setTelemetry(res.telemetry)
|
||||
setStatus('ready')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
setError('Failed to save GPS settings')
|
||||
}
|
||||
}, [draft])
|
||||
|
||||
const ping = useCallback(async () => {
|
||||
setStatus('pinging')
|
||||
setError(null)
|
||||
try {
|
||||
await api.post(endpoints.admin.gpsPing(), {})
|
||||
setStatus('ready')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
setError('Ping failed')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const reconnect = useCallback(async () => {
|
||||
setStatus('reconnecting')
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.post<GpsDeviceResponse>(endpoints.admin.gpsReconnect(), {})
|
||||
setTelemetry(res.telemetry)
|
||||
setStatus('ready')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
setError('Reconnect failed')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setDraft(persisted)
|
||||
}, [persisted])
|
||||
|
||||
return { draft, setDraft, telemetry, status, error, save, ping, reconnect, reset } as const
|
||||
}
|
||||
+61
-7
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
Vendored
+7
-4
@@ -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 },
|
||||
]
|
||||
|
||||
Vendored
+5
-5
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
]
|
||||
@@ -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 })
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user