Looking for full logs...
`;
- try {
- const response = await fetch(`/api/routes/${route.name}/logs`);
- const data = await response.json();
- if (!response.ok) {
- logsPanel.innerHTML = `
-
`
}
diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js
new file mode 100644
index 0000000000..41372c48a5
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js
@@ -0,0 +1,129 @@
+export const MAX_RENDERED_ROUTES = 250
+
+function validDate(value) {
+ if (!value) return null
+ const date = value instanceof Date ? new Date(value.getTime()) : new Date(value)
+ return Number.isNaN(date.getTime()) ? null : date
+}
+
+export function formatRouteDate(value, locale) {
+ const date = validDate(value)
+ if (!date) return "Unknown date"
+ return new Intl.DateTimeFormat(locale, {
+ dateStyle: "long",
+ timeStyle: "short",
+ }).format(date)
+}
+
+export function normalizeRoute(route, locale) {
+ const timestamp = route?.timestamp == null ? null : String(route.timestamp)
+ const startedAtDate = validDate(route?.startedAt)
+ const timestampDate = validDate(timestamp)
+ const displayDate = formatRouteDate(startedAtDate || timestampDate, locale)
+ const isCustomName = Boolean(route?.isCustomName) || Boolean(timestamp && !timestampDate)
+
+ return {
+ ...route,
+ name: String(route?.name || ""),
+ timestamp,
+ startedAt: route?.startedAt || null,
+ isCustomName,
+ displayDate,
+ displayName: isCustomName ? timestamp : displayDate,
+ _startedAtMs: startedAtDate?.getTime() ?? timestampDate?.getTime() ?? null,
+ }
+}
+
+export function sortRoutes(routes, sortOrder = "newest") {
+ const direction = sortOrder === "oldest" ? 1 : -1
+ return [...routes].sort((left, right) => {
+ const leftTime = left?._startedAtMs
+ const rightTime = right?._startedAtMs
+ if (leftTime == null && rightTime == null) return String(left?.name || "").localeCompare(String(right?.name || ""))
+ if (leftTime == null) return 1
+ if (rightTime == null) return -1
+ if (leftTime !== rightTime) return (leftTime - rightTime) * direction
+ return String(left?.name || "").localeCompare(String(right?.name || "")) * -direction
+ })
+}
+
+export function routeMatchesSearch(route, searchQuery) {
+ const query = String(searchQuery || "").trim().toLocaleLowerCase()
+ if (!query) return true
+ return [route?.name, route?.timestamp, route?.displayName, route?.displayDate]
+ .filter(Boolean)
+ .some(value => String(value).toLocaleLowerCase().includes(query))
+}
+
+export function buildRouteView(routes, options = {}) {
+ const matching = sortRoutes(
+ routes.filter(route => (!options.preservedOnly || route.is_preserved) && routeMatchesSearch(route, options.searchQuery)),
+ options.sortOrder,
+ )
+ return {
+ matching,
+ visible: matching.slice(0, MAX_RENDERED_ROUTES),
+ truncated: matching.length > MAX_RENDERED_ROUTES,
+ }
+}
+
+function localDayKey(date) {
+ return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
+}
+
+export function groupRoutesByDate(routes, now = new Date(), locale) {
+ const today = validDate(now) || new Date()
+ today.setHours(0, 0, 0, 0)
+ const yesterday = new Date(today)
+ yesterday.setDate(yesterday.getDate() - 1)
+ const groups = []
+ const byKey = new Map()
+
+ for (const route of routes) {
+ const routeDate = route?._startedAtMs == null ? null : new Date(route._startedAtMs)
+ const key = routeDate ? localDayKey(routeDate) : "unknown"
+ let group = byKey.get(key)
+ if (!group) {
+ let label = "Unknown date"
+ if (routeDate) {
+ if (key === localDayKey(today)) label = "Today"
+ else if (key === localDayKey(yesterday)) label = "Yesterday"
+ else label = new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(routeDate)
+ }
+ group = { key, label, routes: [] }
+ byKey.set(key, group)
+ groups.push(group)
+ }
+ group.routes.push(route)
+ }
+ return groups
+}
+
+export function formatApproxDuration(seconds) {
+ const minutes = Math.max(0, Math.round(Number(seconds) / 60) || 0)
+ if (minutes < 1) return "Less than 1 min"
+ if (minutes < 60) return `About ${minutes} min`
+ const hours = Math.floor(minutes / 60)
+ const remaining = minutes % 60
+ return `About ${hours} hr${remaining ? ` ${remaining} min` : ""}`
+}
+
+export function parseStoredSegmentNumber(segmentUrl) {
+ const cleanPath = String(segmentUrl || "").split(/[?#]/, 1)[0]
+ const match = cleanPath.match(/--(\d+)\/?$/)
+ if (!match) return null
+ const value = Number(match[1])
+ return Number.isSafeInteger(value) ? value : null
+}
+
+export function getSegmentStatus(segmentUrls, playbackIndex) {
+ if (!Array.isArray(segmentUrls) || !Number.isInteger(playbackIndex) || playbackIndex < 0 || playbackIndex >= segmentUrls.length) return ""
+ const segmentNumber = parseStoredSegmentNumber(segmentUrls[playbackIndex])
+ if (segmentNumber == null) return ""
+ return `Segment ${segmentNumber} · ${playbackIndex + 1} of ${segmentUrls.length}`
+}
+
+export function cameraVideoUrl(segmentUrl, camera) {
+ const separator = String(segmentUrl).includes("?") ? "&" : "?"
+ return `${segmentUrl}${separator}camera=${encodeURIComponent(camera)}`
+}
diff --git a/starpilot/system/the_galaxy/assets/js/utils.js b/starpilot/system/the_galaxy/assets/js/utils.js
index b0303bee3e..b00d24aaca 100644
--- a/starpilot/system/the_galaxy/assets/js/utils.js
+++ b/starpilot/system/the_galaxy/assets/js/utils.js
@@ -37,6 +37,20 @@ export function parseErrorLogToDate(filename) {
return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}`)
}
+/**
+ * Escape a value for interpolation into an HTML string
+ * @param {unknown} value
+ * @returns {string}
+ */
+export function escapeHtml(value) {
+ return String(value ?? "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'")
+}
+
/**
* Capitalize the first character of a string
* @param {string} str
diff --git a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py
index 95a94a738b..2f50f7ab8b 100644
--- a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py
+++ b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py
@@ -59,6 +59,8 @@ sys.modules.setdefault("openpilot.starpilot.assets.theme_manager", theme_manager
import utilities
+_REAL_COMMON_PARAMS_MODULE = sys.modules.get("openpilot.common.params")
+
for _module_name, _module in _INITIAL_MODULES.items():
if _module is None:
sys.modules.pop(_module_name, None)
@@ -86,6 +88,8 @@ def _simple_module(name, **attrs):
def _install_server_import_stubs():
+ if _REAL_COMMON_PARAMS_MODULE is not None:
+ sys.modules["openpilot.common.params"] = _REAL_COMMON_PARAMS_MODULE
sys.modules["openpilot.system.loggerd.config"] = loggerd_config
sys.modules["openpilot.system.loggerd.deleter"] = loggerd_deleter
sys.modules["openpilot.system.loggerd.uploader"] = loggerd_uploader
@@ -130,6 +134,14 @@ def _install_server_import_stubs():
)
sys.modules["openpilot.common.realtime"] = _simple_module("openpilot.common.realtime", DT_HW=0.01)
+ sys.modules["openpilot.common.swaglog"] = _simple_module(
+ "openpilot.common.swaglog",
+ cloudlog=SimpleNamespace(
+ error=lambda *args, **kwargs: None,
+ exception=lambda *args, **kwargs: None,
+ info=lambda *args, **kwargs: None,
+ ),
+ )
sys.modules["openpilot.common.time_helpers"] = _simple_module("openpilot.common.time_helpers", system_time_valid=lambda: True)
sys.modules["openpilot.system.hardware"] = _simple_module(
"openpilot.system.hardware",
@@ -149,6 +161,15 @@ def _install_server_import_stubs():
get_longitudinal_maneuver_support=lambda *args, **kwargs: {},
)
sys.modules["panda"] = _simple_module("panda", Panda=lambda *args, **kwargs: SimpleNamespace(can_send=lambda *send_args, **send_kwargs: None))
+ msgq_module = _simple_module("msgq")
+ msgq_visionipc = _simple_module(
+ "msgq.visionipc",
+ VisionIpcClient=lambda *args, **kwargs: SimpleNamespace(connect=lambda *connect_args: False),
+ VisionStreamType=SimpleNamespace(VISION_STREAM_DRIVER=0),
+ )
+ msgq_module.visionipc = msgq_visionipc
+ sys.modules["msgq"] = msgq_module
+ sys.modules["msgq.visionipc"] = msgq_visionipc
model_manager.is_builtin_model_key = lambda value: False
model_manager.model_key_aliases = lambda value: [value]
@@ -337,17 +358,16 @@ class FakeDashboardAnalyzerProcess:
def test_route_inventory_counts_segments_without_video_probing(monkeypatch):
- segments = [
- SimpleNamespace(route_name=SimpleNamespace(time_str="route-new")),
- SimpleNamespace(route_name=SimpleNamespace(time_str="route-new")),
- SimpleNamespace(route_name=SimpleNamespace(time_str="route-new")),
- SimpleNamespace(route_name=SimpleNamespace(time_str="route-old")),
- ]
+ def segment(time_str, segment_num):
+ return SimpleNamespace(route_name=SimpleNamespace(time_str=time_str), segment_num=segment_num)
+
+ # route-new has aged out of its first two segments, so it no longer starts at --0.
+ segments = [segment("route-new", 4), segment("route-new", 2), segment("route-new", 3), segment("route-old", 0)]
monkeypatch.setattr(utilities, "get_all_segment_names", lambda _path: segments)
- assert utilities.get_routes_with_segment_counts("/tmp/routes") == [
- ("route-old", 1),
- ("route-new", 3),
+ assert utilities.get_routes_with_segment_details("/tmp/routes") == [
+ ("route-old", {"segmentCount": 1, "firstSegmentNum": 0}),
+ ("route-new", {"segmentCount": 3, "firstSegmentNum": 2}),
]
diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py
new file mode 100644
index 0000000000..4c3d9dbac6
--- /dev/null
+++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py
@@ -0,0 +1,416 @@
+from concurrent.futures import ThreadPoolExecutor
+from datetime import datetime, timezone
+import io
+from pathlib import Path
+import threading
+import time
+
+import pytest
+
+from test_dashboard_stats import FakeParams, 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("dashcam_routes_server", MODULE_DIR / "the_galaxy.py")
+ module = importlib.util.module_from_spec(spec)
+ sys.modules["dashcam_routes_server"] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+the_galaxy = _load_server_module()
+utilities = the_galaxy.utilities
+ROUTE_NAME = "0000006a--9f0a7bdf9c"
+
+
+def _make_segment(root, route_name=ROUTE_NAME, segment_num=0):
+ segment = root / f"{route_name}--{segment_num}"
+ segment.mkdir(parents=True)
+ return segment
+
+
+def _make_client(monkeypatch, root):
+ assert the_galaxy._import_galaxy_web_symbols()
+ monkeypatch.setattr(the_galaxy, "FOOTAGE_PATHS", [str(root) + "/"])
+ monkeypatch.setattr(the_galaxy, "params", FakeParams())
+ app = the_galaxy.Flask(
+ f"dashcam_routes_{time.monotonic_ns()}",
+ template_folder=str(MODULE_DIR / "templates"),
+ static_folder=str(MODULE_DIR / "assets"),
+ )
+ the_galaxy.setup(app)
+ return app.test_client()
+
+
+def test_process_route_is_metadata_only_and_retains_fields(monkeypatch, tmp_path):
+ segment = _make_segment(tmp_path, segment_num=3)
+ (segment / "qlog.zst").write_bytes(b"log")
+ (segment / "Morning school run").touch()
+ started_at = datetime(2026, 8, 26, 15, 30, tzinfo=timezone.utc)
+ monkeypatch.setattr(utilities, "get_route_start_time", lambda path: started_at)
+ monkeypatch.setattr(utilities, "has_preserve_attr", lambda path: True)
+ monkeypatch.setattr(utilities, "video_to_png", lambda *args: (_ for _ in ()).throw(AssertionError("preview generation must stay lazy")))
+
+ result = utilities.process_route(str(tmp_path), ROUTE_NAME, segment_count=4, first_segment_num=3)
+
+ assert result == {
+ "name": ROUTE_NAME,
+ "png": f"/thumbnails/{ROUTE_NAME}--3/preview.png",
+ "timestamp": "Morning school run",
+ "startedAt": "2026-08-26T15:30:00Z",
+ "isCustomName": True,
+ "is_preserved": True,
+ "segmentCount": 4,
+ "approxDurationSeconds": 240,
+ }
+
+
+def test_process_route_uses_display_timestamp_without_losing_started_at(monkeypatch, tmp_path):
+ _make_segment(tmp_path)
+ started_at = datetime(2026, 8, 26, 15, 30, tzinfo=timezone.utc)
+ monkeypatch.setattr(utilities, "get_route_start_time", lambda path: started_at)
+
+ result = utilities.process_route(str(tmp_path), ROUTE_NAME, segment_count=1)
+
+ assert result["timestamp"] == started_at.isoformat()
+ assert result["startedAt"] == "2026-08-26T15:30:00Z"
+ assert result["isCustomName"] is False
+
+
+def test_route_scan_deduplicates_using_footage_root_priority(monkeypatch):
+ first = "/priority/"
+ second = "/fallback/"
+ details = {
+ first: [(ROUTE_NAME, {"segmentCount": 2, "firstSegmentNum": 1})],
+ second: [
+ (ROUTE_NAME, {"segmentCount": 8, "firstSegmentNum": 0}),
+ ("0000006b--9f0a7bdf9d", {"segmentCount": 1, "firstSegmentNum": 4}),
+ ],
+ }
+ monkeypatch.setattr(utilities, "get_routes_with_segment_details", lambda path: details[path])
+
+ entries = the_galaxy._route_scan_entries([first, second])
+
+ assert entries == [
+ (first, ROUTE_NAME, 2, 1),
+ (second, "0000006b--9f0a7bdf9d", 1, 4),
+ ]
+
+
+def test_route_metadata_stream_batches_eight_with_progress_and_retained_fields():
+ entries = [
+ ("/routes/", f"{index:08x}--{index:010x}", index + 1, index % 3)
+ for index in range(18)
+ ]
+
+ def process(path, name, segment_count, first_segment_num):
+ return {
+ "name": name,
+ "png": f"/thumbnails/{name}--{first_segment_num}/preview.png",
+ "timestamp": f"Route {segment_count}",
+ "startedAt": "2026-08-26T15:30:00Z",
+ "isCustomName": True,
+ "is_preserved": False,
+ "segmentCount": segment_count,
+ "approxDurationSeconds": segment_count * 60,
+ }
+
+ events = list(the_galaxy._route_metadata_events(entries, "dongle", process))
+
+ assert events[0] == {"routes": [], "progress": 0, "total": 18, "connectDongleId": "dongle"}
+ assert [len(event["routes"]) for event in events[1:]] == [8, 8, 2]
+ assert [event["progress"] for event in events[1:]] == [8, 16, 18]
+ assert all(event["total"] == 18 for event in events)
+ results = [route for event in events[1:] for route in event["routes"]]
+ assert len(results) == 18
+ assert all({
+ "name", "png", "timestamp", "startedAt", "isCustomName",
+ "is_preserved", "segmentCount", "approxDurationSeconds",
+ } <= result.keys() for result in results)
+
+
+def test_route_metadata_stream_cancels_queued_work_when_closed():
+ entries = [("/routes/", f"{index:08x}--{index:010x}", 1, 0) for index in range(40)]
+ release = threading.Event()
+ started = []
+ lock = threading.Lock()
+
+ def process(path, name, segment_count, first_segment_num):
+ index = int(name.split("--", 1)[0], 16)
+ with lock:
+ started.append(index)
+ if index >= 8:
+ release.wait(timeout=2)
+ return {"name": name}
+
+ stream = the_galaxy._route_metadata_events(entries, process_route=process)
+ next(stream)
+ batch = next(stream)
+ assert len(batch["routes"]) == 8
+ stream.close()
+ release.set()
+ time.sleep(0.1)
+
+ # At most four already-running workers continue; the remaining queue is cancelled.
+ assert len(started) <= 12
+
+
+def test_thumbnail_path_validation_is_strict(tmp_path):
+ _make_segment(tmp_path)
+ valid = f"{ROUTE_NAME}--0/preview.png"
+
+ assert the_galaxy._resolve_route_thumbnail(valid, [tmp_path]) == tmp_path / f"{ROUTE_NAME}--0" / "preview.png"
+ for invalid in (
+ "../preview.png",
+ f"{ROUTE_NAME}--0/qcamera.ts",
+ f"{ROUTE_NAME}--0/subdir/preview.png",
+ f"{ROUTE_NAME}--nope/preview.png",
+ f"/{ROUTE_NAME}--0/preview.png",
+ f"{ROUTE_NAME}--0\\preview.png",
+ ):
+ assert the_galaxy._resolve_route_thumbnail(invalid, [tmp_path]) is None
+
+
+def test_thumbnail_path_validation_rejects_symlinks_outside_the_footage_root(tmp_path):
+ footage_root = tmp_path / "footage"
+ outside_segment = tmp_path / "outside"
+ footage_root.mkdir()
+ outside_segment.mkdir()
+ (footage_root / f"{ROUTE_NAME}--0").symlink_to(outside_segment, target_is_directory=True)
+
+ assert the_galaxy._resolve_route_thumbnail(f"{ROUTE_NAME}--0/preview.png", [footage_root]) is None
+
+
+def test_thumbnail_generation_is_lazy_and_reuses_completed_preview(monkeypatch, tmp_path):
+ segment = _make_segment(tmp_path)
+ (segment / "qcamera.ts").write_bytes(b"video")
+ calls = []
+
+ def generate(source, output):
+ calls.append((Path(source), Path(output)))
+ Path(output).write_bytes(b"png")
+ return True
+
+ monkeypatch.setattr(utilities, "video_to_png", generate)
+ relative_path = f"{ROUTE_NAME}--0/preview.png"
+
+ first = the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path])
+ second = the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path])
+
+ assert first == second == segment / "preview.png"
+ assert len(calls) == 1
+ assert calls[0][0] == segment / "qcamera.ts"
+
+
+def test_thumbnail_failure_returns_none_and_does_not_cache_partial_file(monkeypatch, tmp_path):
+ segment = _make_segment(tmp_path)
+ (segment / "qcamera.ts").write_bytes(b"video")
+ monkeypatch.setattr(utilities, "video_to_png", lambda source, output: False)
+
+ result = the_galaxy._get_or_create_route_thumbnail(f"{ROUTE_NAME}--0/preview.png", [tmp_path])
+
+ assert result is None
+ assert not (segment / "preview.png").exists()
+
+
+def test_duplicate_thumbnail_requests_share_one_generation_job(monkeypatch, tmp_path):
+ segment = _make_segment(tmp_path)
+ (segment / "qcamera.ts").write_bytes(b"video")
+ release = threading.Event()
+ started = threading.Event()
+ calls = []
+
+ def generate(preview_path):
+ calls.append(preview_path)
+ started.set()
+ release.wait(timeout=2)
+ preview_path.write_bytes(b"png")
+ return preview_path
+
+ monkeypatch.setattr(the_galaxy, "_generate_route_thumbnail", generate)
+ relative_path = f"{ROUTE_NAME}--0/preview.png"
+ with ThreadPoolExecutor(max_workers=2) as callers:
+ first = callers.submit(the_galaxy._get_or_create_route_thumbnail, relative_path, [tmp_path])
+ assert started.wait(timeout=1)
+ second = callers.submit(the_galaxy._get_or_create_route_thumbnail, relative_path, [tmp_path])
+ time.sleep(0.05)
+ release.set()
+ assert first.result(timeout=1) == segment / "preview.png"
+ assert second.result(timeout=1) == segment / "preview.png"
+
+ assert len(calls) == 1
+ assert the_galaxy._ROUTE_THUMBNAIL_EXECUTOR._max_workers == 2
+
+
+def test_timed_out_thumbnail_job_stays_deduplicated_until_completion(monkeypatch, tmp_path):
+ segment = _make_segment(tmp_path)
+ (segment / "qcamera.ts").write_bytes(b"video")
+ release = threading.Event()
+ started = threading.Event()
+ calls = []
+
+ def generate(preview_path):
+ calls.append(preview_path)
+ started.set()
+ release.wait(timeout=2)
+ preview_path.write_bytes(b"png")
+ return preview_path
+
+ monkeypatch.setattr(the_galaxy, "_generate_route_thumbnail", generate)
+ monkeypatch.setattr(the_galaxy, "ROUTE_THUMBNAIL_WAIT_SECONDS", 0.01)
+ relative_path = f"{ROUTE_NAME}--0/preview.png"
+ preview_key = str((tmp_path / f"{ROUTE_NAME}--0" / "preview.png").resolve())
+
+ assert the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path]) is None
+ assert started.is_set()
+ assert preview_key in the_galaxy._ROUTE_THUMBNAIL_FUTURES
+
+ # A retry while the original job is still running must reuse that job.
+ assert the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path]) is None
+ assert len(calls) == 1
+
+ release.set()
+ for _ in range(100):
+ if preview_key not in the_galaxy._ROUTE_THUMBNAIL_FUTURES:
+ break
+ time.sleep(0.01)
+
+ assert preview_key not in the_galaxy._ROUTE_THUMBNAIL_FUTURES
+ assert the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path]) == segment / "preview.png"
+ assert len(calls) == 1
+
+
+def test_routes_endpoint_uses_sse_no_buffering_headers(monkeypatch, tmp_path):
+ segment = _make_segment(tmp_path)
+ (segment / "qlog.zst").write_bytes(b"log")
+ monkeypatch.setattr(utilities, "get_route_start_time", lambda path: datetime(2026, 8, 26, tzinfo=timezone.utc))
+ client = _make_client(monkeypatch, tmp_path)
+
+ response = client.get("/api/routes")
+
+ assert response.status_code == 200
+ assert response.mimetype == "text/event-stream"
+ assert response.headers["X-Accel-Buffering"] == "no"
+ assert "no-cache" in response.headers["Cache-Control"]
+ assert b'"progress": 1' in response.data
+ assert b'"startedAt": "2026-08-26T00:00:00Z"' in response.data
+
+
+def test_thumbnail_endpoint_sets_cache_headers(monkeypatch, tmp_path):
+ segment = _make_segment(tmp_path)
+ preview = segment / "preview.png"
+ preview.write_bytes(b"not-a-real-png-but-send-file-does-not-mind")
+ client = _make_client(monkeypatch, tmp_path)
+
+ response = client.get(f"/thumbnails/{ROUTE_NAME}--0/preview.png")
+
+ assert response.status_code == 200
+ assert response.mimetype == "image/png"
+ assert response.headers["Cache-Control"] == f"public, max-age={the_galaxy.ROUTE_THUMBNAIL_CACHE_SECONDS}"
+ assert response.data == preview.read_bytes()
+
+
+def test_rename_and_reset_keep_logs_and_use_both_reset_urls(monkeypatch, tmp_path):
+ segments = [_make_segment(tmp_path, segment_num=number) for number in (0, 3)]
+ for segment in segments:
+ (segment / "qlog.zst").write_bytes(b"log")
+ (segment / "Old_name").touch()
+ monkeypatch.setattr(utilities, "get_route_start_time", lambda path: datetime(2026, 8, 26, tzinfo=timezone.utc))
+ client = _make_client(monkeypatch, tmp_path)
+
+ renamed = client.post("/api/routes/rename", json={"old": ROUTE_NAME, "new": "New name"})
+ assert renamed.status_code == 200
+ assert all((segment / "New_name").exists() for segment in segments)
+ assert all((segment / "qlog.zst").read_bytes() == b"log" for segment in segments)
+
+ reset = client.post("/api/routes/reset_name", json={"name": ROUTE_NAME})
+ assert reset.status_code == 200
+ assert reset.get_json()["timestamp"].startswith("2026-08-26")
+ assert all(not (segment / "New_name").exists() for segment in segments)
+ assert all((segment / "qlog.zst").exists() for segment in segments)
+
+ # The legacy URL remains available for older clients.
+ for segment in segments:
+ (segment / "Another_name").touch()
+ assert client.post("/api/routes/clear_name", json={"name": ROUTE_NAME}).status_code == 200
+
+
+def test_preserve_unpreserve_and_delete_route_endpoints(monkeypatch, tmp_path):
+ segment = _make_segment(tmp_path)
+ client = _make_client(monkeypatch, tmp_path)
+ attributes = set()
+ deleted = []
+ monkeypatch.setattr(the_galaxy, "PRESERVE_COUNT", 10)
+ monkeypatch.setattr(the_galaxy.os, "listxattr", lambda path: list(attributes), raising=False)
+ monkeypatch.setattr(the_galaxy.os, "getxattr", lambda path, name: the_galaxy.PRESERVE_ATTR_VALUE, raising=False)
+ monkeypatch.setattr(the_galaxy.os, "setxattr", lambda path, name, value: attributes.add(name), raising=False)
+ monkeypatch.setattr(the_galaxy.os, "removexattr", lambda path, name: attributes.discard(name), raising=False)
+ monkeypatch.setattr(the_galaxy, "delete_file", deleted.append)
+
+ assert client.post(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200
+ assert the_galaxy.PRESERVE_ATTR_NAME in attributes
+ assert client.delete(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200
+ assert the_galaxy.PRESERVE_ATTR_NAME not in attributes
+ assert client.delete(f"/api/routes/{ROUTE_NAME}").status_code == 200
+ assert deleted == [str(segment)]
+
+
+def test_preserve_follows_the_first_surviving_segment(monkeypatch, tmp_path):
+ segment = _make_segment(tmp_path, segment_num=3) # --0 and --1 already aged out
+ client = _make_client(monkeypatch, tmp_path)
+ attributes = {}
+ monkeypatch.setattr(the_galaxy, "PRESERVE_COUNT", 10)
+ monkeypatch.setattr(the_galaxy.os, "listxattr", lambda path: list(attributes.get(str(path), ())), raising=False)
+ monkeypatch.setattr(the_galaxy.os, "getxattr", lambda path, name: the_galaxy.PRESERVE_ATTR_VALUE, raising=False)
+ monkeypatch.setattr(the_galaxy.os, "setxattr", lambda path, name, value: attributes.setdefault(str(path), set()).add(name), raising=False)
+ monkeypatch.setattr(the_galaxy.os, "removexattr", lambda path, name: attributes[str(path)].discard(name), raising=False)
+
+ assert client.post(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200
+ assert attributes == {str(segment): {the_galaxy.PRESERVE_ATTR_NAME}}
+ assert utilities.process_route(str(tmp_path) + "/", ROUTE_NAME, 1, 3)["is_preserved"] is True
+
+ assert client.delete(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200
+ assert attributes[str(segment)] == set()
+
+
+def test_preserve_limit_counts_routes_not_segments(monkeypatch, tmp_path):
+ for segment_num in (5, 6, 7):
+ _make_segment(tmp_path, segment_num=segment_num)
+ client = _make_client(monkeypatch, tmp_path)
+ monkeypatch.setattr(the_galaxy, "PRESERVE_COUNT", 1)
+ monkeypatch.setattr(the_galaxy.os, "listxattr", lambda path: [the_galaxy.PRESERVE_ATTR_NAME], raising=False)
+ monkeypatch.setattr(the_galaxy.os, "getxattr", lambda path, name: the_galaxy.PRESERVE_ATTR_VALUE, raising=False)
+ monkeypatch.setattr(the_galaxy.os, "setxattr", lambda path, name, value: None, raising=False)
+
+ # Three preserved segments belong to one route, so the cap of 1 is not already spent on it.
+ assert client.post(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200
+ assert client.post("/api/routes/00000099--9f0a7bdf9c/preserve").status_code == 400
+
+
+def test_sparse_route_metadata_and_video_downloads(monkeypatch, tmp_path):
+ segments = [_make_segment(tmp_path, segment_num=number) for number in (0, 3, 11)]
+ for segment in segments:
+ (segment / "fcamera.hevc").write_bytes(b"hevc")
+ monkeypatch.setattr(utilities, "get_video_duration", lambda path: 60)
+ monkeypatch.setattr(utilities, "get_route_start_time", lambda path: datetime(2026, 8, 26, tzinfo=timezone.utc))
+ monkeypatch.setattr(utilities, "ffmpeg_mp4_wrap_process_builder", lambda path: io.BytesIO(b"wrapped-video"))
+ monkeypatch.setattr(utilities, "ffmpeg_concat_segments_to_mp4", lambda paths, cache_key=None: io.BytesIO(b"combined-video"))
+ client = _make_client(monkeypatch, tmp_path)
+
+ metadata = client.get(f"/api/routes/{ROUTE_NAME}")
+ assert metadata.status_code == 200
+ assert metadata.get_json()["segment_urls"] == [f"/video/{ROUTE_NAME}--{number}" for number in (0, 3, 11)]
+
+ segment_video = client.get(f"/video/{ROUTE_NAME}--3?camera=forward")
+ assert segment_video.status_code == 200
+ assert segment_video.mimetype == "video/mp4"
+ assert segment_video.data == b"wrapped-video"
+
+ combined_video = client.get(f"/video/{ROUTE_NAME}/combined?camera=forward")
+ assert combined_video.status_code == 200
+ assert combined_video.mimetype == "video/mp4"
+ assert combined_video.data == b"combined-video"
diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py
new file mode 100644
index 0000000000..d0818191ba
--- /dev/null
+++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py
@@ -0,0 +1,186 @@
+"""Covers assets/components/recordings/dashcam_routes_helpers.js.
+
+The helpers are browser ES modules, so pytest drives them through node rather than
+re-implementing the date/sort/grouping rules in Python. Snippets run with helper
+exports in scope and return JSON, which keeps every assertion here in pytest.
+"""
+
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+
+import pytest
+
+HELPERS_PATH = Path(__file__).resolve().parent.parent / "assets" / "components" / "recordings" / "dashcam_routes_helpers.js"
+
+# node infers ESM from `export` syntax in a bare .js file from 22.7 on, so the helpers
+# need no package.json and stay a normal asset next to the component that imports them.
+MIN_NODE_MAJOR = 23
+
+HARNESS = f'''
+import * as helpers from {json.dumps(HELPERS_PATH.as_uri())}
+const run = new Function(...Object.keys(helpers), process.env.DASHCAM_HELPER_SNIPPET)
+process.stdout.write(JSON.stringify(run(...Object.values(helpers)) ?? null))
+'''
+
+PRELUDE = '''
+const route = (name, startedAt, extra = {}) => normalizeRoute({
+ name,
+ startedAt,
+ timestamp: startedAt,
+ segmentCount: 1,
+ approxDurationSeconds: 60,
+ is_preserved: false,
+ ...extra,
+}, "en-US")
+'''
+
+
+def _node_binary():
+ node = shutil.which("node")
+ if node is None:
+ pytest.skip("node is not installed")
+
+ version = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=30).stdout.strip()
+ try:
+ major = int(version.lstrip("v").split(".")[0])
+ except ValueError:
+ pytest.skip(f"could not read node version from {version!r}")
+ if major < MIN_NODE_MAJOR:
+ pytest.skip(f"node {version} cannot import a bare .js ES module; need v{MIN_NODE_MAJOR}+")
+ return node
+
+
+def evaluate(snippet):
+ """Run a snippet with the helper exports in scope and return its JSON value."""
+ node = _node_binary()
+ # Fixed TZ so "Today"/"Yesterday" grouping does not depend on the developer's clock.
+ environment = {**os.environ, "TZ": "UTC", "DASHCAM_HELPER_SNIPPET": PRELUDE + snippet}
+ result = subprocess.run([node, "--input-type=module"], input=HARNESS, env=environment,
+ capture_output=True, text=True, timeout=60)
+ assert result.returncode == 0, result.stderr
+ return json.loads(result.stdout)
+
+
+def test_helpers_module_is_a_plain_js_asset():
+ assert HELPERS_PATH.is_file()
+ assert not list(HELPERS_PATH.parent.glob("*.mjs"))
+
+
+def test_groups_routes_into_today_yesterday_dates_and_unknown():
+ groups = evaluate('''
+ const routes = [
+ route("today", "2026-08-26T08:00:00Z"),
+ route("yesterday", "2026-08-25T08:00:00Z"),
+ route("older", "2026-08-20T08:00:00Z"),
+ route("unknown", null, { timestamp: null }),
+ ]
+ return groupRoutesByDate(routes, new Date("2026-08-26T12:00:00Z"), "en-US")
+ .map(group => [group.label, group.routes[0].name])
+ ''')
+
+ assert groups == [
+ ["Today", "today"],
+ ["Yesterday", "yesterday"],
+ ["August 20, 2026", "older"],
+ ["Unknown date", "unknown"],
+ ]
+
+
+def test_sorts_newest_and_oldest_while_leaving_unknown_dates_last():
+ order = evaluate('''
+ const routes = [
+ route("middle", "2026-08-20T08:00:00Z"),
+ route("unknown", null, { timestamp: null }),
+ route("new", "2026-08-26T08:00:00Z"),
+ route("old", "2026-08-10T08:00:00Z"),
+ ]
+ return {
+ newest: sortRoutes(routes, "newest").map(item => item.name),
+ oldest: sortRoutes(routes, "oldest").map(item => item.name),
+ }
+ ''')
+
+ assert order["newest"] == ["new", "middle", "old", "unknown"]
+ assert order["oldest"] == ["old", "middle", "new", "unknown"]
+
+
+def test_searches_custom_names_displayed_dates_and_route_ids():
+ matches = evaluate('''
+ const custom = route("0000006a--9f0a7bdf9c", "2026-08-26T08:00:00Z", {
+ timestamp: "Morning school run",
+ isCustomName: true,
+ })
+ return ["school", "August 26", "9f0a7b", "evening"]
+ .map(searchQuery => buildRouteView([custom], { searchQuery }).matching.length)
+ ''')
+
+ assert matches == [1, 1, 1, 0]
+
+
+def test_filters_preserved_routes_before_applying_the_render_limit():
+ view = evaluate('''
+ const routes = Array.from({ length: MAX_RENDERED_ROUTES + 25 }, (_, index) => route(
+ `route-${index}`,
+ new Date(Date.UTC(2026, 0, 1, 0, index)).toISOString(),
+ { is_preserved: index % 2 === 0 },
+ ))
+ const all = buildRouteView(routes)
+ const preserved = buildRouteView(routes, { preservedOnly: true })
+ return {
+ limit: MAX_RENDERED_ROUTES,
+ all: [all.matching.length, all.visible.length, all.truncated],
+ preserved: [preserved.matching.length, preserved.visible.length, preserved.truncated],
+ allPreserved: preserved.visible.every(item => item.is_preserved),
+ }
+ ''')
+
+ assert view["limit"] == 250
+ assert view["all"] == [275, 250, True]
+ assert view["preserved"] == [138, 138, False]
+ assert view["allPreserved"] is True
+
+
+def test_segment_status_uses_stored_sparse_numbers_and_playback_position():
+ statuses = evaluate('''
+ const segments = [
+ "/video/0000006a--9f0a7bdf9c--0",
+ "/video/0000006a--9f0a7bdf9c--3",
+ "/video/0000006a--9f0a7bdf9c--11",
+ ]
+ return segments.map((_, index) => getSegmentStatus(segments, index))
+ ''')
+
+ assert statuses == [
+ "Segment 0 · 1 of 3",
+ "Segment 3 · 2 of 3",
+ "Segment 11 · 3 of 3",
+ ]
+
+
+def test_hides_segment_status_when_the_stored_number_is_unsafe():
+ results = evaluate('''
+ return [
+ parseStoredSegmentNumber("/video/route--9007199254740992"),
+ getSegmentStatus(["/video/not-a-segment"], 0),
+ getSegmentStatus(undefined, 0),
+ ]
+ ''')
+
+ assert results == [None, "", ""]
+
+
+def test_switching_camera_changes_only_the_url_and_not_segment_status():
+ result = evaluate('''
+ const segments = ["/video/0000006a--9f0a7bdf9c--7"]
+ const before = getSegmentStatus(segments, 0)
+ return {
+ url: cameraVideoUrl(segments[0], "driver"),
+ unchanged: getSegmentStatus(segments, 0) === before,
+ }
+ ''')
+
+ assert result["url"] == "/video/0000006a--9f0a7bdf9c--7?camera=driver"
+ assert result["unchanged"] is True
diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py
index 556efbaec2..392d656796 100644
--- a/starpilot/system/the_galaxy/the_galaxy.py
+++ b/starpilot/system/the_galaxy/the_galaxy.py
@@ -1124,7 +1124,11 @@ def _route_log_files(name):
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])):
+ try:
+ segments = utilities.get_segments_in_route(name, footage_path)
+ except OSError:
+ continue
+ for segment in sorted(segments, 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):
@@ -1213,6 +1217,143 @@ except TypeError:
# 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")
+ROUTE_METADATA_WORKERS = 4
+ROUTE_METADATA_BATCH_SIZE = 8
+ROUTE_THUMBNAIL_CACHE_SECONDS = 7 * 24 * 60 * 60
+# Browsers only allow a handful of connections per origin, so a request must never
+# park on the preview queue: give up and let the card fall back, the job keeps running.
+ROUTE_THUMBNAIL_WAIT_SECONDS = 25
+_ROUTE_THUMBNAIL_EXECUTOR = ThreadPoolExecutor(max_workers=2, thread_name_prefix="route-thumbnail")
+_ROUTE_THUMBNAIL_FUTURES = {}
+_ROUTE_THUMBNAIL_LOCK = threading.Lock()
+
+
+def _route_scan_entries(footage_paths):
+ """Route scan entries in footage-root priority order, deduplicated by route id."""
+ entries = []
+ seen_names = set()
+ for footage_path in footage_paths:
+ try:
+ route_details = utilities.get_routes_with_segment_details(footage_path)
+ except OSError:
+ continue
+ for name, details in route_details:
+ if name in seen_names:
+ continue
+ seen_names.add(name)
+ entries.append((
+ footage_path,
+ name,
+ max(0, int(details.get("segmentCount", 0))),
+ max(0, int(details.get("firstSegmentNum", 0))),
+ ))
+ return entries
+
+
+def _route_metadata_events(entries, connect_dongle_id="", process_route=None):
+ """Yield SSE payloads while keeping queued metadata work cancellable."""
+ route_processor = process_route or utilities.process_route
+ total = len(entries)
+ yield {"routes": [], "progress": 0, "total": total, "connectDongleId": connect_dongle_id}
+ if total == 0:
+ return
+
+ executor = ThreadPoolExecutor(max_workers=ROUTE_METADATA_WORKERS, thread_name_prefix="route-metadata")
+ futures = []
+ try:
+ futures = [
+ executor.submit(route_processor, path, name, segment_count, first_segment_num)
+ for path, name, segment_count, first_segment_num in entries
+ ]
+ batch = []
+ for processed, future in enumerate(as_completed(futures), start=1):
+ try:
+ batch.append(future.result())
+ except Exception as exception:
+ print(f"Error processing route: {exception}")
+
+ if len(batch) >= ROUTE_METADATA_BATCH_SIZE or processed == total:
+ yield {"routes": batch, "progress": processed, "total": total}
+ batch = []
+ finally:
+ for future in futures:
+ future.cancel()
+ executor.shutdown(wait=False, cancel_futures=True)
+
+
+def _route_first_segment_path(name, footage_path):
+ """Oldest surviving segment of a route. loggerd ages out --0 first, so it is not always --0."""
+ try:
+ segments = utilities.get_segments_in_route(name, footage_path)
+ except OSError:
+ return None
+ return os.path.join(footage_path, segments[0]) if segments else None
+
+
+def _resolve_route_thumbnail(file_path, footage_paths=None):
+ """Resolve only
/preview.png below a configured footage root."""
+ parts = Path(str(file_path or "")).parts
+ if len(parts) != 2 or parts[1] != "preview.png" or not utilities.SEGMENT_RE.fullmatch(parts[0]):
+ return None
+
+ for footage_path in footage_paths if footage_paths is not None else FOOTAGE_PATHS:
+ footage_root = Path(footage_path).resolve()
+ segment_path = (footage_root / parts[0]).resolve()
+ if segment_path.parent != footage_root or not segment_path.is_dir():
+ continue
+ preview_path = segment_path / "preview.png"
+ if preview_path.is_symlink():
+ continue
+ if preview_path.exists():
+ resolved_preview = preview_path.resolve()
+ if resolved_preview.parent != segment_path:
+ continue
+ return resolved_preview
+ return preview_path
+ return None
+
+
+def _generate_route_thumbnail(preview_path):
+ if preview_path.is_file():
+ return preview_path
+
+ for filename in ("qcamera.ts", "fcamera.hevc"):
+ source_path = preview_path.parent / filename
+ if source_path.resolve().parent == preview_path.parent and source_path.is_file() and utilities.video_to_png(source_path, preview_path) and preview_path.is_file():
+ return preview_path
+ return None
+
+
+def _remove_route_thumbnail_future(key, future):
+ with _ROUTE_THUMBNAIL_LOCK:
+ if _ROUTE_THUMBNAIL_FUTURES.get(key) is future:
+ _ROUTE_THUMBNAIL_FUTURES.pop(key, None)
+
+
+def _get_or_create_route_thumbnail(file_path, footage_paths=None):
+ preview_path = _resolve_route_thumbnail(file_path, footage_paths)
+ if preview_path is None:
+ return None
+ if preview_path.is_file():
+ return preview_path
+
+ key = str(preview_path)
+ created = False
+ with _ROUTE_THUMBNAIL_LOCK:
+ future = _ROUTE_THUMBNAIL_FUTURES.get(key)
+ if future is None:
+ future = _ROUTE_THUMBNAIL_EXECUTOR.submit(_generate_route_thumbnail, preview_path)
+ _ROUTE_THUMBNAIL_FUTURES[key] = future
+ created = True
+
+ if created:
+ future.add_done_callback(lambda completed: _remove_route_thumbnail_future(key, completed))
+
+ try:
+ return future.result(timeout=ROUTE_THUMBNAIL_WAIT_SECONDS)
+ except TimeoutError:
+ # The completion callback keeps the running job deduplicated, then evicts it when done.
+ return None
class _TarBuffer(io.RawIOBase):
@@ -6154,33 +6295,22 @@ def setup(app):
@app.route("/api/routes", methods=["GET"])
def list_routes():
def generate():
- routes = [
- (path, name, segment_count)
- for path in FOOTAGE_PATHS
- for name, segment_count in utilities.get_routes_with_segment_counts(path)
- ]
- total = len(routes)
+ routes = _route_scan_entries(FOOTAGE_PATHS)
connect_dongle_id = params.get("StockDongleId", encoding="utf-8") or params.get("DongleId", encoding="utf-8") or ""
- yield f"data: {json.dumps({'progress': 0, 'total': total, 'connectDongleId': connect_dongle_id})}\n\n"
+ for payload in _route_metadata_events(routes, connect_dongle_id):
+ yield f"data: {json.dumps(payload)}\n\n"
- with ThreadPoolExecutor(max_workers=10) as executor:
- futures = {
- executor.submit(utilities.process_route, path, name, segment_count): (path, name)
- for path, name, segment_count in routes
- }
- for processed, future in enumerate(as_completed(futures), start=1):
- try:
- result = future.result()
- yield f"data: {json.dumps({'routes': [result]})}\n\n"
- except Exception as exception:
- print(f"Error processing route: {exception}")
- yield f"data: {json.dumps({'progress': processed, 'total': total})}\n\n"
-
- return Response(generate(), mimetype="text/event-stream")
+ response = Response(generate(), mimetype="text/event-stream")
+ response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
+ response.headers["Pragma"] = "no-cache"
+ response.headers["X-Accel-Buffering"] = "no"
+ return response
@app.route("/api/routes/", methods=["DELETE"])
def delete_route(name):
for footage_path in FOOTAGE_PATHS:
+ if not os.path.isdir(footage_path):
+ continue
for segment in os.listdir(footage_path):
if segment.startswith(name):
delete_file(os.path.join(footage_path, segment))
@@ -6226,21 +6356,21 @@ def setup(app):
@app.route("/api/routes//preserve", methods=["POST"])
def preserve_route(name):
- preserved_routes = 0
+ preserved_routes = set()
for footage_path in FOOTAGE_PATHS:
+ if not os.path.isdir(footage_path):
+ continue
for segment in os.listdir(footage_path):
- if segment.endswith("--0"):
- segment_path = os.path.join(footage_path, segment)
- if PRESERVE_ATTR_NAME in os.listxattr(segment_path) and os.getxattr(segment_path, PRESERVE_ATTR_NAME) == PRESERVE_ATTR_VALUE:
- preserved_routes += 1
+ if utilities.SEGMENT_RE.fullmatch(segment) and utilities.has_preserve_attr(os.path.join(footage_path, segment)):
+ preserved_routes.add(segment.rsplit("--", 1)[0])
- if preserved_routes >= PRESERVE_COUNT:
+ if name not in preserved_routes and len(preserved_routes) >= PRESERVE_COUNT:
return {"error": f"Maximum of {PRESERVE_COUNT} preserved routes reached..."}, 400
for footage_path in FOOTAGE_PATHS:
- route_path = os.path.join(footage_path, f"{name}--0")
- if os.path.exists(route_path):
- os.setxattr(route_path, PRESERVE_ATTR_NAME, PRESERVE_ATTR_VALUE)
+ segment_path = _route_first_segment_path(name, footage_path)
+ if segment_path is not None:
+ os.setxattr(segment_path, PRESERVE_ATTR_NAME, PRESERVE_ATTR_VALUE)
return {"message": "Route preserved!!"}, 200
return {"error": "Route not found"}, 404
@@ -6248,9 +6378,9 @@ def setup(app):
@app.route("/api/routes//preserve", methods=["DELETE"])
def un_preserve_route(name):
for footage_path in FOOTAGE_PATHS:
- route_path = os.path.join(footage_path, f"{name}--0")
- if PRESERVE_ATTR_NAME in os.listxattr(route_path):
- os.removexattr(route_path, PRESERVE_ATTR_NAME)
+ segment_path = _route_first_segment_path(name, footage_path)
+ if segment_path is not None and utilities.has_preserve_attr(segment_path):
+ os.removexattr(segment_path, PRESERVE_ATTR_NAME)
return {"message": "Route unpreserved!"}, 200
return {"error": "Route not found"}, 404
@@ -6258,7 +6388,10 @@ def setup(app):
def get_combined_route_video(name):
camera = request.args.get("camera", "forward")
for footage_path in FOOTAGE_PATHS:
- segments = utilities.get_segments_in_route(name, footage_path)
+ try:
+ segments = utilities.get_segments_in_route(name, footage_path)
+ except OSError:
+ continue
if segments:
cam_file = {
"forward": "fcamera.hevc",
@@ -6282,15 +6415,20 @@ def setup(app):
@app.route("/api/routes/", methods=["GET"])
def get_route(name):
+ if not utilities.ROUTE_RE.fullmatch(name or ""):
+ return {"error": "Invalid route name"}, 400
for footage_path in FOOTAGE_PATHS:
- base_path = f"{footage_path}{name}--0"
- if os.path.exists(base_path):
+ try:
segments = utilities.get_segments_in_route(name, footage_path)
- if not segments:
- break
-
+ except OSError:
+ continue
+ if segments:
+ base_path = os.path.join(footage_path, segments[0])
segment_urls = [f"/video/{segment}" for segment in segments]
- total_duration = sum(utilities.get_video_duration(f"{footage_path}{name}--{i}/fcamera.hevc") for i in range(len(segment_urls)))
+ total_duration = sum(
+ utilities.get_video_duration(os.path.join(footage_path, segment, "fcamera.hevc"))
+ for segment in segments
+ )
return {
"name": name,
"segment_urls": segment_urls,
@@ -6355,6 +6493,7 @@ def setup(app):
return response
@app.route("/api/routes/clear_name", methods=["POST"])
+ @app.route("/api/routes/reset_name", methods=["POST"])
def clear_route_name():
data = request.get_json()
route_name = data.get("name")
@@ -6375,7 +6514,7 @@ def setup(app):
for segment in segments_to_process:
segment_dir = os.path.join(footage_path, segment)
for item in os.listdir(segment_dir):
- if not item.endswith((".hevc", ".ts", ".png", ".gif")) and item not in utilities.LOG_CANDIDATES:
+ if utilities.is_route_marker_file(item):
try:
os.remove(os.path.join(segment_dir, item))
cleared = True
@@ -6414,7 +6553,7 @@ def setup(app):
for segment in segments_to_process:
segment_dir = os.path.join(footage_path, segment)
for item in os.listdir(segment_dir):
- if not item.endswith((".hevc", ".ts", ".png", ".gif", "rlog")):
+ if utilities.is_route_marker_file(item):
try:
os.remove(os.path.join(segment_dir, item))
except OSError:
@@ -8768,10 +8907,18 @@ def setup(app):
@app.route("/thumbnails/", methods=["GET"])
def get_thumbnail(file_path):
- for footage_path in FOOTAGE_PATHS:
- if os.path.exists(os.path.join(footage_path, file_path)):
- return send_from_directory(footage_path, file_path, as_attachment=True)
- return {"error": "Thumbnail not found"}, 404
+ preview_path = _get_or_create_route_thumbnail(file_path)
+ if preview_path is None:
+ return {"error": "Thumbnail not found"}, 404
+
+ response = send_file(
+ preview_path,
+ mimetype="image/png",
+ conditional=True,
+ max_age=ROUTE_THUMBNAIL_CACHE_SECONDS,
+ )
+ response.headers["Cache-Control"] = f"public, max-age={ROUTE_THUMBNAIL_CACHE_SECONDS}"
+ return response
@app.route("/video/", methods=["GET"])
def get_video(path):
diff --git a/starpilot/system/the_galaxy/utilities.py b/starpilot/system/the_galaxy/utilities.py
index db0a97d0f6..4bee69c82f 100644
--- a/starpilot/system/the_galaxy/utilities.py
+++ b/starpilot/system/the_galaxy/utilities.py
@@ -15,7 +15,7 @@ import sys
import threading
import time
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import List
from urllib.parse import quote
@@ -2993,19 +2993,23 @@ def get_routes_names(footage_path):
route_times = {segment.route_name.time_str for segment in segments}
return sorted(route_times, reverse=True)
-def get_routes_with_segment_counts(footage_path):
- route_counts = {}
+def get_routes_with_segment_details(footage_path):
+ route_details = {}
for segment in get_all_segment_names(footage_path):
route_name = segment.route_name.time_str
- route_counts[route_name] = route_counts.get(route_name, 0) + 1
- return sorted(route_counts.items(), reverse=True)
+ segment_num = int(getattr(segment, "segment_num", 0))
+ details = route_details.setdefault(route_name, {"segmentCount": 0, "firstSegmentNum": segment_num})
+ details["segmentCount"] += 1
+ details["firstSegmentNum"] = min(details["firstSegmentNum"], segment_num)
+ return sorted(route_details.items(), reverse=True)
def get_segments_in_route(route_time_str, footage_path):
- return [
+ segments = [
f"{segment.time_str}--{segment.segment_num}"
for segment in get_all_segment_names(footage_path)
if segment.time_str == route_time_str
]
+ return sorted(segments, key=lambda segment: int(segment.rsplit("--", 1)[1]))
def get_video_duration(input_path):
try:
@@ -3018,7 +3022,10 @@ def get_video_duration(input_path):
return 60
def has_preserve_attr(path: str):
- return PRESERVE_ATTR_NAME in os.listxattr(path) and os.getxattr(path, PRESERVE_ATTR_NAME) == PRESERVE_ATTR_VALUE
+ try:
+ return PRESERVE_ATTR_NAME in os.listxattr(path) and os.getxattr(path, PRESERVE_ATTR_NAME) == PRESERVE_ATTR_VALUE
+ except (AttributeError, OSError):
+ return False
def list_file(path):
return sorted(os.listdir(path), reverse=True)
@@ -3035,30 +3042,35 @@ def normalize_theme_name(name, for_path=False):
return f"{normalized_parts[0]} ({' '.join(normalized_parts[1:])})".replace(" Week", "")
return ' '.join(normalized_parts).replace(" Week", "")
-def process_route(footage_path, route_name, segment_count=0):
- segment_path = f"{footage_path}{route_name}--0"
- qcamera_path = f"{segment_path}/qcamera.ts"
+def is_route_marker_file(filename):
+ """A renamed route stores its display name as an empty marker file in the segment."""
+ return not filename.endswith((".hevc", ".ts", ".png", ".gif")) and filename not in LOG_CANDIDATES
- png_output_path = os.path.join(segment_path, "preview.png")
- if not os.path.exists(png_output_path):
- video_to_png(qcamera_path, png_output_path)
+def _utc_rfc3339(value):
+ if value is None:
+ return None
+ # Naive values come off the filesystem in local time; astimezone reads them that way.
+ return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
+def process_route(footage_path, route_name, segment_count=0, first_segment_num=0):
+ segment_name = f"{route_name}--{max(0, int(first_segment_num))}"
+ segment_path = os.path.join(footage_path, segment_name)
custom_name = None
if os.path.isdir(segment_path):
for item in os.listdir(segment_path):
- if not item.endswith((".hevc", ".ts", ".png", ".gif")) and item not in LOG_CANDIDATES:
+ if is_route_marker_file(item):
custom_name = item
break
- route_timestamp_str = custom_name
- if not custom_name:
- route_timestamp_dt = get_route_start_time(segment_path)
- route_timestamp_str = route_timestamp_dt.isoformat() if route_timestamp_dt else None
+ route_timestamp_dt = get_route_start_time(segment_path)
+ route_timestamp_str = custom_name or (route_timestamp_dt.isoformat() if route_timestamp_dt else None)
return {
"name": route_name,
- "png": f"/thumbnails/{route_name}--0/preview.png",
+ "png": f"/thumbnails/{segment_name}/preview.png",
"timestamp": route_timestamp_str,
+ "startedAt": _utc_rfc3339(route_timestamp_dt),
+ "isCustomName": custom_name is not None,
"is_preserved": has_preserve_attr(segment_path),
"segmentCount": max(0, int(segment_count)),
"approxDurationSeconds": max(0, int(segment_count)) * 60,
@@ -3088,6 +3100,8 @@ def segment_to_segment_name(data_dir, segment):
full_path = os.path.join(data_dir, f"FakeDongleID1337|{segment}")
return SegmentName(full_path)
+VIDEO_TO_PNG_TIMEOUT_SECONDS = 20
+
def video_to_png(input_path, output_path):
try:
subprocess.run([
@@ -3097,11 +3111,17 @@ def video_to_png(input_path, output_path):
"-frames:v", "1",
"-y",
str(output_path)
- ], capture_output=True, check=True, text=True)
- except subprocess.CalledProcessError as e:
+ ], capture_output=True, check=True, text=True, timeout=VIDEO_TO_PNG_TIMEOUT_SECONDS)
+ return os.path.isfile(output_path)
+ except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
print(f"Failed to generate PNG for {input_path}")
- if e.stderr:
+ if getattr(e, "stderr", None):
print(e.stderr)
+ try:
+ Path(output_path).unlink(missing_ok=True)
+ except OSError:
+ pass
+ return False
def xor_encrypt_decrypt(data, key):
return "".join(chr(ord(c) ^ ord(key[i % len(key)])) for i, c in enumerate(data))
From e3bfb66ded91e946c743a0925d5ac9877117ca32 Mon Sep 17 00:00:00 2001
From: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Date: Thu, 27 Aug 2026 11:43:35 -0700
Subject: [PATCH 04/22] Update Layout
---
.../components/recordings/dashcam_routes.css | 637 +++++++++++++++---
.../components/recordings/dashcam_routes.js | 192 ++++--
.../recordings/dashcam_routes_helpers.js | 48 ++
3 files changed, 759 insertions(+), 118 deletions(-)
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 4286d1e6b8..3d3186b021 100644
--- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css
+++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css
@@ -445,7 +445,15 @@
.dashcam-library-header {
justify-content: space-between;
+ align-items: flex-start;
gap: var(--gap-md);
+ flex-wrap: wrap;
+}
+
+.dashcam-header-info {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
}
.dashcam-library-header h1,
@@ -471,89 +479,230 @@
text-transform: uppercase;
}
-.dashcam-refresh-button,
-.dashcam-preserved-filter,
-.dashcam-sort,
-.dashcam-search {
+/* Stats Chips in Header */
+.dashcam-stats-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--gap-sm);
+ margin-top: 0.4rem;
+}
+
+.dashcam-stat-chip {
+ align-items: center;
+ background: var(--input-bg);
+ border: var(--border-width-thin) solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-xl);
+ color: var(--text-muted);
+ display: inline-flex;
+ font-size: var(--font-size-xs);
+ gap: 0.35rem;
+ padding: 0.3rem 0.75rem;
+}
+
+.dashcam-stat-chip strong {
+ color: var(--text-color);
+ font-weight: var(--font-weight-demi-bold);
+}
+
+.dashcam-stat-chip i {
+ color: var(--main-fg);
+ font-size: 0.85rem;
+}
+
+.dashcam-stat-chip.stat-chip-preserved i {
+ color: #ef6078;
+}
+
+.dashcam-header-controls {
+ display: flex;
+ align-items: center;
+ gap: var(--gap-sm);
+}
+
+.dashcam-refresh-button {
background: var(--input-bg);
border: var(--border-width-thin) solid var(--sidebar-border-color);
border-radius: var(--border-radius-md);
color: var(--text-color);
-}
-
-.dashcam-refresh-button,
-.dashcam-preserved-filter {
cursor: pointer;
- font-size: var(--font-size-base);
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: var(--font-size-sm);
font-weight: var(--font-weight-demi-bold);
- padding: 0.7rem 1rem;
+ padding: 0.6rem 0.95rem;
+ transition: background-color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
}
-.dashcam-refresh-button:disabled,
-.dashcam-player-actions button:disabled {
+.dashcam-refresh-button:hover {
+ background: var(--sidebar-active-bg);
+ border-color: var(--main-fg);
+ transform: translateY(-1px);
+}
+
+.dashcam-refresh-button:disabled {
cursor: not-allowed;
opacity: var(--disabled-opacity);
}
+/* Modern Toolbar */
.dashcam-toolbar {
flex-wrap: wrap;
gap: var(--gap-sm);
margin-top: var(--margin-lg);
}
-.dashcam-search {
+.dashcam-search-box {
align-items: center;
+ background: var(--input-bg);
+ border: var(--border-width-thin) solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-md);
display: flex;
- flex: 1 1 22rem;
+ flex: 1 1 18rem;
gap: var(--gap-sm);
- padding: 0 0.9rem;
+ padding: 0 0.8rem;
+ position: relative;
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
-.dashcam-search input,
-.dashcam-sort select {
+.dashcam-search-box:focus-within {
+ border-color: var(--main-fg);
+ box-shadow: 0 0 0 2px rgba(139, 108, 197, 0.2);
+}
+
+.dashcam-search-icon {
+ color: var(--text-muted);
+ font-size: 0.95rem;
+}
+
+.dashcam-search-box input {
background: transparent;
border: 0;
color: var(--text-color);
font: inherit;
- outline: 0;
-}
-
-.dashcam-search input {
+ font-size: var(--font-size-sm);
min-width: 0;
- padding: 0.75rem 0;
+ outline: 0;
+ padding: 0.65rem 0;
width: 100%;
}
+.dashcam-search-clear {
+ background: transparent;
+ border: 0;
+ color: var(--text-muted);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.2rem;
+ border-radius: 50%;
+ font-size: 1.1rem;
+ line-height: 1;
+ transition: color var(--transition-fast), background-color var(--transition-fast);
+}
+
+.dashcam-search-clear:hover {
+ color: var(--text-color);
+ background: var(--sidebar-active-bg);
+}
+
+.dashcam-filter-group {
+ display: inline-flex;
+ background: var(--input-bg);
+ border: var(--border-width-thin) solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-md);
+ padding: 2px;
+ gap: 2px;
+}
+
+.dashcam-filter-pill {
+ background: transparent;
+ border: 0;
+ border-radius: calc(var(--border-radius-md) - 2px);
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-demi-bold);
+ padding: 0.5rem 0.85rem;
+ transition: background-color var(--transition-fast), color var(--transition-fast);
+}
+
+.dashcam-filter-pill.active {
+ background: var(--sidebar-active-bg);
+ color: var(--text-color);
+ box-shadow: inset 0 0 0 1px var(--main-fg);
+}
+
+.dashcam-filter-pill .bi-heart-fill {
+ color: #ef6078;
+ margin-right: 0.25rem;
+}
+
.dashcam-sort {
+ align-items: center;
+ background: var(--input-bg);
+ border: var(--border-width-thin) solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-md);
+ display: flex;
gap: var(--gap-sm);
- padding: 0.65rem 0.85rem;
+ padding: 0.5rem 0.85rem;
}
.dashcam-sort span {
color: var(--text-muted);
- font-size: var(--font-size-sm);
+ font-size: var(--font-size-xs);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
}
.dashcam-sort select {
+ background: transparent;
+ border: 0;
+ color: var(--text-color);
cursor: pointer;
+ font: inherit;
+ font-size: var(--font-size-sm);
+ outline: 0;
}
-.dashcam-preserved-filter[aria-pressed="true"] {
- background: rgba(230, 78, 102, 0.16);
- border-color: rgba(230, 78, 102, 0.65);
+.dashcam-view-toggle {
+ display: inline-flex;
+ background: var(--input-bg);
+ border: var(--border-width-thin) solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-md);
+ padding: 2px;
+ gap: 2px;
}
-.dashcam-preserved-filter .bi-heart-fill {
- color: #ef6078;
- margin-right: 0.35rem;
+.dashcam-view-btn {
+ background: transparent;
+ border: 0;
+ border-radius: calc(var(--border-radius-md) - 2px);
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 1.1rem;
+ padding: 0.4rem 0.65rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color var(--transition-fast), color var(--transition-fast);
+}
+
+.dashcam-view-btn.active {
+ background: var(--sidebar-active-bg);
+ color: var(--main-fg);
+ box-shadow: inset 0 0 0 1px var(--main-fg);
}
.dashcam-results-summary {
color: var(--text-muted);
- font-size: var(--font-size-sm);
+ font-size: var(--font-size-xs);
justify-content: space-between;
margin-top: var(--margin-base);
min-height: 1.5rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
}
.dashcam-date-groups,
@@ -565,51 +714,332 @@
margin-top: var(--margin-lg);
}
-.dashcam-date-group > h2 {
+.dashcam-date-group-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
border-bottom: var(--border-width-thin) solid var(--sidebar-border-color);
- color: var(--text-color);
- font-size: var(--font-size-lg);
- margin: 0;
- padding-bottom: var(--padding-sm);
+ padding-bottom: var(--padding-xs);
+ margin-bottom: var(--margin-sm);
}
+.dashcam-date-group-header h2 {
+ color: var(--text-color);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-bold);
+ margin: 0;
+}
+
+.dashcam-date-group-count {
+ color: var(--text-muted);
+ font-size: var(--font-size-xs);
+}
+
+/* ============================================================
+ Modernized List View (Data & Telemetry Focused)
+ ============================================================ */
+.dashcam-routes-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+}
+
+.dashcam-route-row {
+ align-items: center;
+ background: var(--card-bg);
+ border: var(--border-width-thin) solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-md);
+ box-shadow: var(--shadow-xs);
+ cursor: pointer;
+ display: flex;
+ gap: var(--gap-md);
+ padding: 0.75rem 1rem;
+ position: relative;
+ transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast);
+}
+
+.dashcam-route-row:hover {
+ background: rgba(18, 18, 36, 0.95);
+ border-color: rgba(139, 108, 197, 0.4);
+ box-shadow: var(--shadow-sm);
+ transform: translateY(-1px);
+}
+
+.dashcam-route-row.is-preserved {
+ border-left: 3px solid #ef6078;
+}
+
+/* Compact Mini Preview (Non-dominant) */
+.dashcam-mini-preview {
+ aspect-ratio: 16 / 9;
+ background: var(--input-bg);
+ border: var(--border-width-thin) solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-sm);
+ flex-shrink: 0;
+ height: 52px;
+ overflow: hidden;
+ position: relative;
+ width: 92px;
+}
+
+.dashcam-mini-fallback {
+ align-items: center;
+ color: var(--text-muted);
+ display: flex;
+ inset: 0;
+ justify-content: center;
+ position: absolute;
+ font-size: 1.25rem;
+}
+
+.dashcam-mini-preview:not(.thumbnail-failed) .dashcam-mini-fallback {
+ visibility: hidden;
+}
+
+.dashcam-mini-img {
+ background: var(--sidebar-bg);
+ height: 100%;
+ object-fit: cover;
+ position: absolute;
+ width: 100%;
+ z-index: 1;
+}
+
+.dashcam-mini-play-overlay {
+ align-items: center;
+ background: rgba(10, 10, 22, 0.6);
+ color: white;
+ display: flex;
+ font-size: 1.2rem;
+ inset: 0;
+ justify-content: center;
+ opacity: 0;
+ position: absolute;
+ transition: opacity var(--transition-fast);
+ z-index: 2;
+}
+
+.dashcam-route-row:hover .dashcam-mini-play-overlay {
+ opacity: 1;
+}
+
+/* Route Info & Telemetry */
+.dashcam-route-info {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 0.25rem;
+ min-width: 0;
+}
+
+.dashcam-route-title-row {
+ align-items: center;
+ display: flex;
+ gap: 0.5rem;
+ min-width: 0;
+}
+
+.dashcam-route-title {
+ color: var(--text-color);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-demi-bold);
+ line-height: 1.3;
+ margin: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dashcam-custom-badge {
+ align-items: center;
+ background: rgba(139, 108, 197, 0.15);
+ border: 1px solid rgba(139, 108, 197, 0.35);
+ border-radius: var(--border-radius-sm);
+ color: var(--main-fg);
+ display: inline-flex;
+ font-size: 0.7rem;
+ font-weight: var(--font-weight-bold);
+ gap: 0.25rem;
+ padding: 0.1rem 0.4rem;
+ text-transform: uppercase;
+ flex-shrink: 0;
+}
+
+.dashcam-route-subdate {
+ color: var(--text-muted);
+ font-size: var(--font-size-xs);
+ margin: 0;
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.dashcam-route-meta-pills {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ margin-top: 0.15rem;
+}
+
+.meta-pill {
+ align-items: center;
+ background: var(--input-bg);
+ border: var(--border-width-thin) solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-sm);
+ color: var(--text-muted);
+ display: inline-flex;
+ font-size: var(--font-size-xs);
+ gap: 0.3rem;
+ padding: 0.15rem 0.5rem;
+ white-space: nowrap;
+}
+
+.meta-pill.duration-pill {
+ color: var(--text-color);
+}
+
+.meta-pill.duration-pill i {
+ color: var(--main-fg);
+}
+
+.meta-pill.segments-pill i {
+ color: var(--text-muted);
+}
+
+.meta-pill.preserved-pill {
+ background: rgba(239, 96, 120, 0.12);
+ border-color: rgba(239, 96, 120, 0.35);
+ color: #ef6078;
+}
+
+.meta-pill.id-pill {
+ font-family: var(--font-mono);
+ font-size: 0.7rem;
+ letter-spacing: 0.02em;
+ opacity: 0.85;
+}
+
+/* Route Action Buttons */
+.dashcam-route-actions {
+ align-items: center;
+ display: flex;
+ flex-shrink: 0;
+ gap: 0.4rem;
+}
+
+.btn-route-action {
+ align-items: center;
+ background: var(--input-bg);
+ border: var(--border-width-thin) solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-md);
+ color: var(--text-color);
+ cursor: pointer;
+ display: inline-flex;
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-demi-bold);
+ gap: 0.35rem;
+ justify-content: center;
+ padding: 0.45rem 0.75rem;
+ transition: background-color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
+}
+
+.btn-route-action:hover {
+ background: var(--sidebar-active-bg);
+ border-color: var(--main-fg);
+ transform: translateY(-1px);
+}
+
+.btn-route-action.btn-play {
+ background: var(--main-fg);
+ border-color: var(--main-fg);
+ color: var(--text-on-primary);
+}
+
+.btn-route-action.btn-play:hover {
+ filter: brightness(1.12);
+}
+
+.btn-route-action.btn-preserve {
+ padding: 0.45rem 0.6rem;
+ color: var(--text-muted);
+}
+
+.btn-route-action.btn-preserve.active {
+ background: rgba(239, 96, 120, 0.15);
+ border-color: rgba(239, 96, 120, 0.45);
+ color: #ef6078;
+}
+
+.btn-route-action.btn-preserve:hover {
+ color: #ef6078;
+}
+
+.btn-route-action.btn-icon {
+ color: var(--text-muted);
+ padding: 0.45rem 0.6rem;
+}
+
+.btn-route-action.btn-icon:hover {
+ color: var(--text-color);
+}
+
+.btn-route-action.btn-danger-action:hover {
+ background: rgba(224, 85, 119, 0.15);
+ border-color: var(--danger-bg);
+ color: var(--danger-bg);
+}
+
+/* ============================================================
+ Compact Grid View Mode
+ ============================================================ */
.dashcam-library .screen-recordings-grid.dashcam-routes-grid {
- gap: clamp(0.8rem, 1.5vw, 1.25rem);
- grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
- margin-top: var(--margin-base);
+ gap: clamp(0.8rem, 1.5vw, 1.1rem);
+ grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
+ margin-top: var(--margin-sm);
}
.dashcam-library .recording-card.dashcam-route-card {
+ background: var(--card-bg);
border: var(--border-width-thin) solid var(--sidebar-border-color);
- border-radius: var(--border-radius-lg);
- box-shadow: var(--shadow-sm);
+ border-radius: var(--border-radius-md);
+ box-shadow: var(--shadow-xs);
+ display: flex;
+ flex-direction: column;
overflow: hidden;
position: relative;
text-align: left;
+ transition: border-color var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast);
}
.dashcam-library .recording-card.dashcam-route-card:hover {
- box-shadow: var(--shadow-md);
+ border-color: rgba(139, 108, 197, 0.45);
+ box-shadow: var(--shadow-sm);
transform: translateY(-2px);
}
-.dashcam-route-card .preserved-icon {
+.dashcam-card-top-bar {
align-items: center;
- background: rgba(10, 12, 18, 0.7);
- border: 0;
- border-radius: 50%;
- color: white;
+ background: var(--secondary-bg);
+ border-bottom: var(--border-width-thin) solid var(--sidebar-border-color);
display: flex;
- height: 2.6rem;
- justify-content: center;
- right: 0.7rem;
- top: 0.7rem;
- width: 2.6rem;
+ justify-content: space-between;
+ padding: 0.35rem 0.6rem;
+}
+
+.dashcam-card-top-bar .btn-preserve {
+ background: transparent;
+ border: 0;
+ padding: 0.15rem 0.3rem;
}
.dashcam-route-card .dashcam-preview {
+ aspect-ratio: 16 / 9;
background: linear-gradient(135deg, var(--sidebar-bg), var(--input-bg));
+ max-height: 120px;
overflow: hidden;
+ position: relative;
+ width: 100%;
}
.dashcam-preview-fallback {
@@ -624,7 +1054,7 @@
}
.dashcam-preview-fallback i {
- font-size: 2rem;
+ font-size: 1.6rem;
}
.dashcam-preview:not(.thumbnail-failed) .dashcam-preview-fallback {
@@ -633,16 +1063,42 @@
.dashcam-preview img {
background: var(--sidebar-bg);
+ height: 100%;
+ object-fit: cover;
+ width: 100%;
z-index: 1;
}
+.dashcam-grid-play-overlay {
+ align-items: center;
+ background: rgba(10, 10, 22, 0.55);
+ color: white;
+ display: flex;
+ font-size: 1.8rem;
+ inset: 0;
+ justify-content: center;
+ opacity: 0;
+ position: absolute;
+ transition: opacity var(--transition-fast);
+ z-index: 2;
+}
+
+.dashcam-route-card:hover .dashcam-grid-play-overlay {
+ opacity: 1;
+}
+
.dashcam-card-body {
- padding: var(--padding-base);
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 0.4rem;
+ padding: 0.75rem;
}
.dashcam-card-body h3 {
color: var(--text-color);
- font-size: var(--font-size-lg);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-demi-bold);
line-height: 1.3;
margin: 0;
overflow: hidden;
@@ -650,34 +1106,26 @@
white-space: nowrap;
}
-.dashcam-card-date,
-.dashcam-card-details,
-.dashcam-preserve-status {
+.dashcam-card-date {
color: var(--text-muted);
- font-size: var(--font-size-sm);
- margin: var(--margin-xs) 0 0;
-}
-
-.dashcam-card-details {
- display: flex;
- gap: var(--gap-sm);
-}
-
-.dashcam-card-details span + span::before {
- content: "·";
- margin-right: var(--gap-sm);
-}
-
-.dashcam-preserve-status {
font-size: var(--font-size-xs);
- font-weight: var(--font-weight-demi-bold);
- text-transform: uppercase;
+ margin: 0;
}
-.dashcam-preserve-status.preserved {
- color: #ef6078;
+.dashcam-card-actions {
+ align-items: center;
+ border-top: var(--border-width-thin) solid var(--sidebar-border-color);
+ display: flex;
+ gap: 0.3rem;
+ margin-top: auto;
+ padding-top: 0.5rem;
}
+.dashcam-card-actions .btn-play {
+ flex: 1;
+}
+
+/* Empty & Loading States */
.dashcam-loading,
.dashcam-empty-state {
align-items: center;
@@ -840,6 +1288,7 @@
margin-left: auto;
}
+/* Responsive Adaptations */
@media only screen and (max-width: 768px) {
.screen-recordings-wrapper.dashcam-routes-wrapper {
padding: 0 var(--padding-sm) var(--padding-lg);
@@ -856,9 +1305,19 @@
flex-direction: column;
}
- .dashcam-refresh-button,
- .dashcam-preserved-filter,
- .dashcam-sort {
+ .dashcam-toolbar {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .dashcam-search-box {
+ flex: 1 1 auto;
+ width: 100%;
+ }
+
+ .dashcam-filter-group,
+ .dashcam-sort,
+ .dashcam-view-toggle {
justify-content: center;
}
@@ -867,6 +1326,24 @@
flex: 1;
}
+ .dashcam-route-row {
+ flex-direction: column;
+ align-items: stretch;
+ gap: var(--gap-sm);
+ }
+
+ .dashcam-mini-preview {
+ width: 100%;
+ height: auto;
+ max-height: 140px;
+ }
+
+ .dashcam-route-actions {
+ border-top: var(--border-width-thin) solid var(--sidebar-border-color);
+ padding-top: var(--padding-xs);
+ justify-content: space-between;
+ }
+
.dashcam-library .screen-recordings-grid.dashcam-routes-grid {
grid-template-columns: 1fr;
}
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 d60554c6ac..652ef44599 100644
--- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js
+++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js
@@ -4,6 +4,7 @@ import { Modal } from "/assets/components/modal.js"
import {
buildRouteView,
cameraVideoUrl,
+ computeRouteStats,
formatApproxDuration,
getSegmentStatus,
groupRoutesByDate,
@@ -19,6 +20,7 @@ const state = reactive({
searchQuery: "",
sortOrder: "newest",
showPreservedOnly: false,
+ viewMode: "list",
progress: 0,
total: 0,
showDeleteAllModal: false,
@@ -29,6 +31,7 @@ let routesAbortController = null
let routesRequestToken = 0
let seenRouteNames = new Set()
let overlay = null
+const routeLogsCache = new Map()
function routeLabel(route) {
return route.displayName || route.displayDate || route.name
@@ -218,7 +221,7 @@ function openLogsDialog(route, logsButton, getCachedLogs, setCachedLogs) {
const closeLogsDialog = () => {
document.removeEventListener("keydown", handleKeydown)
closeDialog(logsDialog)
- logsButton.focus()
+ logsButton?.focus()
}
const handleKeydown = event => { if (event.key === "Escape") closeLogsDialog() }
closeButton.onclick = closeLogsDialog
@@ -240,7 +243,7 @@ function openLogsDialog(route, logsButton, getCachedLogs, setCachedLogs) {
`
}
- const cachedLogs = getCachedLogs()
+ const cachedLogs = getCachedLogs?.()
if (cachedLogs) {
renderLogs(cachedLogs)
return
@@ -253,7 +256,7 @@ function openLogsDialog(route, logsButton, getCachedLogs, setCachedLogs) {
if (content.isConnected) content.innerHTML = `${escapeHtml(data.error || "Could not read logs.")}
`
return
}
- setCachedLogs(data)
+ setCachedLogs?.(data)
if (content.isConnected) renderLogs(data)
})
.catch(error => {
@@ -261,6 +264,16 @@ function openLogsDialog(route, logsButton, getCachedLogs, setCachedLogs) {
})
}
+function openLogsFromRow(route, event) {
+ const button = event.currentTarget
+ openLogsDialog(
+ route,
+ button,
+ () => routeLogsCache.get(route.name) || null,
+ value => { routeLogsCache.set(route.name, value) },
+ )
+}
+
async function openOverlay(route) {
if (overlay) return
overlay = document.createElement("div")
@@ -369,7 +382,6 @@ async function openOverlay(route) {
setPlayerMessage("Switching camera…")
video.src = cameraVideoUrl(segments[current], selectedCamera)
video.load()
- // Switching cameras deliberately leaves current and the status strip unchanged.
})
}
@@ -406,7 +418,7 @@ function closeOverlay() {
}
async function togglePreserved(route, event) {
- event.stopPropagation()
+ event?.stopPropagation?.()
const isPreserved = !route.is_preserved
try {
const response = await fetch(`/api/routes/${route.name}/preserve`, { method: isPreserved ? "POST" : "DELETE" })
@@ -456,35 +468,71 @@ export function RouteRecordings() {
return html`