import { useState, useEffect, useCallback, useMemo, useRef, type ReactNode, } from "react"; import { useTranslation, useToast, usePluginApi, usePermission, } from "@termix/plugin-sdk/frontend"; import { Input, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, copyToClipboard, } from "@termix/plugin-sdk/ui"; import { ArrowLeft, Check, Copy, Download, Eye, FileText, Loader2, ScrollText, Search, X, } from "lucide-react"; import { SessionRecordingPlayer } from "./SessionRecordingPlayer"; import { asciicastToPlainText, parseAsciicast } from "./asciicast"; import { createSessionRecordingApi, type SessionLogRecord, } from "./session-recording-api"; function useAdaptivePolling( fn: () => Promise, options: { minIntervalMs: number; maxIntervalMs: number }, enabled: boolean, ) { const fnRef = useRef(fn); fnRef.current = fn; useEffect(() => { if (!enabled) return; let interval = options.minIntervalMs; let stable = 0; let cancelled = false; let timer: ReturnType; const tick = async () => { if (cancelled) return; try { const changed = await fnRef.current(); if (changed) { interval = options.minIntervalMs; stable = 0; } else { stable++; if (stable >= 3) { interval = Math.min(interval * 1.5, options.maxIntervalMs); } } } catch { // keep polling } if (!cancelled) timer = setTimeout(tick, interval); }; timer = setTimeout(tick, interval); return () => { cancelled = true; clearTimeout(timer); }; }, [enabled, options.minIntervalMs, options.maxIntervalMs]); } 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 LogMeta({ items, className, }: { items: (string | null | undefined)[]; className?: string; }) { return ( {items.filter(Boolean).map((item, i) => ( {item} ))} ); } const ACTION_BUTTON = "size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"; function ActionButton({ label, onClick, destructive, children, }: { label: string; onClick: () => void; destructive?: boolean; children: ReactNode; }) { return ( {label} ); } 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 ?? t("sessionLogs.hostFallback", { id: log.hostId }); return (
{hostLabel}
{log.format === "asciicast" && ( )}
); } export function SessionLogsPanel() { const { t } = useTranslation(); const toast = useToast(); const pluginApi = usePluginApi(); const api = useMemo(() => createSessionRecordingApi(pluginApi), [pluginApi]); const canView = usePermission("view"); 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 logsRef = useRef(logs); logsRef.current = logs; const load = useCallback( async (initial = false) => { if (initial) setLoading(true); try { const fresh = await api.list(); 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); } }, [api, t, toast], ); useEffect(() => { if (!canView) return; void load(true); }, [canView, load]); useAdaptivePolling( () => load(false), { minIntervalMs: 5_000, maxIntervalMs: 30_000, }, canView, ); 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 api.getContent(log.id); setViewContent(text); setViewText(text); } else { const blob = await api.getBlob(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 api.getBlob(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 api.getBlob(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 api.delete(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 ?? t("sessionLogs.hostFallback", { id: viewLog.hostId }); return (
{/* Viewer header */}
{hostLabel}
{viewText != null && ( {copied ? ( ) : ( )} )} {viewLog.format === "asciicast" && ( handleDownloadText(viewLog)} > )} handleDownload(viewLog)} >
{/* Log content */}
{viewLoading ? (
) : viewBlob ? ( ) : (
              {viewContent || t("sessionLogs.empty")}
            
)}
); } return (
{logs.length === 0 ? (
{t("sessionLogs.noLogs")} {t("sessionLogs.noLogsDesc")}
) : ( <>
setFilter(e.target.value)} className="pl-8 h-7 text-xs" />
{filtered.length === 0 ? (
{t("sessionLogs.noResults", { query: 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 ?? t("sessionLogs.sessionFallback", { id: deleteTarget.id })} {formatDate(deleteTarget.startedAt)}
)}
); }