Record worker browser sessions

This commit is contained in:
2026-08-02 23:23:23 +03:30
parent 601b8ec69e
commit 66c8c19e0c
11 changed files with 384 additions and 14 deletions
+60
View File
@@ -3,10 +3,13 @@
from __future__ import annotations
import secrets
from datetime import UTC, datetime
from pathlib import Path
from typing import Annotated, Any
import speedtest # type: ignore[import-untyped]
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from pydantic import BaseModel
@@ -232,6 +235,63 @@ async def list_batch_runs(_: Auth) -> list[dict[str, object]]:
return await batch_runs_repo.list() # type: ignore[return-value]
# ---------------------------------------------------------------------------
# Browser-session recordings
# ---------------------------------------------------------------------------
def _recording_path(filename: str) -> Path:
if filename != Path(filename).name or Path(filename).suffix.lower() != ".mp4":
raise HTTPException(status_code=404, detail="Recording not found")
directory = Path(config.recordings_dir).resolve()
path = directory / filename
if path.is_symlink() or not path.is_file():
raise HTTPException(status_code=404, detail="Recording not found")
return path
@router.get("/api/recordings")
def list_recordings(_: Auth) -> list[dict[str, object]]:
directory = Path(config.recordings_dir)
if not directory.is_dir():
return []
rows: list[dict[str, object]] = []
for path in directory.glob("*.mp4"):
if path.is_symlink():
continue
try:
stat_result = path.stat()
except FileNotFoundError:
continue
rows.append(
{
"name": path.name,
"size_bytes": stat_result.st_size,
"created_at": datetime.fromtimestamp(
stat_result.st_mtime, tz=UTC
).isoformat(),
}
)
rows.sort(key=lambda row: str(row["created_at"]), reverse=True)
return rows
@router.get("/api/recordings/{filename}")
def download_recording(filename: str, _: Auth) -> FileResponse:
path = _recording_path(filename)
return FileResponse(path, media_type="video/mp4", filename=path.name)
@router.delete("/api/recordings/{filename}", status_code=204)
def delete_recording(filename: str, _: Auth) -> None:
path = _recording_path(filename)
try:
path.unlink()
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Recording not found") from None
# ---------------------------------------------------------------------------
# Log viewer
# ---------------------------------------------------------------------------
+75
View File
@@ -348,6 +348,7 @@
<div class="tab active" data-tab="dashboard">Dashboard</div>
<div class="tab" data-tab="flows">Flow Configs</div>
<div class="tab" data-tab="batch">Batch Run</div>
<div class="tab" data-tab="recordings">Recordings</div>
<div class="tab" data-tab="logs">Logs</div>
</div>
@@ -589,6 +590,29 @@
</main>
</div>
<!-- ── Recordings Tab ──────────────────────────────────────────── -->
<div class="tab-panel" id="tab-recordings">
<main>
<div class="panel" style="max-width:1100px">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:14px">
<div>
<div class="section-title" style="margin:0">Browser Session Recordings</div>
<div style="color:var(--muted);font-size:12px;margin-top:4px">The newest three completed worker sessions are retained at 12 FPS.</div>
</div>
<button class="btn" onclick="loadRecordings()" style="margin-left:auto;padding:5px 14px;font-size:12px">↻ Refresh</button>
</div>
<div style="overflow-x:auto">
<table>
<thead><tr><th>Recorded</th><th>Session</th><th>Size</th><th>Actions</th></tr></thead>
<tbody id="recordings-tbody">
<tr><td colspan="4" class="empty">No recordings yet.</td></tr>
</tbody>
</table>
</div>
</div>
</main>
</div>
<!-- ── Logs Tab ───────────────────────────────────────────────── -->
<div class="tab-panel" id="tab-logs">
<main>
@@ -712,6 +736,7 @@ document.querySelectorAll('.tab').forEach(tab => {
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
if (tab.dataset.tab === 'flows') loadConfigs();
if (tab.dataset.tab === 'batch') { loadConfigs(); pollBatchStatus(); loadBatchHistory(); }
if (tab.dataset.tab === 'recordings') loadRecordings();
if (tab.dataset.tab === 'logs') { loadLogs(); _startLogPoll(); }
else _stopLogPoll();
});
@@ -1421,6 +1446,56 @@ async function loadBatchHistory() {
renderBatchHistory(await res.json());
}
/* ─────────────────────────── Recordings ─────────────────────── */
function fmtBytes(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / Math.pow(1024, index)).toFixed(index ? 1 : 0)} ${units[index]}`;
}
async function loadRecordings() {
const res = await apiFetch('/api/recordings');
if (!res || !res.ok) return;
const rows = await res.json();
const tbody = document.getElementById('recordings-tbody');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="4" class="empty">No recordings yet.</td></tr>';
return;
}
tbody.innerHTML = rows.map(row => {
const encodedName = encodeURIComponent(row.name);
return `<tr>
<td style="white-space:nowrap;color:var(--muted)">${fmtTime(row.created_at)}</td>
<td style="font-family:monospace;font-size:12px">${esc(row.name)}</td>
<td style="white-space:nowrap;color:var(--muted)">${fmtBytes(row.size_bytes)}</td>
<td style="white-space:nowrap">
<button class="btn" onclick="downloadRecording('${encodedName}')" style="padding:4px 10px;font-size:12px">↓ Download</button>
<button class="btn btn-danger btn-sm" onclick="deleteRecording('${encodedName}')">Delete</button>
</td>
</tr>`;
}).join('');
}
async function downloadRecording(encodedName) {
const res = await apiFetch(`/api/recordings/${encodedName}`);
if (!res || !res.ok) return;
const blobUrl = URL.createObjectURL(await res.blob());
const link = document.createElement('a');
link.href = blobUrl;
link.download = decodeURIComponent(encodedName);
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(blobUrl);
}
async function deleteRecording(encodedName) {
if (!confirm(`Delete recording ${decodeURIComponent(encodedName)}?`)) return;
const res = await apiFetch(`/api/recordings/${encodedName}`, { method: 'DELETE' });
if (res && res.ok) await loadRecordings();
}
/* ─────────────────────────── Logs ──────────────────────────── */
let _logPollTimer = null;