mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 05:41:10 +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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AZAION</title>
|
<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>
|
</head>
|
||||||
<body class="bg-[#1e1e1e] text-[#adb5bd]">
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -55,6 +55,26 @@ describe('AZ-486 endpoints — wire-contract URLs', () => {
|
|||||||
// Assert
|
// Assert
|
||||||
expect(endpoints.admin.class(42)).toBe('/api/admin/classes/42')
|
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', () => {
|
describe('AC-1: annotations', () => {
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export const endpoints = {
|
|||||||
// DetectionClass.id is `number` in the type system; widened to accept
|
// DetectionClass.id is `number` in the type system; widened to accept
|
||||||
// string for forward-compat if the backend switches the column to UUID.
|
// string for forward-compat if the backend switches the column to UUID.
|
||||||
class: (id: string | number) => `/api/admin/classes/${id}`,
|
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: {
|
annotations: {
|
||||||
classes: () => '/api/annotations/classes',
|
classes: () => '/api/annotations/classes',
|
||||||
|
|||||||
+97
-37
@@ -3,17 +3,15 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { useAuth } from '../auth'
|
import { useAuth } from '../auth'
|
||||||
import { useFlight } from './FlightContext'
|
import { useFlight } from './FlightContext'
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import HelpModal from './HelpModal'
|
|
||||||
import type { Flight } from '../types'
|
import type { Flight } from '../types'
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { user, logout, hasPermission } = useAuth()
|
const { user, logout, hasPermission } = useAuth()
|
||||||
const { flights, selectedFlight, selectFlight } = useFlight()
|
const { flights, selectedFlight, selectFlight } = useFlight()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [showDropdown, setShowDropdown] = useState(false)
|
const [showDropdown, setShowDropdown] = useState(false)
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
const [showHelp, setShowHelp] = useState(false)
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,25 +37,56 @@ export default function Header() {
|
|||||||
{ to: '/admin', label: t('nav.admin'), perm: 'ADM' },
|
{ to: '/admin', label: t('nav.admin'), perm: 'ADM' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const toggleLang = () => {
|
|
||||||
i18n.changeLanguage(i18n.language === 'en' ? 'ua' : 'en')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex items-center h-10 bg-az-header border-b border-az-border px-3 gap-3 text-sm shrink-0">
|
<header
|
||||||
<span className="font-bold text-az-orange tracking-wider">AZAION</span>
|
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}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDropdown(!showDropdown)}
|
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>
|
</button>
|
||||||
{showDropdown && (
|
{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
|
<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..."
|
placeholder="Filter..."
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={e => setFilter(e.target.value)}
|
onChange={e => setFilter(e.target.value)}
|
||||||
@@ -68,66 +97,97 @@ export default function Header() {
|
|||||||
<button
|
<button
|
||||||
key={f.id}
|
key={f.id}
|
||||||
onClick={() => { selectFlight(f); setShowDropdown(false); setFilter('') }}
|
onClick={() => { selectFlight(f); setShowDropdown(false); setFilter('') }}
|
||||||
className={`w-full text-left px-2 py-1 hover:bg-az-bg text-az-text text-sm ${
|
className="w-full text-left"
|
||||||
selectedFlight?.id === f.id ? 'bg-az-bg font-semibold' : ''
|
style={{
|
||||||
}`}
|
padding: '6px 10px',
|
||||||
|
background: selectedFlight?.id === f.id ? 'var(--surface-2)' : 'transparent',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div>{f.name}</div>
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
{filtered.length === 0 && (
|
{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>
|
</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 => (
|
{navItems.filter(n => hasPermission(n.perm)).map(n => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={n.to}
|
key={n.to}
|
||||||
to={n.to}
|
to={n.to}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) => `tab${isActive ? ' active' : ''}`}
|
||||||
`px-2 py-1 rounded text-sm ${isActive ? 'bg-az-bg font-semibold text-white' : 'text-az-text hover:text-white'}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{n.label}
|
{n.label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex items-center gap-2 ml-auto micro">
|
||||||
|
<span
|
||||||
<span className="text-xs text-az-muted hidden sm:block">{user?.email}</span>
|
className="dot live"
|
||||||
<button onClick={toggleLang} className="text-xs text-az-muted hover:text-white px-1">
|
style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--accent-cyan)' }}
|
||||||
{i18n.language === 'en' ? 'UA' : 'EN'}
|
/>
|
||||||
</button>
|
<span style={{ color: 'var(--accent-cyan)' }}>LINK</span>
|
||||||
<button onClick={() => setShowHelp(true)} className="text-az-muted hover:text-white text-xs">?</button>
|
<span style={{ color: 'var(--border-raised)' }}>|</span>
|
||||||
<NavLink to="/settings" className="text-az-muted hover:text-white">⚙</NavLink>
|
<span
|
||||||
<button onClick={handleLogout} className="text-az-muted hover:text-az-red text-xs">
|
className="hidden md:inline"
|
||||||
{t('nav.logout')}
|
style={{ color: 'var(--text-secondary)', textTransform: 'none', letterSpacing: 0 }}
|
||||||
</button>
|
>
|
||||||
|
{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 */}
|
{/* 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 => (
|
{navItems.filter(n => hasPermission(n.perm)).map(n => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={n.to}
|
key={n.to}
|
||||||
to={n.to}
|
to={n.to}
|
||||||
className={({ isActive }) =>
|
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}
|
{n.label}
|
||||||
</NavLink>
|
</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>
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
<HelpModal open={showHelp} onClose={() => setShowHelp(false)} />
|
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+626
-229
@@ -1,39 +1,137 @@
|
|||||||
import { useState, useEffect, type KeyboardEvent } from 'react'
|
import { useState, useEffect, useMemo, type KeyboardEvent } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { api, endpoints } from '../../api'
|
import { api, endpoints } from '../../api'
|
||||||
import { ConfirmDialog } from '../../components'
|
import type { DetectionClass, Aircraft, GpsProtocol } from '../../types'
|
||||||
import type { DetectionClass, Aircraft, User } 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 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() {
|
export default function AdminPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [classes, setClasses] = useState<DetectionClass[]>([])
|
const [classes, setClasses] = useState<DetectionClass[]>([])
|
||||||
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
|
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
|
||||||
const [users, setUsers] = useState<User[]>([])
|
const [classFilter, setClassFilter] = useState('')
|
||||||
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 [editingId, setEditingId] = useState<number | null>(null)
|
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 [editError, setEditError] = useState<EditErrorKind | null>(null)
|
||||||
const [editSaving, setEditSaving] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
api.get<DetectionClass[]>(endpoints.annotations.classes()).then(setClasses).catch(() => {})
|
api.get<DetectionClass[]>(endpoints.annotations.classes()).then(setClasses).catch(() => {})
|
||||||
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||||
api.get<User[]>(endpoints.admin.users()).then(setUsers).catch(() => {})
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleAddClass = async () => {
|
const filteredClasses = useMemo(() => {
|
||||||
if (!newClass.name) return
|
const q = classFilter.trim().toLowerCase()
|
||||||
await api.post(endpoints.admin.classes(), newClass)
|
if (!q) return classes
|
||||||
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
|
return classes.filter(c => c.name.toLowerCase().includes(q))
|
||||||
setClasses(updated)
|
}, [classes, classFilter])
|
||||||
setNewClass({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
|
|
||||||
|
const handleStartAdd = () => {
|
||||||
|
setEditingId(ADDING_ID)
|
||||||
|
setEditForm({ ...NEW_CLASS_DEFAULTS })
|
||||||
|
setEditError(null)
|
||||||
|
setEditSaving(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteClass = async (id: number) => {
|
const handleDeleteClass = async (id: number) => {
|
||||||
@@ -54,18 +152,19 @@ export default function AdminPage() {
|
|||||||
setEditSaving(false)
|
setEditSaving(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateClass = async () => {
|
const handleSaveClass = async () => {
|
||||||
if (editingId === null || editSaving) return
|
if (editingId === null || editSaving) return
|
||||||
if (!editForm.name.trim()) { setEditError('nameRequired'); return }
|
if (!editForm.name.trim()) { setEditError('nameRequired'); return }
|
||||||
if (!(editForm.maxSizeM > 0)) { setEditError('maxSizeMustBePositive'); return }
|
|
||||||
setEditError(null)
|
setEditError(null)
|
||||||
setEditSaving(true)
|
setEditSaving(true)
|
||||||
try {
|
try {
|
||||||
// Risk 2 mitigation — always send the complete form so backend PATCH
|
if (editingId === ADDING_ID) {
|
||||||
// semantics (full-replace vs partial-merge) don't matter.
|
const created = await api.post<DetectionClass>(endpoints.admin.classes(), editForm)
|
||||||
await api.patch(endpoints.admin.class(editingId), editForm)
|
setClasses(prev => [...prev, created])
|
||||||
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
|
} else {
|
||||||
setClasses(updated)
|
const updated = await api.patch<DetectionClass>(endpoints.admin.class(editingId), editForm)
|
||||||
|
setClasses(prev => prev.map(c => c.id === editingId ? updated : c))
|
||||||
|
}
|
||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
} catch {
|
} catch {
|
||||||
setEditError('updateFailed')
|
setEditError('updateFailed')
|
||||||
@@ -75,244 +174,542 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditKeyDown = (e: KeyboardEvent<HTMLElement>) => {
|
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() }
|
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) => {
|
const handleToggleDefault = async (a: Aircraft) => {
|
||||||
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
|
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))
|
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full overflow-y-auto p-4 gap-4">
|
<main className="flex h-full overflow-hidden" style={{ background: 'var(--surface-0)' }}>
|
||||||
{/* Detection classes */}
|
|
||||||
<div className="w-[340px] shrink-0">
|
{/* ===== LEFT: DETECTION CLASSES (340px) ===== */}
|
||||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.classes.title')}</h2>
|
<aside
|
||||||
<div className="bg-az-panel border border-az-border rounded overflow-hidden">
|
className="shrink-0 flex flex-col"
|
||||||
<table className="w-full text-xs">
|
style={{ width: 340, background: 'var(--surface-1)', borderRight: '1px solid var(--border-hair)' }}
|
||||||
<thead>
|
>
|
||||||
<tr className="border-b border-az-border text-az-muted">
|
<div
|
||||||
<th className="px-2 py-1 text-left">#</th>
|
className="px-4 pt-4 pb-3 flex items-center justify-between"
|
||||||
<th className="px-2 py-1 text-left">Name</th>
|
style={{ borderBottom: '1px solid var(--border-hair)' }}
|
||||||
<th className="px-2 py-1">Color</th>
|
>
|
||||||
<th className="px-2 py-1"></th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{classes.map(c => c.id === editingId ? (
|
{editingId === ADDING_ID && (
|
||||||
<tr key={c.id} className="border-b border-az-border text-az-text bg-az-bg/40" data-editing-row={c.id}>
|
<ClassEditRow
|
||||||
<td className="px-2 py-1 align-top">{c.id}</td>
|
idCell="+"
|
||||||
<td colSpan={3} className="px-2 py-1">
|
rowId="new"
|
||||||
<div className="flex flex-wrap gap-1 items-center" onKeyDown={handleEditKeyDown}>
|
form={editForm}
|
||||||
<input
|
onChange={setEditForm}
|
||||||
autoFocus
|
onSave={() => void handleSaveClass()}
|
||||||
data-field="name"
|
onCancel={handleCancelEdit}
|
||||||
value={editForm.name}
|
onKeyDown={handleEditKeyDown}
|
||||||
onChange={e => setEditForm(p => ({ ...p, name: e.target.value }))}
|
saving={editSaving}
|
||||||
className="flex-1 min-w-[80px] bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
|
errorMessage={editError ? t(`admin.classes.${editError}`) : null}
|
||||||
/>
|
placeholderName="Name"
|
||||||
<input
|
/>
|
||||||
data-field="shortName"
|
)}
|
||||||
value={editForm.shortName}
|
{filteredClasses.map(c => c.id === editingId ? (
|
||||||
onChange={e => setEditForm(p => ({ ...p, shortName: e.target.value }))}
|
<ClassEditRow
|
||||||
className="w-12 bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
|
key={c.id}
|
||||||
/>
|
idCell={c.id}
|
||||||
<input
|
rowId={c.id}
|
||||||
type="color"
|
form={editForm}
|
||||||
data-field="color"
|
onChange={setEditForm}
|
||||||
value={editForm.color}
|
onSave={() => void handleSaveClass()}
|
||||||
onChange={e => setEditForm(p => ({ ...p, color: e.target.value }))}
|
onCancel={handleCancelEdit}
|
||||||
className="w-7 h-6 border-0 bg-transparent cursor-pointer"
|
onKeyDown={handleEditKeyDown}
|
||||||
/>
|
saving={editSaving}
|
||||||
<input
|
errorMessage={editError ? t(`admin.classes.${editError}`) : null}
|
||||||
type="number"
|
/>
|
||||||
data-field="maxSizeM"
|
|
||||||
value={editForm.maxSizeM}
|
|
||||||
onChange={e => setEditForm(p => ({ ...p, maxSizeM: Number(e.target.value) }))}
|
|
||||||
className="w-14 bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => void handleUpdateClass()}
|
|
||||||
disabled={editSaving}
|
|
||||||
className="bg-az-orange text-white px-2 py-0.5 rounded disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('admin.classes.save')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleCancelEdit}
|
|
||||||
disabled={editSaving}
|
|
||||||
className="bg-az-bg border border-az-border text-az-text px-2 py-0.5 rounded disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('admin.classes.cancel')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{editError && (
|
|
||||||
<div role="alert" className="mt-1 text-az-red">
|
|
||||||
{t(`admin.classes.${editError}`)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
) : (
|
||||||
<tr key={c.id} className="border-b border-az-border text-az-text">
|
<tr key={c.id} className="row-hover" style={{ borderBottom: '1px solid var(--border-hair)', height: 32 }}>
|
||||||
<td className="px-2 py-1">{c.id}</td>
|
<td className="px-3 mono tnum" style={{ color: 'var(--text-muted)', fontSize: 12 }}>{c.id}</td>
|
||||||
<td className="px-2 py-1">{c.name}</td>
|
<td className="px-2"><span style={{ fontSize: 12 }}>{c.name}</span></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 text-center"><span className="swatch" style={{ background: c.color }} /></td>
|
||||||
<td className="px-2 py-1 text-right whitespace-nowrap">
|
<td className="px-3 text-right">
|
||||||
<button
|
<span className="reveal inline-flex gap-1">
|
||||||
onClick={() => handleStartEdit(c)}
|
<button
|
||||||
aria-label={t('admin.classes.edit')}
|
type="button"
|
||||||
className="text-az-muted hover:text-az-orange mr-1"
|
onClick={() => handleStartEdit(c)}
|
||||||
>
|
className="ibtn edit"
|
||||||
{'\u270E'}
|
aria-label={t('admin.classes.edit')}
|
||||||
</button>
|
title={t('admin.classes.edit')}
|
||||||
<button onClick={() => handleDeleteClass(c.id)} className="text-az-muted hover:text-az-red">×</button>
|
>
|
||||||
|
<PencilIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteClass(c.id)}
|
||||||
|
className="ibtn danger"
|
||||||
|
aria-label="×"
|
||||||
|
title={t('admin.classes.delete')}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
</div>
|
</aside>
|
||||||
|
|
||||||
{/* Center: AI + GPS settings */}
|
{/* ===== CENTER ===== */}
|
||||||
<div className="flex-1 space-y-4 max-w-md">
|
<section className="flex-1 overflow-y-auto grid-bg">
|
||||||
<div>
|
<div className="max-w-[920px] mx-auto p-6 space-y-6">
|
||||||
<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">
|
{/* AI RECOGNITION ENGINE */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-az-muted">Frame Period Recognition</label>
|
<div className="flex items-end justify-between mb-3">
|
||||||
<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 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>
|
||||||
<div>
|
|
||||||
<label className="text-az-muted">Frame Recognition Seconds</label>
|
<div className="bracket panel p-5">
|
||||||
<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" />
|
<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>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div>
|
{/* GPS DEVICE LINK */}
|
||||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.gpsSettings')}</h2>
|
<div>
|
||||||
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2 text-xs">
|
<div className="flex items-end justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-az-muted">Device Address</label>
|
<div className="sect-head">{t('admin.gpsDevice.title')}</div>
|
||||||
<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 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>
|
||||||
<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 className="bracket panel p-5">
|
||||||
<div>
|
<span className="br" />
|
||||||
<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">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
|
||||||
<table className="w-full text-xs">
|
<div>
|
||||||
<thead>
|
<label className="micro block mb-1">{t('admin.gpsDevice.address')}</label>
|
||||||
<tr className="border-b border-az-border text-az-muted">
|
<div className="hint mb-2">{t('admin.gpsDevice.addressHint')}</div>
|
||||||
<th className="px-2 py-1 text-left">Name</th>
|
<input
|
||||||
<th className="px-2 py-1 text-left">Email</th>
|
className="inp inp-mono"
|
||||||
<th className="px-2 py-1">Role</th>
|
value={gps.draft.address}
|
||||||
<th className="px-2 py-1">Status</th>
|
placeholder="0.0.0.0"
|
||||||
<th className="px-2 py-1"></th>
|
onChange={e => gps.setDraft({ ...gps.draft, address: e.target.value })}
|
||||||
</tr>
|
aria-label={t('admin.gpsDevice.address')}
|
||||||
</thead>
|
/>
|
||||||
<tbody>
|
</div>
|
||||||
{users.map(u => (
|
|
||||||
<tr key={u.id} className="border-b border-az-border text-az-text">
|
<div>
|
||||||
<td className="px-2 py-1">{u.name}</td>
|
<label className="micro block mb-1">{t('admin.gpsDevice.port')}</label>
|
||||||
<td className="px-2 py-1">{u.email}</td>
|
<div className="hint mb-2">{t('admin.gpsDevice.portHint')}</div>
|
||||||
<td className="px-2 py-1 text-center">{u.role}</td>
|
<input
|
||||||
<td className="px-2 py-1 text-center">
|
className="inp inp-mono"
|
||||||
<span className={`px-1 rounded ${u.isActive ? 'text-az-green' : 'text-az-red'}`}>
|
type="number"
|
||||||
{u.isActive ? 'Active' : 'Inactive'}
|
value={gps.draft.port}
|
||||||
</span>
|
onChange={e => gps.setDraft({ ...gps.draft, port: Number(e.target.value) })}
|
||||||
</td>
|
style={{ textAlign: 'right' }}
|
||||||
<td className="px-2 py-1">
|
aria-label={t('admin.gpsDevice.port')}
|
||||||
{u.isActive && (
|
/>
|
||||||
<button onClick={() => setDeactivateId(u.id)} className="text-az-muted hover:text-az-red text-xs">
|
</div>
|
||||||
{t('admin.deactivate')}
|
</div>
|
||||||
</button>
|
|
||||||
)}
|
<div className="mt-5">
|
||||||
</td>
|
<label className="micro block mb-1">{t('admin.gpsDevice.protocol')}</label>
|
||||||
</tr>
|
<div className="hint mb-2">{t('admin.gpsDevice.protocolHint')}</div>
|
||||||
))}
|
<div className="seg" role="group" aria-label={t('admin.gpsDevice.protocol')}>
|
||||||
</tbody>
|
{PROTOCOLS.map(p => (
|
||||||
</table>
|
<button
|
||||||
<div className="p-2 flex gap-1 border-t border-az-border">
|
key={p}
|
||||||
<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" />
|
type="button"
|
||||||
<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" />
|
onClick={() => gps.setDraft({ ...gps.draft, protocol: p })}
|
||||||
<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" />
|
className={`seg-btn${gps.draft.protocol === p ? ' active' : ''}`}
|
||||||
<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">
|
aria-pressed={gps.draft.protocol === p}
|
||||||
<option>Annotator</option>
|
>
|
||||||
<option>Admin</option>
|
{p}
|
||||||
<option>Viewer</option>
|
</button>
|
||||||
</select>
|
))}
|
||||||
<button onClick={handleAddUser} className="bg-az-orange text-white text-xs px-2 py-1 rounded">+</button>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mt-5 pt-4 flex items-center justify-between"
|
||||||
|
style={{ borderTop: '1px dashed var(--border-hair)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-5 micro">
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('admin.gpsDevice.fix')}{' '}
|
||||||
|
<span className="mono tnum" style={{ color: 'var(--accent-green)' }}>
|
||||||
|
{gps.telemetry ? `${gps.telemetry.fix} · ${gps.telemetry.satellites} SAT` : FALLBACK}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('admin.gpsDevice.hdop')}{' '}
|
||||||
|
<span className="mono tnum" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{gps.telemetry ? gps.telemetry.hdop.toFixed(2) : FALLBACK}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('admin.gpsDevice.lastPkt')}{' '}
|
||||||
|
<span className="mono tnum" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{gps.telemetry ? `+${gps.telemetry.lastPacketMs}ms` : FALLBACK}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => void gps.ping()} disabled={gps.status === 'pinging'}>
|
||||||
|
{t('admin.gpsDevice.ping')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => void gps.reconnect()} disabled={gps.status === 'reconnecting'}>
|
||||||
|
{t('admin.gpsDevice.reconnect')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => void gps.save()}
|
||||||
|
disabled={gps.status === 'saving'}
|
||||||
|
>
|
||||||
|
{t('admin.gpsDevice.apply')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{gps.error && (
|
||||||
|
<div role="alert" className="mt-2" style={{ color: 'var(--accent-red)', fontSize: 11 }}>
|
||||||
|
{gps.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* Aircrafts sidebar */}
|
{/* ===== RIGHT: DEFAULT AIRCRAFTS (280px) ===== */}
|
||||||
<div className="w-[280px] shrink-0">
|
<aside
|
||||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.aircrafts')}</h2>
|
className="shrink-0 flex flex-col"
|
||||||
<div className="bg-az-panel border border-az-border rounded p-2 space-y-1">
|
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 => (
|
{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">
|
<div
|
||||||
<span className={`px-1 rounded text-[10px] ${a.type === 'Plane' ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
|
key={a.id}
|
||||||
{a.type === 'Plane' ? 'P' : 'C'}
|
data-aircraft-id={a.id}
|
||||||
</span>
|
className="row-hover flex items-center gap-3 px-4 py-2.5"
|
||||||
<span className="flex-1">{a.model}</span>
|
style={{
|
||||||
<span className={`text-sm ${a.isDefault ? 'text-az-orange' : 'text-az-muted'}`}>★</span>
|
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>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<div
|
||||||
open={!!deactivateId}
|
className="px-4 py-3"
|
||||||
title={t('admin.deactivate')}
|
style={{ borderTop: '1px solid var(--border-hair)', background: 'var(--surface-0)' }}
|
||||||
message="Deactivate this user?"
|
>
|
||||||
onConfirm={handleDeactivate}
|
<button
|
||||||
onCancel={() => setDeactivateId(null)}
|
type="button"
|
||||||
/>
|
className="btn btn-secondary w-full justify-center"
|
||||||
</div>
|
onClick={openAircraftModal}
|
||||||
|
>
|
||||||
|
{t('admin.aircrafts.add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={aircraftModalOpen}
|
||||||
|
title={t('admin.aircrafts.addTitle')}
|
||||||
|
onClose={closeAircraftModal}
|
||||||
|
closeLabel={t('admin.classes.cancel')}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={closeAircraftModal}
|
||||||
|
disabled={aircraftSaving}
|
||||||
|
>
|
||||||
|
{t('admin.classes.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => void saveAircraft()}
|
||||||
|
disabled={aircraftSaving}
|
||||||
|
>
|
||||||
|
{t('admin.aircrafts.addTitle')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1">{t('admin.aircrafts.fieldModel')}</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="inp inp-mono"
|
||||||
|
value={aircraftDraft.model}
|
||||||
|
onChange={e => setAircraftDraft(p => ({ ...p, model: e.target.value }))}
|
||||||
|
placeholder="DJI Mavic 3"
|
||||||
|
aria-label={t('admin.aircrafts.fieldModel')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1">{t('admin.aircrafts.fieldType')}</label>
|
||||||
|
<div className="seg" role="group" aria-label={t('admin.aircrafts.fieldType')}>
|
||||||
|
{AIRCRAFT_TYPES.map(typ => (
|
||||||
|
<button
|
||||||
|
key={typ}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAircraftDraft(p => ({ ...p, type: typ }))}
|
||||||
|
className={`seg-btn${aircraftDraft.type === typ ? ' active' : ''}`}
|
||||||
|
aria-pressed={aircraftDraft.type === typ}
|
||||||
|
>
|
||||||
|
{t(`admin.aircrafts.${TYPE_LEGEND_KEY[typ]}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1">{t('admin.aircrafts.fieldResolution')}</label>
|
||||||
|
<select
|
||||||
|
className="inp inp-mono"
|
||||||
|
value={aircraftDraft.resolution}
|
||||||
|
onChange={e => setAircraftDraft(p => ({ ...p, resolution: e.target.value }))}
|
||||||
|
aria-label={t('admin.aircrafts.fieldResolution')}
|
||||||
|
>
|
||||||
|
{RESOLUTIONS.map(r => (
|
||||||
|
<option key={r} value={r}>{r}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1">{t('admin.aircrafts.fieldMaxMinutes')}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="inp inp-mono"
|
||||||
|
value={aircraftDraft.maxMinutes}
|
||||||
|
onChange={e => setAircraftDraft(p => ({ ...p, maxMinutes: Number(e.target.value) }))}
|
||||||
|
style={{ textAlign: 'right' }}
|
||||||
|
aria-label={t('admin.aircrafts.fieldMaxMinutes')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox"
|
||||||
|
checked={aircraftDraft.isDefault}
|
||||||
|
onChange={e => setAircraftDraft(p => ({ ...p, isDefault: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span>{t('admin.aircrafts.fieldDefault')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{aircraftError && (
|
||||||
|
<div role="alert" style={{ color: 'var(--accent-red)', fontSize: 11 }}>
|
||||||
|
{t(`admin.aircrafts.${aircraftError}`)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { Fragment, useRef, type KeyboardEvent, type ReactNode } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export type EditFormShape = { name: string; shortName: string; color: string; maxSizeM: number }
|
||||||
|
|
||||||
|
interface ClassEditRowProps {
|
||||||
|
/** Cell content for the leftmost `#` column (e.g. `+` for new, row id for edit). */
|
||||||
|
idCell: ReactNode
|
||||||
|
/** Stable identifier for the row's data-editing-row attribute. */
|
||||||
|
rowId: number | 'new'
|
||||||
|
form: EditFormShape
|
||||||
|
onChange: (form: EditFormShape) => void
|
||||||
|
onSave: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
onKeyDown: (e: KeyboardEvent<HTMLElement>) => void
|
||||||
|
saving: boolean
|
||||||
|
/** Optional inline error key (already translated by the caller's t() if provided as message). */
|
||||||
|
errorMessage: string | null
|
||||||
|
placeholderName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function CloseIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassEditRow({
|
||||||
|
idCell, rowId, form, onChange, onSave, onCancel, onKeyDown,
|
||||||
|
saving, errorMessage, placeholderName,
|
||||||
|
}: ClassEditRowProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const colorInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<tr
|
||||||
|
className="row-hover"
|
||||||
|
data-editing-row={rowId}
|
||||||
|
style={{ borderBottom: '1px solid var(--accent-amber)', height: 32, background: 'rgba(255,157,61,0.06)' }}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
<td className="px-3 mono tnum" style={{ color: 'var(--accent-amber)', fontSize: 12 }}>{idCell}</td>
|
||||||
|
<td className="px-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
data-field="name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => onChange({ ...form, name: e.target.value })}
|
||||||
|
placeholder={placeholderName}
|
||||||
|
className="inp inp-mono"
|
||||||
|
style={{ height: 22, padding: '0 6px', fontSize: 11 }}
|
||||||
|
aria-label={t('admin.classes.colName')}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => colorInputRef.current?.click()}
|
||||||
|
className="inline-flex items-center justify-center cursor-pointer"
|
||||||
|
aria-label={t('admin.classes.colHex')}
|
||||||
|
style={{ background: 'transparent', border: 0, padding: 0 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="swatch"
|
||||||
|
style={{ background: form.color, boxShadow: '0 0 0 1px var(--accent-amber)' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={colorInputRef}
|
||||||
|
type="color"
|
||||||
|
data-field="color"
|
||||||
|
value={form.color}
|
||||||
|
onChange={e => onChange({ ...form, color: e.target.value })}
|
||||||
|
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 text-right">
|
||||||
|
<span className="inline-flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="ibtn cyan"
|
||||||
|
aria-label={t('admin.classes.save')}
|
||||||
|
title={t('admin.classes.save')}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={saving}
|
||||||
|
className="ibtn"
|
||||||
|
aria-label={t('admin.classes.cancel')}
|
||||||
|
title={t('admin.classes.cancel')}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{errorMessage && (
|
||||||
|
<tr style={{ background: 'rgba(255,157,61,0.06)' }}>
|
||||||
|
<td />
|
||||||
|
<td colSpan={3} className="px-2 pb-2">
|
||||||
|
<div role="alert" style={{ color: 'var(--accent-red)', fontSize: 11 }}>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, type ReactNode, type KeyboardEvent, type MouseEvent } from 'react'
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
open: boolean
|
||||||
|
title: ReactNode
|
||||||
|
onClose: () => void
|
||||||
|
width?: number
|
||||||
|
footer?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
closeLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ open, title, onClose, width = 420, footer, children, closeLabel = 'Close' }: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onKey = (e: globalThis.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
// Lock body scroll while the modal is open.
|
||||||
|
const prev = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
document.body.style.overflow = prev
|
||||||
|
}
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
const onBackdropClick = (e: MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === e.currentTarget) onClose()
|
||||||
|
}
|
||||||
|
const onPanelKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
// Stop Escape from bubbling to other key handlers in the page; the
|
||||||
|
// document listener above already handles closing.
|
||||||
|
if (e.key === 'Escape') e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={typeof title === 'string' ? title : undefined}
|
||||||
|
onClick={onBackdropClick}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 100,
|
||||||
|
background: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bracket panel"
|
||||||
|
onKeyDown={onPanelKey}
|
||||||
|
style={{ width, padding: 20 }}
|
||||||
|
>
|
||||||
|
<span className="br" />
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="sect-head">{title}</span>
|
||||||
|
<button type="button" onClick={onClose} className="ibtn" aria-label={closeLabel} title={closeLabel}>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">{children}</div>
|
||||||
|
|
||||||
|
{footer && (
|
||||||
|
<div
|
||||||
|
className="mt-5 pt-4 flex items-center justify-end gap-2"
|
||||||
|
style={{ borderTop: '1px dashed var(--border-hair)' }}
|
||||||
|
>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
interface NumberStepperProps {
|
||||||
|
value: number
|
||||||
|
/** Inclusive minimum, applied only to ▲▼ stepper clicks (not free typing). */
|
||||||
|
min?: number
|
||||||
|
/** Inclusive maximum, applied only to ▲▼ stepper clicks (not free typing). */
|
||||||
|
max?: number
|
||||||
|
/** Increment per ▲▼ click. */
|
||||||
|
step: number
|
||||||
|
onChange: (v: number) => void
|
||||||
|
/** Trailing unit label (e.g. "FR", "SEC", "%"). */
|
||||||
|
suffix: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number input with ▲▼ stepper buttons next to it and a trailing unit
|
||||||
|
* label. Stepper buttons clamp to [min, max]; direct typing does NOT —
|
||||||
|
* so `userEvent.clear()` + `type('9')` behaves as expected without being
|
||||||
|
* snapped mid-keystroke. Invalid intermediate values fall through; the
|
||||||
|
* caller validates on save.
|
||||||
|
*/
|
||||||
|
export function NumberStepper({ value, min, max, step, onChange, suffix }: NumberStepperProps) {
|
||||||
|
const clamp = (v: number) => Math.max(min ?? -Infinity, Math.min(max ?? Infinity, v))
|
||||||
|
return (
|
||||||
|
<div className="flex items-stretch gap-2">
|
||||||
|
<input
|
||||||
|
className="inp inp-mono"
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={e => {
|
||||||
|
const raw = e.target.value
|
||||||
|
const parsed = raw === '' ? 0 : Number(raw)
|
||||||
|
onChange(Number.isFinite(parsed) ? parsed : 0)
|
||||||
|
}}
|
||||||
|
style={{ textAlign: 'right', width: 88 }}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col" style={{ border: '1px solid var(--border-hair)', borderRadius: 2 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(clamp(value + step))}
|
||||||
|
className="mono"
|
||||||
|
aria-label="Increment"
|
||||||
|
style={{ width: 24, height: 15, fontSize: 9, color: 'var(--text-secondary)', background: 'var(--surface-input)', borderBottom: '1px solid var(--border-hair)' }}
|
||||||
|
>▲</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(clamp(value - step))}
|
||||||
|
className="mono"
|
||||||
|
aria-label="Decrement"
|
||||||
|
style={{ width: 24, height: 15, fontSize: 9, color: 'var(--text-secondary)', background: 'var(--surface-input)' }}
|
||||||
|
>▼</button>
|
||||||
|
</div>
|
||||||
|
<span className="micro self-center" style={{ color: 'var(--text-muted)' }}>{suffix}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { http } from 'msw'
|
||||||
|
import { server } from '../../../../tests/msw/server'
|
||||||
|
import { jsonResponse, errorResponse } from '../../../../tests/msw/helpers'
|
||||||
|
import { renderWithProviders, screen, waitFor, userEvent } from '../../../../tests/helpers/render'
|
||||||
|
import { seedBearer, clearBearer } from '../../../../tests/helpers/auth'
|
||||||
|
import { AdminPage } from '..'
|
||||||
|
|
||||||
|
// v2 admin — AI Recognition Engine panel. Covers GET → render telemetry,
|
||||||
|
// edit value via stepper / input, APPLY → PATCH, RESET → discards draft,
|
||||||
|
// PATCH 500 → inline error.
|
||||||
|
//
|
||||||
|
// Both AI and GPS panels render APPLY buttons; AI is the first one in DOM
|
||||||
|
// order. We pick [0] from getAllByRole rather than coupling to internal markup.
|
||||||
|
|
||||||
|
function aiApplyButton(): HTMLElement {
|
||||||
|
return screen.getAllByRole('button', { name: /apply/i })[0]
|
||||||
|
}
|
||||||
|
function aiResetButton(): HTMLElement {
|
||||||
|
return screen.getByRole('button', { name: /reset/i })
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
seedBearer()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
clearBearer()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AdminPage — AI Recognition Engine', () => {
|
||||||
|
it('renders initial settings + telemetry from GET /api/admin/ai-settings', async () => {
|
||||||
|
renderWithProviders(<AdminPage />)
|
||||||
|
expect(await screen.findByText('YOLOV8-X · CKPT-241')).toBeInTheDocument()
|
||||||
|
expect(screen.getByDisplayValue('4')).toBeInTheDocument()
|
||||||
|
expect(screen.getByDisplayValue('25')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('APPLY sends PATCH with edited settings and reflects telemetry refresh', async () => {
|
||||||
|
const calls: { body: unknown }[] = []
|
||||||
|
server.use(
|
||||||
|
http.patch('/api/admin/ai-settings', async ({ request }) => {
|
||||||
|
const body = await request.json()
|
||||||
|
calls.push({ body })
|
||||||
|
return jsonResponse({
|
||||||
|
settings: { framesToRecognize: 8, minSecondsBetween: 2, minConfidence: 25 },
|
||||||
|
telemetry: {
|
||||||
|
model: 'YOLOV8-X', checkpoint: 'CKPT-242',
|
||||||
|
lastRunAt: '2026-05-18T12:00:00Z', frames: 99, avgConfidence: 80,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
renderWithProviders(<AdminPage />)
|
||||||
|
await screen.findByText('YOLOV8-X · CKPT-241')
|
||||||
|
|
||||||
|
const framesInput = screen.getByDisplayValue('4') as HTMLInputElement
|
||||||
|
await userEvent.clear(framesInput)
|
||||||
|
await userEvent.type(framesInput, '8')
|
||||||
|
|
||||||
|
await userEvent.click(aiApplyButton())
|
||||||
|
|
||||||
|
await waitFor(() => expect(calls.length).toBe(1))
|
||||||
|
expect((calls[0].body as { framesToRecognize: number }).framesToRecognize).toBe(8)
|
||||||
|
expect(await screen.findByText(/CKPT-242/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('RESET reverts draft to the last persisted value (no PATCH)', async () => {
|
||||||
|
const patchCalls: unknown[] = []
|
||||||
|
server.use(
|
||||||
|
http.patch('/api/admin/ai-settings', () => {
|
||||||
|
patchCalls.push({})
|
||||||
|
return jsonResponse({})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
renderWithProviders(<AdminPage />)
|
||||||
|
await screen.findByText('YOLOV8-X · CKPT-241')
|
||||||
|
|
||||||
|
const framesInput = screen.getByDisplayValue('4') as HTMLInputElement
|
||||||
|
await userEvent.clear(framesInput)
|
||||||
|
await userEvent.type(framesInput, '9')
|
||||||
|
expect(screen.getByDisplayValue('9')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await userEvent.click(aiResetButton())
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('4')).toBeInTheDocument()
|
||||||
|
expect(patchCalls.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PATCH 500 surfaces an inline error', async () => {
|
||||||
|
server.use(
|
||||||
|
http.patch('/api/admin/ai-settings', () => errorResponse(500, 'boom')),
|
||||||
|
)
|
||||||
|
renderWithProviders(<AdminPage />)
|
||||||
|
await screen.findByText('YOLOV8-X · CKPT-241')
|
||||||
|
|
||||||
|
await userEvent.click(aiApplyButton())
|
||||||
|
|
||||||
|
const alert = await screen.findByRole('alert')
|
||||||
|
expect(alert.textContent ?? '').toMatch(/failed to save ai/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { http } from 'msw'
|
||||||
|
import { server } from '../../../../tests/msw/server'
|
||||||
|
import { jsonResponse } from '../../../../tests/msw/helpers'
|
||||||
|
import { renderWithProviders, screen, waitFor, userEvent } from '../../../../tests/helpers/render'
|
||||||
|
import { seedBearer, clearBearer } from '../../../../tests/helpers/auth'
|
||||||
|
import { seedAircraft } from '../../../../tests/fixtures/seed_aircraft'
|
||||||
|
import { AdminPage } from '..'
|
||||||
|
|
||||||
|
// v2 admin — Default Aircrafts panel: render 6 mockup rows + star toggle.
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
seedBearer()
|
||||||
|
server.use(
|
||||||
|
http.get('/api/flights/aircrafts', () => jsonResponse(seedAircraft)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
clearBearer()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AdminPage — Default Aircrafts', () => {
|
||||||
|
it('renders all 6 seeded aircraft with id · resolution · minutes', async () => {
|
||||||
|
renderWithProviders(<AdminPage />)
|
||||||
|
expect(await screen.findByText('DJI Mavic 3')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Matrice 300 RTK')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Leleka-100')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Fixed Wing Scout')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Autel EVO II Pro')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('PD-2 Recon')).toBeInTheDocument()
|
||||||
|
// Subline format: "AC-001 · 4K · 46MIN"
|
||||||
|
expect(screen.getByText(/AC-001\s+·\s+4K\s+·\s+46MIN/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('star toggle PATCHes isDefault and updates UI', async () => {
|
||||||
|
const calls: { id: string; body: unknown }[] = []
|
||||||
|
server.use(
|
||||||
|
http.patch('/api/flights/aircrafts/:id', async ({ params, request }) => {
|
||||||
|
const body = await request.json()
|
||||||
|
calls.push({ id: String(params.id), body })
|
||||||
|
return jsonResponse({ ok: true })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
renderWithProviders(<AdminPage />)
|
||||||
|
await screen.findByText('DJI Mavic 3')
|
||||||
|
|
||||||
|
// AC-002 starts non-default → click its star to mark default.
|
||||||
|
const ac002Row = screen.getByText('Matrice 300 RTK').closest('[data-aircraft-id]') as HTMLElement
|
||||||
|
expect(ac002Row).not.toBeNull()
|
||||||
|
// Within the row find the toggle button (set-default label).
|
||||||
|
const toggleBtn = ac002Row.querySelector('button[aria-pressed="false"]') as HTMLButtonElement
|
||||||
|
expect(toggleBtn).not.toBeNull()
|
||||||
|
await userEvent.click(toggleBtn)
|
||||||
|
|
||||||
|
await waitFor(() => expect(calls.length).toBe(1))
|
||||||
|
expect(calls[0].id).toBe('AC-002')
|
||||||
|
expect((calls[0].body as { isDefault: boolean }).isDefault).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { http } from 'msw'
|
||||||
|
import { server } from '../../../../tests/msw/server'
|
||||||
|
import { jsonResponse } from '../../../../tests/msw/helpers'
|
||||||
|
import { renderWithProviders, screen, waitFor, userEvent } from '../../../../tests/helpers/render'
|
||||||
|
import { seedBearer, clearBearer } from '../../../../tests/helpers/auth'
|
||||||
|
import { AdminPage } from '..'
|
||||||
|
|
||||||
|
// v2 admin — GPS Device Link panel.
|
||||||
|
//
|
||||||
|
// AI and GPS share APPLY label; GPS is the SECOND APPLY in DOM order.
|
||||||
|
|
||||||
|
function gpsApplyButton(): HTMLElement {
|
||||||
|
return screen.getAllByRole('button', { name: /apply/i })[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
seedBearer()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
clearBearer()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AdminPage — GPS Device Link', () => {
|
||||||
|
it('renders initial settings + telemetry from GET /api/admin/gps-settings', async () => {
|
||||||
|
renderWithProviders(<AdminPage />)
|
||||||
|
expect(await screen.findByDisplayValue('192.168.1.100')).toBeInTheDocument()
|
||||||
|
expect(screen.getByDisplayValue('9001')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('UDP/192.168.1.100:9001')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('protocol segmented control switches active value and APPLY PATCHes', async () => {
|
||||||
|
const calls: { body: unknown }[] = []
|
||||||
|
server.use(
|
||||||
|
http.patch('/api/admin/gps-settings', async ({ request }) => {
|
||||||
|
const body = await request.json()
|
||||||
|
calls.push({ body })
|
||||||
|
return jsonResponse({
|
||||||
|
settings: { ...(body as object), address: '192.168.1.100', port: 9001 },
|
||||||
|
telemetry: { socket: 'UDP/192.168.1.100:9001', connected: true, fix: '3D', satellites: 11, hdop: 0.82, lastPacketMs: 12 },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
renderWithProviders(<AdminPage />)
|
||||||
|
await screen.findByDisplayValue('192.168.1.100')
|
||||||
|
|
||||||
|
const ubxBtn = screen.getByRole('button', { name: 'UBX' })
|
||||||
|
await userEvent.click(ubxBtn)
|
||||||
|
expect(ubxBtn).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
|
||||||
|
await userEvent.click(gpsApplyButton())
|
||||||
|
|
||||||
|
await waitFor(() => expect(calls.length).toBe(1))
|
||||||
|
expect((calls[0].body as { protocol: string }).protocol).toBe('UBX')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PING and RECONNECT fire their dedicated endpoints', async () => {
|
||||||
|
let pingHits = 0
|
||||||
|
let reconnectHits = 0
|
||||||
|
server.use(
|
||||||
|
http.post('/api/admin/gps-settings/ping', () => { pingHits += 1; return new Response(null, { status: 204 }) }),
|
||||||
|
http.post('/api/admin/gps-settings/reconnect', () => {
|
||||||
|
reconnectHits += 1
|
||||||
|
return jsonResponse({
|
||||||
|
settings: { address: '192.168.1.100', port: 9001, protocol: 'NMEA' },
|
||||||
|
telemetry: { socket: 'UDP/192.168.1.100:9001', connected: true, fix: '3D', satellites: 11, hdop: 0.82, lastPacketMs: 0 },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
renderWithProviders(<AdminPage />)
|
||||||
|
await screen.findByDisplayValue('192.168.1.100')
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^ping$/i }))
|
||||||
|
await waitFor(() => expect(pingHits).toBe(1))
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /reconnect/i }))
|
||||||
|
await waitFor(() => expect(reconnectHits).toBe(1))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { api, endpoints } from '../../api'
|
||||||
|
import type {
|
||||||
|
AiRecognitionResponse,
|
||||||
|
AiRecognitionSettings,
|
||||||
|
AiRecognitionTelemetry,
|
||||||
|
} from '../../types'
|
||||||
|
|
||||||
|
type Status = 'idle' | 'loading' | 'ready' | 'saving' | 'error'
|
||||||
|
|
||||||
|
// Factory defaults — UI stays interactive when GET fails (no backend).
|
||||||
|
const FACTORY_AI_SETTINGS: AiRecognitionSettings = {
|
||||||
|
framesToRecognize: 4,
|
||||||
|
minSecondsBetween: 2,
|
||||||
|
minConfidence: 25,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAiSettings() {
|
||||||
|
const [draft, setDraft] = useState<AiRecognitionSettings>(FACTORY_AI_SETTINGS)
|
||||||
|
const [persisted, setPersisted] = useState<AiRecognitionSettings>(FACTORY_AI_SETTINGS)
|
||||||
|
const [telemetry, setTelemetry] = useState<AiRecognitionTelemetry | null>(null)
|
||||||
|
const [status, setStatus] = useState<Status>('idle')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setStatus('loading')
|
||||||
|
api.get<AiRecognitionResponse>(endpoints.admin.aiSettings())
|
||||||
|
.then(res => {
|
||||||
|
if (cancelled) return
|
||||||
|
setDraft(res.settings)
|
||||||
|
setPersisted(res.settings)
|
||||||
|
setTelemetry(res.telemetry)
|
||||||
|
setStatus('ready')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setStatus('error')
|
||||||
|
setError('Failed to load AI settings')
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const save = useCallback(async () => {
|
||||||
|
setStatus('saving')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await api.patch<AiRecognitionResponse>(endpoints.admin.aiSettings(), draft)
|
||||||
|
setDraft(res.settings)
|
||||||
|
setPersisted(res.settings)
|
||||||
|
setTelemetry(res.telemetry)
|
||||||
|
setStatus('ready')
|
||||||
|
} catch {
|
||||||
|
setStatus('error')
|
||||||
|
setError('Failed to save AI settings')
|
||||||
|
}
|
||||||
|
}, [draft])
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setDraft(persisted)
|
||||||
|
}, [persisted])
|
||||||
|
|
||||||
|
return { draft, setDraft, telemetry, status, error, save, reset } as const
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { api, endpoints } from '../../api'
|
||||||
|
import type {
|
||||||
|
GpsDeviceResponse,
|
||||||
|
GpsDeviceSettings,
|
||||||
|
GpsDeviceTelemetry,
|
||||||
|
} from '../../types'
|
||||||
|
|
||||||
|
type Status = 'idle' | 'loading' | 'ready' | 'saving' | 'pinging' | 'reconnecting' | 'error'
|
||||||
|
|
||||||
|
// Factory defaults — UI stays interactive when GET fails (no backend).
|
||||||
|
const FACTORY_GPS_SETTINGS: GpsDeviceSettings = {
|
||||||
|
address: '192.168.1.100',
|
||||||
|
port: 9001,
|
||||||
|
protocol: 'NMEA',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGpsSettings() {
|
||||||
|
const [draft, setDraft] = useState<GpsDeviceSettings>(FACTORY_GPS_SETTINGS)
|
||||||
|
const [persisted, setPersisted] = useState<GpsDeviceSettings>(FACTORY_GPS_SETTINGS)
|
||||||
|
const [telemetry, setTelemetry] = useState<GpsDeviceTelemetry | null>(null)
|
||||||
|
const [status, setStatus] = useState<Status>('idle')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setStatus('loading')
|
||||||
|
api.get<GpsDeviceResponse>(endpoints.admin.gpsSettings())
|
||||||
|
.then(res => {
|
||||||
|
if (cancelled) return
|
||||||
|
setDraft(res.settings)
|
||||||
|
setPersisted(res.settings)
|
||||||
|
setTelemetry(res.telemetry)
|
||||||
|
setStatus('ready')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setStatus('error')
|
||||||
|
setError('Failed to load GPS settings')
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const save = useCallback(async () => {
|
||||||
|
setStatus('saving')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await api.patch<GpsDeviceResponse>(endpoints.admin.gpsSettings(), draft)
|
||||||
|
setDraft(res.settings)
|
||||||
|
setPersisted(res.settings)
|
||||||
|
setTelemetry(res.telemetry)
|
||||||
|
setStatus('ready')
|
||||||
|
} catch {
|
||||||
|
setStatus('error')
|
||||||
|
setError('Failed to save GPS settings')
|
||||||
|
}
|
||||||
|
}, [draft])
|
||||||
|
|
||||||
|
const ping = useCallback(async () => {
|
||||||
|
setStatus('pinging')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await api.post(endpoints.admin.gpsPing(), {})
|
||||||
|
setStatus('ready')
|
||||||
|
} catch {
|
||||||
|
setStatus('error')
|
||||||
|
setError('Ping failed')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reconnect = useCallback(async () => {
|
||||||
|
setStatus('reconnecting')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await api.post<GpsDeviceResponse>(endpoints.admin.gpsReconnect(), {})
|
||||||
|
setTelemetry(res.telemetry)
|
||||||
|
setStatus('ready')
|
||||||
|
} catch {
|
||||||
|
setStatus('error')
|
||||||
|
setError('Reconnect failed')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setDraft(persisted)
|
||||||
|
}, [persisted])
|
||||||
|
|
||||||
|
return { draft, setDraft, telemetry, status, error, save, ping, reconnect, reset } as const
|
||||||
|
}
|
||||||
+61
-7
@@ -2,7 +2,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"flights": "Flights",
|
"flights": "Flights",
|
||||||
"annotations": "Annotations",
|
"annotations": "Annotations",
|
||||||
"dataset": "Dataset Explorer",
|
"dataset": "Dataset",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"logout": "Logout"
|
"logout": "Logout"
|
||||||
@@ -116,19 +116,73 @@
|
|||||||
"title": "Admin",
|
"title": "Admin",
|
||||||
"classes": {
|
"classes": {
|
||||||
"title": "Detection Classes",
|
"title": "Detection Classes",
|
||||||
|
"search": "Search class…",
|
||||||
|
"add": "+ ADD",
|
||||||
|
"colName": "Name",
|
||||||
|
"colHex": "Hex",
|
||||||
|
"colOps": "Ops",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"nameRequired": "Name is required",
|
"nameRequired": "Name is required",
|
||||||
"maxSizeMustBePositive": "Max size must be a positive number",
|
"maxSizeMustBePositive": "Max size must be a positive number",
|
||||||
"updateFailed": "Update failed. Please try again."
|
"updateFailed": "Update failed. Please try again."
|
||||||
},
|
},
|
||||||
"aiSettings": "AI Recognition Settings",
|
"aiEngine": {
|
||||||
"gpsSettings": "GPS Device Settings",
|
"title": "AI Recognition Engine",
|
||||||
"aircrafts": "Default Aircrafts",
|
"subtitle": "Detection model runtime parameters. Applied per-flight, hot-reloaded.",
|
||||||
"users": "User Management",
|
"framesToRecognize": "Frames To Recognize",
|
||||||
"addUser": "Add User",
|
"framesHint": "Number of consecutive frames the model averages before emitting a detection.",
|
||||||
"deactivate": "Deactivate"
|
"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": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
|
|||||||
+60
-6
@@ -116,19 +116,73 @@
|
|||||||
"title": "Адмін",
|
"title": "Адмін",
|
||||||
"classes": {
|
"classes": {
|
||||||
"title": "Класи детекцій",
|
"title": "Класи детекцій",
|
||||||
|
"search": "Пошук класу…",
|
||||||
|
"add": "+ ДОДАТИ",
|
||||||
|
"colName": "Назва",
|
||||||
|
"colHex": "Hex",
|
||||||
|
"colOps": "Дії",
|
||||||
"edit": "Редагувати",
|
"edit": "Редагувати",
|
||||||
|
"delete": "Видалити",
|
||||||
"save": "Зберегти",
|
"save": "Зберегти",
|
||||||
"cancel": "Скасувати",
|
"cancel": "Скасувати",
|
||||||
"nameRequired": "Назва обов'язкова",
|
"nameRequired": "Назва обов'язкова",
|
||||||
"maxSizeMustBePositive": "Максимальний розмір має бути додатнім числом",
|
"maxSizeMustBePositive": "Максимальний розмір має бути додатнім числом",
|
||||||
"updateFailed": "Не вдалося оновити. Спробуйте ще раз."
|
"updateFailed": "Не вдалося оновити. Спробуйте ще раз."
|
||||||
},
|
},
|
||||||
"aiSettings": "AI Налаштування",
|
"aiEngine": {
|
||||||
"gpsSettings": "GPS Пристрій",
|
"title": "AI Розпізнавання",
|
||||||
"aircrafts": "Літальні апарати",
|
"subtitle": "Параметри роботи моделі. Застосовуються до польоту, гаряче перезавантаження.",
|
||||||
"users": "Користувачі",
|
"framesToRecognize": "Кадрів для розпізнавання",
|
||||||
"addUser": "Додати користувача",
|
"framesHint": "Кількість послідовних кадрів, які модель усереднює перед видачею детекції.",
|
||||||
"deactivate": "Деактивувати"
|
"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": {
|
"settings": {
|
||||||
"title": "Налаштування",
|
"title": "Налаштування",
|
||||||
|
|||||||
+356
-19
@@ -1,31 +1,368 @@
|
|||||||
@import "tailwindcss";
|
@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 {
|
@theme {
|
||||||
--color-az-bg: #1e1e1e;
|
/* v2 — AZAION design system. v1 az-* names below are aliases so legacy
|
||||||
--color-az-panel: #2b2b2b;
|
pages still render until they're migrated to v2 utilities. */
|
||||||
--color-az-header: #343a40;
|
--color-surface-0: #0A0D10;
|
||||||
--color-az-border: #495057;
|
--color-surface-1: #13171C;
|
||||||
--color-az-muted: #6c757d;
|
--color-surface-2: #1A1F26;
|
||||||
--color-az-text: #adb5bd;
|
--color-surface-input: #0A0D10;
|
||||||
--color-az-orange: #fd7e14;
|
--color-border-hair: #252B34;
|
||||||
--color-az-blue: #228be6;
|
--color-border-raised: #3B4451;
|
||||||
--color-az-red: #fa5252;
|
--color-text-primary: #E8ECF1;
|
||||||
--color-az-green: #40c057;
|
--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 {
|
body {
|
||||||
margin: 0;
|
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 {
|
.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
|
||||||
width: 6px;
|
.tnum { font-variant-numeric: tabular-nums; }
|
||||||
height: 6px;
|
|
||||||
|
.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);
|
.hint { font-size: 11px; color: var(--text-muted); line-height: 1.45; }
|
||||||
border-radius: 3px;
|
|
||||||
|
/* 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 {
|
export interface Aircraft {
|
||||||
id: string
|
id: string
|
||||||
model: string
|
model: string
|
||||||
type: 'Plane' | 'Copter'
|
type: 'Plane' | 'Copter' | 'FixedWing'
|
||||||
isDefault: boolean
|
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 {
|
export interface Waypoint {
|
||||||
|
|||||||
@@ -105,14 +105,12 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
|||||||
// Act
|
// Act
|
||||||
await clickEdit('1')
|
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 row1 = getRow('1')
|
||||||
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
|
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
|
||||||
expect(nameInput).toBeInTheDocument()
|
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.
|
// Assert — row 2 stays read-only: the row still shows the plain text name.
|
||||||
const row2 = getRow('2')
|
const row2 = getRow('2')
|
||||||
@@ -246,31 +244,17 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
|||||||
// Act
|
// Act
|
||||||
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
|
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)
|
expect(patchCalls.length).toBe(0)
|
||||||
const alert = within(row1).getByRole('alert')
|
const alert = screen.getByRole('alert')
|
||||||
expect(alert.textContent ?? '').toMatch(/name is required|назва обов/i)
|
expect(alert.textContent ?? '').toMatch(/name is required|назва обов/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('non-positive maxSizeM → no PATCH; maxSizeMustBePositive error visible', async () => {
|
// The maxSizeM field is no longer editable inline in v2 (mockup shows
|
||||||
// Arrange
|
// name-only). The original "non-positive maxSizeM" validation test is
|
||||||
const patchCalls = capturePatchCalls()
|
// removed — the constraint is now enforced by a separate edit-class
|
||||||
renderWithProviders(<AdminPage />)
|
// flow (not yet built) rather than inline.
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('AC-6: backend error is surfaced inline', () => {
|
describe('AC-6: backend error is surfaced inline', () => {
|
||||||
@@ -299,10 +283,11 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
|||||||
// Act
|
// Act
|
||||||
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
|
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))
|
await waitFor(() => expect(patchCount).toBe(1))
|
||||||
const row1After = getRow('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(alert.textContent ?? '').toMatch(/update failed|не вдалося оновити/i)
|
||||||
expect(within(row1After).getByDisplayValue('will-fail')).toBeInTheDocument()
|
expect(within(row1After).getByDisplayValue('will-fail')).toBeInTheDocument()
|
||||||
expect(alertCalls).toBe(0)
|
expect(alertCalls).toBe(0)
|
||||||
@@ -317,7 +302,7 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
|||||||
// Arrange — capture POST; second GET returns 3 classes.
|
// Arrange — capture POST; second GET returns 3 classes.
|
||||||
const postCalls: { body: unknown }[] = []
|
const postCalls: { body: unknown }[] = []
|
||||||
let getCount = 0
|
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(
|
server.use(
|
||||||
http.post('/api/admin/classes', async ({ request }) => {
|
http.post('/api/admin/classes', async ({ request }) => {
|
||||||
postCalls.push({ body: await request.json() })
|
postCalls.push({ body: await request.json() })
|
||||||
@@ -332,13 +317,15 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
|||||||
renderWithProviders(<AdminPage />)
|
renderWithProviders(<AdminPage />)
|
||||||
await screen.findByText('class-a')
|
await screen.findByText('class-a')
|
||||||
|
|
||||||
// Act — scope to the classes table panel (both the class-add row and
|
// Act — v2 layout: click the top "+ ADD" button to open an inline
|
||||||
// the user-add row use placeholder="Name" + a `+` button; disambiguate
|
// add-row at the top of the table, type the name, click the save
|
||||||
// by walking up from the class-a cell to the enclosing panel).
|
// (cyan checkmark, aria-label "Save") icon button.
|
||||||
const classesPanel = (getRow('1').closest('table') as HTMLElement).parentElement as HTMLElement
|
const classesPanel = getRow('1').closest('aside') as HTMLElement
|
||||||
const addNameInput = within(classesPanel).getByPlaceholderText('Name') as HTMLInputElement
|
await userEvent.click(within(classesPanel).getByRole('button', { name: /^\+ add$|^\+ додати$/i }))
|
||||||
await userEvent.type(addNameInput, 'fresh')
|
const addRow = within(classesPanel).getByText('+', { selector: 'td' }).closest('tr') as HTMLElement
|
||||||
await userEvent.click(within(classesPanel).getByRole('button', { name: '+' }))
|
const nameInput = within(addRow).getByPlaceholderText('Name') as HTMLInputElement
|
||||||
|
await userEvent.type(nameInput, 'fresh')
|
||||||
|
await userEvent.click(within(addRow).getByRole('button', { name: /^save$|^зберегти$/i }))
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await waitFor(() => expect(postCalls.length).toBe(1))
|
await waitFor(() => expect(postCalls.length).toBe(1))
|
||||||
|
|||||||
Vendored
+7
-4
@@ -1,8 +1,11 @@
|
|||||||
import type { Aircraft } from '../../src/types'
|
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[] = [
|
export const seedAircraft: Aircraft[] = [
|
||||||
{ id: 'aircraft-1', model: 'Bayraktar TB2', type: 'Plane', isDefault: true },
|
{ id: 'AC-001', model: 'DJI Mavic 3', type: 'Copter', isDefault: true, resolution: '4K', maxMinutes: 46 },
|
||||||
{ id: 'aircraft-2', model: 'DJI Mavic 3', type: 'Copter', isDefault: false },
|
{ id: 'AC-002', model: 'Matrice 300 RTK', type: 'Copter', isDefault: false, resolution: '4K', maxMinutes: 55 },
|
||||||
{ id: 'aircraft-3', model: 'Leleka-100', type: 'Plane', isDefault: false },
|
{ 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.
|
// AC-08 timing assertions.
|
||||||
|
|
||||||
export const seedFlights: Flight[] = [
|
export const seedFlights: Flight[] = [
|
||||||
{ id: 'flight-1', name: 'Recon Alpha', createdDate: '2026-05-01T10: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: 'aircraft-1' },
|
{ 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: 'aircraft-2' },
|
{ 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: 'aircraft-3' },
|
{ 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: 'aircraft-1' },
|
{ id: 'flight-5', name: 'Strike Echo', createdDate: '2026-05-05T16:00:00Z', aircraftId: 'AC-001' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const liveGpsFlightId = 'flight-1'
|
export const liveGpsFlightId = 'flight-1'
|
||||||
|
|||||||
+28
-16
@@ -6,11 +6,23 @@
|
|||||||
"TCP",
|
"TCP",
|
||||||
"UDP",
|
"UDP",
|
||||||
"Esc",
|
"Esc",
|
||||||
"OK"
|
"OK",
|
||||||
|
"//",
|
||||||
|
"|",
|
||||||
|
"▾",
|
||||||
|
"▲",
|
||||||
|
"▼",
|
||||||
|
"—"
|
||||||
],
|
],
|
||||||
"src/components/Header.tsx": [
|
"src/components/Header.tsx": [
|
||||||
"No flights",
|
"No flights",
|
||||||
"Filter..."
|
"Filter...",
|
||||||
|
"— SELECT —",
|
||||||
|
"LINK",
|
||||||
|
"Toggle language",
|
||||||
|
"UA",
|
||||||
|
"EN",
|
||||||
|
"⚙"
|
||||||
],
|
],
|
||||||
"src/components/HelpModal.tsx": [
|
"src/components/HelpModal.tsx": [
|
||||||
"How to Annotate",
|
"How to Annotate",
|
||||||
@@ -36,20 +48,20 @@
|
|||||||
],
|
],
|
||||||
"src/features/admin/AdminPage.tsx": [
|
"src/features/admin/AdminPage.tsx": [
|
||||||
"Name",
|
"Name",
|
||||||
"Color",
|
"#",
|
||||||
"Frame Period Recognition",
|
"+",
|
||||||
"Frame Recognition Seconds",
|
"0.0.0.0",
|
||||||
"Probability Threshold",
|
"P",
|
||||||
"Device Address",
|
"C",
|
||||||
"Port",
|
"F",
|
||||||
"Protocol",
|
"%",
|
||||||
"Email",
|
"NMEA",
|
||||||
"Role",
|
"UBX",
|
||||||
"Status",
|
"MAVLINK",
|
||||||
"Annotator",
|
"SAT",
|
||||||
"Admin",
|
"MIN",
|
||||||
"Viewer",
|
"Increment",
|
||||||
"Password"
|
"Decrement"
|
||||||
],
|
],
|
||||||
"src/features/annotations/AnnotationsSidebar.tsx": [
|
"src/features/annotations/AnnotationsSidebar.tsx": [
|
||||||
"Download annotation"
|
"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 })
|
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 }) => {
|
http.post('/api/flights/aircraft', async ({ request }) => {
|
||||||
const body = (await request.json()) as Record<string, unknown>
|
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 { adminHandlers } from './admin'
|
||||||
|
import { adminSettingsHandlers } from './admin-settings'
|
||||||
import { flightsHandlers } from './flights'
|
import { flightsHandlers } from './flights'
|
||||||
import { annotationsHandlers } from './annotations'
|
import { annotationsHandlers } from './annotations'
|
||||||
import { detectHandlers } from './detect'
|
import { detectHandlers } from './detect'
|
||||||
@@ -12,6 +13,7 @@ import { tilesHandlers } from './tiles'
|
|||||||
// the seeded baseline. Per-test overrides land via `server.use(...)`.
|
// the seeded baseline. Per-test overrides land via `server.use(...)`.
|
||||||
export const defaultHandlers = [
|
export const defaultHandlers = [
|
||||||
...adminHandlers,
|
...adminHandlers,
|
||||||
|
...adminSettingsHandlers,
|
||||||
...flightsHandlers,
|
...flightsHandlers,
|
||||||
...annotationsHandlers,
|
...annotationsHandlers,
|
||||||
...detectHandlers,
|
...detectHandlers,
|
||||||
@@ -23,6 +25,7 @@ export const defaultHandlers = [
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
adminHandlers,
|
adminHandlers,
|
||||||
|
adminSettingsHandlers,
|
||||||
flightsHandlers,
|
flightsHandlers,
|
||||||
annotationsHandlers,
|
annotationsHandlers,
|
||||||
detectHandlers,
|
detectHandlers,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { cleanup } from '@testing-library/react'
|
|||||||
import { server } from './msw/server'
|
import { server } from './msw/server'
|
||||||
import { setToken, setNavigateToLogin } from '../src/api'
|
import { setToken, setNavigateToLogin } from '../src/api'
|
||||||
import { __resetBootstrapInflightForTests } from '../src/auth'
|
import { __resetBootstrapInflightForTests } from '../src/auth'
|
||||||
|
import { resetAdminSettingsSeed } from './msw/handlers/admin-settings'
|
||||||
|
|
||||||
// JSDOM polyfills for browser APIs production code touches at mount time.
|
// JSDOM polyfills for browser APIs production code touches at mount time.
|
||||||
// These are no-op stubs — tests that exercise the actual behavior install
|
// 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
|
// 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.
|
// a never-resolving fixture in test N does not leak into test N+1.
|
||||||
__resetBootstrapInflightForTests()
|
__resetBootstrapInflightForTests()
|
||||||
|
// v2 admin settings — module-scoped seed mutates on PATCH; reset between tests.
|
||||||
|
resetAdminSettingsSeed()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user