diff --git a/README.md b/README.md index d21216ee..9dfb1b02 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,8 @@ services: - termix-data:/app/data environment: PORT: "8080" + GUACD_HOST: "guacd" + GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole" depends_on: - guacd networks: @@ -277,6 +279,8 @@ services: restart: unless-stopped ports: - "4822:4822" + volumes: + - termix-data:/termix-data networks: - termix-net @@ -289,6 +293,13 @@ networks: driver: bridge ``` +Termix records enabled SSH, RDP, VNC, and Telnet sessions for replay and audit. +When using an external `guacd`, mount the same recording storage into both +services and set `GUACD_RECORDING_PATH` to the path visible to `guacd` and +`GUACD_RECORDING_BACKEND_PATH` to the corresponding path visible to Termix. +Administrators can change the default 30-day retention period from Session Logs +or with `SESSION_RECORDING_RETENTION_DAYS`. + ## Donate diff --git a/docker/compose-dev.yml b/docker/compose-dev.yml index 20a632b5..14b703a8 100644 --- a/docker/compose-dev.yml +++ b/docker/compose-dev.yml @@ -12,6 +12,8 @@ services: environment: PORT: "8080" NODE_ENV: development + GUACD_HOST: "guacd-dev" + GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole" depends_on: - guacd-dev networks: @@ -21,6 +23,8 @@ services: image: guacamole/guacd:1.6.0 container_name: guacd-dev restart: unless-stopped + volumes: + - termix-dev-data:/termix-data networks: - termix-dev-net diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9a2db205..eed4d172 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -10,6 +10,7 @@ services: environment: PORT: "8080" GUACD_HOST: "guacd" + GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole" depends_on: - guacd networks: @@ -19,6 +20,8 @@ services: image: guacamole/guacd:1.6.0 container_name: guacd restart: unless-stopped + volumes: + - termix-data:/termix-data networks: - termix-net diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index c278bb9d..b0cb41c5 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -460,6 +460,8 @@ async function initializeCompleteDatabase(): Promise { commands TEXT, dangerous_actions TEXT, recording_path TEXT, + protocol TEXT NOT NULL DEFAULT 'ssh', + format TEXT NOT NULL DEFAULT 'text', terminated_by_owner INTEGER DEFAULT 0, termination_reason TEXT, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, @@ -713,6 +715,17 @@ const addColumnIfNotExists = ( }; const migrateSchema = () => { + addColumnIfNotExists( + "session_recordings", + "protocol", + "TEXT NOT NULL DEFAULT 'ssh'", + ); + addColumnIfNotExists( + "session_recordings", + "format", + "TEXT NOT NULL DEFAULT 'text'", + ); + addColumnIfNotExists("user_preferences", "theme", "TEXT"); addColumnIfNotExists("user_preferences", "font_size", "TEXT"); addColumnIfNotExists("user_preferences", "accent_color", "TEXT"); @@ -1575,6 +1588,8 @@ const migrateSchema = () => { commands TEXT, dangerous_actions TEXT, recording_path TEXT, + protocol TEXT NOT NULL DEFAULT 'ssh', + format TEXT NOT NULL DEFAULT 'text', terminated_by_owner INTEGER DEFAULT 0, termination_reason TEXT, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 917c3d70..cc03c6aa 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -660,6 +660,8 @@ export const sessionRecordings = sqliteTable("session_recordings", { dangerousActions: text("dangerous_actions"), recordingPath: text("recording_path"), + protocol: text("protocol").notNull().default("ssh"), + format: text("format").notNull().default("text"), terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" }) .default(false), diff --git a/src/backend/database/routes/session-log-routes.ts b/src/backend/database/routes/session-log-routes.ts index 8b39699d..1ae1c12d 100644 --- a/src/backend/database/routes/session-log-routes.ts +++ b/src/backend/database/routes/session-log-routes.ts @@ -2,24 +2,58 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; import fs from "fs"; import path from "path"; -import { eq, and, desc, lt } from "drizzle-orm"; +import { eq, desc, lt } from "drizzle-orm"; import { db } from "../db/index.js"; -import { sessionRecordings, hosts } from "../db/schema.js"; +import { sessionRecordings, hosts, users } from "../db/schema.js"; import { apiLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import type { Request, Response } from "express"; +import { PermissionManager } from "../../utils/permission-manager.js"; const router = express.Router(); const DATA_DIR = process.env.DATA_DIR ?? "./db/data"; -// Delete session log files and DB rows older than this many days -const LOG_RETENTION_DAYS = 30; +const permissionManager = PermissionManager.getInstance(); + +function isAllowedRecordingPath(filePath: string): boolean { + const resolved = path.resolve(filePath); + return ["session_logs", "session_recordings"].some((directory) => { + const base = `${path.resolve(DATA_DIR, directory)}${path.sep}`; + return resolved.startsWith(base); + }); +} + +function getRetentionDays(): number { + const envDays = parseInt( + process.env.SESSION_RECORDING_RETENTION_DAYS || "", + 10, + ); + try { + const row = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'session_recording_retention_days'", + ) + .get() as { value?: string } | undefined; + const configured = parseInt(row?.value || "", 10); + if (configured >= 1 && configured <= 3650) return configured; + } catch { + // use environment/default below + } + return envDays >= 1 && envDays <= 3650 ? envDays : 30; +} + +async function canAccessRecording( + userId: string, + ownerId: string, +): Promise { + return userId === ownerId || permissionManager.isAdmin(userId); +} async function pruneOldLogs(): Promise { try { const cutoff = new Date( - Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000, + Date.now() - getRetentionDays() * 24 * 60 * 60 * 1000, ).toISOString(); const old = await db @@ -33,8 +67,7 @@ async function pruneOldLogs(): Promise { for (const row of old) { if (row.recordingPath) { const resolved = path.resolve(row.recordingPath); - const allowed = path.resolve(DATA_DIR, "session_logs"); - if (resolved.startsWith(allowed) && fs.existsSync(resolved)) { + if (isAllowedRecordingPath(resolved) && fs.existsSync(resolved)) { await fs.promises.unlink(resolved).catch(() => {}); } } @@ -81,6 +114,7 @@ const authenticateJWT = authManager.createAuthMiddleware(); router.get("/", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { + const isAdmin = await permissionManager.isAdmin(userId); const rows = await db .select({ id: sessionRecordings.id, @@ -90,12 +124,16 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { endedAt: sessionRecordings.endedAt, duration: sessionRecordings.duration, recordingPath: sessionRecordings.recordingPath, + protocol: sessionRecordings.protocol, + format: sessionRecordings.format, + username: users.username, hostName: hosts.name, hostIp: hosts.ip, }) .from(sessionRecordings) .leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id)) - .where(eq(sessionRecordings.userId, userId)) + .leftJoin(users, eq(sessionRecordings.userId, users.id)) + .where(isAdmin ? undefined : eq(sessionRecordings.userId, userId)) .orderBy(desc(sessionRecordings.startedAt)); const records = rows.map((row) => { @@ -120,6 +158,46 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { } }); +router.get( + "/retention", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + if (!(await permissionManager.isAdmin(userId))) { + return res.status(403).json({ error: "Admin access required" }); + } + res.json({ retentionDays: getRetentionDays() }); + }, +); + +router.put( + "/retention", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + if (!(await permissionManager.isAdmin(userId))) { + return res.status(403).json({ error: "Admin access required" }); + } + const retentionDays = Number(req.body?.retentionDays); + if ( + !Number.isInteger(retentionDays) || + retentionDays < 1 || + retentionDays > 3650 + ) { + return res + .status(400) + .json({ error: "Retention must be between 1 and 3650 days" }); + } + db.$client + .prepare( + "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + ) + .run("session_recording_retention_days", String(retentionDays)); + void pruneOldLogs(); + res.json({ retentionDays }); + }, +); + /** * @openapi * /session_logs/{id}: @@ -153,12 +231,13 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => { const rows = await db .select() .from(sessionRecordings) - .where( - and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)), - ) + .where(eq(sessionRecordings.id, id)) .limit(1); if (rows.length === 0) return res.status(404).json({ error: "Not found" }); + if (!(await canAccessRecording(userId, rows[0].userId))) { + return res.status(403).json({ error: "Forbidden" }); + } res.json({ log: rows[0] }); } catch (error) { apiLogger.error("Failed to fetch session log", error, { @@ -206,26 +285,27 @@ router.get( try { const rows = await db - .select({ recordingPath: sessionRecordings.recordingPath }) + .select({ + recordingPath: sessionRecordings.recordingPath, + userId: sessionRecordings.userId, + format: sessionRecordings.format, + }) .from(sessionRecordings) - .where( - and( - eq(sessionRecordings.id, id), - eq(sessionRecordings.userId, userId), - ), - ) + .where(eq(sessionRecordings.id, id)) .limit(1); if (rows.length === 0) return res.status(404).json({ error: "Not found" }); + if (!(await canAccessRecording(userId, rows[0].userId))) { + return res.status(403).json({ error: "Forbidden" }); + } const filePath = rows[0].recordingPath; if (!filePath) return res.status(404).json({ error: "No recording file" }); const resolvedPath = path.resolve(filePath); - const allowedBase = path.resolve(DATA_DIR, "session_logs"); - if (!resolvedPath.startsWith(allowedBase)) { + if (!isAllowedRecordingPath(resolvedPath)) { return res.status(403).json({ error: "Forbidden" }); } @@ -233,8 +313,14 @@ router.get( return res.status(404).json({ error: "File not found" }); } - const content = await fs.promises.readFile(resolvedPath, "utf-8"); - res.type("text/plain").send(content); + const content = await fs.promises.readFile(resolvedPath); + const contentType = + rows[0].format === "guacamole" + ? "application/vnd.apache.guacamole.recording" + : rows[0].format === "asciicast" + ? "application/x-asciicast" + : "text/plain"; + res.type(contentType).send(content); } catch (error) { apiLogger.error("Failed to read session log content", error, { operation: "session_log_content", @@ -277,27 +363,26 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => { try { const rows = await db - .select({ recordingPath: sessionRecordings.recordingPath }) + .select({ + recordingPath: sessionRecordings.recordingPath, + userId: sessionRecordings.userId, + }) .from(sessionRecordings) - .where( - and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)), - ) + .where(eq(sessionRecordings.id, id)) .limit(1); if (rows.length === 0) return res.status(404).json({ error: "Not found" }); + if (!(await canAccessRecording(userId, rows[0].userId))) { + return res.status(403).json({ error: "Forbidden" }); + } const filePath = rows[0].recordingPath; - await db - .delete(sessionRecordings) - .where( - and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)), - ); + await db.delete(sessionRecordings).where(eq(sessionRecordings.id, id)); if (filePath) { const resolvedPath = path.resolve(filePath); - const allowedBase = path.resolve(DATA_DIR, "session_logs"); - if (resolvedPath.startsWith(allowedBase) && fs.existsSync(resolvedPath)) { + if (isAllowedRecordingPath(resolvedPath) && fs.existsSync(resolvedPath)) { await fs.promises.unlink(resolvedPath).catch(() => {}); } } diff --git a/src/backend/guacamole/guacamole-server.ts b/src/backend/guacamole/guacamole-server.ts index 746007af..62bd2d8e 100644 --- a/src/backend/guacamole/guacamole-server.ts +++ b/src/backend/guacamole/guacamole-server.ts @@ -3,6 +3,10 @@ import { guacLogger } from "../utils/logger.js"; import { GuacamoleTokenService } from "./token-service.js"; import { getDb } from "../database/db/index.js"; import { resolveGuacdOptions } from "../utils/guacd-config.js"; +import fs from "fs"; +import path from "path"; +import { sessionRecordings } from "../database/db/schema.js"; +import type { GuacamoleRecordingMetadata } from "./token-service.js"; const tokenService = GuacamoleTokenService.getInstance(); @@ -21,6 +25,63 @@ function readGuacdOptions(): { host: string; port: number } { } const GUAC_WS_PORT = 30008; +const DATA_DIR = process.env.DATA_DIR || "./db/data"; +const GUACAMOLE_RECORDINGS_DIR = + process.env.GUACD_RECORDING_BACKEND_PATH || + path.join(DATA_DIR, "session_recordings", "guacamole"); + +type GuacamoleClientConnection = { + connectionSettings?: { + connection?: { type?: string }; + recording?: GuacamoleRecordingMetadata; + }; +}; + +async function persistGuacamoleRecording( + clientConnection: GuacamoleClientConnection, +): Promise { + const recording = clientConnection.connectionSettings?.recording; + if (!recording) return; + + const resolvedPath = path.resolve(GUACAMOLE_RECORDINGS_DIR, recording.path); + const allowedBase = `${path.resolve(GUACAMOLE_RECORDINGS_DIR)}${path.sep}`; + if (!resolvedPath.startsWith(allowedBase)) return; + + // guacd may flush/rename the recording just after the websocket closes. + for ( + let attempt = 0; + attempt < 10 && !fs.existsSync(resolvedPath); + attempt++ + ) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (!fs.existsSync(resolvedPath)) { + guacLogger.warn("Guacamole recording file was not found", { + operation: "guac_recording_missing", + hostId: recording.hostId, + path: resolvedPath, + }); + return; + } + + const endedAt = new Date(); + const startedAt = new Date(recording.startedAt); + await getDb() + .insert(sessionRecordings) + .values({ + hostId: recording.hostId, + userId: recording.userId, + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + duration: Math.max( + 0, + Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000), + ), + recordingPath: resolvedPath, + protocol: recording.protocol, + format: "guacamole", + }); +} const websocketOptions = { port: GUAC_WS_PORT, @@ -89,35 +150,31 @@ function createGuacServer(): GuacamoleLite { clientOptions, ); - server.on( - "open", - (clientConnection: { connectionSettings?: Record }) => { - guacLogger.info("Guacamole connection opened", { - operation: "guac_connection_open", - type: clientConnection.connectionSettings?.type, - }); - }, - ); + server.on("open", (clientConnection: GuacamoleClientConnection) => { + guacLogger.info("Guacamole connection opened", { + operation: "guac_connection_open", + type: clientConnection.connectionSettings?.connection?.type, + }); + }); - server.on( - "close", - (clientConnection: { connectionSettings?: Record }) => { - guacLogger.info("Guacamole connection closed", { - operation: "guac_connection_close", - type: clientConnection.connectionSettings?.type, + server.on("close", (clientConnection: GuacamoleClientConnection) => { + guacLogger.info("Guacamole connection closed", { + operation: "guac_connection_close", + type: clientConnection.connectionSettings?.connection?.type, + }); + persistGuacamoleRecording(clientConnection).catch((error) => { + guacLogger.error("Failed to persist Guacamole recording", error, { + operation: "guac_recording_persist_error", }); - }, - ); + }); + }); server.on( "error", - ( - clientConnection: { connectionSettings?: Record }, - error: Error, - ) => { + (clientConnection: GuacamoleClientConnection, error: Error) => { guacLogger.error("Guacamole connection error", error, { operation: "guac_connection_error", - type: clientConnection.connectionSettings?.type, + type: clientConnection.connectionSettings?.connection?.type, }); }, ); diff --git a/src/backend/guacamole/routes.ts b/src/backend/guacamole/routes.ts index 2e3e0da0..03c6249c 100644 --- a/src/backend/guacamole/routes.ts +++ b/src/backend/guacamole/routes.ts @@ -9,12 +9,15 @@ import { hosts, sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { Client } from "ssh2"; import net from "net"; +import crypto from "crypto"; +import path from "path"; import type { AuthenticatedRequest } from "../../types/index.js"; import { resolveGuacdOptions } from "../utils/guacd-config.js"; const router = express.Router(); const tokenService = GuacamoleTokenService.getInstance(); const authManager = AuthManager.getInstance(); +const DATA_DIR = process.env.DATA_DIR || "./db/data"; router.use(authManager.createAuthMiddleware()); @@ -526,6 +529,28 @@ router.post( ? { guacdPort: perConnectionGuacdPort } : {}), }; + const recordingEnabled = host.enableSessionLogging !== false; + const recordingName = `${crypto.randomUUID()}.guac`; + const recordingPath = + process.env.GUACD_RECORDING_PATH || + process.env.GUACD_RECORDING_BACKEND_PATH || + path.resolve(DATA_DIR, "session_recordings", "guacamole"); + const recordingMetadata = recordingEnabled + ? { + hostId, + userId, + protocol: connectionType as "rdp" | "vnc" | "telnet", + path: recordingName, + startedAt: new Date().toISOString(), + } + : undefined; + if (recordingEnabled) { + guacConfig["recording-path"] = recordingPath; + guacConfig["recording-name"] = recordingName; + guacConfig["create-recording-path"] = true; + guacConfig["recording-exclude-output"] = false; + guacConfig["recording-include-keys"] = true; + } switch (connectionType) { case "rdp": @@ -533,22 +558,28 @@ router.post( guacConfig["drive-path"] = "/drive"; guacConfig["create-drive-path"] = true; } - token = tokenService.createRdpToken(hostname, username, password, { - port, - domain, - security: - (host.rdpSecurity as string) || - (host.security as string) || - undefined, - "ignore-cert": - host.rdpIgnoreCert !== undefined - ? !!host.rdpIgnoreCert - : host.ignoreCert !== undefined - ? !!host.ignoreCert - : true, - ...guacConfig, - ...guacdOverrides, - }); + token = tokenService.createRdpToken( + hostname, + username, + password, + { + port, + domain, + security: + (host.rdpSecurity as string) || + (host.security as string) || + undefined, + "ignore-cert": + host.rdpIgnoreCert !== undefined + ? !!host.rdpIgnoreCert + : host.ignoreCert !== undefined + ? !!host.ignoreCert + : true, + ...guacConfig, + ...guacdOverrides, + }, + recordingMetadata, + ); break; case "vnc": token = tokenService.createVncToken( @@ -561,14 +592,21 @@ router.post( ...guacConfig, ...guacdOverrides, }, + recordingMetadata, ); break; case "telnet": - token = tokenService.createTelnetToken(hostname, username, password, { - port, - ...guacConfig, - ...guacdOverrides, - }); + token = tokenService.createTelnetToken( + hostname, + username, + password, + { + port, + ...guacConfig, + ...guacdOverrides, + }, + recordingMetadata, + ); break; default: return res.status(400).json({ error: "Invalid connection type" }); diff --git a/src/backend/guacamole/token-service.test.ts b/src/backend/guacamole/token-service.test.ts index 6ad3110a..1b764028 100644 --- a/src/backend/guacamole/token-service.test.ts +++ b/src/backend/guacamole/token-service.test.ts @@ -45,4 +45,23 @@ describe("GuacamoleTokenService", () => { }); expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined(); }); + + it("preserves recording metadata outside guacd connection settings", () => { + const recording = { + hostId: 42, + userId: "user-1", + protocol: "vnc" as const, + path: "recording.guac", + startedAt: "2026-07-14T00:00:00.000Z", + }; + const token = tokenService.createVncToken( + "vnc.example.test", + "user", + "secret", + {}, + recording, + ); + + expect(tokenService.decryptToken(token)?.recording).toEqual(recording); + }); }); diff --git a/src/backend/guacamole/token-service.ts b/src/backend/guacamole/token-service.ts index f3322e06..7126924a 100644 --- a/src/backend/guacamole/token-service.ts +++ b/src/backend/guacamole/token-service.ts @@ -30,6 +30,15 @@ export interface GuacamoleConnectionSettings { export interface GuacamoleToken { connection: GuacamoleConnectionSettings; + recording?: GuacamoleRecordingMetadata; +} + +export interface GuacamoleRecordingMetadata { + hostId: number; + userId: string; + protocol: "rdp" | "vnc" | "telnet"; + path: string; + startedAt: string; } const CIPHER = "aes-256-cbc"; @@ -127,6 +136,7 @@ export class GuacamoleTokenService { guacdHost?: string; guacdPort?: number; } = {}, + recording?: GuacamoleRecordingMetadata, ): string { const { guacdHost, guacdPort, ...settingsOptions } = options; const token: GuacamoleToken = { @@ -144,6 +154,7 @@ export class GuacamoleTokenService { ...settingsOptions, }, }, + recording, }; return this.encryptToken(token); } @@ -156,6 +167,7 @@ export class GuacamoleTokenService { guacdHost?: string; guacdPort?: number; } = {}, + recording?: GuacamoleRecordingMetadata, ): string { const { guacdHost, guacdPort, ...settingsOptions } = options; const token: GuacamoleToken = { @@ -171,6 +183,7 @@ export class GuacamoleTokenService { ...settingsOptions, }, }, + recording, }; return this.encryptToken(token); } @@ -183,6 +196,7 @@ export class GuacamoleTokenService { guacdHost?: string; guacdPort?: number; } = {}, + recording?: GuacamoleRecordingMetadata, ): string { const { guacdHost, guacdPort, ...settingsOptions } = options; const token: GuacamoleToken = { @@ -198,6 +212,7 @@ export class GuacamoleTokenService { ...settingsOptions, }, }, + recording, }; return this.encryptToken(token); } diff --git a/src/backend/ssh/terminal-session-manager.test.ts b/src/backend/ssh/terminal-session-manager.test.ts index 8b48136a..f43270f3 100644 --- a/src/backend/ssh/terminal-session-manager.test.ts +++ b/src/backend/ssh/terminal-session-manager.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Stub all external imports before loading the module under test -const mockInsertValues = vi.fn().mockResolvedValue(undefined); +const mockReturning = vi.fn().mockResolvedValue([{ id: 1 }]); +const mockInsertValues = vi.fn().mockReturnValue({ returning: mockReturning }); const mockInsert = vi.fn().mockReturnValue({ values: mockInsertValues }); vi.mock("../database/db/index.js", () => ({ @@ -29,21 +30,25 @@ vi.mock("../utils/logger.js", () => ({ // Mock individual fs.promises methods via a stub object const mockMkdir = vi.fn().mockResolvedValue(undefined); const mockWriteFile = vi.fn().mockResolvedValue(undefined); +const mockAppendFile = vi.fn().mockResolvedValue(undefined); +const mockUnlink = vi.fn().mockResolvedValue(undefined); vi.mock("fs", () => ({ default: { promises: { mkdir: mockMkdir, writeFile: mockWriteFile, + appendFile: mockAppendFile, readFile: vi.fn(), - unlink: vi.fn(), + unlink: mockUnlink, }, }, promises: { mkdir: mockMkdir, writeFile: mockWriteFile, + appendFile: mockAppendFile, readFile: vi.fn(), - unlink: vi.fn(), + unlink: mockUnlink, }, })); @@ -55,7 +60,8 @@ describe("TerminalSessionManager - session logging", () => { // Re-apply resolved values after clearAllMocks mockMkdir.mockResolvedValue(undefined); mockWriteFile.mockResolvedValue(undefined); - mockInsertValues.mockResolvedValue(undefined); + mockReturning.mockResolvedValue([{ id: 1 }]); + mockInsertValues.mockReturnValue({ returning: mockReturning }); mockInsert.mockReturnValue({ values: mockInsertValues }); }); diff --git a/src/backend/ssh/terminal-session-manager.ts b/src/backend/ssh/terminal-session-manager.ts index 01d19186..d273e952 100644 --- a/src/backend/ssh/terminal-session-manager.ts +++ b/src/backend/ssh/terminal-session-manager.ts @@ -2,6 +2,7 @@ import { type Client, type ClientChannel } from "ssh2"; import { WebSocket } from "ws"; import fs from "fs"; import path from "path"; +import { eq } from "drizzle-orm"; import { sshLogger } from "../utils/logger.js"; import { getDb } from "../database/db/index.js"; import { sessionRecordings } from "../database/db/schema.js"; @@ -36,6 +37,12 @@ export interface TerminalSession { outputBuffer: string[]; outputBufferBytes: number; + recordingPath: string | null; + recordingHeader: string | null; + recordingBytes: number; + recordingId: number | null; + recordingWriteChain: Promise; + recordingPersistChain: Promise; tmuxSessionName: string | null; sessionLoggingEnabled: boolean; sessionStartedAt: number; @@ -117,6 +124,19 @@ class TerminalSessionManager { const id = crypto.randomUUID(); const now = Date.now(); + let recordingPath: string | null = null; + let recordingHeader: string | null = null; + if (sessionLoggingEnabled) { + const userLogDir = path.join(SESSION_LOGS_DIR, userId); + recordingPath = path.join(userLogDir, `${id}.cast`); + recordingHeader = `${JSON.stringify({ + version: 2, + width: cols, + height: rows, + timestamp: Math.floor(now / 1000), + env: { TERM: "xterm-256color", SHELL: "/bin/sh" }, + })}\n`; + } const session: TerminalSession = { id, userId, @@ -135,6 +155,12 @@ class TerminalSessionManager { detachTimeout: null, outputBuffer: [], outputBufferBytes: 0, + recordingPath, + recordingHeader, + recordingBytes: 0, + recordingId: null, + recordingWriteChain: Promise.resolve(), + recordingPersistChain: Promise.resolve(), tmuxSessionName: null, sessionLoggingEnabled, sessionStartedAt: now, @@ -334,6 +360,9 @@ class TerminalSessionManager { } this.maybePersistLog(session, true); + if (session.recordingPath && session.recordingBytes === 0) { + fs.promises.unlink(session.recordingPath).catch(() => {}); + } if (session.sshStream) { try { @@ -376,65 +405,51 @@ class TerminalSessionManager { }); } - private stripAnsi(raw: string): string { - return ( - raw - // ESC sequences: CSI, OSC, DCS, PM, APC, SOS - .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "") - .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, "") - // Single-char ESC sequences (e.g. ESC M, ESC =) - .replace(/\x1b[^[\]PX^_]/g, "") - // Other C0/C1 control chars except newline, carriage return, tab - .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") - // Collapse carriage returns used for line overwrites - .replace(/[^\n]*\r(?!\n)/g, "") - ); - } - private maybePersistLog(session: TerminalSession, force = false): void { if (!session.sessionLoggingEnabled) return; - if (session.outputBufferBytes === 0) return; - // Only save if new output arrived since last persist (unless forced) - if (!force && session.outputBufferBytes === session.lastPersistedBytes) - return; - const snapshot = session.outputBuffer.join(""); - session.lastPersistedBytes = session.outputBufferBytes; - this.persistSessionLog(session, snapshot).catch((err) => { - sshLogger.warn("Failed to persist session log", { - operation: "session_log_persist_error", - sessionId: session.id, - error: err instanceof Error ? err.message : String(err), + if (session.recordingBytes === 0) return; + if (!force && session.recordingBytes === session.lastPersistedBytes) return; + session.lastPersistedBytes = session.recordingBytes; + session.recordingPersistChain = session.recordingPersistChain + .then(() => this.persistSessionLog(session)) + .catch((err) => { + sshLogger.warn("Failed to persist session log", { + operation: "session_log_persist_error", + sessionId: session.id, + error: err instanceof Error ? err.message : String(err), + }); }); - }); } - private async persistSessionLog( - session: TerminalSession, - logContent: string, - ): Promise { - const cleaned = this.stripAnsi(logContent); - if (!cleaned.trim()) return; - - const userLogDir = path.join(SESSION_LOGS_DIR, session.userId); - await fs.promises.mkdir(userLogDir, { recursive: true }); - - const logFile = path.join(userLogDir, `${session.id}.log`); - await fs.promises.writeFile(logFile, cleaned, "utf-8"); - + private async persistSessionLog(session: TerminalSession): Promise { + if (!session.recordingPath) return; + await session.recordingWriteChain; const endedAt = Date.now(); const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000); try { const db = getDb(); - await db.insert(sessionRecordings).values({ - hostId: session.hostId, - userId: session.userId, - startedAt: new Date(session.sessionStartedAt).toISOString(), - endedAt: new Date(endedAt).toISOString(), - duration, - recordingPath: logFile, - }); + if (session.recordingId == null) { + const rows = await db + .insert(sessionRecordings) + .values({ + hostId: session.hostId, + userId: session.userId, + startedAt: new Date(session.sessionStartedAt).toISOString(), + endedAt: new Date(endedAt).toISOString(), + duration, + recordingPath: session.recordingPath, + protocol: "ssh", + format: "asciicast", + }) + .returning({ id: sessionRecordings.id }); + session.recordingId = rows[0]?.id ?? null; + } else { + await db + .update(sessionRecordings) + .set({ endedAt: new Date(endedAt).toISOString(), duration }) + .where(eq(sessionRecordings.id, session.recordingId)); + } } catch (err) { sshLogger.warn("Failed to insert session recording row", { operation: "session_recording_insert_error", @@ -449,7 +464,7 @@ class TerminalSessionManager { userId: session.userId, hostId: session.hostId, duration, - bytes: cleaned.length, + bytes: session.recordingBytes, }); } @@ -477,6 +492,47 @@ class TerminalSessionManager { const removed = session.outputBuffer.shift(); if (removed) session.outputBufferBytes -= removed.length; } + + this.recordSessionEvent(session, "o", data); + } + + bufferInput(sessionId: string, data: string): void { + const session = this.sessions.get(sessionId); + if (!session) return; + this.recordSessionEvent(session, "i", data); + } + + bufferResize(sessionId: string, cols: number, rows: number): void { + const session = this.sessions.get(sessionId); + if (!session) return; + this.recordSessionEvent(session, "r", `${cols}x${rows}`); + } + + private recordSessionEvent( + session: TerminalSession, + type: "i" | "o" | "r", + data: string, + ): void { + if (!session.sessionLoggingEnabled || !session.recordingPath || !data) + return; + const elapsed = (Date.now() - session.sessionStartedAt) / 1000; + const line = `${JSON.stringify([elapsed, type, data])}\n`; + const firstEvent = session.recordingBytes === 0; + session.recordingBytes += Buffer.byteLength(line); + session.recordingWriteChain = session.recordingWriteChain.then(async () => { + if (firstEvent) { + await fs.promises.mkdir(path.dirname(session.recordingPath!), { + recursive: true, + }); + await fs.promises.writeFile( + session.recordingPath!, + `${session.recordingHeader}${line}`, + "utf8", + ); + return; + } + await fs.promises.appendFile(session.recordingPath!, line, "utf8"); + }); } flushBuffer(session: TerminalSession): string | null { diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 2c351f0b..0ca34d8a 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -539,6 +539,9 @@ wss.on("connection", async (ws: WebSocket, req) => { case "input": { const inputData = data as string; + if (currentSessionId) { + sessionManager.bufferInput(currentSessionId, inputData); + } const inputStream = sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream; if (inputStream) { @@ -2913,6 +2916,7 @@ wss.on("connection", async (ws: WebSocket, req) => { if (session) { session.cols = data.cols; session.rows = data.rows; + sessionManager.bufferResize(session.id, data.cols, data.rows); } ws.send( JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }), diff --git a/src/types/guacamole-common-js.d.ts b/src/types/guacamole-common-js.d.ts index 5c494e91..3dbb36c3 100644 --- a/src/types/guacamole-common-js.d.ts +++ b/src/types/guacamole-common-js.d.ts @@ -41,6 +41,26 @@ declare module "guacamole-common-js" { constructor(url: string); } + class SessionRecording { + constructor(source: Blob | Tunnel, refreshInterval?: number); + onload: (() => void) | null; + onerror: ((message: string) => void) | null; + onprogress: ((duration: number, parsedSize: number) => void) | null; + onplay: (() => void) | null; + onpause: (() => void) | null; + onseek: + | ((position: number, current: number, total: number) => void) + | null; + getDisplay(): Display; + getPosition(): number; + getDuration(): number; + isPlaying(): boolean; + play(): void; + pause(): void; + seek(position: number, callback?: () => void): void; + abort(): void; + } + class Mouse { constructor(element: HTMLElement); onmousedown: ((state: Mouse.State) => void) | null; diff --git a/src/ui/api/session-log-api.ts b/src/ui/api/session-log-api.ts index c3ffa911..25e696a3 100644 --- a/src/ui/api/session-log-api.ts +++ b/src/ui/api/session-log-api.ts @@ -11,6 +11,9 @@ export type SessionLogRecord = { hostName: string | null; hostIp: string | null; sizeBytes: number | null; + protocol: "ssh" | "rdp" | "vnc" | "telnet"; + format: "text" | "asciicast" | "guacamole"; + username: string | null; }; export async function getSessionLogs(): Promise { @@ -22,6 +25,36 @@ export async function getSessionLogs(): Promise { } } +export async function getSessionLogBlob(id: number): Promise { + try { + const response = await authApi.get(`/session_logs/${id}/content`, { + responseType: "blob", + }); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch session recording"); + } +} + +export async function getSessionRecordingRetention(): Promise { + try { + const response = await authApi.get("/session_logs/retention"); + return response.data.retentionDays; + } catch (error) { + throw handleApiError(error, "fetch session recording retention"); + } +} + +export async function setSessionRecordingRetention( + retentionDays: number, +): Promise { + try { + await authApi.put("/session_logs/retention", { retentionDays }); + } catch (error) { + throw handleApiError(error, "update session recording retention"); + } +} + export async function getSessionLogContent(id: number): Promise { try { const response = await authApi.get(`/session_logs/${id}/content`, { diff --git a/src/ui/features/session-recording/SessionRecordingPlayer.tsx b/src/ui/features/session-recording/SessionRecordingPlayer.tsx new file mode 100644 index 00000000..8cadaa97 --- /dev/null +++ b/src/ui/features/session-recording/SessionRecordingPlayer.tsx @@ -0,0 +1,293 @@ +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 type { SessionLogRecord } from "@/api/session-log-api"; +import { parseAsciicast, type Asciicast } from "./asciicast"; +import "@xterm/xterm/css/xterm.css"; + +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 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 ( + + + {playing ? ( + + ) : ( + + )} + + + {formatPosition(position)} + + onSeek(Number(event.target.value))} + className="min-w-0 flex-1 accent-primary" + aria-label="Recording timeline" + /> + + {formatPosition(duration)} + + onSpeed(Number(event.target.value))} + className="h-7 border border-border bg-background px-1 text-[10px]" + aria-label="Playback speed" + > + {SPEEDS.map((value) => ( + + {value}× + + ))} + + + ); +} + +function AsciicastPlayer({ blob }: { blob: Blob }) { + const containerRef = useRef(null); + const terminalRef = useRef(null); + const recordingRef = useRef(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(reason instanceof Error ? reason.message : 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 {error}; + return ( + + + { + if (!playing && position >= duration) renderAt(0); + setPlaying((value) => !value); + }} + onSeek={renderAt} + onSpeed={setSpeed} + /> + + ); +} + +function GuacamolePlayer({ blob }: { blob: Blob }) { + const containerRef = useRef(null); + const recordingRef = useRef(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 {error}; + return ( + + + { + 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} + /> + + ); +} + +export function SessionRecordingPlayer({ + log, + blob, +}: { + log: SessionLogRecord; + blob: Blob; +}) { + return log.format === "guacamole" ? ( + + ) : ( + + ); +} diff --git a/src/ui/features/session-recording/asciicast.test.ts b/src/ui/features/session-recording/asciicast.test.ts new file mode 100644 index 00000000..65c669e2 --- /dev/null +++ b/src/ui/features/session-recording/asciicast.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { parseAsciicast } from "./asciicast"; + +describe("parseAsciicast", () => { + it("parses terminal dimensions and timed input/output", () => { + const recording = parseAsciicast( + '{"version":2,"width":120,"height":30}\n[0.1,"o","hello"]\n[1.5,"i","ls\\r"]\n[2,"r","100x40"]\n', + ); + expect(recording.width).toBe(120); + expect(recording.height).toBe(30); + expect(recording.duration).toBe(2); + expect(recording.events).toHaveLength(3); + }); + + it("rejects unsupported recording versions", () => { + expect(() => parseAsciicast('{"version":1}\n')).toThrow( + "Unsupported asciicast version", + ); + }); +}); diff --git a/src/ui/features/session-recording/asciicast.ts b/src/ui/features/session-recording/asciicast.ts new file mode 100644 index 00000000..bf13ac30 --- /dev/null +++ b/src/ui/features/session-recording/asciicast.ts @@ -0,0 +1,39 @@ +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, + }; +} diff --git a/src/ui/sidebar/SessionLogsPanel.tsx b/src/ui/sidebar/SessionLogsPanel.tsx index 5ce598de..b6f7a5d5 100644 --- a/src/ui/sidebar/SessionLogsPanel.tsx +++ b/src/ui/sidebar/SessionLogsPanel.tsx @@ -15,6 +15,7 @@ import { Download, Eye, Loader2, + Save, ScrollText, Search, X, @@ -23,9 +24,13 @@ 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"; function formatDuration(seconds: number | null): string { if (seconds == null) return "--"; @@ -63,7 +68,13 @@ function buildFilename(log: SessionLogRecord): string { const h = String(d.getHours()).padStart(2, "0"); const min = String(d.getMinutes()).padStart(2, "0"); const s = String(d.getSeconds()).padStart(2, "0"); - return `${host}_${y}-${m}-${day}_${h}-${min}-${s}.log`; + const extension = + log.format === "guacamole" + ? "guac" + : log.format === "asciicast" + ? "cast" + : "log"; + return `${host}_${y}-${m}-${day}_${h}-${min}-${s}.${extension}`; } function SectionHeader({ label, count }: { label: string; count: number }) { @@ -105,6 +116,9 @@ function LogRow({ {formatDate(log.startedAt)} {" · "} + {log.protocol.toUpperCase()} + {log.username ? ` · ${log.username}` : ""} + {" · "} {formatDuration(log.duration)} {" · "} {formatBytes(log.sizeBytes)} @@ -159,12 +173,15 @@ export function SessionLogsPanel() { const [filter, setFilter] = useState(""); const [viewLog, setViewLog] = useState(null); const [viewContent, setViewContent] = useState(""); + const [viewBlob, setViewBlob] = useState(null); const [viewLoading, setViewLoading] = useState(false); const [deleteTarget, setDeleteTarget] = useState( null, ); const [deleting, setDeleting] = useState(false); const [copied, setCopied] = useState(false); + const [retentionDays, setRetentionDays] = useState(null); + const [savingRetention, setSavingRetention] = useState(false); const load = useCallback( (initial = false) => { @@ -192,6 +209,9 @@ export function SessionLogsPanel() { useEffect(() => { load(true); + getSessionRecordingRetention() + .then(setRetentionDays) + .catch(() => setRetentionDays(null)); const interval = setInterval(() => load(false), 5000); return () => clearInterval(interval); }, [load]); @@ -199,17 +219,23 @@ export function SessionLogsPanel() { const q = filter.trim().toLowerCase(); const filtered = q ? logs.filter((l) => - (l.hostName ?? l.hostIp ?? "").toLowerCase().includes(q), + `${l.hostName ?? ""} ${l.hostIp ?? ""} ${l.username ?? ""} ${l.protocol}` + .toLowerCase() + .includes(q), ) : logs; const handleView = async (log: SessionLogRecord) => { setViewLog(log); setViewContent(""); + setViewBlob(null); setViewLoading(true); try { - const content = await getSessionLogContent(log.id); - setViewContent(content); + if (log.format === "text") { + setViewContent(await getSessionLogContent(log.id)); + } else { + setViewBlob(await getSessionLogBlob(log.id)); + } } catch { toast.error(t("sessionLogs.loadError")); } finally { @@ -219,8 +245,7 @@ export function SessionLogsPanel() { const handleDownload = async (log: SessionLogRecord) => { try { - const content = await getSessionLogContent(log.id); - const blob = new Blob([content], { type: "text/plain" }); + const blob = await getSessionLogBlob(log.id); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -285,6 +310,7 @@ export function SessionLogsPanel() { {formatDate(viewLog.startedAt)} + {` · ${viewLog.protocol.toUpperCase()}`} {viewLog.duration != null ? ` · ${formatDuration(viewLog.duration)}` : ""} @@ -294,25 +320,27 @@ export function SessionLogsPanel() { - - - - {copied ? ( - - ) : ( - - )} - - - - {copied - ? t("sessionLogs.copied") - : t("sessionLogs.copyContent")} - - + {viewLog.format === "text" && ( + + + + {copied ? ( + + ) : ( + + )} + + + + {copied + ? t("sessionLogs.copied") + : t("sessionLogs.copyContent")} + + + )} + ) : viewBlob ? ( + ) : ( {viewContent || "(empty)"} @@ -349,6 +379,44 @@ export function SessionLogsPanel() { return ( + {retentionDays != null && ( + + Recording retention + setRetentionDays(Number(event.target.value))} + className="h-7 w-20 text-xs" + aria-label="Recording retention days" + /> + days + { + 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 ? ( + + ) : ( + + )} + + + )} {logs.length === 0 ? (
{viewContent || "(empty)"} @@ -349,6 +379,44 @@ export function SessionLogsPanel() { return ( + {retentionDays != null && ( + + Recording retention + setRetentionDays(Number(event.target.value))} + className="h-7 w-20 text-xs" + aria-label="Recording retention days" + /> + days + { + 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 ? ( + + ) : ( + + )} + + + )} {logs.length === 0 ? (