Merge branch 'dev' into feat/dataset-explorer

This commit is contained in:
Armen Rohalov
2026-05-14 20:26:20 +03:00
383 changed files with 40090 additions and 923 deletions
+126 -16
View File
@@ -1,9 +1,12 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, type KeyboardEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { api } from '../../api/client'
import ConfirmDialog from '../../components/ConfirmDialog'
import { api, endpoints } from '../../api'
import { ConfirmDialog } from '../../components'
import type { DetectionClass, Aircraft, User } from '../../types'
type EditForm = { name: string; shortName: string; color: string; maxSizeM: number }
type EditErrorKind = 'nameRequired' | 'maxSizeMustBePositive' | 'updateFailed'
export default function AdminPage() {
const { t } = useTranslation()
const [classes, setClasses] = useState<DetectionClass[]>([])
@@ -12,43 +15,87 @@ export default function AdminPage() {
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 [editForm, setEditForm] = useState<EditForm>({ name: '', shortName: '', color: '#FF0000', maxSizeM: 0 })
const [editError, setEditError] = useState<EditErrorKind | null>(null)
const [editSaving, setEditSaving] = useState(false)
useEffect(() => {
api.get<DetectionClass[]>('/api/annotations/classes').then(setClasses).catch(() => {})
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
api.get<User[]>('/api/admin/users').then(setUsers).catch(() => {})
api.get<DetectionClass[]>(endpoints.annotations.classes()).then(setClasses).catch(() => {})
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
api.get<User[]>(endpoints.admin.users()).then(setUsers).catch(() => {})
}, [])
const handleAddClass = async () => {
if (!newClass.name) return
await api.post('/api/admin/classes', newClass)
const updated = await api.get<DetectionClass[]>('/api/annotations/classes')
await api.post(endpoints.admin.classes(), newClass)
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
setClasses(updated)
setNewClass({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
}
const handleDeleteClass = async (id: number) => {
await api.delete(`/api/admin/classes/${id}`)
await api.delete(endpoints.admin.class(id))
setClasses(prev => prev.filter(c => c.id !== id))
}
const handleStartEdit = (c: DetectionClass) => {
setEditingId(c.id)
setEditForm({ name: c.name, shortName: c.shortName, color: c.color, maxSizeM: c.maxSizeM })
setEditError(null)
setEditSaving(false)
}
const handleCancelEdit = () => {
setEditingId(null)
setEditError(null)
setEditSaving(false)
}
const handleUpdateClass = async () => {
if (editingId === null || editSaving) return
if (!editForm.name.trim()) { setEditError('nameRequired'); return }
if (!(editForm.maxSizeM > 0)) { setEditError('maxSizeMustBePositive'); return }
setEditError(null)
setEditSaving(true)
try {
// Risk 2 mitigation — always send the complete form so backend PATCH
// semantics (full-replace vs partial-merge) don't matter.
await api.patch(endpoints.admin.class(editingId), editForm)
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
setClasses(updated)
setEditingId(null)
} catch {
setEditError('updateFailed')
} finally {
setEditSaving(false)
}
}
const handleEditKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (e.key === 'Enter') { e.preventDefault(); void handleUpdateClass() }
else if (e.key === 'Escape') { e.preventDefault(); handleCancelEdit() }
}
const handleAddUser = async () => {
if (!newUser.email || !newUser.password) return
await api.post('/api/admin/users', newUser)
const updated = await api.get<User[]>('/api/admin/users')
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(`/api/admin/users/${deactivateId}`, { isActive: false })
await api.patch(endpoints.admin.user(deactivateId), { isActive: false })
setUsers(prev => prev.map(u => u.id === deactivateId ? { ...u, isActive: false } : u))
setDeactivateId(null)
}
const handleToggleDefault = async (a: Aircraft) => {
await api.patch(`/api/flights/aircrafts/${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))
}
@@ -56,7 +103,7 @@ export default function AdminPage() {
<div className="flex h-full overflow-y-auto p-4 gap-4">
{/* Detection classes */}
<div className="w-[340px] shrink-0">
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.classes')}</h2>
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.classes.title')}</h2>
<div className="bg-az-panel border border-az-border rounded overflow-hidden">
<table className="w-full text-xs">
<thead>
@@ -68,12 +115,75 @@ export default function AdminPage() {
</tr>
</thead>
<tbody>
{classes.map(c => (
{classes.map(c => c.id === editingId ? (
<tr key={c.id} className="border-b border-az-border text-az-text bg-az-bg/40" data-editing-row={c.id}>
<td className="px-2 py-1 align-top">{c.id}</td>
<td colSpan={3} className="px-2 py-1">
<div className="flex flex-wrap gap-1 items-center" onKeyDown={handleEditKeyDown}>
<input
autoFocus
data-field="name"
value={editForm.name}
onChange={e => setEditForm(p => ({ ...p, name: e.target.value }))}
className="flex-1 min-w-[80px] bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
/>
<input
data-field="shortName"
value={editForm.shortName}
onChange={e => setEditForm(p => ({ ...p, shortName: e.target.value }))}
className="w-12 bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
/>
<input
type="color"
data-field="color"
value={editForm.color}
onChange={e => setEditForm(p => ({ ...p, color: e.target.value }))}
className="w-7 h-6 border-0 bg-transparent cursor-pointer"
/>
<input
type="number"
data-field="maxSizeM"
value={editForm.maxSizeM}
onChange={e => setEditForm(p => ({ ...p, maxSizeM: Number(e.target.value) }))}
className="w-14 bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
/>
<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">
<td className="px-2 py-1">{c.id}</td>
<td className="px-2 py-1">{c.name}</td>
<td className="px-2 py-1 text-center"><span className="inline-block w-3 h-3 rounded-full" style={{ backgroundColor: c.color }} /></td>
<td className="px-2 py-1"><button onClick={() => handleDeleteClass(c.id)} className="text-az-muted hover:text-az-red">×</button></td>
<td className="px-2 py-1 text-right whitespace-nowrap">
<button
onClick={() => handleStartEdit(c)}
aria-label={t('admin.classes.edit')}
className="text-az-muted hover:text-az-orange mr-1"
>
{'\u270E'}
</button>
<button onClick={() => handleDeleteClass(c.id)} className="text-az-muted hover:text-az-red">×</button>
</td>
</tr>
))}
</tbody>
+1
View File
@@ -0,0 +1 @@
export { default as AdminPage } from './AdminPage'
+7 -8
View File
@@ -1,15 +1,14 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { useResizablePanel } from '../../hooks/useResizablePanel'
import { api } from '../../api/client'
import { useResizablePanel } from '../../hooks'
import { api, endpoints } from '../../api'
import MediaList from './MediaList'
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
import AnnotationsSidebar from './AnnotationsSidebar'
import DetectionClasses from '../../components/DetectionClasses'
import { DetectionClasses, useFlight } from '../../components'
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
import { useFlight } from '../../components/FlightContext'
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from './classColors'
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from '../../class-colors'
import { captureThumbnails } from './thumbnail'
import type { Media, AnnotationListItem, Detection } from '../../types'
@@ -65,9 +64,9 @@ export default function AnnotationsPage() {
if (!selectedMedia.path.startsWith('blob:')) {
try {
await api.post('/api/annotations/annotations', body)
await api.post(endpoints.annotations.annotations(), body)
const res = await api.get<{ items: AnnotationListItem[] }>(
`/api/annotations/annotations?mediaId=${selectedMedia.id}&pageSize=1000`,
endpoints.annotations.annotationsByMedia(selectedMedia.id),
)
setAnnotations(res.items)
pushToStore(`saved-${crypto.randomUUID()}`)
@@ -127,7 +126,7 @@ export default function AnnotationsPage() {
img.crossOrigin = 'anonymous'
img.src = selectedMedia.path.startsWith('blob:')
? selectedMedia.path
: `/api/annotations/media/${selectedMedia.id}/file`
: endpoints.annotations.mediaFile(selectedMedia.id)
await new Promise(res => { img.onload = res; img.onerror = res })
w = img.naturalWidth
h = img.naturalHeight
@@ -1,9 +1,8 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FaDownload } from 'react-icons/fa'
import { api } from '../../api/client'
import { createSSE } from '../../api/sse'
import { getClassColor } from './classColors'
import { api, createSSE, endpoints } from '../../api'
import { getClassColor } from '../../class-colors'
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
interface Props {
@@ -22,10 +21,10 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
useEffect(() => {
if (!media) return
return createSSE<{ annotationId: string; mediaId: string; status: number }>('/api/annotations/annotations/events', (event) => {
return createSSE<{ annotationId: string; mediaId: string; status: number }>(endpoints.annotations.annotationEvents(), (event) => {
if (event.mediaId === media.id) {
api.get<PaginatedResponse<AnnotationListItem>>(
`/api/annotations/annotations?mediaId=${media.id}&pageSize=1000`
endpoints.annotations.annotationsByMedia(media.id),
).then(res => onAnnotationsUpdate(res.items)).catch(() => {})
}
})
@@ -36,7 +35,7 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
setDetecting(true)
setDetectLog(['Starting AI detection...'])
try {
await api.post(`/api/detect/${media.id}`)
await api.post(endpoints.detect.media(media.id))
setDetectLog(prev => [...prev, 'Detection complete.'])
} catch (e: any) {
setDetectLog(prev => [...prev, `Error: ${e.message}`])
+4 -3
View File
@@ -1,7 +1,8 @@
import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
import { endpoints } from '../../api'
import { MediaType } from '../../types'
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types'
import { getClassColor, getPhotoModeSuffix, getClassNameFallback } from './classColors'
import { getClassColor, getPhotoModeSuffix, getClassNameFallback } from '../../class-colors'
interface Props {
media: Media
@@ -77,11 +78,11 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
img.crossOrigin = 'anonymous'
const isLocalPath = media.path.startsWith('blob:') || media.path.startsWith('data:')
if (annotation && !isLocalPath) {
img.src = `/api/annotations/annotations/${annotation.id}/image`
img.src = endpoints.annotations.annotationImage(annotation.id)
} else if (isLocalPath) {
img.src = media.path
} else {
img.src = `/api/annotations/media/${media.id}/file`
img.src = endpoints.annotations.mediaFile(media.id)
}
img.onload = () => {
imgRef.current = img
+7 -8
View File
@@ -1,10 +1,9 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useDropzone } from 'react-dropzone'
import { useFlight } from '../../components/FlightContext'
import { api } from '../../api/client'
import { useDebounce } from '../../hooks/useDebounce'
import ConfirmDialog from '../../components/ConfirmDialog'
import { useFlight, ConfirmDialog } from '../../components'
import { api, endpoints } from '../../api'
import { useDebounce } from '../../hooks'
import { MediaType } from '../../types'
import type { Media, PaginatedResponse, AnnotationListItem } from '../../types'
@@ -28,7 +27,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
if (selectedFlight) params.set('flightId', selectedFlight.id)
if (debouncedFilter) params.set('name', debouncedFilter)
try {
const res = await api.get<PaginatedResponse<Media>>(`/api/annotations/media?${params}`)
const res = await api.get<PaginatedResponse<Media>>(endpoints.annotations.media(params.toString()))
setMedia(prev => {
// Keep local-only (blob URL) entries, merge with backend entries
const local = prev.filter(m => m.path.startsWith('blob:'))
@@ -56,7 +55,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
}
try {
const res = await api.get<PaginatedResponse<AnnotationListItem>>(
`/api/annotations/annotations?mediaId=${m.id}&pageSize=1000`
endpoints.annotations.annotationsByMedia(m.id),
)
onAnnotationsLoaded(res.items)
} catch {
@@ -73,7 +72,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
setDeleteId(null)
return
}
try { await api.delete(`/api/annotations/media/${deleteId}`) } catch {}
try { await api.delete(endpoints.annotations.mediaItem(deleteId)) } catch {}
setDeleteId(null)
fetchMedia()
}
@@ -88,7 +87,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
const form = new FormData()
form.append('waypointId', '')
for (const file of arr) form.append('files', file)
await api.upload('/api/annotations/media/batch', form)
await api.upload(endpoints.annotations.mediaBatch(), form)
fetchMedia()
return
} catch {
+2 -1
View File
@@ -1,5 +1,6 @@
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
import { FaPlay, FaPause, FaStop, FaStepBackward, FaStepForward, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'
import { endpoints } from '../../api'
import type { Media } from '../../types'
interface Props {
@@ -38,7 +39,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
const videoUrl = media.path.startsWith('blob:')
? media.path
: `/api/annotations/media/${media.id}/file`
: endpoints.annotations.mediaFile(media.id)
const stepFrames = useCallback((count: number) => {
const video = videoRef.current
-24
View File
@@ -1,24 +0,0 @@
const CLASS_COLORS = [
'#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF',
'#800000', '#008000', '#000080', '#808000', '#800080', '#008080',
]
export const FALLBACK_CLASS_NAMES = [
'Car', 'Person', 'Truck', 'Bicycle', 'Motorcycle', 'Bus',
'Animal', 'Tree', 'Building', 'Sign', 'Boat', 'Plane',
]
export function getClassColor(classNum: number): string {
const base = classNum % 20
return CLASS_COLORS[base % CLASS_COLORS.length]
}
export function getPhotoModeSuffix(classNum: number): string {
const mode = Math.floor(classNum / 20)
return mode === 1 ? ' (winter)' : mode === 2 ? ' (night)' : ''
}
export function getClassNameFallback(classNum: number): string {
const base = classNum % 20
return FALLBACK_CLASS_NAMES[base % FALLBACK_CLASS_NAMES.length] ?? `#${classNum}`
}
+4
View File
@@ -0,0 +1,4 @@
export { default as AnnotationsPage } from './AnnotationsPage'
// CanvasEditor remains in the Public API while F2 (cross-feature edge to
// 07_dataset) is open. Closing F2 will remove this re-export.
export { default as CanvasEditor } from './CanvasEditor'
+1 -1
View File
@@ -1,6 +1,6 @@
import { MediaType } from '../../types'
import type { Detection, Media } from '../../types'
import { getClassColor } from './classColors'
import { getClassColor } from '../../class-colors'
const THUMB_MAX = 240
const CROP_PAD = 0.15
+8 -10
View File
@@ -1,12 +1,10 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { FaPen } from 'react-icons/fa'
import { api } from '../../api/client'
import { useDebounce } from '../../hooks/useDebounce'
import { useResizablePanel } from '../../hooks/useResizablePanel'
import { useFlight } from '../../components/FlightContext'
import { api, endpoints } from '../../api'
import { useDebounce, useResizablePanel } from '../../hooks'
import { useFlight, DetectionClasses } from '../../components'
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
import DetectionClasses from '../../components/DetectionClasses'
import CanvasEditor from '../annotations/CanvasEditor'
import { recaptureThumbnails } from '../annotations/thumbnail'
import type { SavedDetection } from '../../components/SavedAnnotationsContext'
@@ -64,7 +62,7 @@ export default function DatasetPage() {
if (objectsOnly) params.set('hasDetections', 'true')
if (debouncedSearch) params.set('name', debouncedSearch)
try {
const res = await api.get<PaginatedResponse<DatasetItem>>(`/api/annotations/dataset?${params}`)
const res = await api.get<PaginatedResponse<DatasetItem>>(endpoints.annotations.dataset(params.toString()))
setItems(res.items)
setTotalCount(res.totalCount)
} catch {}
@@ -103,7 +101,7 @@ export default function DatasetPage() {
imageName: item.imageName,
status: item.status,
createdDate: item.createdDate,
thumbnailUrl: `/api/annotations/annotations/${item.annotationId}/thumbnail`,
thumbnailUrl: endpoints.annotations.annotationThumbnail(item.annotationId),
isSeed: item.isSeed,
isLocal: false,
}))
@@ -138,7 +136,7 @@ export default function DatasetPage() {
setEditorFullFrame('')
setEditorLocalGroupId(null)
try {
const ann = await api.get<AnnotationListItem>(`/api/annotations/dataset/${card.annotationId}`)
const ann = await api.get<AnnotationListItem>(endpoints.annotations.datasetItem(card.annotationId))
setEditorAnnotation(ann)
setEditorDetections(ann.detections)
setTab('editor')
@@ -188,7 +186,7 @@ export default function DatasetPage() {
if (backendIds.length > 0) {
try {
await api.post('/api/annotations/dataset/bulk-status', {
await api.post(endpoints.annotations.datasetBulkStatus(), {
annotationIds: backendIds,
status: AnnotationStatus.Validated,
})
@@ -203,7 +201,7 @@ export default function DatasetPage() {
const loadDistribution = useCallback(async () => {
try {
const data = await api.get<ClassDistributionItem[]>('/api/annotations/dataset/class-distribution')
const data = await api.get<ClassDistributionItem[]>(endpoints.annotations.datasetClassDistribution())
setDistribution(data)
} catch {}
}, [])
+1
View File
@@ -0,0 +1 @@
export { default as DatasetPage } from './DatasetPage'
+5 -12
View File
@@ -8,7 +8,7 @@ import DrawControl from './DrawControl'
import MapPoint from './MapPoint'
import MiniMap from './MiniMap'
import { defaultIcon } from './mapIcons'
import { TILE_URLS } from './types'
import { getTileUrl } from './types'
import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, MovingPointInfo } from './types'
interface MapEventsProps {
@@ -86,7 +86,6 @@ export default function FlightMap({
}: Props) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const [mapType, setMapType] = useState<'classic' | 'satellite'>('satellite')
const [movingPoint, setMovingPoint] = useState<MovingPointInfo | null>(null)
const [draggablePoints, setDraggablePoints] = useState(points)
const polylineClickRef = useRef(false)
@@ -123,13 +122,14 @@ export default function FlightMap({
<MapContainer center={currentPosition} zoom={15} className="h-full w-full">
<ClickHandler />
<TileLayer
url={mapType === 'classic' ? TILE_URLS.classic : TILE_URLS.satellite}
attribution={mapType === 'classic' ? '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>' : 'Satellite'}
url={getTileUrl()}
crossOrigin="use-credentials"
attribution="Satellite"
/>
<MapEvents points={draggablePoints} handlePolylineClick={handlePolylineClick} containerRef={containerRef} onMapMove={onMapMove} />
<SetView center={currentPosition} />
{movingPoint && <MiniMap pointPosition={movingPoint} mapType={mapType} />}
{movingPoint && <MiniMap pointPosition={movingPoint} />}
{draggablePoints.map((point, index) => (
<MapPoint key={point.id}
@@ -171,13 +171,6 @@ export default function FlightMap({
Click and drag on the map to draw a {actionMode === 'workArea' ? 'work area' : 'no-go zone'}
</div>
)}
<button onClick={() => setMapType(m => m === 'classic' ? 'satellite' : 'classic')}
className={`absolute top-2 right-2 z-[400] px-2 py-1 text-xs rounded border ${
mapType === 'satellite' ? 'bg-az-panel border-az-orange text-white' : 'bg-az-panel border-az-border text-az-text'
}`}>
{t('flights.planner.satellite')}
</button>
</div>
)
}
+10 -12
View File
@@ -1,10 +1,8 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import L from 'leaflet'
import { useFlight } from '../../components/FlightContext'
import { api } from '../../api/client'
import { createSSE } from '../../api/sse'
import ConfirmDialog from '../../components/ConfirmDialog'
import { useFlight, ConfirmDialog } from '../../components'
import { api, createSSE, endpoints } from '../../api'
import FlightListSidebar from './FlightListSidebar'
import FlightParamsPanel from './FlightParamsPanel'
import FlightMap from './FlightMap'
@@ -40,7 +38,7 @@ export default function FlightsPage() {
const [jsonDialog, setJsonDialog] = useState({ open: false, text: '' })
useEffect(() => {
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
setAircraft(getMockAircraftParams())
navigator.geolocation.getCurrentPosition(
(pos) => setCurrentPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
@@ -50,7 +48,7 @@ export default function FlightsPage() {
useEffect(() => {
if (!selectedFlight) { setPoints([]); return }
api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`)
api.get<Waypoint[]>(endpoints.flights.flightWaypoints(selectedFlight.id))
.then(wps => {
setPoints(wps.sort((a, b) => a.order - b.order).map(wp => ({
id: wp.id,
@@ -64,7 +62,7 @@ export default function FlightsPage() {
useEffect(() => {
if (!selectedFlight || mode !== 'gps') return
return createSSE<{ lat: number; lon: number; satellites: number; status: string }>(`/api/flights/${selectedFlight.id}/live-gps`, (data) => setLiveGps(data))
return createSSE<{ lat: number; lon: number; satellites: number; status: string }>(endpoints.flights.flightLiveGps(selectedFlight.id), (data) => setLiveGps(data))
}, [selectedFlight, mode])
useEffect(() => {
@@ -73,12 +71,12 @@ export default function FlightsPage() {
}, [points, aircraft, initialAltitude])
const handleCreateFlight = async (name: string) => {
await api.post('/api/flights', { name })
await api.post(endpoints.flights.collection(), { name })
refreshFlights()
}
const handleDeleteFlight = async () => {
if (!deleteId) return
await api.delete(`/api/flights/${deleteId}`)
await api.delete(endpoints.flights.flight(deleteId))
if (selectedFlight?.id === deleteId) selectFlight(null)
setDeleteId(null)
refreshFlights()
@@ -201,12 +199,12 @@ export default function FlightsPage() {
const handleSave = async () => {
if (!selectedFlight) return
const existing = await api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`).catch(() => [] as Waypoint[])
const existing = await api.get<Waypoint[]>(endpoints.flights.flightWaypoints(selectedFlight.id)).catch(() => [] as Waypoint[])
for (const wp of existing) {
await api.delete(`/api/flights/${selectedFlight.id}/waypoints/${wp.id}`).catch(() => {})
await api.delete(endpoints.flights.flightWaypoint(selectedFlight.id, wp.id)).catch(() => {})
}
for (let i = 0; i < points.length; i++) {
await api.post(`/api/flights/${selectedFlight.id}/waypoints`, {
await api.post(endpoints.flights.flightWaypoints(selectedFlight.id), {
name: `Point ${i + 1}`,
latitude: points[i].position.lat,
longitude: points[i].position.lng,
+3 -4
View File
@@ -1,7 +1,7 @@
import { MapContainer, TileLayer, CircleMarker, useMap } from 'react-leaflet'
import { useEffect } from 'react'
import type L from 'leaflet'
import { TILE_URLS } from './types'
import { getTileUrl } from './types'
import type { MovingPointInfo } from './types'
function UpdateCenter({ latlng }: { latlng: L.LatLng }) {
@@ -12,10 +12,9 @@ function UpdateCenter({ latlng }: { latlng: L.LatLng }) {
interface Props {
pointPosition: MovingPointInfo
mapType: 'classic' | 'satellite'
}
export default function MiniMap({ pointPosition, mapType }: Props) {
export default function MiniMap({ pointPosition }: Props) {
return (
<div
className="absolute w-[240px] h-[180px] border border-az-border rounded shadow-lg z-[1000] overflow-hidden pointer-events-none"
@@ -23,7 +22,7 @@ export default function MiniMap({ pointPosition, mapType }: Props) {
>
<MapContainer center={pointPosition.latlng} zoom={18} zoomControl={false}
className="w-full h-full" attributionControl={false}>
<TileLayer url={mapType === 'classic' ? TILE_URLS.classic : TILE_URLS.satellite} />
<TileLayer url={getTileUrl()} crossOrigin="use-credentials" />
<CircleMarker center={pointPosition.latlng} radius={3} color="#fa5252" />
<UpdateCenter latlng={pointPosition.latlng} />
</MapContainer>
@@ -0,0 +1,217 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type L from 'leaflet'
import { renderWithProviders, screen } from '../../../../tests/helpers/render'
// AZ-498 — self-hosted satellite tiles + drop classic/satellite toggle.
//
// Colocated under src/features/flights/__tests__/ per module-layout's "Tests"
// guidance: keeps the cross-component import surface clean (these tests
// reach into 05_flights internals — `./FlightMap`, `./MiniMap`, `./types` —
// which is intra-component access). Tests/ is reserved for cross-cutting
// black-box suites whose imports must go through public-API barrels.
//
// Covers the spec's fast-profile ACs:
// AC-1 — env-resolved getTileUrl() returns the env var verbatim.
// AC-2 — when the env var is unset, getTileUrl() returns the dev default
// `http://localhost:5100/tiles/{z}/{x}/{y}` (cycle-2 assumption).
// AC-3 — every <TileLayer> the SPA renders sets crossOrigin="use-credentials"
// so the browser attaches the satellite-provider auth cookie.
// AC-4 — the classic/satellite toggle, the `mapType` state, and the
// `MiniMap.Props.mapType` prop are all gone.
//
// Notes:
// - AC-5 is statically enforced by tsc on the new ImportMetaEnv shape +
// the `.env.example` audit; no runtime test needed.
// - AC-6, AC-7 are e2e/contract; AC-8 in the original spec misattributed
// `tile_split_zoom*` (image-annotation surface) — see implementation
// report. AC-9 is enforced by STC-ARCH-01 / STC-ARCH-02.
interface TileLayerProps {
url?: string
crossOrigin?: string
attribution?: string
}
interface MapContainerProps {
children?: React.ReactNode
className?: string
}
vi.mock('react-leaflet', () => ({
MapContainer: ({ children, className }: MapContainerProps) => (
<div data-testid="map-container" className={className}>{children}</div>
),
TileLayer: (props: TileLayerProps) => (
<img
data-testid="tile-layer"
data-tile-url={props.url ?? ''}
data-cross-origin={props.crossOrigin ?? ''}
data-attribution={props.attribution ?? ''}
alt=""
/>
),
Marker: () => null,
Popup: () => null,
Polyline: () => null,
Rectangle: () => null,
CircleMarker: () => null,
useMap: () => ({
on: () => undefined,
off: () => undefined,
setView: () => undefined,
removeLayer: () => undefined,
getCenter: () => ({ lat: 0, lng: 0 }),
invalidateSize: () => undefined,
}),
useMapEvents: () => null,
}))
// Leaflet itself is touched at import time by FlightMap (`L.polyline`,
// `L.Symbol.arrowHead`). Mock the bits the component reaches for so the
// import doesn't blow up under jsdom.
vi.mock('leaflet', () => {
const Lstub = {
polyline: () => ({ addTo: () => Lstub.polyline(), on: () => undefined }),
polylineDecorator: () => ({ addTo: () => undefined }),
Symbol: { arrowHead: () => ({}) },
Icon: { Default: class { mergeOptions() {} } },
Marker: class {},
Layer: class {},
LatLngBounds: class {},
}
return { default: Lstub }
})
vi.mock('leaflet/dist/leaflet.css', () => ({}))
vi.mock('leaflet-polylinedecorator', () => ({}))
vi.mock('../DrawControl', () => ({ default: () => null }))
vi.mock('../MapPoint', () => ({ default: () => null }))
vi.mock('../mapIcons', () => ({ defaultIcon: {} }))
import FlightMap from '../FlightMap'
import MiniMap from '../MiniMap'
import { getTileUrl, DEFAULT_SATELLITE_TILE_URL } from '../types'
const stubLatLng = { lat: 0, lng: 0 } as unknown as L.LatLng
const fixedPosition = { lat: 50, lng: 30 }
const baseFlightMapProps = {
points: [],
calculatedPointInfo: [],
currentPosition: fixedPosition,
rectangles: [],
setRectangles: () => undefined,
rectangleColor: 'red',
actionMode: 'points' as const,
onAddPoint: () => undefined,
onUpdatePoint: () => undefined,
onRemovePoint: () => undefined,
onAltitudeChange: () => undefined,
onMetaChange: () => undefined,
onPolylineClick: () => undefined,
onPositionChange: () => undefined,
onMapMove: () => undefined,
}
describe('AZ-498 — getTileUrl() env resolution', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
it('AC-1: returns the env-set VITE_SATELLITE_TILE_URL verbatim', () => {
// Arrange
vi.stubEnv('VITE_SATELLITE_TILE_URL', 'http://satellite-provider:5100/tiles/{z}/{x}/{y}')
// Assert
expect(getTileUrl()).toBe('http://satellite-provider:5100/tiles/{z}/{x}/{y}')
})
it('AC-2: returns the dev default when VITE_SATELLITE_TILE_URL is unset', () => {
// Arrange
vi.stubEnv('VITE_SATELLITE_TILE_URL', '')
// Assert
expect(getTileUrl()).toBe(DEFAULT_SATELLITE_TILE_URL)
expect(DEFAULT_SATELLITE_TILE_URL).toBe('http://localhost:5100/tiles/{z}/{x}/{y}')
})
it('AC-2: strips trailing slashes off the env-set URL', () => {
// Arrange
vi.stubEnv('VITE_SATELLITE_TILE_URL', 'http://satellite-provider:5100/tiles/{z}/{x}/{y}/')
// Assert
expect(getTileUrl()).toBe('http://satellite-provider:5100/tiles/{z}/{x}/{y}')
})
})
describe('AZ-498 — FlightMap satellite-only TileLayer', () => {
beforeEach(() => {
vi.stubEnv('VITE_SATELLITE_TILE_URL', '')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('AC-3: <TileLayer> declares crossOrigin="use-credentials"', () => {
// Act
renderWithProviders(<FlightMap {...baseFlightMapProps} />, { withoutAuth: true })
// Assert
const tile = screen.getByTestId('tile-layer')
expect(tile.getAttribute('data-cross-origin')).toBe('use-credentials')
})
it('AC-3: <TileLayer> renders the dev-default URL when env is unset', () => {
// Act
renderWithProviders(<FlightMap {...baseFlightMapProps} />, { withoutAuth: true })
// Assert
const tile = screen.getByTestId('tile-layer')
expect(tile.getAttribute('data-tile-url')).toBe(DEFAULT_SATELLITE_TILE_URL)
})
it('AC-4: the classic/satellite toggle button is gone', () => {
// Act
renderWithProviders(<FlightMap {...baseFlightMapProps} />, { withoutAuth: true })
// Assert
expect(screen.queryByRole('button', { name: /satellite|classic/i })).toBeNull()
// Only one <TileLayer> is mounted (no per-mode branching).
expect(screen.getAllByTestId('tile-layer')).toHaveLength(1)
})
})
describe('AZ-498 — MiniMap satellite-only TileLayer', () => {
beforeEach(() => {
vi.stubEnv('VITE_SATELLITE_TILE_URL', '')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('AC-3: MiniMap <TileLayer> declares crossOrigin="use-credentials"', () => {
// Act
renderWithProviders(
<MiniMap pointPosition={{ x: 0, y: 0, latlng: stubLatLng }} />,
{ withoutAuth: true },
)
// Assert
const tile = screen.getByTestId('tile-layer')
expect(tile.getAttribute('data-cross-origin')).toBe('use-credentials')
})
it('AC-4: MiniMap mounts with only `pointPosition` prop (no `mapType`)', () => {
// Act — explicitly omit mapType; if MiniMap still required it, TS would
// error at compile time. The runtime render also confirms the component
// mounts with just the position prop.
renderWithProviders(
<MiniMap pointPosition={{ x: 0, y: 0, latlng: stubLatLng }} />,
{ withoutAuth: true },
)
// Assert
expect(screen.getByTestId('tile-layer')).toBeInTheDocument()
})
})
+11 -2
View File
@@ -56,9 +56,18 @@ export function calculateDistance(
return ascentVertical + horizontalDistance + descentVertical
}
const DEFAULT_OWM_BASE_URL = 'https://api.openweathermap.org/data/2.5'
function getOwmBaseUrl(): string {
const raw = import.meta.env.VITE_OWM_BASE_URL
if (!raw) return DEFAULT_OWM_BASE_URL
return raw.replace(/\/+$/, '')
}
export async function getWeatherData(lat: number, lon: number) {
const apiKey = '335799082893fad97fa36118b131f919'
const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${apiKey}&units=metric`
const apiKey = import.meta.env.VITE_OWM_API_KEY
if (!apiKey) return null
const url = `${getOwmBaseUrl()}/weather?lat=${lat}&lon=${lon}&appid=${apiKey}&units=metric`
try {
const res = await fetch(url)
const data = await res.json()
+1
View File
@@ -0,0 +1 @@
export { default as FlightsPage } from './FlightsPage'
+2 -1
View File
@@ -1,4 +1,5 @@
import L from 'leaflet'
import markerIcon from 'leaflet/dist/images/marker-icon.png'
function pinIcon(color: string) {
return L.divIcon({
@@ -15,7 +16,7 @@ export const pointIconBlue = pinIcon('#228be6')
export const pointIconRed = pinIcon('#fa5252')
export const defaultIcon = new L.Icon({
iconUrl: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png',
iconUrl: markerIcon,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
+15 -4
View File
@@ -52,7 +52,18 @@ export const PURPOSES = [
export const COORDINATE_PRECISION = 8
export const TILE_URLS = {
classic: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
satellite: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
} as const
// AZ-498 — single self-hosted satellite tile URL. The previous classic/satellite
// pair (OSM + Esri) was retired so the SPA only consumes the suite's own
// satellite-provider service. Production builds MUST set VITE_SATELLITE_TILE_URL
// to the same-origin nginx path (e.g. `/tiles/{z}/{x}/{y}`); the dev default
// targets the satellite-provider container on its conventional dev port.
//
// Read via a function (mirrors `getOwmBaseUrl` in flightPlanUtils.ts) so tests
// can stub `import.meta.env` per-case without module-reload tricks.
export const DEFAULT_SATELLITE_TILE_URL = 'http://localhost:5100/tiles/{z}/{x}/{y}'
export function getTileUrl(): string {
const raw = import.meta.env.VITE_SATELLITE_TILE_URL
if (!raw) return DEFAULT_SATELLITE_TILE_URL
return raw.replace(/\/+$/, '')
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../../auth/AuthContext'
import { useAuth } from '../../auth'
type UnlockStep = 'idle' | 'authenticating' | 'downloadingKey' | 'decrypting' | 'startingServices' | 'ready'
+1
View File
@@ -0,0 +1 @@
export { default as LoginPage } from './LoginPage'
+7 -7
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { api } from '../../api/client'
import { api, endpoints } from '../../api'
import type { SystemSettings, DirectorySettings, Aircraft } from '../../types'
export default function SettingsPage() {
@@ -11,27 +11,27 @@ export default function SettingsPage() {
const [saving, setSaving] = useState(false)
useEffect(() => {
api.get<SystemSettings>('/api/annotations/settings/system').then(setSystem).catch(() => {})
api.get<DirectorySettings>('/api/annotations/settings/directories').then(setDirs).catch(() => {})
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
api.get<SystemSettings>(endpoints.annotations.settingsSystem()).then(setSystem).catch(() => {})
api.get<DirectorySettings>(endpoints.annotations.settingsDirectories()).then(setDirs).catch(() => {})
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
}, [])
const saveSystem = async () => {
if (!system) return
setSaving(true)
await api.put('/api/annotations/settings/system', system)
await api.put(endpoints.annotations.settingsSystem(), system)
setSaving(false)
}
const saveDirs = async () => {
if (!dirs) return
setSaving(true)
await api.put('/api/annotations/settings/directories', dirs)
await api.put(endpoints.annotations.settingsDirectories(), dirs)
setSaving(false)
}
const handleToggleDefault = async (a: Aircraft) => {
await api.patch(`/api/flights/aircrafts/${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))
}
+1
View File
@@ -0,0 +1 @@
export { default as SettingsPage } from './SettingsPage'