Record worker browser sessions
This commit is contained in:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user