Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ The **Hosted Web App** (internal runtime key `cloud`) is a pure browser SPA that
| Static SPA over HTTPS | Accounts or login |
| Web Serial CRSF + MAVLink link (user-granted port only) | Server-side telemetry persistence |
| Replay and simulation (frontend-only, no upload) | Fleet or project management |
| Same dashboard UI as desktop/browser dev | Cloud logging or sync |
| **Download session** — buffer live telemetry in memory; export replay JSONL locally (no upload) | Cloud logging or sync |
| | Billing or paid tiers |

Self-host the Hosted Web App with `pnpm build:cloud` and serve **`apps/web/dist` from that command only** — not the output of `pnpm build` (desktop/Node stack). A wrong artifact shows the COM port dropdown and spams `WebSocket connection to …/ws failed` because the app is in `web` mode instead of `cloud`. Do not embed the app in a cross-origin iframe without a `Permissions-Policy` that allows `serial` for your origin — Web Serial is blocked by default in embedded contexts.
Expand Down Expand Up @@ -61,7 +61,7 @@ Prefer bench testing and disconnected telemetry validation before using the soft
- **Optional video stream** (MJPEG, etc.) via environment variables; crosshair overlay; when the stream is live, a **Ground Target** panel docks beside the camera feed
- **Ground target estimation** (desktop) — image-center target with GeoTIFF DEM ray marching, map marker, line-of-sight, and sample-log export (shown next to the camera when video is live)
- **Preflight health advisory** — sensor and link health checks with configurable thresholds
- **Session logging** and reset for new flights
- **Session logging** — desktop and browser dev write JSONL to disk via **Start Log** / **Stop Log**; after stopping, use **Open in Replay** to load the saved log without manual import. The **Hosted Web App** and browser dev also buffer telemetry in memory — **Download session** exports replay JSONL locally, and **Open in Replay** loads the buffer directly (never uploaded). **Reset** clears the in-memory buffer.
- **Onboarding tour** — first-run walkthrough of link controls, telemetry sidebar, map, camera, and activity log; skip anytime; restart from the **?** button in the top bar (`localStorage` keys `uav-gcs.onboarding.*`)
- **Replay & Simulation** — frontend-only, read-only telemetry sources that drive the same dashboard without hardware: replay recorded `.jsonl`/`.json` logs (start/pause/seek/step, speed and timing modes) or run deterministic seeded simulations. See [`docs/replay-mode.md`](docs/replay-mode.md) and [`docs/adr/0003-frontend-only-replay-simulation.md`](docs/adr/0003-frontend-only-replay-simulation.md)
- **Flight Review** — post-flight analysis over a loaded replay log (not simulation): summary stats, findings, colored path, seekable timeline with markers, and five click-to-seek graphs. Open manually from replay controls while in **Replay** mode; shares the replay clock with dashboard scrubbing. Frontend-only — nothing is uploaded. See [`docs/adr/0007-flight-review-replay-analysis-view.md`](docs/adr/0007-flight-review-replay-analysis-view.md)
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@uav-ground-control-station/desktop",
"version": "0.2.43",
"version": "0.2.45",
"private": true,
"license": "GPL-3.0-only",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "uav-ground-control-station"
version = "0.2.43"
version = "0.2.45"
description = "Native desktop shell for UAV Ground Control Station"
authors = ["F. Eber"]
license = "GPL-3.0-only"
Expand Down
27 changes: 26 additions & 1 deletion apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,11 +408,35 @@ fn stop_logging(state: State<DesktopState>) -> Result<LoggingStatus, String> {
writer.flush().map_err(|error| error.to_string())?;
}
logging.writer = None;
logging.file_path = None;

Ok(logging_status_from_logger(&logging))
}

#[tauri::command]
fn read_session_log(path: String, app: AppHandle) -> Result<String, String> {
let logs_dir = app
.path()
.app_log_dir()
.unwrap_or_else(|_| PathBuf::from("logs"));
let candidate = PathBuf::from(path.trim());
let canonical = candidate
.canonicalize()
.map_err(|_| "Session log file not found.".to_string())?;
let logs_canonical = logs_dir
.canonicalize()
.unwrap_or_else(|_| logs_dir.clone());
if !canonical.starts_with(&logs_canonical) {
return Err("Session log path is not allowed.".to_string());
}
let lower = canonical
.to_string_lossy()
.to_ascii_lowercase();
if !lower.ends_with(".jsonl") {
return Err("Session log must be a .jsonl file.".to_string());
}
std::fs::read_to_string(canonical).map_err(|_| "Unable to read session log.".to_string())
}

