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

@@ -0,0 +1,42 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { sessionRecordings } from "./tables.js";
import { createSessionRecordingRepository } from "./repository.js";
import { createRecordingsWriter, type RecordingsWriterV1 } from "./service.js";
import { registerSessionRecordingRoutes } from "./routes.js";
import { startRetentionSweep } from "./retention.js";
export type { RecordingsWriterV1, RecordingSink } from "./service.js";
export async function activate(ctx: PluginContext) {
const table = await ctx.db.define(sessionRecordings);
const repository = createSessionRecordingRepository(ctx.db, table, ctx.hosts);
ctx.http
.router<Router>()
.use(
registerSessionRecordingRoutes(ctx, repository, () =>
ctx.files.dataDir(),
),
);
ctx.services.provide<RecordingsWriterV1>(
"recordings.writer",
createRecordingsWriter(ctx, repository),
);
// A recording is evidence about the host as much as the person, so it
// outlives the account: the row's userId is cleared rather than cascading.
ctx.events.on("user.deleted", (payload) => {
const { userId } = payload as { userId: string };
void repository.anonymizeByUserId(userId);
});
startRetentionSweep(ctx, repository, () => ctx.files.dataDir());
ctx.log.info(
"Session Recording routes mounted at /plugin-api/session-recording",
);
}
export async function deactivate() {}
@@ -0,0 +1,240 @@
import { and, desc, eq, inArray, lt } from "drizzle-orm";
import type { PluginDatabase, PluginHosts } from "@termix/plugin-sdk/backend";
/* eslint-disable @typescript-eslint/no-explicit-any */
// The table comes from ctx.db.define, which the SDK hands back untyped, and
// the drizzle handle is the server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface SessionRecordingRecord {
id: number;
hostId: number;
userId: string | null;
username: string | null;
startedAt: string;
endedAt: string | null;
duration: number | null;
recordingPath: string | null;
protocol: string;
format: string | null;
terminatedByOwner: boolean | null;
terminationReason: string | null;
}
export interface SessionRecordingListRecord {
id: number;
hostId: number;
userId: string | null;
startedAt: string;
endedAt: string | null;
duration: number | null;
recordingPath: string | null;
protocol: string;
format: string | null;
username: string | null;
hostName: string | null;
hostIp: string | null;
}
export interface SessionRecordingCreateInput {
hostId: number;
userId: string;
username?: string | null;
startedAt: string;
endedAt?: string | null;
duration?: number | null;
recordingPath?: string | null;
protocol?: string | null;
format?: string | null;
terminatedByOwner?: boolean | null;
terminationReason?: string | null;
}
export type SessionRecordingRepository = ReturnType<
typeof createSessionRecordingRepository
>;
/**
* Written without RETURNING so it runs the same on all three engines: an
* insert is read back by a marker key it was given, an update by its id.
*/
export function createSessionRecordingRepository(
db: PluginDatabase,
table: Table,
hosts: PluginHosts,
) {
const client = () => db.client<Drizzle>();
async function usernameFor(userId: string): Promise<string | null> {
const { users } = await db.refs<{ users: Table }>();
const drizzle = await client();
const rows = await drizzle
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId));
return rows[0]?.username ?? null;
}
async function withHostNames(
rows: SessionRecordingListRecord[],
): Promise<SessionRecordingListRecord[]> {
const list = await hosts.list();
const byId = new Map(list.map((host) => [host.id, host]));
return rows.map((row) => {
const host = byId.get(row.hostId);
return { ...row, hostName: host?.name ?? null, hostIp: host?.ip ?? null };
});
}
return {
usernameFor,
/**
* Inserts a row and returns it. recordingPath is the caller's read-back
* key: every caller sets it to something unique (the file path or a
* session id) before insert, since MySQL has no RETURNING to read a new
* id back with.
*/
async create(
input: SessionRecordingCreateInput & { recordingPath: string },
): Promise<SessionRecordingRecord> {
const drizzle = await client();
await drizzle.insert(table).values(input);
const rows = await drizzle
.select()
.from(table)
.where(eq(table.recordingPath, input.recordingPath))
.orderBy(desc(table.id))
.limit(1);
await db.persist();
return rows[0];
},
async updateEnded(
id: number,
input: {
endedAt: string;
duration: number | null;
terminatedByOwner?: boolean;
terminationReason?: string | null;
},
): Promise<void> {
const drizzle = await client();
await drizzle.update(table).set(input).where(eq(table.id, id));
await db.persist();
},
async findByIdForUser(
userId: string,
id: number,
): Promise<SessionRecordingRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(and(eq(table.id, id), eq(table.userId, userId)))
.limit(1);
return rows[0] ?? null;
},
async findPathByIdForUser(
userId: string,
id: number,
): Promise<{
id: number;
recordingPath: string | null;
format: string | null;
} | null> {
const drizzle = await client();
const rows = await drizzle
.select({
id: table.id,
recordingPath: table.recordingPath,
format: table.format,
})
.from(table)
.where(and(eq(table.id, id), eq(table.userId, userId)))
.limit(1);
return rows[0] ?? null;
},
async listByUserIdWithHost(
userId: string,
): Promise<SessionRecordingListRecord[]> {
const drizzle = await client();
const rows = (await drizzle
.select()
.from(table)
.where(eq(table.userId, userId))
.orderBy(desc(table.startedAt))) as SessionRecordingListRecord[];
return withHostNames(rows);
},
async listPathsOlderThan(
cutoff: string,
): Promise<Array<{ id: number; recordingPath: string | null }>> {
const drizzle = await client();
return drizzle
.select({ id: table.id, recordingPath: table.recordingPath })
.from(table)
.where(lt(table.startedAt, cutoff));
},
async deleteForUser(userId: string, id: number): Promise<boolean> {
const drizzle = await client();
const existing = await drizzle
.select({ id: table.id })
.from(table)
.where(and(eq(table.id, id), eq(table.userId, userId)))
.limit(1);
if (existing.length === 0) return false;
await drizzle
.delete(table)
.where(and(eq(table.id, id), eq(table.userId, userId)));
await db.persist();
return true;
},
async deleteById(id: number): Promise<boolean> {
const drizzle = await client();
const existing = await drizzle
.select({ id: table.id })
.from(table)
.where(eq(table.id, id))
.limit(1);
if (existing.length === 0) return false;
await drizzle.delete(table).where(eq(table.id, id));
await db.persist();
return true;
},
async deleteByHostId(hostId: number): Promise<void> {
const drizzle = await client();
await drizzle.delete(table).where(eq(table.hostId, hostId));
await db.persist();
},
async deleteByHostIds(hostIds: number[]): Promise<void> {
if (hostIds.length === 0) return;
const drizzle = await client();
await drizzle.delete(table).where(inArray(table.hostId, hostIds));
await db.persist();
},
/**
* Detaches recordings from a user being deleted instead of removing them.
* A recording is evidence about the host as much as about the person, and
* the file stays on disk regardless of the row.
*/
async anonymizeByUserId(userId: string): Promise<void> {
const drizzle = await client();
await drizzle
.update(table)
.set({ userId: null })
.where(eq(table.userId, userId));
await db.persist();
},
};
}
@@ -0,0 +1,64 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { SessionRecordingRepository } from "./repository.js";
const DEFAULT_RETENTION_DAYS = 30;
const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
async function getRetentionDays(ctx: PluginContext): Promise<number> {
const configured = await ctx.settings.get<number>("retentionDays");
if (typeof configured === "number" && configured >= 1 && configured <= 3650) {
return configured;
}
return DEFAULT_RETENTION_DAYS;
}
async function pruneOldRecordings(
ctx: PluginContext,
repository: SessionRecordingRepository,
dataDir: () => Promise<string>,
): Promise<void> {
try {
const retentionDays = await getRetentionDays(ctx);
const cutoff = new Date(
Date.now() - retentionDays * 24 * 60 * 60 * 1000,
).toISOString();
const old = await repository.listPathsOlderThan(cutoff);
if (old.length === 0) return;
const base = `${path.resolve(await dataDir())}${path.sep}`;
for (const row of old) {
if (row.recordingPath) {
const resolved = path.resolve(row.recordingPath);
if (resolved.startsWith(base)) {
await fs.unlink(resolved).catch(() => {});
}
}
await repository.deleteById(row.id);
}
ctx.log.info(`Pruned ${old.length} old session recording(s)`);
} catch (error) {
ctx.log.warn(
`Failed to prune old session recordings: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
/** Runs the retention sweep at startup and every 24 hours. */
export function startRetentionSweep(
ctx: PluginContext,
repository: SessionRecordingRepository,
dataDir: () => Promise<string>,
): void {
void pruneOldRecordings(ctx, repository, dataDir);
const timer = setInterval(
() => void pruneOldRecordings(ctx, repository, dataDir),
PRUNE_INTERVAL_MS,
);
timer.unref?.();
ctx.disposables.add(() => clearInterval(timer));
}
@@ -0,0 +1,253 @@
import fs from "node:fs";
import path from "node:path";
import express, { type Request, type Response, type Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { SessionRecordingRepository } from "./repository.js";
function actorId(ctx: PluginContext): string | undefined {
return ctx.currentActor();
}
/**
* @openapi
* /plugin-api/session-recording/:
* get:
* summary: List session recordings
* description: Returns every terminal session recording for the authenticated user.
* tags:
* - Session Recording
* responses:
* 200:
* description: List of session recordings.
* 500:
* description: Failed to fetch session recordings.
*/
function registerList(
router: Router,
ctx: PluginContext,
repository: SessionRecordingRepository,
): void {
router.get("/", async (_req: Request, res: Response) => {
const userId = actorId(ctx);
if (!userId) return res.status(401).json({ error: "Not authenticated" });
try {
const rows = await repository.listByUserIdWithHost(userId);
const records = rows.map((row) => {
let sizeBytes: number | null = null;
if (row.recordingPath) {
try {
sizeBytes = fs.statSync(row.recordingPath).size;
} catch {
// file may have been removed
}
}
return { ...row, sizeBytes };
});
res.json({ logs: records });
} catch (error) {
ctx.log.error(
"Failed to fetch session recordings",
error instanceof Error ? error : new Error(String(error)),
);
res.status(500).json({ error: "Failed to fetch session recordings" });
}
});
}
/**
* @openapi
* /plugin-api/session-recording/{id}:
* get:
* summary: Get session recording metadata
* description: Returns metadata for a single session recording.
* tags:
* - Session Recording
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Session recording metadata.
* 404:
* description: Session recording not found.
* 500:
* description: Failed to fetch session recording.
*/
function registerGet(
router: Router,
ctx: PluginContext,
repository: SessionRecordingRepository,
): void {
router.get("/:id", async (req: Request, res: Response) => {
const userId = actorId(ctx);
if (!userId) return res.status(401).json({ error: "Not authenticated" });
const rawId = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const id = parseInt(rawId, 10);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const row = await repository.findByIdForUser(userId, id);
if (!row) return res.status(404).json({ error: "Not found" });
res.json({ log: row });
} catch (error) {
ctx.log.error(
"Failed to fetch session recording",
error instanceof Error ? error : new Error(String(error)),
);
res.status(500).json({ error: "Failed to fetch session recording" });
}
});
}
/**
* @openapi
* /plugin-api/session-recording/{id}/content:
* get:
* summary: Get session recording content
* description: Returns the raw content of a session recording file.
* tags:
* - Session Recording
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Raw recording content.
* 404:
* description: Session recording or file not found.
* 500:
* description: Failed to read session recording.
*/
function registerContent(
router: Router,
ctx: PluginContext,
repository: SessionRecordingRepository,
dataDir: () => Promise<string>,
): void {
router.get("/:id/content", async (req: Request, res: Response) => {
const userId = actorId(ctx);
if (!userId) return res.status(401).json({ error: "Not authenticated" });
const rawId = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const id = parseInt(rawId, 10);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const row = await repository.findPathByIdForUser(userId, id);
if (!row) return res.status(404).json({ error: "Not found" });
const filePath = row.recordingPath;
if (!filePath)
return res.status(404).json({ error: "No recording file" });
const resolvedPath = path.resolve(filePath);
const base = `${path.resolve(await dataDir())}${path.sep}`;
if (!resolvedPath.startsWith(base)) {
return res.status(403).json({ error: "Forbidden" });
}
if (!fs.existsSync(resolvedPath)) {
return res.status(404).json({ error: "File not found" });
}
const content = await fs.promises.readFile(resolvedPath);
const format =
row.format ?? (filePath.endsWith(".cast") ? "asciicast" : "text");
const contentType =
format === "guacamole"
? "application/vnd.apache.guacamole.recording"
: format === "asciicast"
? "application/x-asciicast"
: "text/plain";
res.type(contentType).send(content);
} catch (error) {
ctx.log.error(
"Failed to read session recording content",
error instanceof Error ? error : new Error(String(error)),
);
res.status(500).json({ error: "Failed to read session recording" });
}
});
}
/**
* @openapi
* /plugin-api/session-recording/{id}:
* delete:
* summary: Delete session recording
* description: Deletes a session recording and its file.
* tags:
* - Session Recording
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Session recording deleted.
* 404:
* description: Session recording not found.
* 500:
* description: Failed to delete session recording.
*/
function registerDelete(
router: Router,
ctx: PluginContext,
repository: SessionRecordingRepository,
dataDir: () => Promise<string>,
): void {
router.delete("/:id", async (req: Request, res: Response) => {
const userId = actorId(ctx);
if (!userId) return res.status(401).json({ error: "Not authenticated" });
const rawId = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const id = parseInt(rawId, 10);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const row = await repository.findPathByIdForUser(userId, id);
if (!row) return res.status(404).json({ error: "Not found" });
const filePath = row.recordingPath;
const deleted = await repository.deleteForUser(userId, id);
if (!deleted) return res.status(404).json({ error: "Not found" });
if (filePath) {
const resolvedPath = path.resolve(filePath);
const base = `${path.resolve(await dataDir())}${path.sep}`;
if (resolvedPath.startsWith(base) && fs.existsSync(resolvedPath)) {
await fs.promises.unlink(resolvedPath).catch(() => {});
}
}
res.json({ success: true });
} catch (error) {
ctx.log.error(
"Failed to delete session recording",
error instanceof Error ? error : new Error(String(error)),
);
res.status(500).json({ error: "Failed to delete session recording" });
}
});
}
export function registerSessionRecordingRoutes(
ctx: PluginContext,
repository: SessionRecordingRepository,
dataDir: () => Promise<string>,
): Router {
const router = express.Router();
registerList(router, ctx, repository);
registerGet(router, ctx, repository);
registerContent(router, ctx, repository, dataDir);
registerDelete(router, ctx, repository, dataDir);
return router;
}
@@ -0,0 +1,116 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { SessionRecordingRepository } from "./repository.js";
/**
* recordings.writer v1, the service ssh-terminal calls to record sessions.
*
* One call per already-batched chunk (ssh-terminal coalesces on a 300ms
* trailing edge, issue #1049): mkdir and writeFile on the first append,
* appendFile after that, never per chunk.
*/
export interface RecordingSink {
append: (chunk: string) => Promise<void>;
persist: (summary: {
endedAt: number;
durationSeconds: number;
terminatedByOwner: boolean;
terminationReason: string | null;
}) => Promise<void>;
discard: () => void;
}
export interface RecordingsWriterV1 {
/** Null when recording is off for this user or host. */
open: (meta: {
sessionId: string;
hostId: number;
userId: string;
protocol: "ssh";
format: "asciicast";
startedAt: number;
}) => Promise<RecordingSink | null>;
/**
* Inserts a row for a recording a caller already wrote to disk itself
* (remote desktop's guacd recordings), rather than streaming through
* open()/append(). The caller already decided to record; this only writes
* the row.
*/
createFinished: (input: {
hostId: number;
userId: string;
startedAt: string;
endedAt: string;
duration: number | null;
recordingPath: string;
protocol: string;
format: string;
}) => Promise<{ id: number }>;
}
export function createRecordingsWriter(
ctx: PluginContext,
repository: SessionRecordingRepository,
): RecordingsWriterV1 {
return {
async open(meta) {
const enabled = await ctx.settings.getHost<boolean>(
meta.hostId,
"enableSessionRecording",
);
if (enabled === false) return null;
const dataDir = await ctx.files.dataDir();
const dir = path.join(dataDir, "session_logs", meta.userId);
const filePath = path.join(dir, `${meta.sessionId}.cast`);
let wroteFirst = false;
let discarded = false;
const username = await repository.usernameFor(meta.userId);
const row = await repository.create({
hostId: meta.hostId,
userId: meta.userId,
username,
startedAt: new Date(meta.startedAt).toISOString(),
recordingPath: filePath,
protocol: meta.protocol,
format: meta.format,
});
return {
async append(chunk: string) {
if (discarded) return;
if (!wroteFirst) {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, chunk);
wroteFirst = true;
} else {
await fs.appendFile(filePath, chunk);
}
},
async persist(summary) {
if (discarded) return;
await repository.updateEnded(row.id, {
endedAt: new Date(summary.endedAt).toISOString(),
duration: summary.durationSeconds,
terminatedByOwner: summary.terminatedByOwner,
terminationReason: summary.terminationReason,
});
},
discard() {
discarded = true;
void repository.deleteById(row.id).catch(() => {});
},
};
},
async createFinished(input) {
const username = await repository.usernameFor(input.userId);
const row = await repository.create({ ...input, username });
return { id: row.id };
},
};
}
@@ -0,0 +1,52 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
integer,
refHost,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/**
* userId is a plain column, not refUser(): a recording is evidence about the
* host as much as the person, so it outlives the account. The plugin listens
* for user.deleted and clears userId itself instead of cascading. username
* keeps an anonymized row attributable.
*/
export const sessionRecordings = adoptLegacyTable(
"session_recordings",
defineTable(
"session_recordings",
{
id: id(),
hostId: refHost(),
userId: varchar(),
username: text(),
accessId: integer(),
startedAt: timestamp().notNull().defaultNow(),
endedAt: timestamp(),
duration: integer(),
commands: text(),
dangerousActions: text(),
recordingPath: text(),
protocol: text().notNull().default("ssh"),
format: text().notNull().default("text"),
terminatedByOwner: boolean().default(false),
terminationReason: text(),
},
{
indexes: [
{
name: "idx_session_recordings_user_started",
columns: ["userId", "startedAt"],
},
{ name: "idx_session_recordings_host", columns: ["hostId"] },
],
},
),
);
export const tables = [sessionRecordings];
@@ -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 &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>
);
}
@@ -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}`);
},
};
}