Compare commits

..

No commits in common. "2715c56b4fc48e277fe8f2e53fe8f86faba4e426" and "f8ae8d7d5498b1c14264f2bcabc6e1dcd0834444" have entirely different histories.

10 changed files with 931 additions and 685 deletions

View file

@ -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;
}

File diff suppressed because it is too large Load diff

View file

@ -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>

View file

@ -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 />);

View file

@ -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 = {

View file

@ -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

View file

@ -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,
}

View file

@ -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()}

View file

@ -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)

View file

@ -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()