#[tauri::command]
fn load_terrain_model(path: String, state: State<DesktopState>) -> Result<dem::TerrainMetadataResponse, String> {
let mut dem = state.dem.lock().map_err(lock_error)?;
Expand Down Expand Up @@ -514,6 +538,7 @@ pub fn run() {
get_telemetry,
start_logging,
stop_logging,
read_session_log,
logging_status,
load_terrain_model,
get_terrain_metadata,
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "UAV Ground Control Station",
"version": "0.2.43",
"version": "0.2.45",
"identifier": "com.uav.ground-control-station",
"build": {
"beforeDevCommand": "pnpm --filter @uav-ground-control-station/web dev",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/buildApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export async function buildApp(services?: Partial<AppServices>): Promise<Fastify

app.get("/api/logging/status", async () => logger.status());

app.get("/api/logging/read", { preHandler: guardControlRoute }, async () => logger.readStoppedLog());

app.get("/ws", { websocket: true }, (socket) => {
hub.add(socket, latestTelemetry, serial.getStatus());
});
Expand Down
15 changes: 13 additions & 2 deletions apps/server/src/services/loggerService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createWriteStream, existsSync, mkdirSync, type WriteStream } from "node:fs";
import { createWriteStream, existsSync, mkdirSync, readFileSync, type WriteStream } from "node:fs";
import { basename } from "node:path";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { REPLAY_LOG_SCHEMA_VERSION, type LoggingStatus, type TelemetryState } from "@uav-ground-control-station/shared";
Expand Down Expand Up @@ -29,10 +30,20 @@ export class LoggerService {
stop(): LoggingStatus {
this.stream?.end();
this.stream = null;
this.filePath = null;
return this.status();
}

readStoppedLog(): { text: string; fileName: string } {
if (!this.filePath) {
throw new Error("No session log file available.");
}

return {
text: readFileSync(this.filePath, "utf8"),
fileName: basename(this.filePath)
};
}

status(): LoggingStatus {
return {
active: this.stream !== null,
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,18 @@ export function App() {
linkConnection,
wsConnected,
runtimeMode,
browserSessionExportEnabled,
sessionSnapshot,
refreshPorts,
connect,
disconnect,
resetSession,
startLogging,
stopLogging,
downloadSession,
canOpenLogInReplay,
logReplayHandoffError,
openStoppedLogInReplay,
clearLogs,
activeSourceMode,
setSourceMode,
Expand Down Expand Up @@ -170,6 +176,12 @@ export function App() {
onReset={resetAll}
onStartLogging={startLogging}
onStopLogging={stopLogging}
onDownloadSession={downloadSession}
sessionExportEnabled={browserSessionExportEnabled}
sessionEventCount={sessionSnapshot.eventCount}
canOpenLogInReplay={canOpenLogInReplay}
logReplayHandoffError={logReplayHandoffError}
onOpenLogInReplay={openStoppedLogInReplay}
onRestartTour={() => setTourRestartToken((token) => token + 1)}
linkIssues={linkIssues}
/>
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/components/Topbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ interface TopbarProps {
onReset: () => Promise<void>;
onStartLogging: () => Promise<void>;
onStopLogging: () => Promise<void>;
onDownloadSession?: () => void;
sessionEventCount?: number;
sessionExportEnabled?: boolean;
canOpenLogInReplay?: boolean;
logReplayHandoffError?: string | null;
onOpenLogInReplay?: () => Promise<void>;
onRestartTour: () => void;
linkIssues?: LinkIssue[];
}
Expand All @@ -59,6 +65,12 @@ export function Topbar({
onReset,
onStartLogging,
onStopLogging,
onDownloadSession,
sessionEventCount = 0,
sessionExportEnabled = false,
canOpenLogInReplay = false,
logReplayHandoffError = null,
onOpenLogInReplay,
onRestartTour,
linkIssues = []
}: TopbarProps) {
Expand Down Expand Up @@ -227,6 +239,21 @@ export function Topbar({
Reset
</button>

{sessionExportEnabled && (
<button
className="btn-secondary whitespace-nowrap border-cyan-300/30 text-cyan-100"
disabled={busy || sessionEventCount === 0}
title={
sessionEventCount === 0
? "Connect and receive telemetry to build a downloadable session."
: "Download replay JSONL to your device. Nothing is uploaded."
}
onClick={() => onDownloadSession?.()}
>
Download session
</button>
)}

{!isCloud &&
(loggingStatus.active ? (
<button
Expand Down Expand Up @@ -284,6 +311,32 @@ export function Topbar({
))}
</div>
)}

{activeSourceMode === "live" && canOpenLogInReplay && onOpenLogInReplay && (
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-lg border border-amber-400/35 bg-amber-950/70 px-3 py-2 text-xs text-amber-100">
<div className="leading-snug">
{loggingStatus.filePath && !loggingStatus.active
? "Session log saved to disk."
: "Session buffer is ready for replay."}
{" "}
Open it in Replay to scrub the flight without re-importing a file. Flight Review stays manual.
</div>
<button
type="button"
className="btn-secondary shrink-0 whitespace-nowrap border-amber-300/40 text-amber-50"
disabled={busy}
onClick={() => run(onOpenLogInReplay)}
>
Open in Replay
</button>
</div>
)}

{logReplayHandoffError && (
<div className="mt-2 rounded-lg border border-red-400/30 bg-red-950/90 px-3 py-2 text-xs text-red-100">
{logReplayHandoffError}
</div>
)}
</header>
);
}
Expand Down
Loading