mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: turn session recording into a plugin (B13)
This commit is contained in:
1 parent
a276f6898c
commit
0af2c94983
147 files changed
+25915
-2074
No files matched your search
@@ -0,0 +1,21 @@
|
||||
import { Circle } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/** What ssh-terminal hands a "terminal.toolbarStatus" component. */
|
||||
interface TerminalToolbarStatusProps {
|
||||
host?: { pluginSettings?: Record<string, Record<string, unknown>> };
|
||||
isConnected?: boolean;
|
||||
}
|
||||
|
||||
export function RecordingStatus(props: Record<string, unknown>) {
|
||||
const { isConnected } = props as TerminalToolbarStatusProps;
|
||||
const { t } = useTranslation();
|
||||
if (!isConnected) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<Circle className="size-2 fill-red-500 text-red-500" />
|
||||
{t("terminalStatus.recording")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
import { useState, useEffect, useCallback, useRef } 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<boolean | void>,
|
||||
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<typeof setTimeout>;
|
||||
|
||||
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<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 toast = useToast();
|
||||
const api = createSessionRecordingApi(usePluginApi());
|
||||
const canView = usePermission("view");
|
||||
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 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 (
|
||||
<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">
|
||||
{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 "{filter}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Pause, Play } from "lucide-react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { Select2 } from "@termix/plugin-sdk/ui";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { parseAsciicast, type Asciicast } from "./asciicast";
|
||||
import type { SessionLogRecord } from "./session-recording-api";
|
||||
|
||||
const SPEEDS = [0.5, 1, 2, 4];
|
||||
|
||||
function formatPosition(seconds: number) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
function PlaybackControls({
|
||||
playing,
|
||||
position,
|
||||
duration,
|
||||
speed,
|
||||
onToggle,
|
||||
onSeek,
|
||||
onSpeed,
|
||||
}: {
|
||||
playing: boolean;
|
||||
position: number;
|
||||
duration: number;
|
||||
speed: number;
|
||||
onToggle: () => void;
|
||||
onSeek: (position: number) => void;
|
||||
onSpeed: (speed: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-t border-border/60 bg-muted/20 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex size-7 items-center justify-center hover:bg-muted"
|
||||
aria-label={playing ? "Pause recording" : "Play recording"}
|
||||
>
|
||||
{playing ? (
|
||||
<Pause className="size-3.5" />
|
||||
) : (
|
||||
<Play className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<span className="w-10 text-[10px] tabular-nums text-muted-foreground">
|
||||
{formatPosition(position)}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(duration, 0.01)}
|
||||
step={0.01}
|
||||
value={Math.min(position, duration)}
|
||||
onChange={(event) => onSeek(Number(event.target.value))}
|
||||
className="min-w-0 flex-1 accent-primary"
|
||||
aria-label="Recording timeline"
|
||||
/>
|
||||
<span className="w-10 text-right text-[10px] tabular-nums text-muted-foreground">
|
||||
{formatPosition(duration)}
|
||||
</span>
|
||||
<Select2
|
||||
value={speed}
|
||||
onChange={(event) => onSpeed(Number(event.target.value))}
|
||||
className="h-7 border border-border bg-background px-1 text-[10px]"
|
||||
aria-label="Playback speed"
|
||||
>
|
||||
{SPEEDS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}×
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AsciicastPlayer({ blob }: { blob: Blob }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const recordingRef = useRef<Asciicast | null>(null);
|
||||
const positionRef = useRef(0);
|
||||
const eventIndexRef = useRef(0);
|
||||
const [position, setPosition] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [speed, setSpeed] = useState(1);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const renderAt = useCallback((nextPosition: number) => {
|
||||
const terminal = terminalRef.current;
|
||||
const recording = recordingRef.current;
|
||||
if (!terminal || !recording) return;
|
||||
if (nextPosition < positionRef.current) {
|
||||
terminal.reset();
|
||||
terminal.resize(recording.width, recording.height);
|
||||
eventIndexRef.current = 0;
|
||||
}
|
||||
while (eventIndexRef.current < recording.events.length) {
|
||||
const [time, type, data] = recording.events[eventIndexRef.current];
|
||||
if (time > nextPosition) break;
|
||||
if (type === "o") terminal.write(data);
|
||||
if (type === "r") {
|
||||
const [cols, rows] = data.split("x").map(Number);
|
||||
if (cols > 0 && rows > 0) terminal.resize(cols, rows);
|
||||
}
|
||||
eventIndexRef.current++;
|
||||
}
|
||||
positionRef.current = nextPosition;
|
||||
setPosition(nextPosition);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
convertEol: false,
|
||||
fontSize: 12,
|
||||
theme: { background: "#09090b" },
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(containerRef.current);
|
||||
terminalRef.current = terminal;
|
||||
const observer = new ResizeObserver(() => fitAddon.fit());
|
||||
observer.observe(containerRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
blob
|
||||
.text()
|
||||
.then((source) => {
|
||||
const recording = parseAsciicast(source);
|
||||
recordingRef.current = recording;
|
||||
setDuration(recording.duration);
|
||||
terminal.resize(recording.width, recording.height);
|
||||
renderAt(0);
|
||||
})
|
||||
.catch((reason) => setError(getErrorMessage(reason, String(reason))));
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
terminal.dispose();
|
||||
terminalRef.current = null;
|
||||
};
|
||||
}, [blob, renderAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing || !recordingRef.current) return;
|
||||
let frame = 0;
|
||||
let previous = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const next = Math.min(
|
||||
duration,
|
||||
positionRef.current + ((now - previous) / 1000) * speed,
|
||||
);
|
||||
previous = now;
|
||||
renderAt(next);
|
||||
if (next >= duration) setPlaying(false);
|
||||
else frame = requestAnimationFrame(tick);
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [duration, playing, renderAt, speed]);
|
||||
|
||||
if (error) return <div className="p-4 text-xs text-destructive">{error}</div>;
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div ref={containerRef} className="min-h-0 flex-1 bg-[#09090b] p-2" />
|
||||
<PlaybackControls
|
||||
playing={playing}
|
||||
position={position}
|
||||
duration={duration}
|
||||
speed={speed}
|
||||
onToggle={() => {
|
||||
if (!playing && position >= duration) renderAt(0);
|
||||
setPlaying((value) => !value);
|
||||
}}
|
||||
onSeek={renderAt}
|
||||
onSpeed={setSpeed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GuacamolePlayer({ blob }: { blob: Blob }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const recordingRef = useRef<Guacamole.SessionRecording | null>(null);
|
||||
const positionRef = useRef(0);
|
||||
const [position, setPosition] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [speed, setSpeed] = useState(1);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const recording = new Guacamole.SessionRecording(blob, 100);
|
||||
recordingRef.current = recording;
|
||||
const display = recording.getDisplay();
|
||||
const element = display.getElement();
|
||||
containerRef.current.replaceChildren(element);
|
||||
const fit = () => {
|
||||
const width = display.getWidth();
|
||||
if (width && containerRef.current) {
|
||||
display.scale(containerRef.current.clientWidth / width);
|
||||
}
|
||||
};
|
||||
const observer = new ResizeObserver(fit);
|
||||
observer.observe(containerRef.current);
|
||||
recording.onload = () => {
|
||||
setDuration(recording.getDuration() / 1000);
|
||||
fit();
|
||||
};
|
||||
recording.onprogress = (nextDuration) => setDuration(nextDuration / 1000);
|
||||
recording.onseek = (nextPosition) => {
|
||||
positionRef.current = nextPosition / 1000;
|
||||
setPosition(positionRef.current);
|
||||
};
|
||||
recording.onerror = setError;
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
recording.abort();
|
||||
recordingRef.current = null;
|
||||
};
|
||||
}, [blob]);
|
||||
|
||||
useEffect(() => {
|
||||
const recording = recordingRef.current;
|
||||
if (!recording || !playing) return;
|
||||
recording.pause();
|
||||
let previous = performance.now();
|
||||
let nextPosition = positionRef.current;
|
||||
const interval = window.setInterval(() => {
|
||||
const now = performance.now();
|
||||
nextPosition = Math.min(
|
||||
duration,
|
||||
nextPosition + ((now - previous) / 1000) * speed,
|
||||
);
|
||||
previous = now;
|
||||
recording.seek(nextPosition * 1000);
|
||||
if (nextPosition >= duration) setPlaying(false);
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, [duration, playing, speed]);
|
||||
|
||||
if (error) return <div className="p-4 text-xs text-destructive">{error}</div>;
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="min-h-0 flex-1 overflow-hidden bg-black"
|
||||
/>
|
||||
<PlaybackControls
|
||||
playing={playing}
|
||||
position={position}
|
||||
duration={duration}
|
||||
speed={speed}
|
||||
onToggle={() => {
|
||||
const recording = recordingRef.current;
|
||||
if (!recording) return;
|
||||
if (!playing && position >= duration) recording.seek(0);
|
||||
setPlaying((value) => !value);
|
||||
}}
|
||||
onSeek={(nextPosition) => {
|
||||
recordingRef.current?.seek(nextPosition * 1000);
|
||||
positionRef.current = nextPosition;
|
||||
setPosition(nextPosition);
|
||||
}}
|
||||
onSpeed={setSpeed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionRecordingPlayer({
|
||||
log,
|
||||
blob,
|
||||
}: {
|
||||
log: SessionLogRecord;
|
||||
blob: Blob;
|
||||
}) {
|
||||
return log.format === "guacamole" ? (
|
||||
<GuacamolePlayer blob={blob} />
|
||||
) : (
|
||||
<AsciicastPlayer blob={blob} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
export type AsciicastEvent = [
|
||||
time: number,
|
||||
type: "i" | "o" | "r",
|
||||
data: string,
|
||||
];
|
||||
|
||||
export type Asciicast = {
|
||||
width: number;
|
||||
height: number;
|
||||
duration: number;
|
||||
events: AsciicastEvent[];
|
||||
};
|
||||
|
||||
export function parseAsciicast(source: string): Asciicast {
|
||||
const lines = source.trim().split("\n");
|
||||
const header = JSON.parse(lines.shift() || "{}") as {
|
||||
version?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
if (header.version !== 2) throw new Error("Unsupported asciicast version");
|
||||
|
||||
const events = lines
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as AsciicastEvent)
|
||||
.filter(
|
||||
([time, type, data]) =>
|
||||
Number.isFinite(time) &&
|
||||
(type === "i" || type === "o" || type === "r") &&
|
||||
typeof data === "string",
|
||||
);
|
||||
|
||||
return {
|
||||
width: header.width || 80,
|
||||
height: header.height || 24,
|
||||
duration: events.at(-1)?.[0] || 0,
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
function writeAsync(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise((resolve) => terminal.write(data, resolve));
|
||||
}
|
||||
|
||||
// Replays the recording into an offscreen terminal and reads back its
|
||||
// rendered buffer, so cursor movement/overwrites resolve the same way
|
||||
// they would on screen instead of leaving raw ANSI escapes in the text.
|
||||
export async function asciicastToPlainText(
|
||||
recording: Asciicast,
|
||||
): Promise<string> {
|
||||
const terminal = new Terminal({
|
||||
cols: recording.width,
|
||||
rows: recording.height,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
try {
|
||||
for (const [, type, data] of recording.events) {
|
||||
if (type === "o") await writeAsync(terminal, data);
|
||||
if (type === "r") {
|
||||
const [cols, rows] = data.split("x").map(Number);
|
||||
if (cols > 0 && rows > 0) terminal.resize(cols, rows);
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = terminal.buffer.active;
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
lines.push(buffer.getLine(i)?.translateToString(true) ?? "");
|
||||
}
|
||||
while (lines.length > 0 && lines.at(-1) === "") lines.pop();
|
||||
return lines.join("\n");
|
||||
} finally {
|
||||
terminal.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ScrollText } from "lucide-react";
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { SessionLogsPanel } from "./SessionLogsPanel";
|
||||
import { RecordingStatus } from "./RecordingStatus";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerRailItem({
|
||||
id: "session-logs",
|
||||
icon: ScrollText,
|
||||
titleKey: "nav.sessionLogs",
|
||||
kind: "panel",
|
||||
promotable: true,
|
||||
rightDockable: true,
|
||||
separatorAfter: true,
|
||||
permission: "view",
|
||||
});
|
||||
|
||||
app.registerPanel("session-logs", () => <SessionLogsPanel />);
|
||||
|
||||
app.registerTab("session-logs", () => <SessionLogsPanel />, {
|
||||
icon: ScrollText,
|
||||
titleKey: "nav.sessionLogs",
|
||||
hostless: true,
|
||||
});
|
||||
|
||||
app.registerSlotContribution("terminal.toolbarStatus", {
|
||||
actionId: "session-recording.terminalStatus",
|
||||
titleKey: "nav.sessionLogs",
|
||||
kind: "component",
|
||||
component: RecordingStatus,
|
||||
when: (context) => {
|
||||
const host = context.host as
|
||||
| { pluginSettings?: Record<string, Record<string, unknown>> }
|
||||
| undefined;
|
||||
const enabled =
|
||||
host?.pluginSettings?.["session-recording"]?.enableSessionRecording;
|
||||
return enabled !== false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivate(): void {}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export type SessionLogRecord = {
|
||||
id: number;
|
||||
hostId: number;
|
||||
userId: string | null;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
duration: number | null;
|
||||
recordingPath: string | null;
|
||||
hostName: string | null;
|
||||
hostIp: string | null;
|
||||
sizeBytes: number | null;
|
||||
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
format: "text" | "asciicast" | "guacamole";
|
||||
username: string | null;
|
||||
};
|
||||
|
||||
export function createSessionRecordingApi(api: PluginApiClient) {
|
||||
return {
|
||||
async list(): Promise<SessionLogRecord[]> {
|
||||
const response = await api.get<{ logs: SessionLogRecord[] }>("/");
|
||||
return response.data.logs;
|
||||
},
|
||||
|
||||
async getBlob(id: number): Promise<Blob> {
|
||||
const response = await api.get<Blob>(`/${id}/content`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getContent(id: number): Promise<string> {
|
||||
const response = await api.get<string>(`/${id}/content`, {
|
||||
responseType: "text",
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async delete(id: number): Promise<void> {
|
||||
await api.delete(`/${id}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user