feat: turn session recording into a plugin (B13)

This commit is contained in:
LukeGus committed 2026-09-24 00:09:48 -05:00
1 parent a276f6898c
commit 0af2c94983
147 files changed
+25915 -2074

No files matched your search

-1
View File
@@ -23,7 +23,6 @@ export type CoreRailView =
| "quick-connect"
| ToolsTab
| "connections"
| "session-logs"
| "user-profile"
| "admin-settings";
-21
View File
@@ -1789,27 +1789,6 @@ export function HostEditor({
onChange={(v) => setField("autoMosh", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.enableSessionLogging")}
description={
<>
{t("hosts.enableSessionLoggingDesc")}{" "}
<a
href="https://docs.termix.site/features/terminal/session-recording"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</>
}
>
<FakeSwitch
checked={form.enableSessionLogging}
onChange={(v) => setField("enableSessionLogging", v)}
/>
</SettingRow>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.localEchoLabel")}
-603
View File
@@ -1,603 +0,0 @@
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<string | null> {
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 (
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/20">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60 flex-1">
{label}
</span>
<span className="text-[10px] font-semibold text-muted-foreground/40 bg-muted/60 px-1.5 py-0.5">
{count}
</span>
</div>
);
}
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 (
<div className="group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 last:border-b-0 hover:bg-muted/40 transition-colors">
<div className="shrink-0 flex items-center justify-center size-7 bg-muted/60 text-muted-foreground">
<ScrollText className="size-3.5" />
</div>
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
<span className="text-xs font-semibold truncate text-foreground">
{hostLabel}
</span>
<span className="text-[10px] text-muted-foreground/60 truncate">
{formatDate(log.startedAt)}
{" · "}
{(log.protocol ?? "ssh").toUpperCase()}
{log.username ? ` · ${log.username}` : ""}
{" · "}
{formatDuration(log.duration)}
{" · "}
{formatBytes(log.sizeBytes)}
</span>
</div>
<TooltipProvider>
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onView}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<Eye className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">View log</TooltipContent>
</Tooltip>
{log.format === "asciicast" && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onDownloadText}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<FileText className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{t("sessionLogs.downloadAsText")}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onDownload}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<Download className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Download</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onDelete}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<X className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Delete</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
);
}
export function SessionLogsPanel() {
const { t } = useTranslation();
const [logs, setLogs] = useState<SessionLogRecord[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("");
const [viewLog, setViewLog] = useState<SessionLogRecord | null>(null);
const [viewContent, setViewContent] = useState<string>("");
const [viewBlob, setViewBlob] = useState<Blob | null>(null);
const [viewText, setViewText] = useState<string | null>(null);
const [viewLoading, setViewLoading] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<SessionLogRecord | null>(
null,
);
const [deleting, setDeleting] = useState(false);
const [copied, setCopied] = useState(false);
const [retentionDays, setRetentionDays] = useState<number | null>(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 (
<div className="flex items-center justify-center flex-1 p-8">
<Loader2 className="size-5 text-muted-foreground animate-spin" />
</div>
);
}
// Inline log viewer
if (viewLog) {
const hostLabel =
viewLog.hostName ?? viewLog.hostIp ?? `Host ${viewLog.hostId}`;
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Viewer header */}
<div className="flex items-center gap-2 px-2 py-2 border-b border-border/60 bg-muted/20 shrink-0">
<button
onClick={() => setViewLog(null)}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<ArrowLeft className="size-3.5" />
</button>
<div className="flex flex-col flex-1 min-w-0">
<span className="text-xs font-semibold truncate text-foreground">
{hostLabel}
</span>
<span className="text-[10px] text-muted-foreground/50">
{formatDate(viewLog.startedAt)}
{` · ${viewLog.protocol.toUpperCase()}`}
{viewLog.duration != null
? ` · ${formatDuration(viewLog.duration)}`
: ""}
{" · "}
{formatBytes(viewLog.sizeBytes)}
</span>
</div>
<TooltipProvider>
<div className="flex items-center gap-0.5 shrink-0">
{viewText != null && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleCopy}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
{copied ? (
<Check className="size-3 text-green-500" />
) : (
<Copy className="size-3" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="left">
{copied
? t("sessionLogs.copied")
: t("sessionLogs.copyContent")}
</TooltipContent>
</Tooltip>
)}
{viewLog.format === "asciicast" && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => handleDownloadText(viewLog)}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<FileText className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{t("sessionLogs.downloadAsText")}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => handleDownload(viewLog)}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<Download className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{t("sessionLogs.downloadLog")}
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
{/* Log content */}
<div className="flex-1 min-h-0 overflow-y-auto">
{viewLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : viewBlob ? (
<SessionRecordingPlayer log={viewLog} blob={viewBlob} />
) : (
<pre className="p-3 text-[11px] font-mono whitespace-pre-wrap break-all text-foreground/80 leading-relaxed">
{viewContent || "(empty)"}
</pre>
)}
</div>
</div>
);
}
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex-1 overflow-y-auto">
{retentionDays != null && (
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2 text-[10px] text-muted-foreground">
<span className="flex-1">Recording retention</span>
<Input
type="number"
min={1}
max={3650}
value={retentionDays}
onChange={(event) => setRetentionDays(Number(event.target.value))}
className="h-7 w-20 text-xs"
aria-label="Recording retention days"
/>
<span>days</span>
<button
type="button"
disabled={savingRetention}
onClick={async () => {
setSavingRetention(true);
try {
await setSessionRecordingRetention(retentionDays);
toast.success("Recording retention updated");
} catch {
toast.error("Failed to update recording retention");
} finally {
setSavingRetention(false);
}
}}
className="flex size-7 items-center justify-center hover:bg-muted disabled:opacity-50"
aria-label="Save recording retention"
>
{savingRetention ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Save className="size-3" />
)}
</button>
</div>
)}
{logs.length === 0 ? (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
<div className="size-10 bg-muted/40 flex items-center justify-center">
<ScrollText className="size-5 text-muted-foreground/30" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("sessionLogs.noLogs")}
</span>
<span className="text-xs text-muted-foreground/40">
{t("sessionLogs.noLogsDesc")}
</span>
</div>
</div>
) : (
<>
<div className="relative px-3 py-2 border-b border-border/60">
<Search className="absolute left-5.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50 pointer-events-none" />
<Input
placeholder={t("sessionLogs.filterByHost")}
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="pl-8 h-7 text-xs"
/>
</div>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
<span className="text-xs text-muted-foreground/50">
No results for &quot;{filter}&quot;
</span>
</div>
) : (
<div className="flex flex-col">
<SectionHeader
label={t("sessionLogs.title")}
count={filtered.length}
/>
{filtered.map((log) => (
<LogRow
key={log.id}
log={log}
onView={() => handleView(log)}
onDownload={() => handleDownload(log)}
onDownloadText={() => handleDownloadText(log)}
onDelete={() => setDeleteTarget(log)}
/>
))}
</div>
)}
</>
)}
</div>
{/* Inline delete confirmation - positioned against the relative parent in AppShell */}
{deleteTarget && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
<p className="text-sm text-foreground">
{t("sessionLogs.confirmDelete")}
</p>
<p className="text-xs text-muted-foreground">
{deleteTarget.hostName ??
deleteTarget.hostIp ??
`Session ${deleteTarget.id}`}
{" · "}
{formatDate(deleteTarget.startedAt)}
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setDeleteTarget(null)}
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
{t("common.cancel")}
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors flex items-center gap-1.5"
>
{deleting && <Loader2 className="size-3 animate-spin" />}
{t("sessionLogs.deleteLog")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
-9
View File
@@ -5,7 +5,6 @@ import {
KeyRound,
LayoutPanelLeft,
Plug,
ScrollText,
Server,
Settings,
User,
@@ -113,14 +112,6 @@ export const RAIL_ITEMS: RailItemDef[] = [
promotable: true,
rightDockable: true,
},
{
id: "session-logs",
icon: ScrollText,
labelKey: "nav.sessionLogs",
separatorAfter: true,
promotable: true,
rightDockable: true,
},
{
id: "split-screen",
icon: LayoutPanelLeft,