Compare commits
No commits in common. "2715c56b4fc48e277fe8f2e53fe8f86faba4e426" and "f8ae8d7d5498b1c14264f2bcabc6e1dcd0834444" have entirely different histories.
2715c56b4f
...
f8ae8d7d54
10 changed files with 931 additions and 685 deletions
|
|
@ -218,6 +218,14 @@ body, #root {
|
|||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty code {
|
||||
color: #e2e8f0;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.leaflet-control-container {
|
||||
filter: none;
|
||||
}
|
||||
|
|
|
|||
1321
frontend/src/App.tsx
1321
frontend/src/App.tsx
File diff suppressed because it is too large
Load diff
|
|
@ -9,16 +9,6 @@ type SettingsPanelProps = {
|
|||
status: string;
|
||||
theme: "dark" | "light";
|
||||
onToggleTheme: () => void;
|
||||
gnssMode: "simulated" | "serial" | "unknown";
|
||||
simCenterLat: string;
|
||||
simCenterLon: string;
|
||||
onGnssRefresh: () => void;
|
||||
onGnssModeChange: (mode: "simulated" | "serial") => void;
|
||||
onSimCenterLatChange: (value: string) => void;
|
||||
onSimCenterLonChange: (value: string) => void;
|
||||
onSimCenterApply: () => void;
|
||||
vectorSource: "mbtiles" | "tileset";
|
||||
onVectorSourceChange: (source: "mbtiles" | "tileset") => void;
|
||||
tilesets: string[];
|
||||
activeTileset: string;
|
||||
onActivateTileset: (name: string) => void;
|
||||
|
|
@ -42,16 +32,6 @@ export const SettingsPanel = ({
|
|||
status,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
gnssMode,
|
||||
simCenterLat,
|
||||
simCenterLon,
|
||||
onGnssRefresh,
|
||||
onGnssModeChange,
|
||||
onSimCenterLatChange,
|
||||
onSimCenterLonChange,
|
||||
onSimCenterApply,
|
||||
vectorSource,
|
||||
onVectorSourceChange,
|
||||
tilesets,
|
||||
activeTileset,
|
||||
onActivateTileset,
|
||||
|
|
@ -94,44 +74,6 @@ export const SettingsPanel = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<div className="panel-title">GNSS</div>
|
||||
<div className="panel-subtitle">Режим и точка симуляции</div>
|
||||
</div>
|
||||
<button type="button" className="btn ghost small" onClick={onGnssRefresh}>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="field">
|
||||
<span>Режим</span>
|
||||
<select
|
||||
value={gnssMode}
|
||||
onChange={(e) => onGnssModeChange(e.target.value as "simulated" | "serial")}
|
||||
disabled={gnssMode === "unknown"}
|
||||
>
|
||||
<option value="simulated">Симуляция</option>
|
||||
<option value="serial">Реальный (Serial NMEA)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{gnssMode === "simulated" && (
|
||||
<div className="slice-grid">
|
||||
<label className="field">
|
||||
<span>Lat</span>
|
||||
<input value={simCenterLat} onChange={(e) => onSimCenterLatChange(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Lon</span>
|
||||
<input value={simCenterLon} onChange={(e) => onSimCenterLonChange(e.target.value)} />
|
||||
</label>
|
||||
<button type="button" className="btn ghost" onClick={onSimCenterApply}>
|
||||
Применить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<div className="panel-title">Тема</div>
|
||||
|
|
@ -161,14 +103,6 @@ export const SettingsPanel = ({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<label className="field">
|
||||
<span>Векторный источник</span>
|
||||
<select value={vectorSource} onChange={(e) => onVectorSourceChange(e.target.value as "mbtiles" | "tileset")}>
|
||||
<option value="mbtiles">MBTiles (/tiles/vector)</option>
|
||||
<option value="tileset">Tileset папка (/tilesets/active/vector)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<div className="panel-title">Нарезка тайлов</div>
|
||||
|
|
|
|||
|
|
@ -1,46 +1,4 @@
|
|||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
const backendBase =
|
||||
(import.meta.env.VITE_BACKEND_URL as string | undefined) ?? "http://localhost:8000";
|
||||
|
||||
const sendFrontLog = (message: string, level: "info" | "warn" | "error" = "info") => {
|
||||
fetch(`${backendBase}/logs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message, level, source: "frontend" }),
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
const err = event.error instanceof Error ? event.error : null;
|
||||
const details = err?.stack || err?.message || String(event.message || "unknown error");
|
||||
sendFrontLog(`window.error: ${details}`, "error");
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
const reason = event.reason instanceof Error ? event.reason.stack || event.reason.message : String(event.reason);
|
||||
sendFrontLog(`unhandledrejection: ${reason}`, "error");
|
||||
});
|
||||
|
||||
const _consoleError = console.error.bind(console);
|
||||
console.error = (...args: unknown[]) => {
|
||||
try {
|
||||
sendFrontLog(`console.error: ${args.map(String).join(" ")}`, "error");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
_consoleError(...args);
|
||||
};
|
||||
|
||||
const _consoleWarn = console.warn.bind(console);
|
||||
console.warn = (...args: unknown[]) => {
|
||||
try {
|
||||
sendFrontLog(`console.warn: ${args.map(String).join(" ")}`, "warn");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
_consoleWarn(...args);
|
||||
};
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(<App />);
|
||||
|
|
|
|||
|
|
@ -27,15 +27,10 @@ export type AppConfig = {
|
|||
tiles_url_tms?: string;
|
||||
vector_tiles_url?: string;
|
||||
vector_metadata_url?: string;
|
||||
vector_tileset_url?: string;
|
||||
vector_tileset_metadata_url?: string;
|
||||
tilesets_url?: string;
|
||||
tilesets_activate_url?: string;
|
||||
ws_url: string;
|
||||
gnss_source: string;
|
||||
gnss_state_url?: string;
|
||||
gnss_mode_url?: string;
|
||||
gnss_sim_center_url?: string;
|
||||
};
|
||||
|
||||
export type TrackPoint = {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import uvicorn
|
||||
|
|
@ -14,80 +12,19 @@ def _env(name: str, default: str) -> str:
|
|||
return v if v is not None and v != "" else default
|
||||
|
||||
|
||||
class _TeeStream:
|
||||
def __init__(self, primary, secondary):
|
||||
self._primary = primary
|
||||
self._secondary = secondary
|
||||
|
||||
def write(self, data):
|
||||
try:
|
||||
self._primary.write(data)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._secondary.write(data)
|
||||
except Exception:
|
||||
pass
|
||||
return len(data)
|
||||
|
||||
def flush(self):
|
||||
try:
|
||||
self._primary.flush()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._secondary.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def isatty(self):
|
||||
try:
|
||||
return self._primary.isatty()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_uvicorn_logs_to_file(log_path: Path) -> None:
|
||||
import logging
|
||||
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = logging.FileHandler(log_path, encoding="utf-8")
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s"))
|
||||
|
||||
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
if not any(
|
||||
isinstance(h, logging.FileHandler) and getattr(h, "baseFilename", None) == str(log_path)
|
||||
for h in logger.handlers
|
||||
):
|
||||
logger.addHandler(file_handler)
|
||||
logger.propagate = True
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="gnss", description="GNSS Backend (FastAPI)")
|
||||
parser.add_argument("--host", default=_env("GNSS_HOST", "0.0.0.0"))
|
||||
parser.add_argument("--port", type=int, default=int(_env("GNSS_PORT", "8000")))
|
||||
parser.add_argument("--log-level", default=_env("GNSS_LOG_LEVEL", "info"))
|
||||
parser.add_argument("--log-file", default=_env("GNSS_BACKEND_LOG_FILE", "logs/backend.log"))
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
log_path = Path(args.log_file).expanduser()
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
backend_log_file = log_path.open("a", encoding="utf-8", buffering=1)
|
||||
sys.stdout = _TeeStream(sys.stdout, backend_log_file) # type: ignore[assignment]
|
||||
sys.stderr = _TeeStream(sys.stderr, backend_log_file) # type: ignore[assignment]
|
||||
_ensure_uvicorn_logs_to_file(log_path)
|
||||
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
log_level=args.log_level,
|
||||
access_log=True,
|
||||
log_config=None,
|
||||
workers=1,
|
||||
)
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -1,17 +1,12 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from ... import config
|
||||
from .. import state
|
||||
|
||||
router = APIRouter(tags=["config"])
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_config() -> dict:
|
||||
try:
|
||||
gnss_source = state.get_gnss_service().source_kind()
|
||||
except Exception:
|
||||
gnss_source = config.GNSS_SOURCE
|
||||
return {
|
||||
"tiles_url": "/tiles/{z}/{x}/{y}.png",
|
||||
"tiles_url_tms": "/tiles/tms/{z}/{x}/{y}.png",
|
||||
|
|
@ -22,8 +17,5 @@ async def get_config() -> dict:
|
|||
"tilesets_url": "/tilesets",
|
||||
"tilesets_activate_url": "/tilesets/activate",
|
||||
"ws_url": "/ws/track",
|
||||
"gnss_source": gnss_source,
|
||||
"gnss_state_url": "/gnss/state",
|
||||
"gnss_mode_url": "/gnss/mode",
|
||||
"gnss_sim_center_url": "/gnss/sim/center",
|
||||
"gnss_source": config.GNSS_SOURCE,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..dependencies import get_gnss_service
|
||||
from ... import config
|
||||
from ...services.gnss import GNSSService, RectangularPathSource, SerialNMEASource, SimulatedPathSource
|
||||
|
||||
|
||||
router = APIRouter(tags=["gnss"])
|
||||
|
||||
|
||||
@router.get("/gnss/state")
|
||||
async def gnss_state(gnss: GNSSService = Depends(get_gnss_service)) -> dict:
|
||||
return {"status": "ok", "gnss": gnss.describe()}
|
||||
|
||||
|
||||
class SetGNSSModeRequest(BaseModel):
|
||||
mode: str = Field(pattern="^(simulated|serial)$")
|
||||
|
||||
|
||||
@router.post("/gnss/mode")
|
||||
async def set_gnss_mode(payload: SetGNSSModeRequest, gnss: GNSSService = Depends(get_gnss_service)) -> dict:
|
||||
mode = payload.mode.lower()
|
||||
if mode == "serial":
|
||||
source = SerialNMEASource(port=config.GNSS_PORT, baudrate=config.GNSS_BAUD, timeout=config.GNSS_TIMEOUT)
|
||||
await gnss.set_source(source)
|
||||
return {"status": "ok", "gnss": gnss.describe()}
|
||||
|
||||
start_lat, start_lon = config.SIM_START_POINT
|
||||
path = (getattr(config, "SIM_PATH", "circle") or "circle").lower()
|
||||
if path == "rectangle":
|
||||
source = RectangularPathSource(
|
||||
center_lat=start_lat,
|
||||
center_lon=start_lon,
|
||||
width_m=config.SIM_RECT_WIDTH_METERS,
|
||||
height_m=config.SIM_RECT_HEIGHT_METERS,
|
||||
step_m=config.SIM_STEP_METERS,
|
||||
alt_m=config.SIM_ALT_METERS,
|
||||
)
|
||||
else:
|
||||
source = SimulatedPathSource(center_lat=start_lat, center_lon=start_lon, radius_m=config.SIM_RADIUS_METERS)
|
||||
await gnss.set_source(source)
|
||||
return {"status": "ok", "gnss": gnss.describe()}
|
||||
|
||||
|
||||
class UpdateSimulatedCenterRequest(BaseModel):
|
||||
lat: float = Field(ge=-90, le=90)
|
||||
lon: float = Field(ge=-180, le=180)
|
||||
|
||||
|
||||
@router.post("/gnss/sim/center")
|
||||
async def update_simulated_center(
|
||||
payload: UpdateSimulatedCenterRequest,
|
||||
gnss: GNSSService = Depends(get_gnss_service),
|
||||
) -> dict:
|
||||
try:
|
||||
await gnss.update_simulated_center(lat=payload.lat, lon=payload.lon)
|
||||
except TypeError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return {"status": "ok", "gnss": gnss.describe()}
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
|
||||
from . import config
|
||||
from .api import state
|
||||
from .api.routes import config_route, gnss, health, logs, maps, raster_mbtiles, tiles, tilesets, vector_tiles, ws
|
||||
from .api.routes import config_route, health, logs, maps, raster_mbtiles, tiles, tilesets, vector_tiles, ws
|
||||
from .services.gnss import GNSSService, RectangularPathSource, SerialNMEASource, SimulatedPathSource
|
||||
from .services.logs import LogService
|
||||
from .services.tiles import TileService
|
||||
|
|
@ -84,7 +84,6 @@ app.include_router(config_route.router)
|
|||
app.include_router(tiles.router)
|
||||
app.include_router(ws.router)
|
||||
app.include_router(logs.router)
|
||||
app.include_router(gnss.router)
|
||||
app.include_router(maps.router)
|
||||
app.include_router(raster_mbtiles.router)
|
||||
app.include_router(vector_tiles.router)
|
||||
|
|
|
|||
|
|
@ -180,43 +180,6 @@ class GNSSService:
|
|||
async def stop(self) -> None:
|
||||
await self.source.stop()
|
||||
|
||||
def source_kind(self) -> str:
|
||||
if isinstance(self.source, SerialNMEASource):
|
||||
return "serial"
|
||||
if isinstance(self.source, (SimulatedPathSource, RectangularPathSource)):
|
||||
return "simulated"
|
||||
return "unknown"
|
||||
|
||||
def describe(self) -> dict:
|
||||
kind = self.source_kind()
|
||||
payload: dict = {"mode": kind}
|
||||
if kind == "simulated":
|
||||
payload["lat"] = getattr(self.source, "center_lat", None)
|
||||
payload["lon"] = getattr(self.source, "center_lon", None)
|
||||
payload["path"] = "rectangle" if isinstance(self.source, RectangularPathSource) else "circle"
|
||||
if kind == "serial":
|
||||
payload["port"] = getattr(self.source, "port", None)
|
||||
payload["baudrate"] = getattr(self.source, "baudrate", None)
|
||||
return payload
|
||||
|
||||
async def set_source(self, source: BaseGNSSSource) -> None:
|
||||
async with self._lock:
|
||||
try:
|
||||
await self.source.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self.source = source
|
||||
self._last_fix = None
|
||||
await self.source.start()
|
||||
|
||||
async def update_simulated_center(self, lat: float, lon: float) -> None:
|
||||
async with self._lock:
|
||||
if not isinstance(self.source, (SimulatedPathSource, RectangularPathSource)):
|
||||
raise TypeError("current GNSS source is not simulated")
|
||||
self.source.center_lat = lat
|
||||
self.source.center_lon = lon
|
||||
self._last_fix = None
|
||||
|
||||
async def _fetch_fix(self) -> Optional[GNSSPoint]:
|
||||
async with self._lock:
|
||||
fix = await self.source.next_point()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue