From 00fc6d81589ecc8262aca205fc98dd0319ca7854 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:38:51 -0700 Subject: [PATCH] Add ability to download route logs --- .../components/recordings/dashcam_routes.css | 48 ++++++++ .../components/recordings/dashcam_routes.js | 46 ++++++++ .../the_galaxy/tests/test_route_logs.py | 111 ++++++++++++++++++ starpilot/system/the_galaxy/the_galaxy.py | 96 +++++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 starpilot/system/the_galaxy/tests/test_route_logs.py diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css index 8c68365e3..65647709e 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css @@ -244,6 +244,47 @@ transform: var(--hover-scale-sm); } +.route-logs { + margin-top: var(--padding-base); + max-height: 40vh; + overflow-y: auto; + text-align: left; +} + +.route-logs-message { + margin: 0; + padding: var(--padding-sm) 0; + font-size: var(--font-size-sm); +} + +.route-logs-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--padding-sm); + padding-bottom: var(--padding-sm); + font-size: var(--font-size-sm); +} + +.route-logs-list { + list-style: none; + margin: 0; + padding: 0; +} + +.route-logs-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--padding-sm); + padding: var(--padding-sm) 0; + font-size: var(--font-size-sm); +} + +.route-logs a { + text-decoration: underline; +} + @media only screen and (max-width: 768px) and (orientation: portrait) { .media-player-content { width: 90%; @@ -266,4 +307,11 @@ text-align: center; width: 100%; } + + .route-logs-header, + .route-logs-list li { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } } diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js index e0ccefd2e..29fc852a1 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -298,8 +298,10 @@ async function openOverlay(route) { + + `; document.body.appendChild(overlay); @@ -312,6 +314,50 @@ async function openOverlay(route) { const vid = overlay.querySelector("video"); const downloadButton = overlay.querySelector(".action-download"); + const logsButton = overlay.querySelector(".action-logs"); + const logsPanel = overlay.querySelector(".route-logs"); + + const formatBytes = bytes => { + if (!bytes) return "0 MB"; + const mb = bytes / 1e6; + return mb >= 1000 ? `${(mb / 1000).toFixed(2)} GB` : `${mb.toFixed(1)} MB`; + }; + + let logsLoaded = false; + logsButton.onclick = async () => { + if (logsLoaded) { + logsPanel.hidden = !logsPanel.hidden; + return; + } + + logsPanel.hidden = false; + logsPanel.innerHTML = `

Looking for full logs...

`; + try { + const response = await fetch(`/api/routes/${route.name}/logs`); + const data = await response.json(); + if (!response.ok) { + logsPanel.innerHTML = `

${data.error || "Could not read logs."}

`; + return; + } + + // sizes are shown up front so a metered connection is a deliberate choice + logsPanel.innerHTML = ` +
+ ${data.segments.length} segment${data.segments.length === 1 ? "" : "s"} · ${formatBytes(data.totalBytes)} total + Download all (.tar) +
+ `; + logsLoaded = true; + } catch (error) { + logsPanel.innerHTML = `

Could not reach the device: ${error.message}

`; + } + }; let segments; let current = 0; diff --git a/starpilot/system/the_galaxy/tests/test_route_logs.py b/starpilot/system/the_galaxy/tests/test_route_logs.py new file mode 100644 index 000000000..ce8439d1a --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_route_logs.py @@ -0,0 +1,111 @@ +import io +import tarfile + +from test_dashboard_stats import MODULE_DIR, _install_server_import_stubs + + +def _load_server_module(): + import importlib.util + import sys + + _install_server_import_stubs() + spec = importlib.util.spec_from_file_location("route_logs_server", MODULE_DIR / "the_galaxy.py") + module = importlib.util.module_from_spec(spec) + sys.modules["route_logs_server"] = module + spec.loader.exec_module(module) + return module + + +the_galaxy = _load_server_module() + + +def _make_route(root, name, segments, filename="rlog.zst", size=32): + for segment_num in segments: + segment_dir = root / f"{name}--{segment_num}" + segment_dir.mkdir(parents=True) + (segment_dir / filename).write_bytes(bytes([segment_num]) * size) + # a sibling that must never be offered as a full log + (segment_dir / "qlog.zst").write_bytes(b"q") + + +def _use_footage_root(monkeypatch, root): + monkeypatch.setattr(the_galaxy, "FOOTAGE_PATHS", [str(root) + "/"]) + + +def test_route_log_files_are_ordered_numerically(monkeypatch, tmp_path): + _make_route(tmp_path, "0000006a--9f0a7bdf9c", [0, 1, 2, 10]) + _use_footage_root(monkeypatch, tmp_path) + + logs = the_galaxy._route_log_files("0000006a--9f0a7bdf9c") + + # 10 must sort after 2, not lexically between 1 and 2 + assert [segment for segment, _, _, _ in logs] == [ + "0000006a--9f0a7bdf9c--0", + "0000006a--9f0a7bdf9c--1", + "0000006a--9f0a7bdf9c--2", + "0000006a--9f0a7bdf9c--10", + ] + assert {filename for _, filename, _, _ in logs} == {"rlog.zst"} + assert [size for _, _, _, size in logs] == [32, 32, 32, 32] + + +def test_route_log_files_prefers_newest_available_format(monkeypatch, tmp_path): + _make_route(tmp_path, "0000006a--9f0a7bdf9c", [0], filename="rlog.bz2") + (tmp_path / "0000006a--9f0a7bdf9c--0" / "rlog.zst").write_bytes(b"zstd") + _use_footage_root(monkeypatch, tmp_path) + + logs = the_galaxy._route_log_files("0000006a--9f0a7bdf9c") + + assert [filename for _, filename, _, _ in logs] == ["rlog.zst"] + + +def test_route_log_files_rejects_names_that_are_not_routes(monkeypatch, tmp_path): + _make_route(tmp_path, "0000006a--9f0a7bdf9c", [0]) + _use_footage_root(monkeypatch, tmp_path) + + for name in ("", None, "..", "../..", "0000006a--9f0a7bdf9c--0", "0000006a--9f0a7bdf9cx", "/etc"): + assert the_galaxy._route_log_files(name) == [], name + + +def test_route_log_files_skips_segments_without_logs(monkeypatch, tmp_path): + _make_route(tmp_path, "0000006a--9f0a7bdf9c", [0, 1]) + (tmp_path / "0000006a--9f0a7bdf9c--1" / "rlog.zst").unlink() + _use_footage_root(monkeypatch, tmp_path) + + logs = the_galaxy._route_log_files("0000006a--9f0a7bdf9c") + + assert [segment for segment, _, _, _ in logs] == ["0000006a--9f0a7bdf9c--0"] + + +def test_tar_buffer_hands_back_each_write_once(): + buffer = the_galaxy._TarBuffer() + + buffer.write(b"one") + buffer.write(b"two") + + assert buffer.pop() == b"onetwo" + assert buffer.pop() == b"" + + +def test_streamed_archive_is_a_readable_tar(monkeypatch, tmp_path): + _make_route(tmp_path, "0000006a--9f0a7bdf9c", [0, 1], size=4096) + _use_footage_root(monkeypatch, tmp_path) + logs = the_galaxy._route_log_files("0000006a--9f0a7bdf9c") + + buffer = the_galaxy._TarBuffer() + chunks = [] + with tarfile.open(fileobj=buffer, mode="w|") as archive: + for segment, filename, path, _ in logs: + archive.add(path, arcname=f"{segment}/{filename}") + chunks.append(buffer.pop()) + chunks.append(buffer.pop()) + + # more than one chunk means a long route never has to be buffered whole + assert sum(1 for chunk in chunks if chunk) > 1 + + with tarfile.open(fileobj=io.BytesIO(b"".join(chunks)), mode="r:") as archive: + assert archive.getnames() == [ + "0000006a--9f0a7bdf9c--0/rlog.zst", + "0000006a--9f0a7bdf9c--1/rlog.zst", + ] + assert archive.extractfile("0000006a--9f0a7bdf9c--1/rlog.zst").read() == bytes([1]) * 4096 diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 5891ada7c..556efbaec 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -10,6 +10,7 @@ import sys import sysconfig import tarfile +import io from io import BytesIO from pathlib import Path @@ -1116,6 +1117,24 @@ def _get_toggle_backup_keys(): return keys +def _route_log_files(name): + """Full logs for a route as [(segment, filename, path, size)], oldest segment first.""" + if not utilities.ROUTE_RE.match(name or ""): + return [] + + for footage_path in FOOTAGE_PATHS: + logs = [] + for segment in sorted(utilities.get_segments_in_route(name, footage_path), key=lambda s: int(s.rsplit("--", 1)[1])): + for filename in ROUTE_LOG_CANDIDATES: + path = os.path.join(footage_path, segment, filename) + if os.path.isfile(path): + logs.append((segment, filename, path, os.path.getsize(path))) + break + if logs: + return logs + return [] + + def _coerce_toggle_restore_value(key, value): value_type = _get_param_key_type(_params_raw, key) @@ -1192,6 +1211,29 @@ except TypeError: str(Paths.log_root()), ] +# Full drive logs, newest format first. comma only accepts qlog/qcamera uploads, so these come off the device directly. +ROUTE_LOG_CANDIDATES = ("rlog.zst", "rlog.bz2", "rlog") + + +class _TarBuffer(io.RawIOBase): + """Collects tarfile output so a route archive can be streamed out instead of built on disk.""" + + def __init__(self): + self._chunks = [] + + def writable(self): + return True + + def write(self, data): + self._chunks.append(bytes(data)) + return len(data) + + def pop(self): + data = b"".join(self._chunks) + self._chunks.clear() + return data + + KEYS = { "amap1": ("amap1", "", "AMapKey1", "AMap / Gaode key #1", 39), "amap2": ("amap2", "", "AMapKey2", "AMap / Gaode key #2", 39), @@ -6258,6 +6300,60 @@ def setup(app): }, 200 return {"error": "Route not found"}, 404 + @app.route("/api/routes//logs", methods=["GET"]) + def list_route_logs(name): + logs = _route_log_files(name) + if not logs: + return jsonify({"error": "No full logs are stored on the device for this route."}), 404 + + return jsonify({ + "name": name, + "totalBytes": sum(size for *_, size in logs), + "segments": [ + { + "segment": segment, + "segmentNum": int(segment.rsplit("--", 1)[1]), + "filename": filename, + "bytes": size, + "url": f"/api/routes/{name}/logs/{int(segment.rsplit('--', 1)[1])}", + } + for segment, filename, _, size in logs + ], + }), 200 + + @app.route("/api/routes//logs/", methods=["GET"]) + def download_route_log(name, segment_num): + for segment, filename, path, _ in _route_log_files(name): + if int(segment.rsplit("--", 1)[1]) == segment_num: + return send_file(path, as_attachment=True, download_name=f"{segment}-{filename}") + return jsonify({"error": "No full log is stored on the device for this segment."}), 404 + + @app.route("/api/routes//logs/download", methods=["GET"]) + def download_route_logs_archive(name): + logs = _route_log_files(name) + if not logs: + return jsonify({"error": "No full logs are stored on the device for this route."}), 404 + + def generate(): + buffer = _TarBuffer() + # streamed a file at a time so a long route never needs its whole archive in memory + with tarfile.open(fileobj=buffer, mode="w|") as archive: + for segment, filename, path, _ in logs: + try: + archive.add(path, arcname=f"{segment}/{filename}") + except OSError: + continue + chunk = buffer.pop() + if chunk: + yield chunk + chunk = buffer.pop() + if chunk: + yield chunk + + response = Response(generate(), mimetype="application/x-tar") + response.headers["Content-Disposition"] = f'attachment; filename="{name}-logs.tar"' + return response + @app.route("/api/routes/clear_name", methods=["POST"]) def clear_route_name(): data = request.get_json()