import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; import { copyToClipboard } from "@/lib/clipboard"; import { useAdaptivePolling } from "@/hooks/use-adaptive-polling"; import { Input } from "@/components/input"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/tooltip"; import { ArrowLeft, Check, Copy, Download, Eye, FileText, Loader2, Save, ScrollText, Search, X, } from "lucide-react"; import { toast } from "sonner"; import { getSessionLogs, getSessionLogContent, getSessionLogBlob, getSessionRecordingRetention, setSessionRecordingRetention, deleteSessionLog, type SessionLogRecord, } from "@/api/session-log-api"; import { SessionRecordingPlayer } from "@/features/session-recording/SessionRecordingPlayer"; import { asciicastToPlainText, parseAsciicast, } from "@/features/session-recording/asciicast"; function formatDuration(seconds: number | null): string { if (seconds == null) return "--"; if (seconds < 60) return `${seconds}s`; const m = Math.floor(seconds / 60); const s = seconds % 60; return s > 0 ? `${m}m ${s}s` : `${m}m`; } function formatBytes(bytes: number | null): string { if (bytes == null || bytes === 0) return "--"; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function formatDate(iso: string): string { const d = new Date(iso); return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } function buildFilename(log: SessionLogRecord): string { const host = (log.hostName ?? log.hostIp ?? "session") .replace(/[^a-zA-Z0-9._-]/g, "_") .slice(0, 40); const d = new Date(log.startedAt); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); const h = String(d.getHours()).padStart(2, "0"); const min = String(d.getMinutes()).padStart(2, "0"); const s = String(d.getSeconds()).padStart(2, "0"); const extension = log.format === "guacamole" ? "guac" : log.format === "asciicast" ? "cast" : "log"; return `${host}_${y}-${m}-${day}_${h}-${min}-${s}.${extension}`; } function buildTextFilename(log: SessionLogRecord): string { return buildFilename(log).replace(/\.(cast|guac|log)$/, ".txt"); } async function extractPlainText( log: SessionLogRecord, blob: Blob, ): Promise { if (log.format === "guacamole") return null; const source = await blob.text(); if (log.format === "text") return source; return asciicastToPlainText(parseAsciicast(source)); } function SectionHeader({ label, count }: { label: string; count: number }) { return (
{label} {count}
); } function LogRow({ log, onView, onDownload, onDownloadText, onDelete, }: { log: SessionLogRecord; onView: () => void; onDownload: () => void; onDownloadText: () => void; onDelete: () => void; }) { const { t } = useTranslation(); const hostLabel = log.hostName ?? log.hostIp ?? `Host ${log.hostId}`; return (
{hostLabel} {formatDate(log.startedAt)} {" · "} {(log.protocol ?? "ssh").toUpperCase()} {log.username ? ` · ${log.username}` : ""} {" · "} {formatDuration(log.duration)} {" · "} {formatBytes(log.sizeBytes)}
View log {log.format === "asciicast" && ( {t("sessionLogs.downloadAsText")} )} Download Delete
); } export function SessionLogsPanel() { const { t } = useTranslation(); const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState(""); const [viewLog, setViewLog] = useState(null); const [viewContent, setViewContent] = useState(""); const [viewBlob, setViewBlob] = useState(null); const [viewText, setViewText] = useState(null); const [viewLoading, setViewLoading] = useState(false); const [deleteTarget, setDeleteTarget] = useState( null, ); const [deleting, setDeleting] = useState(false); const [copied, setCopied] = useState(false); const [retentionDays, setRetentionDays] = useState(null); const [savingRetention, setSavingRetention] = useState(false); const logsRef = useRef(logs); logsRef.current = logs; const load = useCallback( async (initial = false) => { if (initial) setLoading(true); try { const fresh = await getSessionLogs(); const changed = logsRef.current.length !== fresh.length || logsRef.current.some((log, i) => log.id !== fresh[i]?.id); if (changed) { logsRef.current = fresh; setLogs(fresh); } return changed; } catch (error) { if (initial) { toast.error(t("sessionLogs.loadError")); return false; } throw error; } finally { if (initial) setLoading(false); } }, [t], ); useEffect(() => { void load(true); getSessionRecordingRetention() .then(setRetentionDays) .catch(() => setRetentionDays(null)); }, [load]); useAdaptivePolling( () => load(false), { minIntervalMs: 5_000, maxIntervalMs: 30_000, stablePollsPerStep: 3, }, true, { runImmediately: false }, ); const q = filter.trim().toLowerCase(); const filtered = q ? logs.filter((l) => `${l.hostName ?? ""} ${l.hostIp ?? ""} ${l.username ?? ""} ${l.protocol}` .toLowerCase() .includes(q), ) : logs; const handleView = async (log: SessionLogRecord) => { setViewLog(log); setViewContent(""); setViewBlob(null); setViewText(null); setViewLoading(true); try { if (log.format === "text") { const text = await getSessionLogContent(log.id); setViewContent(text); setViewText(text); } else { const blob = await getSessionLogBlob(log.id); setViewBlob(blob); setViewText(await extractPlainText(log, blob)); } } catch { toast.error(t("sessionLogs.loadError")); } finally { setViewLoading(false); } }; const downloadBlob = (blob: Blob, filename: string) => { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }; const handleDownload = async (log: SessionLogRecord) => { try { downloadBlob(await getSessionLogBlob(log.id), buildFilename(log)); } catch { toast.error(t("sessionLogs.loadError")); } }; const handleDownloadText = async (log: SessionLogRecord) => { try { const text = log.id === viewLog?.id && viewText != null ? viewText : await extractPlainText(log, await getSessionLogBlob(log.id)); if (text == null) { toast.error(t("sessionLogs.loadError")); return; } downloadBlob( new Blob([text], { type: "text/plain" }), buildTextFilename(log), ); } catch { toast.error(t("sessionLogs.loadError")); } }; const handleDelete = async () => { if (!deleteTarget) return; setDeleting(true); try { await deleteSessionLog(deleteTarget.id); setLogs((prev) => prev.filter((l) => l.id !== deleteTarget.id)); setDeleteTarget(null); } catch { toast.error(t("sessionLogs.deleteError")); } finally { setDeleting(false); } }; const handleCopy = async () => { if (!viewText) return; const ok = await copyToClipboard(viewText); if (ok) { setCopied(true); setTimeout(() => setCopied(false), 1500); } else { toast.error(t("common.copyFailed")); } }; if (loading) { return (
); } // Inline log viewer if (viewLog) { const hostLabel = viewLog.hostName ?? viewLog.hostIp ?? `Host ${viewLog.hostId}`; return (
{/* Viewer header */}
{hostLabel} {formatDate(viewLog.startedAt)} {` · ${viewLog.protocol.toUpperCase()}`} {viewLog.duration != null ? ` · ${formatDuration(viewLog.duration)}` : ""} {" · "} {formatBytes(viewLog.sizeBytes)}
{viewText != null && ( {copied ? t("sessionLogs.copied") : t("sessionLogs.copyContent")} )} {viewLog.format === "asciicast" && ( {t("sessionLogs.downloadAsText")} )} {t("sessionLogs.downloadLog")}
{/* Log content */}
{viewLoading ? (
) : viewBlob ? ( ) : (
              {viewContent || "(empty)"}
            
)}
); } return (
{retentionDays != null && (
Recording retention setRetentionDays(Number(event.target.value))} className="h-7 w-20 text-xs" aria-label="Recording retention days" /> days
)} {logs.length === 0 ? (
{t("sessionLogs.noLogs")} {t("sessionLogs.noLogsDesc")}
) : ( <>
setFilter(e.target.value)} className="pl-8 h-7 text-xs" />
{filtered.length === 0 ? (
No results for "{filter}"
) : (
{filtered.map((log) => ( handleView(log)} onDownload={() => handleDownload(log)} onDownloadText={() => handleDownloadText(log)} onDelete={() => setDeleteTarget(log)} /> ))}
)} )}
{/* Inline delete confirmation - positioned against the relative parent in AppShell */} {deleteTarget && (

{t("sessionLogs.confirmDelete")}

{deleteTarget.hostName ?? deleteTarget.hostIp ?? `Session ${deleteTarget.id}`} {" · "} {formatDate(deleteTarget.startedAt)}

)}
); }