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

+2 -9
View File
@@ -519,17 +519,10 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location /session_logs/ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
# File manager routes (recent/pinned/shortcuts, SFTP, transfers) moved
# to the file-manager plugin, served under /plugin-api/file-manager/.
# Session logs moved to the session-recording plugin, served under
# /plugin-api/session-recording/.
location /health {
proxy_pass http://127.0.0.1:30001;
+2 -9
View File
@@ -497,17 +497,10 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location /session_logs/ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
# File manager routes (recent/pinned/shortcuts, SFTP, transfers) moved
# to the file-manager plugin, served under /plugin-api/file-manager/.
# Session logs moved to the session-recording plugin, served under
# /plugin-api/session-recording/.
location /health {
proxy_pass http://127.0.0.1:30001;
+5
View File
@@ -0,0 +1,5 @@
-- session_recordings moved to the session-recording plugin, whose first
-- migration renames it. Dropping it here would lose every recording, so
-- this only stops drizzle tracking it. SELECT 1 because MySQL rejects an
-- empty query.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -288,6 +288,13 @@
"when": 1790224430728,
"tag": "0040_misty_skin",
"breakpoints": true
},
{
"idx": 41,
"version": "5",
"when": 1790225600497,
"tag": "0041_wonderful_cargill",
"breakpoints": true
}
]
}
@@ -0,0 +1,5 @@
-- session_recordings moved to the session-recording plugin, whose first
-- migration renames it. Dropping it here would lose every recording, so
-- this only stops drizzle tracking it. SELECT 1 because MySQL rejects an
-- empty query.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -295,6 +295,13 @@
"when": 1790224430003,
"tag": "0041_watery_zeigeist",
"breakpoints": true
},
{
"idx": 42,
"version": "7",
"when": 1790225599928,
"tag": "0042_strange_shocker",
"breakpoints": true
}
]
}
+5
View File
@@ -0,0 +1,5 @@
-- session_recordings moved to the session-recording plugin, whose first
-- migration renames it. Dropping it here would lose every recording, so
-- this only stops drizzle tracking it. SELECT 1 because MySQL rejects an
-- empty query.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -267,6 +267,13 @@
"when": 1790224429343,
"tag": "0037_burly_enchantress",
"breakpoints": true
},
{
"idx": 38,
"version": "6",
"when": 1790225599344,
"tag": "0038_broad_avengers",
"breakpoints": true
}
]
}
+30 -4
View File
@@ -118,7 +118,11 @@ Plugins own their tables. A plugin declares them with `defineTable()` from
`@termix/plugin-sdk/db` and ships migrations for `sqlite`, `postgres` and
`mysql` under `migrations/<dialect>/NNNN_name.sql`. Table names are prefixed
`p_<id with - as _>_`, which `defineTable` adds. `ctx.kv` is for small
key/value state. Core `schema.ts` ends up holding only core tables.
key/value state, and `ctx.files.dataDir()` (**B13**, needs `files:own`) is a
per-plugin folder under `DATA_DIR`, created on first call, for state too
large for `ctx.kv` (session recordings, uploaded files); a plugin lays out
its own subdirectories underneath it. Core `schema.ts` ends up holding only
core tables.
**One table object, three DDL emitters.** The sqlite-core definitions already
encode correctly on every engine at query time, which is why core's own 44
@@ -608,6 +612,22 @@ the asciicast formatting and the 300 ms batched flush (issue #1049) and hands
the provider one `append` per batch. Service names need a dot, so "tmux" and
"recordings" became `tmux.sessions` and `recordings.writer`.
**B13** is the session-recording plugin, which provides `recordings.writer`.
It adopts `session_recordings`, keeping `userId` a plain column rather than
`refUser()` (a recording is evidence about the host as much as the person,
so it outlives the account) and clearing it on the `user.deleted` event
instead of cascading. `open(meta)` returns null when the host's
`enableSessionRecording` setting is off; otherwise a `RecordingSink` with
`append(chunk)` (one call per already-batched chunk, so it never writes
per-chunk itself), `persist(summary)` and `discard()` for a session that
recorded nothing. `createFinished(input)` is the second half of the
contract, for a caller that already wrote its own recording to disk and only
needs the database row: remote-desktop's guacd recordings use it instead of
importing the old repository directly, and without the plugin enabled a
guacd recording still lands on disk but gets no row. Recording files live
under `ctx.files.dataDir()/session_logs/<user>/<session>.cast`. Retention
(`retentionDays`, an admin setting) runs a sweep at boot and every 24 hours.
A service call runs a permission check for its actor, so a share-link guest,
who has no user, cannot make one. Guest link resolution is therefore
published on `ctx.registry` as `sessions.sharing.guests` instead: the token is
@@ -1161,6 +1181,7 @@ deciding, not the mechanism.
| `hosts:read` | low | See the host list it can already see |
| `db:own` | low | Own tables |
| `kv:own` | low | Small key/value state |
| `files:own` | low | Its own files on disk |
| `secrets:own` | low | Its own encrypted secrets |
| `settings:read-core` | low | Read core server settings |
| `ui:surface` | low | Contribute tabs, panels and settings |
@@ -1190,6 +1211,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.log.*` | none | **A1** |
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
| `ctx.files.dataDir` | `files:own` | **B13** |
| `ctx.registry.*` | none | **A1** |
| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
@@ -1668,9 +1690,13 @@ upgrade on all three engines, and the checks in step 15 are green.
- `src/backend/database/routes/delete-user-data.ts` and the user/host
repositories: explicit deletes of the table. A `refUser`/`refHost` column
cascades on every engine, because the foreign key survives the rename.
Prefer that cascade. A table that cannot carry such a column needs a
core "user deleted" or "host deleted" event on `ctx.events`, added to
the SDK the step 3 way; none exists yet because no plugin needed one.
Prefer that cascade. A table that cannot carry such a column (evidence
that has to outlive the account, like a recording) subscribes to
`ctx.events.on("user.deleted", ...)` or `"host.deleted"` instead, added
to the SDK the step 3 way; **B13** added `user.deleted`
(`TOPICS.userDeleted` in `src/backend/plugins/events.ts`), emitted from
`deleteUserAndRelatedData` right where the old direct repository call
used to sit. `host.deleted` already existed (B7).
- `sync-entities.ts`, if core synced it (step 9).
- Anything else `grep -rn "<table_name>\|<tableConst>" src` finds, including
core tests and `src/types`.
+39
View File
@@ -282,6 +282,40 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
desktop toolbars now; the tab bar's share button is gone, so a host with the
terminal toolbar switched off has no share button. A generic tab-menu slot
would bring it back if that matters. Owner: D1, if wanted.
- **B13 (session-recording):** `enable_session_logging` is still a live
`ssh_data` column; only the copy-into-plugin-settings migration shipped
(`session-recording-settings-migration.ts`). The legacy default was `true`;
the plugin's enable switch defaults to `false` like every other plugin's, so
the migration copies every host's value rather than only the ones that
turned it off - a fresh host created after 2.9.0 defaults to recording off,
a real behaviour change from the old column default, made once here rather
than silently. `ssh-terminal/src/backend/terminal-socket.ts` still reads
`resolvedHostData?.enableSessionLogging ?? hostConfig.enableSessionLogging`
off the resolved SSH host instead of `ctx.settings.getHost`, and
`plugins/remote-desktop/src/backend/routes.ts`'s
`host.enableSessionLogging !== false` check, `HostEditorData.ts`,
`HostManagerData.ts`, `quick-connect-host.ts` and the export/import sample
all still read the column directly. The General tab's checkbox was removed
from `HostEditor.tsx` (the schema-driven Plugins group replaces it); the
data plumbing above was not switched over. Dropping the column in lockstep
(`schema.ts`, `db/index.ts`, a drizzle migration per dialect,
`schema:generate`) needs every one of those switched first. Owner: a
dedicated follow-up step, or D0.
- **B13 (session-recording):** `remote-desktop`'s guacd recordings now insert
through the optional `recordings.writer` service's `createFinished(input)`
(added alongside `open()`/`RecordingSink` for ssh-terminal's incremental
writer) instead of importing `createCurrentSessionRecordingRepository`
directly. Without the session-recording plugin enabled, a guacd recording
is still written to disk but gets no `session_recordings` row and so never
appears in the list - a warning is logged. Owner: none needed unless a
second consumer of `recordings.writer` appears.
- **B13 (session-recording):** `accessId` (the legacy `access_id` column,
referencing the now-core-only `host_access` table) is carried in the
adopted table's shape as a plain unreferenced integer, since the SDK's
`defineTable` has no builder for a foreign key into an arbitrary core
table beyond `refUser()`/`refHost()`. Nothing has ever populated it (true
in core before this step too). Owner: none needed unless a future step
wants to actually use it.
## Manual checks after 2.9.0
@@ -295,3 +329,8 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
window, a user share joined from Active Connections, revoke and "end for
everyone", and a collab room with two members, control hand-off, a guest
link, rotate and end.
- Session recording: an SSH session recorded end to end (playback matches what
happened, including a resize mid-session), a host with recording switched
off records nothing, retention prunes an old recording on schedule, a guacd
RDP/VNC/Telnet recording still gets a row and plays back, and deleting a
user anonymizes their recordings instead of removing them.
+14
View File
@@ -74,6 +74,19 @@ export interface PluginKeyValue {
list: () => Promise<string[]>;
}
/**
* A folder on disk this plugin owns, for state too large for ctx.kv (session
* recordings, uploaded files). Requires files:own.
*/
export interface PluginFiles {
/**
* The plugin's own folder under DATA_DIR, created if it does not exist yet.
* Always the same path for this plugin; a plugin lays out its own
* subdirectories underneath it.
*/
dataDir: () => Promise<string>;
}
/**
* The plugin's own tables.
*
@@ -1147,6 +1160,7 @@ export interface PluginContext {
readonly log: PluginLogger;
readonly events: PluginEvents;
readonly kv: PluginKeyValue;
readonly files: PluginFiles;
readonly db: PluginDatabase;
readonly sync: PluginSync;
readonly registry: PluginRegistry;
+1
View File
@@ -60,6 +60,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("hosts:read", "low"),
entry("db:own", "low"),
entry("kv:own", "low"),
entry("files:own", "low"),
entry("secrets:own", "low"),
entry("settings:read-core", "low"),
entry("ui:surface", "low"),
+13
View File
@@ -300,6 +300,12 @@ export function createFakeContext(
list: async () => [...kv.keys()],
},
files: {
// A fake path; a test that needs a real folder uses the OS temp dir
// itself, not this double.
dataDir: async () => `/tmp/${pluginId}`,
},
db: options.db
? {
...options.db,
@@ -798,6 +804,13 @@ export function createMockCtx(
},
},
files: {
dataDir: async () => {
require("files:own");
return ctx.files.dataDir();
},
},
events: {
emit: (topic, payload) => {
if (
+7
View File
@@ -22,6 +22,13 @@
"audit:read",
"ui:surface"
],
"requires": [
{
"service": "recordings.writer",
"versionRange": "^1.0.0",
"optional": true
}
],
"contributes": {
"tabs": [
{
@@ -4,16 +4,33 @@ import {
GuacamoleTokenService,
type GuacamoleRecordingMetadata,
} from "./token-service.js";
import {
createCurrentSessionRecordingRepository,
getCurrentSettingValue,
} from "../../../../src/backend/database/repositories/factory.js";
import { getCurrentSettingValue } from "../../../../src/backend/database/repositories/factory.js";
import { resolveGuacdOptions } from "../../../../src/backend/utils/guacd-config.js";
import fs from "fs";
import path from "path";
const tokenService = GuacamoleTokenService.getInstance();
/** The recordings.writer service, when the session-recording plugin provides it. */
export interface RecordingsWriter {
createFinished: (input: {
hostId: number;
userId: string;
startedAt: string;
endedAt: string;
duration: number | null;
recordingPath: string;
protocol: string;
format: string;
}) => Promise<{ id: number }>;
}
let pluginRecordings: RecordingsWriter | null = null;
export function setPluginRecordings(recordings: RecordingsWriter | null): void {
pluginRecordings = recordings;
}
function readGuacdOptions(): { host: string; port: number } {
let dbUrl: string | undefined;
try {
@@ -103,9 +120,17 @@ async function persistGuacamoleRecording(
return;
}
if (!pluginRecordings) {
guacLogger.warn(
"Session Recording plugin is off; guacamole recording not saved to the log",
{ operation: "guac_recording_no_writer", hostId: recording.hostId },
);
return;
}
const endedAt = new Date();
const startedAt = new Date(recording.startedAt);
await createCurrentSessionRecordingRepository().create({
await pluginRecordings.createFinished({
hostId: recording.hostId,
userId: recording.userId,
startedAt: startedAt.toISOString(),
@@ -4,15 +4,24 @@ import {
getGuacSessionInfo,
handleGuacamoleUpgrade,
restartGuacServer,
setPluginRecordings,
startGuacamoleService,
stopGuacamoleService,
tokenService,
type RecordingsWriter,
} from "./guacamole-server.js";
import { startRemoteDesktopService } from "./routes.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
// Optional: without the session-recording plugin, a guacd recording is
// still made on disk but never gets a row, so it never shows in the list.
const recordings = ctx.services.get<RecordingsWriter>("recordings.writer");
setPluginRecordings("createFinished" in recordings ? recordings : null);
ctx.disposables.add(() => setPluginRecordings(null));
await startGuacamoleService();
startRemoteDesktopService(ctx.http.router());
+16
View File
@@ -0,0 +1,16 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
- Session recording moved out of core's `/session_logs` routes and now serves
under `/plugin-api/session-recording` through core's connect pipeline.
- Owns its data: core's `session_recordings` table is renamed to
`p_session_recording_session_recordings` on first activation.
- New `session-recording.view` permission, given to the admin and user roles.
- Offers `recordings.writer` to other plugins: `open()`/`append()`/`persist()`
for an incremental writer (ssh-terminal), `createFinished()` for a recording
already written to disk (remote desktop's guacd recordings).
- Retention days becomes an admin plugin setting; per-host recording becomes
a host plugin setting.
+24
View File
@@ -0,0 +1,24 @@
# Session Recording
Records terminal sessions and lets you play them back or download them.
Bundled with Termix and enabled by default. It imports only
`@termix/plugin-sdk`.
## What it does
- **Recording**: an SSH terminal session is recorded as an asciicast, written
incrementally in 300ms batches as it happens (issue #1049).
- **Playback**: view a recording inline, scrub through it, copy its plain
text, or download it as a recording file or plain text.
- **Retention**: an admin setting prunes recordings older than a configured
number of days, checked at startup and every 24 hours.
- **Per-host switch**: recording can be turned off for a host.
- **`recordings.writer`**: a service other plugins consume. `open(meta)`
starts an incremental recording (ssh-terminal); `createFinished(input)`
inserts a row for a recording the caller already wrote to disk itself
(remote desktop's guacd recordings).
Recordings outlive the account they were made under: deleting a user
anonymizes their recordings rather than deleting them, since a recording is
evidence about the host as much as about the person.
+48
View File
@@ -0,0 +1,48 @@
{
"nav": {
"sessionLogs": "Session Logs"
},
"hosts": {
"enableSessionRecording": "Session Recording",
"enableSessionRecordingDesc": "Record terminal session output for later review"
},
"permissions": {
"view": {
"title": "View session recordings",
"description": "See and play back your own session recordings."
}
},
"settings": {
"admin": {
"retentionDays": {
"label": "Retention (days)",
"description": "How long a session recording is kept before it is deleted automatically."
}
},
"host": {
"enable": {
"label": "Enable session recording"
}
}
},
"terminalStatus": {
"recording": "Recording"
},
"sessionLogs": {
"title": "Session Logs",
"noLogs": "No session logs yet",
"noLogsDesc": "Enable session logging on a host to start recording",
"duration": "Duration",
"viewLog": "View log",
"downloadLog": "Download",
"downloadAsText": "Download as text",
"deleteLog": "Delete",
"confirmDelete": "Delete this session log?",
"confirmDeleteDesc": "This action cannot be undone.",
"copyContent": "Copy",
"copied": "Copied!",
"loadError": "Failed to load session logs",
"deleteError": "Failed to delete session log",
"filterByHost": "Filter by host..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Sessielogboeke"
},
"hosts": {
"enableSessionRecording": "Sessie-logging",
"enableSessionRecordingDesc": "Neem terminaalsessie-uitvoer op vir latere hersiening"
},
"sessionLogs": {
"title": "Sessielogboeke",
"noLogs": "Geen sessielogboeke nog nie",
"noLogsDesc": "Aktiveer sessie-aanmelding op 'n gasheer om opname te begin",
"duration": "Duur",
"viewLog": "Bekyk logboek",
"downloadLog": "Laai af",
"downloadAsText": "Laai af as teks",
"deleteLog": "Vee uit",
"confirmDelete": "Vee hierdie sessielog uit?",
"confirmDeleteDesc": "Hierdie aksie kan nie ongedaan gemaak word nie.",
"copyContent": "Kopieer",
"copied": "Gekopieer!",
"loadError": "Kon nie sessielogboeke laai nie",
"deleteError": "Kon nie sessielogboek uitvee nie",
"filterByHost": "Filtreer volgens gasheer..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "سجلات الجلسات"
},
"hosts": {
"enableSessionRecording": "تسجيل الجلسات",
"enableSessionRecordingDesc": "تسجيل مخرجات الجلسة الطرفية للمراجعة لاحقاً"
},
"sessionLogs": {
"title": "سجلات الجلسات",
"noLogs": "لا توجد سجلات جلسات بعد",
"noLogsDesc": "فعّل تسجيل الجلسات على مضيف لبدء التسجيل",
"duration": "المدة",
"viewLog": "عرض السجل",
"downloadLog": "تنزيل",
"downloadAsText": "تنزيل كنص",
"deleteLog": "حذف",
"confirmDelete": "حذف سجل الجلسة هذا؟",
"confirmDeleteDesc": "لا يمكن التراجع عن هذا الإجراء.",
"copyContent": "نسخ",
"copied": "تم النسخ!",
"loadError": "فشل في تحميل سجلات الجلسات",
"deleteError": "فشل في حذف سجل الجلسة",
"filterByHost": "تصفية حسب المضيف..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Дневници на сесиите"
},
"hosts": {
"enableSessionRecording": "Записване на сесии",
"enableSessionRecordingDesc": "Записване на изхода от терминалната сесия за по-късен преглед"
},
"sessionLogs": {
"title": "Дневници на сесии",
"noLogs": "Все още няма логове на сесии",
"noLogsDesc": "Активирайте регистрирането на сесията на хост, за да започнете записа",
"duration": "Продължителност",
"viewLog": "Преглед на дневника",
"downloadLog": "Изтегляне",
"downloadAsText": "Изтегляне като текст",
"deleteLog": "Изтриване",
"confirmDelete": "Да се изтрие ли този дневник на сесията?",
"confirmDeleteDesc": "Това действие не може да бъде отменено.",
"copyContent": "Копиране",
"copied": "Копирано!",
"loadError": "Зареждането на лог файловете на сесията не бе успешно",
"deleteError": "Неуспешно изтриване на дневника на сесията",
"filterByHost": "Филтриране по хост..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "সেশন লগ"
},
"hosts": {
"enableSessionRecording": "সেশন লগিং",
"enableSessionRecordingDesc": "পরবর্তী পর্যালোচনার জন্য টার্মিনাল সেশনের আউটপুট রেকর্ড করুন।"
},
"sessionLogs": {
"title": "সেশন লগ",
"noLogs": "এখনো কোনো সেশন লগ নেই",
"noLogsDesc": "রেকর্ডিং শুরু করতে কোনো হোস্টে সেশন লগিং চালু করুন।",
"duration": "সময়কাল",
"viewLog": "লগ দেখুন",
"downloadLog": "ডাউনলোড করুন",
"downloadAsText": "টেক্সট হিসেবে ডাউনলোড করুন",
"deleteLog": "মুছে ফেলুন",
"confirmDelete": "এই সেশন লগটি মুছে ফেলবেন?",
"confirmDeleteDesc": "এই কাজটি পূর্বাবস্থায় ফেরানো যাবে না।",
"copyContent": "অনুলিপি",
"copied": "অনুলিপি করা হয়েছে!",
"loadError": "সেশন লগ লোড করতে ব্যর্থ হয়েছে",
"deleteError": "সেশন লগ মুছে ফেলতে ব্যর্থ হয়েছে",
"filterByHost": "হোস্ট দ্বারা ফিল্টার করুন..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Registres de sessió"
},
"hosts": {
"enableSessionRecording": "Registre de sessions",
"enableSessionRecordingDesc": "Enregistra la sortida de la sessió de terminal per a una revisió posterior"
},
"sessionLogs": {
"title": "Registres de sessió",
"noLogs": "Encara no hi ha registres de sessió",
"noLogsDesc": "Habilita el registre de sessions en un amfitrió per iniciar l'enregistrament",
"duration": "Durada",
"viewLog": "Veure registre",
"downloadLog": "Descarrega",
"downloadAsText": "Descarrega com a text",
"deleteLog": "Suprimeix",
"confirmDelete": "Voleu suprimir aquest registre de sessió?",
"confirmDeleteDesc": "Aquesta acció no es pot desfer.",
"copyContent": "Còpia",
"copied": "Copiat!",
"loadError": "No s'han pogut carregar els registres de sessió",
"deleteError": "No s'ha pogut suprimir el registre de sessió",
"filterByHost": "Filtra per amfitrió..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Protokoly relací"
},
"hosts": {
"enableSessionRecording": "Protokolování relací",
"enableSessionRecordingDesc": "Zaznamenejte výstup terminálové relace pro pozdější kontrolu"
},
"sessionLogs": {
"title": "Protokoly relací",
"noLogs": "Zatím žádné protokoly relací",
"noLogsDesc": "Povolte protokolování relace na hostiteli pro zahájení nahrávání",
"duration": "Trvání",
"viewLog": "Zobrazit protokol",
"downloadLog": "Stáhnout",
"downloadAsText": "Stáhnout jako text",
"deleteLog": "Vymazat",
"confirmDelete": "Smazat tento protokol relace?",
"confirmDeleteDesc": "Tuto akci nelze vrátit zpět.",
"copyContent": "Kopie",
"copied": "Zkopírováno!",
"loadError": "Nepodařilo se načíst protokoly relací",
"deleteError": "Nepodařilo se smazat protokol relace",
"filterByHost": "Filtrovat podle hostitele..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Sessionslogfiler"
},
"hosts": {
"enableSessionRecording": "Sessionslogning",
"enableSessionRecordingDesc": "Optag output fra terminalsessionen til senere gennemgang"
},
"sessionLogs": {
"title": "Sessionslogfiler",
"noLogs": "Ingen sessionslogfiler endnu",
"noLogsDesc": "Aktivér sessionslogning på en vært for at starte optagelse",
"duration": "Varighed",
"viewLog": "Se log",
"downloadLog": "Download",
"downloadAsText": "Download som tekst",
"deleteLog": "Slet",
"confirmDelete": "Slet denne sessionslog?",
"confirmDeleteDesc": "Denne handling kan ikke fortrydes.",
"copyContent": "Kopi",
"copied": "Kopieret!",
"loadError": "Kunne ikke indlæse sessionslogfiler",
"deleteError": "Kunne ikke slette sessionsloggen",
"filterByHost": "Filtrer efter vært..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Sitzungsprotokolle"
},
"hosts": {
"enableSessionRecording": "Sitzungsprotokollierung",
"enableSessionRecordingDesc": "Terminal-Sitzungsausgabe zur späteren Überprüfung aufzeichnen"
},
"sessionLogs": {
"title": "Sitzungsprotokolle",
"noLogs": "Noch keine Sitzungsprotokolle",
"noLogsDesc": "Aktivieren Sie die Sitzungsprotokollierung auf einem Host, um die Aufzeichnung zu starten",
"duration": "Dauer",
"viewLog": "Protokoll anzeigen",
"downloadLog": "Herunterladen",
"downloadAsText": "Als Text herunterladen",
"deleteLog": "Löschen",
"confirmDelete": "Dieses Sitzungsprotokoll löschen?",
"confirmDeleteDesc": "Diese Aktion kann nicht rückgängig gemacht werden.",
"copyContent": "Kopieren",
"copied": "Kopiert!",
"loadError": "Fehler beim Laden der Sitzungsprotokolle",
"deleteError": "Fehler beim Löschen des Sitzungsprotokolls",
"filterByHost": "Nach Host filtern..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Αρχεία καταγραφής συνεδρίας"
},
"hosts": {
"enableSessionRecording": "Καταγραφή συνεδρίας",
"enableSessionRecordingDesc": "Καταγραφή εξόδου συνεδρίας τερματικού για μελλοντική αναθεώρηση"
},
"sessionLogs": {
"title": "Αρχεία καταγραφής συνεδρίας",
"noLogs": "Δεν υπάρχουν ακόμη αρχεία καταγραφής συνεδρίας",
"noLogsDesc": "Ενεργοποίηση καταγραφής περιόδου λειτουργίας σε έναν κεντρικό υπολογιστή για έναρξη εγγραφής",
"duration": "Διάρκεια",
"viewLog": "Προβολή αρχείου καταγραφής",
"downloadLog": "Λήψη",
"downloadAsText": "Λήψη ως κείμενο",
"deleteLog": "Διαγράφω",
"confirmDelete": "Διαγραφή αυτού του αρχείου καταγραφής περιόδου σύνδεσης;",
"confirmDeleteDesc": "Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
"copyContent": "Αντίγραφο",
"copied": "Αντιγράφηκε!",
"loadError": "Αποτυχία φόρτωσης αρχείων καταγραφής περιόδου λειτουργίας",
"deleteError": "Η διαγραφή του αρχείου καταγραφής περιόδου λειτουργίας απέτυχε",
"filterByHost": "Φιλτράρισμα κατά κεντρικό υπολογιστή..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Registros de sesión"
},
"hosts": {
"enableSessionRecording": "Registro de sesión",
"enableSessionRecordingDesc": "Registrar la salida de la sesión de terminal para su revisión posterior"
},
"sessionLogs": {
"title": "Registros de sesión",
"noLogs": "Aún no hay registros de sesión",
"noLogsDesc": "Activa el registro de sesiones en un host para empezar a grabar",
"duration": "Duración",
"viewLog": "Ver registro",
"downloadLog": "Descargar",
"downloadAsText": "Descargar como texto",
"deleteLog": "Eliminar",
"confirmDelete": "¿Eliminar este registro de sesión?",
"confirmDeleteDesc": "Esta acción no se puede deshacer.",
"copyContent": "Copiar",
"copied": "¡Copiado!",
"loadError": "Error al cargar los registros de sesión",
"deleteError": "Error al eliminar el registro de sesión",
"filterByHost": "Filtrar por host..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Istuntolokit"
},
"hosts": {
"enableSessionRecording": "Istuntojen lokitiedot",
"enableSessionRecordingDesc": "Tallenna pääteistunnon tuloste myöhempää tarkastelua varten"
},
"sessionLogs": {
"title": "Istuntolokit",
"noLogs": "Ei vielä istuntolokeja",
"noLogsDesc": "Ota istunnon lokikirjaus käyttöön isännässä aloittaaksesi tallennuksen",
"duration": "Kesto",
"viewLog": "Näytä loki",
"downloadLog": "Lataa",
"downloadAsText": "Lataa tekstinä",
"deleteLog": "Poistaa",
"confirmDelete": "Poistetaanko tämä istuntoloki?",
"confirmDeleteDesc": "Tätä toimintoa ei voi perua.",
"copyContent": "Kopioida",
"copied": "Kopioitu!",
"loadError": "Istuntolokien lataaminen epäonnistui",
"deleteError": "Istuntolokitietojen poistaminen epäonnistui",
"filterByHost": "Suodata isännän mukaan..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Journaux de session"
},
"hosts": {
"enableSessionRecording": "Journalisation de session",
"enableSessionRecordingDesc": "Enregistrer la sortie de la session terminal pour une consultation ultérieure"
},
"sessionLogs": {
"title": "Journaux de session",
"noLogs": "Aucun journal de session pour l’instant",
"noLogsDesc": "Activez la journalisation des sessions sur un hôte pour démarrer l’enregistrement",
"duration": "Durée",
"viewLog": "Afficher le journal",
"downloadLog": "Télécharger",
"downloadAsText": "Télécharger au format texte",
"deleteLog": "Supprimer",
"confirmDelete": "Supprimer ce journal de session ?",
"confirmDeleteDesc": "Cette action est irréversible.",
"copyContent": "Copier",
"copied": "Copié !",
"loadError": "Échec du chargement des journaux de session",
"deleteError": "Échec de la suppression du journal de session",
"filterByHost": "Filtrer par hôte..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "יומני סשן"
},
"hosts": {
"enableSessionRecording": "רישום מושבים",
"enableSessionRecordingDesc": "הקלט את פלט הפעלת הטרמינל לצורך סקירה מאוחרת יותר"
},
"sessionLogs": {
"title": "יומני סשן",
"noLogs": "אין עדיין יומני סשן",
"noLogsDesc": "הפעל רישום סשן במארח כדי להתחיל להקליט",
"duration": "מֶשֶׁך",
"viewLog": "הצג יומן",
"downloadLog": "הורדה",
"downloadAsText": "הורד כטקסט",
"deleteLog": "לִמְחוֹק",
"confirmDelete": "למחוק את יומן הפעילויות הזה?",
"confirmDeleteDesc": "לא ניתן לבטל פעולה זו.",
"copyContent": "לְהַעְתִיק",
"copied": "מוּעֲתָק!",
"loadError": "טעינת יומני הסשן נכשלה",
"deleteError": "נכשלה מחיקת יומן ההפעלה",
"filterByHost": "סנן לפי מארח..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "सत्र लॉग्स"
},
"hosts": {
"enableSessionRecording": "सत्र लॉगिंग",
"enableSessionRecordingDesc": "बाद में समीक्षा के लिए टर्मिनल सत्र का आउटपुट रिकॉर्ड करें"
},
"sessionLogs": {
"title": "सेशन लॉग्स",
"noLogs": "अभी तक कोई सेशन लॉग नहीं",
"noLogsDesc": "रिकॉर्डिंग शुरू करने के लिए किसी होस्ट पर सेशन लॉगिंग सक्षम करें",
"duration": "अवधि",
"viewLog": "लॉग देखें",
"downloadLog": "डाउनलोड करें",
"downloadAsText": "टेक्स्ट के रूप में डाउनलोड करें",
"deleteLog": "हटाएं",
"confirmDelete": "क्या इस सेशन लॉग को हटाएं?",
"confirmDeleteDesc": "यह क्रिया वापस नहीं ली जा सकती।",
"copyContent": "कॉपी करें",
"copied": "कॉपी हो गया!",
"loadError": "सेशन लॉग लोड करने में विफल",
"deleteError": "सेशन लॉग हटाने में विफल",
"filterByHost": "होस्ट द्वारा फ़िल्टर करें..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Munkamenet-naplók"
},
"hosts": {
"enableSessionRecording": "Munkamenet-naplózás",
"enableSessionRecordingDesc": "Terminál munkamenet kimenetének rögzítése későbbi áttekintés céljából"
},
"sessionLogs": {
"title": "Munkamenet-naplók",
"noLogs": "Még nincsenek munkamenet-naplók",
"noLogsDesc": "A felvétel megkezdéséhez engedélyezze a munkamenet-naplózást egy gazdagépen",
"duration": "Időtartam",
"viewLog": "Napló megtekintése",
"downloadLog": "Letöltés",
"downloadAsText": "Letöltés szövegként",
"deleteLog": "Töröl",
"confirmDelete": "Törli ezt a munkamenet-naplót?",
"confirmDeleteDesc": "Ez a művelet nem vonható vissza.",
"copyContent": "Másolat",
"copied": "Másolva!",
"loadError": "Nem sikerült betölteni a munkamenet-naplókat",
"deleteError": "Nem sikerült törölni a munkamenet-naplót",
"filterByHost": "Szűrés hoszt szerint..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Log Sesi"
},
"hosts": {
"enableSessionRecording": "Pencatatan Sesi",
"enableSessionRecordingDesc": "Rekam output sesi terminal untuk ditinjau nanti."
},
"sessionLogs": {
"title": "Log Sesi",
"noLogs": "Belum ada log sesi.",
"noLogsDesc": "Aktifkan pencatatan sesi pada host untuk memulai perekaman.",
"duration": "Lamanya",
"viewLog": "Lihat log",
"downloadLog": "Unduh",
"downloadAsText": "Unduh sebagai teks",
"deleteLog": "Menghapus",
"confirmDelete": "Hapus log sesi ini?",
"confirmDeleteDesc": "Tindakan ini tidak dapat dibatalkan.",
"copyContent": "Menyalin",
"copied": "Disalin!",
"loadError": "Gagal memuat log sesi",
"deleteError": "Gagal menghapus log sesi",
"filterByHost": "Saring berdasarkan host..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Log di sessione"
},
"hosts": {
"enableSessionRecording": "Registrazione sessione",
"enableSessionRecordingDesc": "Registra l'output della sessione del terminale per la revisione successiva"
},
"sessionLogs": {
"title": "Registri di sessione",
"noLogs": "Ancora nessun registro di sessione",
"noLogsDesc": "Abilita la registrazione delle sessioni su un host per iniziare a registrare",
"duration": "Durata",
"viewLog": "Visualizza registro",
"downloadLog": "Scarica",
"downloadAsText": "Scarica come testo",
"deleteLog": "Elimina",
"confirmDelete": "Eliminare questo registro di sessione?",
"confirmDeleteDesc": "Questa azione non può essere annullata.",
"copyContent": "Copia",
"copied": "Copiato!",
"loadError": "Impossibile caricare i registri di sessione",
"deleteError": "Impossibile eliminare il registro di sessione",
"filterByHost": "Filtra per host..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "セッションログ"
},
"hosts": {
"enableSessionRecording": "セッションログ",
"enableSessionRecordingDesc": "後で確認できるようにターミナルセッションの出力を記録します"
},
"sessionLogs": {
"title": "セッションログ",
"noLogs": "セッションログはまだありません",
"noLogsDesc": "ホストでセッションログを有効にして記録を開始してください",
"duration": "所要時間",
"viewLog": "ログを表示",
"downloadLog": "ダウンロード",
"downloadAsText": "テキストとしてダウンロード",
"deleteLog": "削除",
"confirmDelete": "このセッションログを削除しますか?",
"confirmDeleteDesc": "この操作は元に戻せません。",
"copyContent": "コピー",
"copied": "コピーしました!",
"loadError": "セッションログの読み込みに失敗しました",
"deleteError": "セッションログの削除に失敗しました",
"filterByHost": "ホストでフィルター..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "세션 로그"
},
"hosts": {
"enableSessionRecording": "세션 로깅",
"enableSessionRecordingDesc": "나중에 검토할 수 있도록 터미널 세션 출력을 기록합니다"
},
"sessionLogs": {
"title": "세션 로그",
"noLogs": "아직 세션 로그 없음",
"noLogsDesc": "호스트에서 세션 로깅을 활성화하여 기록 시작",
"duration": "지속 시간",
"viewLog": "로그 보기",
"downloadLog": "다운로드",
"downloadAsText": "텍스트 파일로 다운로드",
"deleteLog": "삭제",
"confirmDelete": "이 세션 로그를 삭제하시겠습니까?",
"confirmDeleteDesc": "이 작업은 취소할 수 없습니다.",
"copyContent": "복사",
"copied": "복사됨!",
"loadError": "세션 로그 로드 실패",
"deleteError": "세션 로그 삭제 실패",
"filterByHost": "호스트로 필터링..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Sessielogboeken"
},
"hosts": {
"enableSessionRecording": "Sessieregistratie",
"enableSessionRecordingDesc": "Sla de uitvoer van de terminalsessie op voor latere beoordeling."
},
"sessionLogs": {
"title": "Sessielogboeken",
"noLogs": "Nog geen sessielogboeken",
"noLogsDesc": "Schakel sessielogging in op een host om te beginnen met opnemen.",
"duration": "Duur",
"viewLog": "Logboek bekijken",
"downloadLog": "Download",
"downloadAsText": "Downloaden als tekst",
"deleteLog": "Verwijderen",
"confirmDelete": "Dit sessielogboek verwijderen?",
"confirmDeleteDesc": "Deze actie kan niet ongedaan gemaakt worden.",
"copyContent": "Kopiëren",
"copied": "Gekopieerd!",
"loadError": "Het laden van de sessielogboeken is mislukt.",
"deleteError": "Het verwijderen van het sessielogboek is mislukt.",
"filterByHost": "Filteren op host..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Øktlogger"
},
"hosts": {
"enableSessionRecording": "Øktlogging",
"enableSessionRecordingDesc": "Ta opp utdata fra terminaløkten for senere gjennomgang"
},
"sessionLogs": {
"title": "Øktlogger",
"noLogs": "Ingen øktlogger ennå",
"noLogsDesc": "Aktiver øktlogging på en vert for å starte opptaket",
"duration": "Varighet",
"viewLog": "Vis logg",
"downloadLog": "Last ned",
"downloadAsText": "Last ned som tekst",
"deleteLog": "Slett",
"confirmDelete": "Slette denne øktloggen?",
"confirmDeleteDesc": "Denne handlingen kan ikke angres.",
"copyContent": "Kopiere",
"copied": "Kopiert!",
"loadError": "Kunne ikke laste inn øktlogger",
"deleteError": "Kunne ikke slette øktloggen",
"filterByHost": "Filtrer etter vert..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Logi sesji"
},
"hosts": {
"enableSessionRecording": "Rejestrowanie sesji",
"enableSessionRecordingDesc": "Zapisuj wyjście sesji terminala do późniejszego przejrzenia"
},
"sessionLogs": {
"title": "Logi sesji",
"noLogs": "Brak logów sesji",
"noLogsDesc": "Włącz rejestrowanie sesji na hoście, aby rozpocząć nagrywanie",
"duration": "Czas trwania",
"viewLog": "Wyświetl log",
"downloadLog": "Pobierz",
"downloadAsText": "Pobierz jako tekst",
"deleteLog": "Usuń",
"confirmDelete": "Usunąć ten log sesji?",
"confirmDeleteDesc": "Tej czynności nie można cofnąć.",
"copyContent": "Kopiuj",
"copied": "Skopiowano!",
"loadError": "Nie udało się załadować logów sesji",
"deleteError": "Nie udało się usunąć logu sesji",
"filterByHost": "Filtruj według hosta..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Registros de sessão"
},
"hosts": {
"enableSessionRecording": "Registro de sessão",
"enableSessionRecordingDesc": "Registre a saída da sessão do terminal para revisão posterior."
},
"sessionLogs": {
"title": "Registros de sessão",
"noLogs": "Ainda não há registros de sessão.",
"noLogsDesc": "Habilite o registro de sessão em um host para iniciar a gravação.",
"duration": "Duração",
"viewLog": "Ver registro",
"downloadLog": "Download",
"downloadAsText": "Baixar como texto",
"deleteLog": "Excluir",
"confirmDelete": "Excluir este registro de sessão?",
"confirmDeleteDesc": "Esta ação não pode ser desfeita.",
"copyContent": "Cópia",
"copied": "Copiado!",
"loadError": "Falha ao carregar os registros da sessão.",
"deleteError": "Falha ao excluir o registro da sessão",
"filterByHost": "Filtrar por host..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Registos de Sessão"
},
"hosts": {
"enableSessionRecording": "Registo de sessão",
"enableSessionRecordingDesc": "Gravar a saída da sessão de terminal para revisão posterior"
},
"sessionLogs": {
"title": "Registos de Sessão",
"noLogs": "Ainda sem registos de sessão",
"noLogsDesc": "Ative o registo de sessão num host para começar a gravar",
"duration": "Duração",
"viewLog": "Ver registo",
"downloadLog": "Transferir",
"downloadAsText": "Descarregar como texto",
"deleteLog": "Eliminar",
"confirmDelete": "Eliminar este registo de sessão?",
"confirmDeleteDesc": "Esta ação não pode ser desfeita.",
"copyContent": "Copiar",
"copied": "Copiado!",
"loadError": "Falha ao carregar registos de sessão",
"deleteError": "Falha ao eliminar registo de sessão",
"filterByHost": "Filtrar por host..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Jurnalele de sesiune"
},
"hosts": {
"enableSessionRecording": "Înregistrarea sesiunilor",
"enableSessionRecordingDesc": "Înregistrați ieșirea sesiunii terminale pentru o revizuire ulterioară"
},
"sessionLogs": {
"title": "Jurnalele de sesiune",
"noLogs": "Încă nu există jurnale de sesiune",
"noLogsDesc": "Activați înregistrarea în jurnal pe o gazdă pentru a începe înregistrarea",
"duration": "Durată",
"viewLog": "Vizualizare jurnal",
"downloadLog": "Descărcare",
"downloadAsText": "Descărcați ca text",
"deleteLog": "Şterge",
"confirmDelete": "Ștergeți acest jurnal de sesiune?",
"confirmDeleteDesc": "Această acțiune nu poate fi anulată.",
"copyContent": "Copie",
"copied": "Copiat!",
"loadError": "Nu s-au putut încărca jurnalele de sesiune",
"deleteError": "Nu s-a putut șterge jurnalul sesiunii",
"filterByHost": "Filtrare după gazdă..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Журналы сессий"
},
"hosts": {
"enableSessionRecording": "Логирование сессии",
"enableSessionRecordingDesc": "Записывать вывод терминальной сессии для последующего просмотра"
},
"sessionLogs": {
"title": "Журналы сеансов",
"noLogs": "Пока нет журналов сеансов",
"noLogsDesc": "Включите ведение журнала сеансов на хосте, чтобы начать запись",
"duration": "Продолжительность",
"viewLog": "Просмотреть журнал",
"downloadLog": "Скачать",
"downloadAsText": "Скачать как текст",
"deleteLog": "Удалить",
"confirmDelete": "Удалить этот журнал сеансов?",
"confirmDeleteDesc": "Это действие нельзя отменить.",
"copyContent": "Копировать",
"copied": "Скопировано!",
"loadError": "Не удалось загрузить журналы сеансов",
"deleteError": "Не удалось удалить журнал сеанса",
"filterByHost": "Фильтровать по хосту..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Записи сесија"
},
"hosts": {
"enableSessionRecording": "Записивање сесије",
"enableSessionRecordingDesc": "Снимите излаз терминалне сесије за каснији преглед"
},
"sessionLogs": {
"title": "Записи сесија",
"noLogs": "Још нема евиденција сесија",
"noLogsDesc": "Омогућите евидентирање сесије на хосту да бисте започели снимање",
"duration": "Трајање",
"viewLog": "Прикажи дневник",
"downloadLog": "Преузми",
"downloadAsText": "Преузми као текст",
"deleteLog": "Обриши",
"confirmDelete": "Да ли желите да обришете овај дневник сесије?",
"confirmDeleteDesc": "Ова радња се не може поништити.",
"copyContent": "Копија",
"copied": "Копирано!",
"loadError": "Учитавање евиденције сесије није успело",
"deleteError": "Брисање дневника сесије није успело",
"filterByHost": "Филтрирај по хосту..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Sessionsloggar"
},
"hosts": {
"enableSessionRecording": "Sessionsloggning",
"enableSessionRecordingDesc": "Spela in utdata från terminalsessionen för senare granskning"
},
"sessionLogs": {
"title": "Sessionsloggar",
"noLogs": "Inga sessionsloggar ännu",
"noLogsDesc": "Aktivera sessionsloggning på en värd för att starta inspelning",
"duration": "Varaktighet",
"viewLog": "Visa logg",
"downloadLog": "Ladda ner",
"downloadAsText": "Ladda ner som text",
"deleteLog": "Radera",
"confirmDelete": "Radera den här sessionsloggen?",
"confirmDeleteDesc": "Den här åtgärden kan inte ångras.",
"copyContent": "Kopiera",
"copied": "Kopierad!",
"loadError": "Misslyckades med att läsa in sessionsloggar",
"deleteError": "Misslyckades med att radera sessionsloggen",
"filterByHost": "Filtrera efter värd..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "บันทึกเซสชัน"
},
"hosts": {
"enableSessionRecording": "การบันทึกเซสชัน",
"enableSessionRecordingDesc": "บันทึกผลลัพธ์จากเทอร์มินัลเพื่อตรวจสอบในภายหลัง"
},
"sessionLogs": {
"title": "บันทึกเซสชัน",
"noLogs": "ยังไม่มีบันทึกการใช้งานใดๆ",
"noLogsDesc": "เปิดใช้งานการบันทึกเซสชันบนโฮสต์เพื่อเริ่มการบันทึก",
"duration": "ระยะเวลา",
"viewLog": "ดูบันทึก",
"downloadLog": "ดาวน์โหลด",
"downloadAsText": "ดาวน์โหลดเป็นข้อความ",
"deleteLog": "ลบ",
"confirmDelete": "ลบบันทึกการใช้งานนี้หรือไม่?",
"confirmDeleteDesc": "การกระทำนี้ไม่สามารถย้อนกลับได้",
"copyContent": "สำเนา",
"copied": "คัดลอกแล้ว!",
"loadError": "ไม่สามารถโหลดบันทึกเซสชันได้",
"deleteError": "ไม่สามารถลบบันทึกเซสชันได้",
"filterByHost": "กรองตามโฮสต์..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Oturum Kayıtları"
},
"hosts": {
"enableSessionRecording": "Oturum Kaydı",
"enableSessionRecordingDesc": "Daha sonra gözden geçirmek için terminal oturumu çıktısını kaydet"
},
"sessionLogs": {
"title": "Oturum Günlükleri",
"noLogs": "Henüz oturum günlüğü yok",
"noLogsDesc": "Kayda başlamak için bir sunucuda oturum günlüğünü etkinleştirin",
"duration": "Süre",
"viewLog": "Günlüğü görüntüle",
"downloadLog": "İndir",
"downloadAsText": "Metin olarak indir",
"deleteLog": "Sil",
"confirmDelete": "Bu oturum günlüğü silinsin mi?",
"confirmDeleteDesc": "Bu işlem geri alınamaz.",
"copyContent": "Kopyala",
"copied": "Kopyalandı!",
"loadError": "Oturum günlükleri yüklenemedi",
"deleteError": "Oturum günlüğü silinemedi",
"filterByHost": "Sunucuya göre filtrele..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Журнали сеансів"
},
"hosts": {
"enableSessionRecording": "Ведення журналу сеансу",
"enableSessionRecordingDesc": "Запис виводу сеансу терміналу для подальшого перегляду"
},
"sessionLogs": {
"title": "Журнали сеансів",
"noLogs": "Журналів сеансів поки що немає",
"noLogsDesc": "Увімкнути ведення журналу сеансу на хості, щоб розпочати запис",
"duration": "Тривалість",
"viewLog": "Переглянути журнал",
"downloadLog": "Завантажити",
"downloadAsText": "Завантажити як текст",
"deleteLog": "Видалити",
"confirmDelete": "Видалити цей журнал сеансу?",
"confirmDeleteDesc": "Цю дію не можна скасувати.",
"copyContent": "Копіювати",
"copied": "Скопійовано!",
"loadError": "Не вдалося завантажити журнали сеансу",
"deleteError": "Не вдалося видалити журнал сеансу",
"filterByHost": "Фільтрувати за хостом..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Nhật ký phiên"
},
"hosts": {
"enableSessionRecording": "Ghi nhật ký phiên",
"enableSessionRecordingDesc": "Ghi lại đầu ra phiên terminal để xem lại sau"
},
"sessionLogs": {
"title": "Nhật ký phiên",
"noLogs": "Chưa có nhật ký phiên nào",
"noLogsDesc": "Bật ghi nhật ký phiên trên máy chủ để bắt đầu ghi",
"duration": "Thời lượng",
"viewLog": "Xem nhật ký",
"downloadLog": "Tải xuống",
"downloadAsText": "Tải xuống dưới dạng văn bản",
"deleteLog": "Xóa",
"confirmDelete": "Xóa nhật ký phiên này?",
"confirmDeleteDesc": "Hành động này không thể hoàn tác.",
"copyContent": "Sao chép",
"copied": "Đã sao chép!",
"loadError": "Tải nhật ký phiên thất bại",
"deleteError": "Xóa nhật ký phiên thất bại",
"filterByHost": "Lọc theo máy chủ..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "会话日志"
},
"hosts": {
"enableSessionRecording": "会话日志记录",
"enableSessionRecordingDesc": "记录终端会话输出以供日后查看"
},
"sessionLogs": {
"title": "会话日志",
"noLogs": "暂无会话日志",
"noLogsDesc": "在主机上启用会话日志以开始记录",
"duration": "持续时间",
"viewLog": "查看日志",
"downloadLog": "下载",
"downloadAsText": "下载为文本",
"deleteLog": "删除",
"confirmDelete": "删除此会话日志?",
"confirmDeleteDesc": "此操作无法撤消。",
"copyContent": "复制",
"copied": "已复制!",
"loadError": "加载会话日志失败",
"deleteError": "删除会话日志失败",
"filterByHost": "按主机筛选..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "工作階段記錄"
},
"hosts": {
"enableSessionRecording": "工作階段記錄",
"enableSessionRecordingDesc": "記錄終端機工作階段輸出以供日後檢視"
},
"sessionLogs": {
"title": "工作階段記錄",
"noLogs": "尚無任何工作階段記錄",
"noLogsDesc": "在主機上啟用工作階段記錄以開始錄製",
"duration": "持續時間",
"viewLog": "檢視記錄",
"downloadLog": "下載",
"downloadAsText": "下載為文字",
"deleteLog": "刪除",
"confirmDelete": "刪除此工作階段記錄?",
"confirmDeleteDesc": "此動作無法復原。",
"copyContent": "複製",
"copied": "已複製!",
"loadError": "載入工作階段記錄失敗",
"deleteError": "刪除工作階段記錄失敗",
"filterByHost": "依主機篩選..."
}
}
+74
View File
@@ -0,0 +1,74 @@
{
"id": "session-recording",
"name": "Session Recording",
"version": "1.0.0",
"description": "Records terminal sessions and lets you play them back or download them.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Terminal",
"icon": "ScrollText",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"hosts:read",
"db:own",
"files:own",
"network:serve",
"ui:surface"
],
"provides": [
{
"service": "recordings.writer",
"version": "1.0.0",
"permission": "session-recording.view"
}
],
"contributes": {
"tabs": [
{
"id": "session-logs",
"titleKey": "nav.sessionLogs",
"icon": "ScrollText",
"openFrom": ["rail"]
}
],
"permissions": [
{
"name": "view",
"titleKey": "permissions.view.title",
"descriptionKey": "permissions.view.description",
"defaultRoles": ["admin", "user"]
}
],
"settings": {
"admin": [
{
"key": "retentionDays",
"type": "number",
"labelKey": "settings.admin.retentionDays.label",
"descriptionKey": "settings.admin.retentionDays.description",
"min": 1,
"max": 3650,
"default": 30
}
],
"host": {
"enableKey": "enableSessionRecording",
"enableLabelKey": "settings.host.enable.label",
"fields": []
}
},
"hostCapability": {
"key": "enableSessionRecording",
"labelKey": "hosts.enableSessionRecording",
"editorTab": "general"
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
@@ -0,0 +1,22 @@
-- session-recording 0001: adopt_session_recordings
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `session_recordings` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`host_id` int NOT NULL,
`user_id` varchar(255),
`username` text,
`access_id` int,
`started_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`ended_at` text,
`duration` int,
`commands` text,
`dangerous_actions` text,
`recording_path` text,
`protocol` text NOT NULL DEFAULT ('ssh'),
`format` text NOT NULL DEFAULT ('text'),
`terminated_by_owner` boolean DEFAULT false,
`termination_reason` text,
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
);
RENAME TABLE `session_recordings` TO `p_session_recording_session_recordings`;
@@ -0,0 +1,24 @@
-- session-recording 0001: adopt_session_recordings
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "session_recordings" (
"id" serial PRIMARY KEY,
"host_id" integer NOT NULL,
"user_id" varchar(255),
"username" text,
"access_id" integer,
"started_at" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ended_at" text,
"duration" integer,
"commands" text,
"dangerous_actions" text,
"recording_path" text,
"protocol" text NOT NULL DEFAULT 'ssh',
"format" text NOT NULL DEFAULT 'text',
"terminated_by_owner" boolean DEFAULT false,
"termination_reason" text,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_session_recordings_user_started" ON "session_recordings" ("user_id", "started_at");
CREATE INDEX IF NOT EXISTS "idx_session_recordings_host" ON "session_recordings" ("host_id");
ALTER TABLE "session_recordings" RENAME TO "p_session_recording_session_recordings";
@@ -0,0 +1,76 @@
{
"version": 1,
"tables": {
"session_recordings": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"hostId": {
"type": "refHost",
"notNull": true
},
"userId": {
"type": "varchar",
"length": 255
},
"username": {
"type": "text"
},
"accessId": {
"type": "integer"
},
"startedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"endedAt": {
"type": "timestamp"
},
"duration": {
"type": "integer"
},
"commands": {
"type": "text"
},
"dangerousActions": {
"type": "text"
},
"recordingPath": {
"type": "text"
},
"protocol": {
"type": "text",
"notNull": true,
"defaultValue": "ssh"
},
"format": {
"type": "text",
"notNull": true,
"defaultValue": "text"
},
"terminatedByOwner": {
"type": "boolean",
"defaultValue": false
},
"terminationReason": {
"type": "text"
}
},
"indexes": [
{
"name": "idx_session_recordings_user_started",
"columns": ["userId", "startedAt"]
},
{
"name": "idx_session_recordings_host",
"columns": ["hostId"]
}
],
"adopts": "session_recordings"
}
}
}
@@ -0,0 +1,24 @@
-- session-recording 0001: adopt_session_recordings
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "session_recordings" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"host_id" integer NOT NULL,
"user_id" text,
"username" text,
"access_id" integer,
"started_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ended_at" text,
"duration" integer,
"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
);
CREATE INDEX IF NOT EXISTS "idx_session_recordings_user_started" ON "session_recordings" ("user_id", "started_at");
CREATE INDEX IF NOT EXISTS "idx_session_recordings_host" ON "session_recordings" ("host_id");
ALTER TABLE "session_recordings" RENAME TO "p_session_recording_session_recordings";
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@termix-plugin/session-recording",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
}
@@ -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>
);
}
@@ -1,14 +1,18 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
import { Input } from "@/components/input";
import {
useTranslation,
useToast,
usePluginApi,
usePermission,
} from "@termix/plugin-sdk/frontend";
import {
Input,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip";
copyToClipboard,
} from "@termix/plugin-sdk/ui";
import {
ArrowLeft,
Check,
@@ -17,26 +21,58 @@ import {
Eye,
FileText,
Loader2,
Save,
ScrollText,
Search,
X,
} from "lucide-react";
import { toast } from "sonner";
import { SessionRecordingPlayer } from "./SessionRecordingPlayer";
import { asciicastToPlainText, parseAsciicast } from "./asciicast";
import {
getSessionLogs,
getSessionLogContent,
getSessionLogBlob,
getSessionRecordingRetention,
setSessionRecordingRetention,
deleteSessionLog,
createSessionRecordingApi,
type SessionLogRecord,
} from "@/api/session-log-api";
import { SessionRecordingPlayer } from "@/features/session-recording/SessionRecordingPlayer";
import {
asciicastToPlainText,
parseAsciicast,
} from "@/features/session-recording/asciicast";
} 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 "--";
@@ -206,6 +242,9 @@ function LogRow({
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("");
@@ -219,8 +258,6 @@ export function SessionLogsPanel() {
);
const [deleting, setDeleting] = useState(false);
const [copied, setCopied] = useState(false);
const [retentionDays, setRetentionDays] = useState<number | null>(null);
const [savingRetention, setSavingRetention] = useState(false);
const logsRef = useRef(logs);
logsRef.current = logs;
@@ -228,7 +265,7 @@ export function SessionLogsPanel() {
async (initial = false) => {
if (initial) setLoading(true);
try {
const fresh = await getSessionLogs();
const fresh = await api.list();
const changed =
logsRef.current.length !== fresh.length ||
logsRef.current.some((log, i) => log.id !== fresh[i]?.id);
@@ -247,25 +284,21 @@ export function SessionLogsPanel() {
if (initial) setLoading(false);
}
},
[t],
[api, t, toast],
);
useEffect(() => {
if (!canView) return;
void load(true);
getSessionRecordingRetention()
.then(setRetentionDays)
.catch(() => setRetentionDays(null));
}, [load]);
}, [canView, load]);
useAdaptivePolling(
() => load(false),
{
minIntervalMs: 5_000,
maxIntervalMs: 30_000,
stablePollsPerStep: 3,
},
true,
{ runImmediately: false },
canView,
);
const q = filter.trim().toLowerCase();
@@ -285,11 +318,11 @@ export function SessionLogsPanel() {
setViewLoading(true);
try {
if (log.format === "text") {
const text = await getSessionLogContent(log.id);
const text = await api.getContent(log.id);
setViewContent(text);
setViewText(text);
} else {
const blob = await getSessionLogBlob(log.id);
const blob = await api.getBlob(log.id);
setViewBlob(blob);
setViewText(await extractPlainText(log, blob));
}
@@ -311,7 +344,7 @@ export function SessionLogsPanel() {
const handleDownload = async (log: SessionLogRecord) => {
try {
downloadBlob(await getSessionLogBlob(log.id), buildFilename(log));
downloadBlob(await api.getBlob(log.id), buildFilename(log));
} catch {
toast.error(t("sessionLogs.loadError"));
}
@@ -322,7 +355,7 @@ export function SessionLogsPanel() {
const text =
log.id === viewLog?.id && viewText != null
? viewText
: await extractPlainText(log, await getSessionLogBlob(log.id));
: await extractPlainText(log, await api.getBlob(log.id));
if (text == null) {
toast.error(t("sessionLogs.loadError"));
return;
@@ -340,7 +373,7 @@ export function SessionLogsPanel() {
if (!deleteTarget) return;
setDeleting(true);
try {
await deleteSessionLog(deleteTarget.id);
await api.delete(deleteTarget.id);
setLogs((prev) => prev.filter((l) => l.id !== deleteTarget.id));
setDeleteTarget(null);
} catch {
@@ -473,44 +506,6 @@ export function SessionLogsPanel() {
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex-1 overflow-y-auto">
{retentionDays != null && (
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2 text-[10px] text-muted-foreground">
<span className="flex-1">Recording retention</span>
<Input
type="number"
min={1}
max={3650}
value={retentionDays}
onChange={(event) => setRetentionDays(Number(event.target.value))}
className="h-7 w-20 text-xs"
aria-label="Recording retention days"
/>
<span>days</span>
<button
type="button"
disabled={savingRetention}
onClick={async () => {
setSavingRetention(true);
try {
await setSessionRecordingRetention(retentionDays);
toast.success("Recording retention updated");
} catch {
toast.error("Failed to update recording retention");
} finally {
setSavingRetention(false);
}
}}
className="flex size-7 items-center justify-center hover:bg-muted disabled:opacity-50"
aria-label="Save recording retention"
>
{savingRetention ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Save className="size-3" />
)}
</button>
</div>
)}
{logs.length === 0 ? (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
<div className="size-10 bg-muted/40 flex items-center justify-center">
@@ -1,13 +1,12 @@
import { getErrorMessage } from "../../lib/error-message.js";
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 { Select2 } from "@termix/plugin-sdk/ui";
import "@xterm/xterm/css/xterm.css";
import { Select2 } from "@/components/select2";
import { parseAsciicast, type Asciicast } from "./asciicast";
import type { SessionLogRecord } from "./session-recording-api";
const SPEEDS = [0.5, 1, 2, 4];
@@ -16,6 +15,10 @@ function formatPosition(seconds: number) {
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,
@@ -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}`);
},
};
}
@@ -0,0 +1,128 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export function host(
overrides: Partial<PluginHostSummary> = {},
): PluginHostSummary {
return {
id: 7,
userId: "user-1",
name: "web",
ip: "10.0.0.5",
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
...overrides,
};
}
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
requestBinary: (
method: string,
path: string,
options?: { user?: string },
) => Promise<{ status: number; body: ArrayBuffer; contentType: string }>;
close: () => Promise<void>;
}
export async function startServer(
options: {
permissions?: string[];
users?: string[];
hosts?: PluginHostSummary[];
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of options.users ?? ["user-1", "user-2"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
for (const h of options.hosts ?? [host()]) {
db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(h.id);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? ["session-recording.view"],
hosts: options.hosts ?? [host()],
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async requestBinary(method, path, { user = "user-1" } = {}) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: { "x-test-user": user },
});
return {
status: response.status,
body: await response.arrayBuffer(),
contentType: response.headers.get("content-type") ?? "",
};
},
async close() {
for (const dispose of [...mock.disposals].reverse()) await dispose();
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
@@ -0,0 +1,141 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// session_recordings as core's SQLite bootstrap created it before 2.9.0.
const LEGACY_DDL = `
CREATE TABLE session_recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
user_id TEXT,
username TEXT,
access_id INTEGER,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TEXT,
duration INTEGER,
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,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
describe("adopting session_recordings", () => {
it("keeps every existing row when the legacy table is there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)");
sqlite.exec(LEGACY_DDL);
sqlite
.prepare(
"INSERT INTO session_recordings (host_id, user_id, username, recording_path, protocol, format) VALUES (?, ?, ?, ?, ?, ?)",
)
.run(
7,
"user-1",
"user-1",
"/data/session_logs/user-1/a.cast",
"ssh",
"asciicast",
);
},
});
expect(db.applied).toEqual(["0001_adopt_session_recordings"]);
expect(tableExists("session_recordings")).toBe(false);
expect(tableExists("p_session_recording_session_recordings")).toBe(true);
expect(
db.sqlite
.prepare(
"SELECT host_id, user_id, recording_path, protocol, format FROM p_session_recording_session_recordings",
)
.all(),
).toEqual([
{
host_id: 7,
user_id: "user-1",
recording_path: "/data/session_logs/user-1/a.cast",
protocol: "ssh",
format: "asciicast",
},
]);
});
it("creates the table fresh when there is no legacy one", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("session_recordings")).toBe(false);
expect(tableExists("p_session_recording_session_recordings")).toBe(true);
expect(
db.sqlite
.prepare("SELECT * FROM p_session_recording_session_recordings")
.all(),
).toEqual([]);
});
it("cascades on host delete", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)");
},
});
db.sqlite
.prepare(
"INSERT INTO p_session_recording_session_recordings (host_id, user_id, protocol, format) VALUES (?, ?, ?, ?)",
)
.run(7, "user-1", "ssh", "asciicast");
db.sqlite.exec("DELETE FROM ssh_data WHERE id = 7");
expect(
db.sqlite
.prepare("SELECT * FROM p_session_recording_session_recordings")
.all(),
).toEqual([]);
});
it("keeps a row whose user was deleted, with userId cleared", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)");
},
});
db.sqlite
.prepare(
"INSERT INTO p_session_recording_session_recordings (host_id, user_id, protocol, format) VALUES (?, ?, ?, ?)",
)
.run(7, "user-1", "ssh", "asciicast");
// userId is a plain column, not refUser(): deleting the user must not
// cascade delete the row (the plugin clears userId itself on
// user.deleted instead).
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
expect(
db.sqlite
.prepare("SELECT host_id FROM p_session_recording_session_recordings")
.all(),
).toEqual([{ host_id: 7 }]);
});
});
@@ -0,0 +1,145 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
import { createSessionRecordingRepository } from "../../src/backend/repository";
import { sessionRecordings } from "../../src/backend/tables";
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const fakeHosts = {
list: async () => [
{
id: 7,
userId: "user-1",
name: "web",
ip: "10.0.0.5",
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
},
],
get: async () => null,
checkAccess: async () => ({
hasAccess: false,
isOwner: false,
isShared: false,
}),
create: async () => {
throw new Error("not used");
},
update: async () => null,
listOwned: async () => [],
share: async () => ({ hostId: 0, shared: false }),
listUsers: async () => [],
listRoles: async () => [],
trackSession: () => () => {},
recordActivity: async () => {},
};
async function setup() {
db = await createTestDb(pluginDir);
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run("user-1", "alice");
db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(7);
const table = await db.database.define(sessionRecordings);
return createSessionRecordingRepository(db.database, table, fakeHosts);
}
describe("session recording repository", () => {
it("creates a row and reads it back by recordingPath", async () => {
const repo = await setup();
const row = await repo.create({
hostId: 7,
userId: "user-1",
username: "alice",
startedAt: new Date().toISOString(),
recordingPath: "/data/session_logs/user-1/a.cast",
protocol: "ssh",
format: "asciicast",
});
expect(row.id).toBeGreaterThan(0);
expect(row.recordingPath).toBe("/data/session_logs/user-1/a.cast");
expect(db!.persisted).toBeGreaterThan(0);
});
it("lists a user's recordings with host name and ip attached", async () => {
const repo = await setup();
await repo.create({
hostId: 7,
userId: "user-1",
startedAt: new Date().toISOString(),
recordingPath: "/data/session_logs/user-1/a.cast",
protocol: "ssh",
format: "asciicast",
});
const rows = await repo.listByUserIdWithHost("user-1");
expect(rows).toHaveLength(1);
expect(rows[0].hostName).toBe("web");
expect(rows[0].hostIp).toBe("10.0.0.5");
});
it("finds and deletes only the owner's row", async () => {
const repo = await setup();
const row = await repo.create({
hostId: 7,
userId: "user-1",
startedAt: new Date().toISOString(),
recordingPath: "/data/session_logs/user-1/a.cast",
protocol: "ssh",
format: "asciicast",
});
expect(await repo.findByIdForUser("user-2", row.id)).toBeNull();
expect(await repo.deleteForUser("user-2", row.id)).toBe(false);
expect(await repo.deleteForUser("user-1", row.id)).toBe(true);
expect(await repo.findByIdForUser("user-1", row.id)).toBeNull();
});
it("anonymizes a user's rows instead of deleting them", async () => {
const repo = await setup();
const row = await repo.create({
hostId: 7,
userId: "user-1",
startedAt: new Date().toISOString(),
recordingPath: "/data/session_logs/user-1/a.cast",
protocol: "ssh",
format: "asciicast",
});
await repo.anonymizeByUserId("user-1");
const rows = await repo.listPathsOlderThan(
new Date(Date.now() + 60_000).toISOString(),
);
expect(rows.map((r) => r.id)).toContain(row.id);
expect(await repo.findByIdForUser("user-1", row.id)).toBeNull();
});
it("lists recordings older than a cutoff for retention pruning", async () => {
const repo = await setup();
await repo.create({
hostId: 7,
userId: "user-1",
startedAt: "2020-01-01T00:00:00.000Z",
recordingPath: "/data/session_logs/user-1/old.cast",
protocol: "ssh",
format: "asciicast",
});
const old = await repo.listPathsOlderThan("2021-01-01T00:00:00.000Z");
expect(old).toHaveLength(1);
const none = await repo.listPathsOlderThan("2019-01-01T00:00:00.000Z");
expect(none).toHaveLength(0);
});
});
@@ -0,0 +1,179 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startServer, type TestServer } from "./helpers";
let server: TestServer | null = null;
let dataDir: string;
beforeEach(async () => {
dataDir = await fs.mkdtemp(
path.join(os.tmpdir(), "session-recording-routes-"),
);
});
afterEach(async () => {
await server?.close();
server = null;
await fs.rm(dataDir, { recursive: true, force: true });
});
describe("session recording routes", () => {
it("401s without an authenticated actor", async () => {
server = await startServer();
const res = await server.request("GET", "/", { user: "" });
expect(res.status).toBe(401);
});
it("lists only the caller's own recordings", async () => {
server = await startServer();
const table = await server.mock.ctx.db.client<{
insert: (t: unknown) => {
values: (v: unknown) => Promise<unknown>;
};
}>();
void table;
// Seed through the repository the way activate() wired it: easiest is a
// direct HTTP round trip isn't available for writes (only ssh-terminal
// and remote-desktop write through the service), so seed the table
// directly.
server.db.sqlite
.prepare(
`INSERT INTO p_session_recording_session_recordings
(host_id, user_id, recording_path, protocol, format, started_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(
7,
"user-1",
"/tmp/a.cast",
"ssh",
"asciicast",
new Date().toISOString(),
);
server.db.sqlite
.prepare(
`INSERT INTO p_session_recording_session_recordings
(host_id, user_id, recording_path, protocol, format, started_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(
7,
"user-2",
"/tmp/b.cast",
"ssh",
"asciicast",
new Date().toISOString(),
);
const res = await server.request("GET", "/", { user: "user-1" });
expect(res.status).toBe(200);
expect(res.body.logs).toHaveLength(1);
expect(res.body.logs[0].userId).toBe("user-1");
});
it("404s getting another user's recording", async () => {
server = await startServer();
server.db.sqlite
.prepare(
`INSERT INTO p_session_recording_session_recordings
(host_id, user_id, recording_path, protocol, format, started_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(
7,
"user-2",
"/tmp/b.cast",
"ssh",
"asciicast",
new Date().toISOString(),
);
const res = await server.request("GET", "/1", { user: "user-1" });
expect(res.status).toBe(404);
});
it("serves file content within the plugin's data directory", async () => {
server = await startServer();
const filePath = path.join(dataDir, "session_logs", "user-1", "a.cast");
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, '{"version":2}\n[0,"o","hi"]\n');
server.db.sqlite
.prepare(
`INSERT INTO p_session_recording_session_recordings
(host_id, user_id, recording_path, protocol, format, started_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(7, "user-1", filePath, "ssh", "asciicast", new Date().toISOString());
// Point ctx.files.dataDir() at the temp dir for this request.
(
server.mock.ctx as unknown as {
files: { dataDir: () => Promise<string> };
}
).files = { dataDir: async () => dataDir };
const res = await server.requestBinary("GET", "/1/content", {
user: "user-1",
});
expect(res.status).toBe(200);
expect(res.contentType).toContain("asciicast");
});
it("refuses a path outside the plugin's data directory", async () => {
server = await startServer();
server.db.sqlite
.prepare(
`INSERT INTO p_session_recording_session_recordings
(host_id, user_id, recording_path, protocol, format, started_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(
7,
"user-1",
"/etc/passwd",
"ssh",
"asciicast",
new Date().toISOString(),
);
(
server.mock.ctx as unknown as {
files: { dataDir: () => Promise<string> };
}
).files = { dataDir: async () => dataDir };
const res = await server.requestBinary("GET", "/1/content", {
user: "user-1",
});
expect(res.status).toBe(403);
});
it("deletes only the caller's own recording", async () => {
server = await startServer();
server.db.sqlite
.prepare(
`INSERT INTO p_session_recording_session_recordings
(host_id, user_id, recording_path, protocol, format, started_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(
7,
"user-1",
"/tmp/a.cast",
"ssh",
"asciicast",
new Date().toISOString(),
);
const denied = await server.request("DELETE", "/1", { user: "user-2" });
expect(denied.status).toBe(404);
const ok = await server.request("DELETE", "/1", { user: "user-1" });
expect(ok.status).toBe(200);
});
});
@@ -0,0 +1,174 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import manifestJson from "../../manifest.json";
import { pluginDir } from "./helpers";
import { createSessionRecordingRepository } from "../../src/backend/repository";
import { sessionRecordings } from "../../src/backend/tables";
import { createRecordingsWriter } from "../../src/backend/service";
const manifest =
manifestJson as unknown as import("@termix/plugin-sdk/manifest").PluginManifest;
let db: TestDb | null = null;
let dataDir: string;
beforeEach(async () => {
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "session-recording-"));
});
afterEach(async () => {
db?.close();
db = null;
await fs.rm(dataDir, { recursive: true, force: true });
});
async function setup(): Promise<{
mock: MockPluginContext;
writer: ReturnType<typeof createRecordingsWriter>;
}> {
db = await createTestDb(pluginDir);
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run("user-1", "alice");
db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(7);
const table = await db.database.define(sessionRecordings);
const hosts = {
list: async () => [],
get: async () => null,
checkAccess: async () => ({
hasAccess: false,
isOwner: false,
isShared: false,
}),
create: async () => {
throw new Error("not used");
},
update: async () => null,
listOwned: async () => [],
share: async () => ({ hostId: 0, shared: false }),
listUsers: async () => [],
listRoles: async () => [],
trackSession: () => () => {},
recordActivity: async () => {},
};
const repo = createSessionRecordingRepository(db.database, table, hosts);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
});
// Point ctx.files.dataDir() at a real temp dir for this test.
(mock.ctx as unknown as { files: { dataDir: () => Promise<string> } }).files =
{
dataDir: async () => dataDir,
};
const writer = createRecordingsWriter(mock.ctx, repo);
return { mock, writer };
}
describe("recordings.writer", () => {
it("is null when the host has recording switched off", async () => {
const { mock, writer } = await setup();
await mock.ctx.settings.setHost(7, "enableSessionRecording", false);
const sink = await writer.open({
sessionId: "s1",
hostId: 7,
userId: "user-1",
protocol: "ssh",
format: "asciicast",
startedAt: Date.now(),
});
expect(sink).toBeNull();
});
it("writes the first append with writeFile and later ones with appendFile", async () => {
const { writer } = await setup();
const sink = await writer.open({
sessionId: "s1",
hostId: 7,
userId: "user-1",
protocol: "ssh",
format: "asciicast",
startedAt: Date.now(),
});
expect(sink).not.toBeNull();
await sink!.append("chunk-one\n");
await sink!.append("chunk-two\n");
const filePath = path.join(dataDir, "session_logs", "user-1", "s1.cast");
const content = await fs.readFile(filePath, "utf-8");
expect(content).toBe("chunk-one\nchunk-two\n");
});
it("persist writes the summary onto the row", async () => {
const { writer } = await setup();
const sink = await writer.open({
sessionId: "s1",
hostId: 7,
userId: "user-1",
protocol: "ssh",
format: "asciicast",
startedAt: Date.now(),
});
await sink!.append("x\n");
const endedAt = Date.now();
await sink!.persist({
endedAt,
durationSeconds: 42,
terminatedByOwner: true,
terminationReason: null,
});
// Not thrown means it wrote successfully; deeper row assertions belong
// to repository.test.ts.
});
it("discard removes the row and further writes are no-ops", async () => {
const { writer } = await setup();
const sink = await writer.open({
sessionId: "s1",
hostId: 7,
userId: "user-1",
protocol: "ssh",
format: "asciicast",
startedAt: Date.now(),
});
sink!.discard();
await sink!.append("should not write");
const filePath = path.join(dataDir, "session_logs", "user-1", "s1.cast");
await expect(fs.access(filePath)).rejects.toThrow();
});
it("createFinished inserts a row for an already-written recording", async () => {
const { writer } = await setup();
const row = await writer.createFinished({
hostId: 7,
userId: "user-1",
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
duration: 10,
recordingPath: "/data/session_recordings/guacamole/x.guac",
protocol: "rdp",
format: "guacamole",
});
expect(row.id).toBeGreaterThan(0);
});
});
@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
function stubApi(overrides: Partial<PluginApiClient> = {}): PluginApiClient {
return {
get: vi.fn(async () => ({ data: { logs: [] } })),
post: vi.fn(async () => ({ data: {} })),
put: vi.fn(async () => ({ data: {} })),
patch: vi.fn(async () => ({ data: {} })),
delete: vi.fn(async () => ({ data: {} })),
...overrides,
} as PluginApiClient;
}
describe(`${manifest.id} activate`, () => {
it("registers only views its manifest declares", async () => {
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: stubApi(),
});
expect(rendered.registered.tabs()).toEqual(["session-logs"]);
expect(rendered.registered.panels()).toEqual(["session-logs"]);
expect(rendered.registered.railItems()).toEqual([
expect.objectContaining({ id: "session-logs" }),
]);
expect(rendered.registered.slot("terminal.toolbarStatus")).toEqual([
"session-recording.terminalStatus",
]);
});
it("removes everything it registered on deactivate", async () => {
const app = await renderWithApp(plugin, {
manifest,
locales,
api: stubApi(),
});
await app.deactivate();
expect(app.registered.tabs()).toEqual([]);
expect(app.registered.panels()).toEqual([]);
expect(app.registered.railItems()).toEqual([]);
});
it("renders the panel without crashing", async () => {
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: stubApi(),
});
const element = rendered.renderPanel("session-logs");
expect(element).toBeTruthy();
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { parseAsciicast } from "../../../features/session-recording/asciicast";
import { parseAsciicast } from "../../src/frontend/asciicast";
describe("parseAsciicast", () => {
it("parses terminal dimensions and timed input/output", () => {
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);
-4
View File
@@ -1,10 +1,6 @@
{
"$comment": "Literals src/ui may still spell that name a plugin id or a plugin-owned view. Each is data, not knowledge of a plugin. Checked by scripts/check-shell-plugin-ids.cjs; D1 and Phase B empty it.",
"files": {
"src/ui/api/session-log-api.ts": {
"literals": ["rdp", "telnet", "vnc"],
"reason": "Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableTelnet, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableVnc, session protocol), a host column until Phase B moves it."
},
"src/ui/lib/connection-origin.ts": {
"literals": ["rdp", "telnet", "vnc"],
"reason": "Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableTelnet, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableVnc, session protocol), a host column until Phase B moves it."
-2
View File
@@ -10,7 +10,6 @@ import hostRoutes from "./routes/host.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
import sessionLogRoutes from "./routes/session-log-routes.js";
import rbacRoutes from "./routes/rbac.js";
import openTabsRoutes from "./routes/open-tabs.js";
import userPreferencesRoutes from "./routes/user-preferences.js";
@@ -1805,7 +1804,6 @@ app.use("/host", hostRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/ssh-auth", sshAuthRoutes);
app.use("/session_logs", sessionLogRoutes);
app.use("/rbac", rbacRoutes);
app.use("/open-tabs", openTabsRoutes);
app.use("/user-preferences", userPreferencesRoutes);
-32
View File
@@ -479,27 +479,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS session_recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
user_id TEXT,
username TEXT,
access_id INTEGER,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TEXT,
duration INTEGER,
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,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL,
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS secret_sources (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
@@ -937,17 +916,6 @@ const relaxPluginGrantGrantedBy = () => {
};
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");
@@ -124,17 +124,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
columns: "user_id",
},
// Recordings and live session sharing.
{
name: "idx_session_recordings_user_started",
table: "session_recordings",
columns: "user_id, started_at",
},
{
name: "idx_session_recordings_host",
table: "session_recordings",
columns: "host_id",
},
// Automations.
{
-42
View File
@@ -719,48 +719,6 @@ export const auditLogs = mysqlTable(
],
);
export const sessionRecordings = mysqlTable(
"session_recordings",
{
id: int("id").autoincrement().primaryKey(),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// Nullable on purpose: a recording is evidence about the host as much as the
// person, so it outlives the account. username keeps it attributable.
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
username: text("username"),
accessId: int("access_id").references(() => hostAccess.id, {
onDelete: "set null",
}),
startedAt: varchar("started_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
endedAt: text("ended_at"),
duration: int("duration"),
commands: text("commands"),
dangerousActions: text("dangerous_actions"),
recordingPath: text("recording_path"),
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
format: text("format").notNull().default("text"),
terminatedByOwner: boolean("terminated_by_owner").default(false),
terminationReason: text("termination_reason"),
},
// Listed newest-first per user, and audited per host.
(table) => [
index("idx_session_recordings_user_started").on(
table.userId,
table.startedAt,
),
index("idx_session_recordings_host").on(table.hostId),
],
);
export const opksshTokens = mysqlTable(
"opkssh_tokens",
{
-42
View File
@@ -720,48 +720,6 @@ export const auditLogs = pgTable(
],
);
export const sessionRecordings = pgTable(
"session_recordings",
{
id: serial("id").primaryKey(),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// Nullable on purpose: a recording is evidence about the host as much as the
// person, so it outlives the account. username keeps it attributable.
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
username: text("username"),
accessId: integer("access_id").references(() => hostAccess.id, {
onDelete: "set null",
}),
startedAt: varchar("started_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
endedAt: text("ended_at"),
duration: integer("duration"),
commands: text("commands"),
dangerousActions: text("dangerous_actions"),
recordingPath: text("recording_path"),
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
format: text("format").notNull().default("text"),
terminatedByOwner: boolean("terminated_by_owner").default(false),
terminationReason: text("termination_reason"),
},
// Listed newest-first per user, and audited per host.
(table) => [
index("idx_session_recordings_user_started").on(
table.userId,
table.startedAt,
),
index("idx_session_recordings_host").on(table.hostId),
],
);
export const opksshTokens = pgTable(
"opkssh_tokens",
{
-44
View File
@@ -712,50 +712,6 @@ export const auditLogs = sqliteTable(
],
);
export const sessionRecordings = sqliteTable(
"session_recordings",
{
id: integer("id").primaryKey({ autoIncrement: true }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// Nullable on purpose: a recording is evidence about the host as much as the
// person, so it outlives the account. username keeps it attributable.
userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
username: text("username"),
accessId: integer("access_id").references(() => hostAccess.id, {
onDelete: "set null",
}),
startedAt: text("started_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
endedAt: text("ended_at"),
duration: integer("duration"),
commands: text("commands"),
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),
terminationReason: text("termination_reason"),
},
// Listed newest-first per user, and audited per host.
(table) => [
index("idx_session_recordings_user_started").on(
table.userId,
table.startedAt,
),
index("idx_session_recordings_host").on(table.hostId),
],
);
export const opksshTokens = sqliteTable(
"opkssh_tokens",
{
@@ -37,7 +37,6 @@ import { RbacAccessRepository } from "./rbac-access-repository.js";
import { RbacPermissionRepository } from "./rbac-permission-repository.js";
import { RecentActivityRepository } from "./recent-activity-repository.js";
import { RoleRepository } from "./role-repository.js";
import { SessionRecordingRepository } from "./session-recording-repository.js";
import { SessionRepository } from "./session-repository.js";
import { SecretSourceRepository } from "./secret-source-repository.js";
import { CredentialAccessRepository } from "./credential-access-repository.js";
@@ -400,13 +399,6 @@ export function createCurrentRoleRepository(): RoleRepository {
);
}
export function createCurrentSessionRecordingRepository(): SessionRecordingRepository {
return new SessionRecordingRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("session_recording_repository_write"),
);
}
export function createCurrentSessionRepository(): SessionRepository {
return new SessionRepository(
createCurrentRepositoryContext(),
@@ -1,262 +0,0 @@
import { and, desc, eq, inArray, lt } from "drizzle-orm";
import { hosts, sessionRecordings } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type SessionRecordingRecord = typeof sessionRecordings.$inferSelect;
export interface SessionRecordingCreateInput {
hostId: number;
userId: string;
startedAt: string;
endedAt?: string | null;
duration?: number | null;
commands?: string | null;
dangerousActions?: string | null;
recordingPath?: string | null;
protocol?: string | null;
format?: string | null;
terminatedByOwner?: boolean | null;
terminationReason?: string | null;
}
export interface SessionRecordingListRecord {
id: number;
hostId: number;
userId: string;
startedAt: string;
endedAt: string | null;
duration: number | null;
recordingPath: string | null;
protocol: string;
format: string | null;
hostName: string | null;
hostIp: string | null;
}
export interface SessionRecordingPathRecord {
id: number;
recordingPath: string | null;
}
export class SessionRecordingRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async create(
input: SessionRecordingCreateInput,
): Promise<SessionRecordingRecord> {
const [created] = await insertReturning(
this.context,
sessionRecordings,
input,
);
await this.afterWrite();
return created;
}
async updateEnded(
id: number,
input: {
endedAt: string;
duration: number | null;
terminatedByOwner?: boolean;
terminationReason?: string;
},
): Promise<void> {
await this.context.drizzle
.update(sessionRecordings)
.set(input)
.where(eq(sessionRecordings.id, id));
await this.afterWrite();
}
async findById(id: number): Promise<SessionRecordingRecord | null> {
const rows = await this.context.drizzle
.select()
.from(sessionRecordings)
.where(eq(sessionRecordings.id, id))
.limit(1);
return rows[0] ?? null;
}
async findPathById(id: number): Promise<
| (SessionRecordingPathRecord & {
userId: string;
format?: string | null;
})
| null
> {
const rows = await this.context.drizzle
.select({
id: sessionRecordings.id,
recordingPath: sessionRecordings.recordingPath,
userId: sessionRecordings.userId,
format: sessionRecordings.format,
})
.from(sessionRecordings)
.where(eq(sessionRecordings.id, id))
.limit(1);
return rows[0] ?? null;
}
async listByUserIdWithHost(
userId: string,
): Promise<SessionRecordingListRecord[]> {
return this.context.drizzle
.select({
id: sessionRecordings.id,
hostId: sessionRecordings.hostId,
userId: sessionRecordings.userId,
startedAt: sessionRecordings.startedAt,
endedAt: sessionRecordings.endedAt,
duration: sessionRecordings.duration,
recordingPath: sessionRecordings.recordingPath,
protocol: sessionRecordings.protocol,
format: sessionRecordings.format,
hostName: hosts.name,
hostIp: hosts.ip,
})
.from(sessionRecordings)
.leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id))
.where(eq(sessionRecordings.userId, userId))
.orderBy(desc(sessionRecordings.startedAt));
}
async findByIdForUser(
userId: string,
id: number,
): Promise<SessionRecordingRecord | null> {
const rows = await this.context.drizzle
.select()
.from(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
)
.limit(1);
return rows[0] ?? null;
}
async findPathByIdForUser(
userId: string,
id: number,
): Promise<SessionRecordingPathRecord | null> {
const rows = await this.context.drizzle
.select({
id: sessionRecordings.id,
recordingPath: sessionRecordings.recordingPath,
})
.from(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
)
.limit(1);
return rows[0] ?? null;
}
async listPathsOlderThan(
cutoff: string,
): Promise<SessionRecordingPathRecord[]> {
return this.context.drizzle
.select({
id: sessionRecordings.id,
recordingPath: sessionRecordings.recordingPath,
})
.from(sessionRecordings)
.where(lt(sessionRecordings.startedAt, cutoff));
}
async deleteById(id: number): Promise<boolean> {
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(eq(sessionRecordings.id, id));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
async deleteForUser(userId: string, id: number): Promise<boolean> {
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
/**
* 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 — deleting only the row would orphan it.
*/
async anonymizeByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.update(sessionRecordings)
.set({ userId: null })
.where(eq(sessionRecordings.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(eq(sessionRecordings.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(eq(sessionRecordings.hostId, hostId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
if (hostIds.length === 0) {
return 0;
}
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(inArray(sessionRecordings.hostId, hostIds));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -22,7 +22,6 @@ import {
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentSessionRepository,
createCurrentSessionRecordingRepository,
createCurrentSettingsRepository,
createCurrentSharedHostSecretsRepository,
createCurrentSshCredentialUsageRepository,
@@ -61,9 +60,11 @@ export async function deleteUserAndRelatedData(
userId,
);
// Retained rather than deleted: these outlive the account by design.
// See anonymizeByUserId on each repository.
await createCurrentSessionRecordingRepository().anonymizeByUserId(userId);
// session_recordings is retained rather than deleted, by design: it
// outlives the account. The session-recording plugin listens for
// user.deleted and anonymizes its own rows instead of cascading.
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
pluginEvents.emit(TOPICS.userDeleted, { userId });
await createCurrentRbacAccessRepository().deleteHostAccessForUserReferences(
userId,
@@ -9,7 +9,6 @@ import {
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentSshCredentialUsageRepository,
createCurrentSessionRecordingRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
@@ -397,9 +396,10 @@ export function registerHostFolderRoutes(
const hostIds = hostsToDelete.map((host) => host.id);
if (hostIds.length > 0) {
// file manager recent/pinned/shortcuts, transfer_recent and command
// history cascade on the host's refHost() foreign key, as the
// file-manager and ssh-terminal plugins' adopted tables.
// file manager recent/pinned/shortcuts, transfer_recent, command
// history and session recordings cascade on the host's refHost()
// foreign key, as the file-manager, ssh-terminal and
// session-recording plugins' adopted tables.
await createCurrentSshCredentialUsageRepository().deleteByHostIds(
hostIds,
@@ -412,10 +412,6 @@ export function registerHostFolderRoutes(
await createCurrentRbacAccessRepository().deleteHostAccessForHosts(
hostIds,
);
await createCurrentSessionRecordingRepository().deleteByHostIds(
hostIds,
);
}
const { hostSyncIds, folderSyncIds } =
+4 -8
View File
@@ -20,7 +20,6 @@ import {
createCurrentOpksshTokenRepository,
createCurrentRecentActivityRepository,
createCurrentSshCredentialUsageRepository,
createCurrentSessionRecordingRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentHostResolutionRepository,
@@ -2524,9 +2523,10 @@ router.delete(
const numericHostId = Number(hostId);
// file manager recent/pinned/shortcuts, transfer_recent and command
// history cascade on the host's refHost() foreign key, as the
// file-manager and ssh-terminal plugins' adopted tables.
// file manager recent/pinned/shortcuts, transfer_recent, command
// history and session recordings cascade on the host's refHost()
// foreign key, as the file-manager, ssh-terminal and
// session-recording plugins' adopted tables.
await createCurrentSshCredentialUsageRepository().deleteByHostId(
numericHostId,
@@ -2540,10 +2540,6 @@ router.delete(
numericHostId,
);
await createCurrentSessionRecordingRepository().deleteByHostId(
numericHostId,
);
await createCurrentHostRepository().deleteForUser(userId, numericHostId);
if (hostToDelete.syncId) {
await createCurrentSyncTombstoneRepository().record(
@@ -1,362 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import fs from "fs";
import path from "path";
import { apiLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import {
createCurrentSessionRecordingRepository,
createCurrentSettingsRepository,
getCurrentSettingValue,
} from "../repositories/factory.js";
const router = express.Router();
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
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 configured = parseInt(
getCurrentSettingValue("session_recording_retention_days") || "",
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<boolean> {
return userId === ownerId || permissionManager.isAdmin(userId);
}
async function pruneOldLogs(): Promise<void> {
try {
const cutoff = new Date(
Date.now() - getRetentionDays() * 24 * 60 * 60 * 1000,
).toISOString();
const sessionRecordingRepository =
createCurrentSessionRecordingRepository();
const old = await sessionRecordingRepository.listPathsOlderThan(cutoff);
for (const row of old) {
if (row.recordingPath) {
const resolved = path.resolve(row.recordingPath);
if (isAllowedRecordingPath(resolved) && fs.existsSync(resolved)) {
await fs.promises.unlink(resolved).catch(() => {});
}
}
await sessionRecordingRepository.deleteById(row.id);
}
if (old.length > 0) {
apiLogger.info(`Pruned ${old.length} old session log(s)`, {
operation: "session_log_prune",
count: old.length,
});
}
} catch (err) {
apiLogger.warn("Failed to prune old session logs", {
operation: "session_log_prune_error",
error: err instanceof Error ? err.message : String(err),
});
}
}
// Run prune once at startup, then every 24 hours
pruneOldLogs();
setInterval(pruneOldLogs, 24 * 60 * 60 * 1000).unref();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* @openapi
* /session_logs:
* get:
* summary: List session logs
* description: Returns all terminal session recordings for the authenticated user.
* tags:
* - Session Logs
* responses:
* 200:
* description: List of session recordings.
* 500:
* description: Failed to fetch session logs.
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows =
await createCurrentSessionRecordingRepository().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) {
apiLogger.error("Failed to fetch session logs", error, {
operation: "session_logs_list",
userId,
});
res.status(500).json({ error: "Failed to fetch session logs" });
}
});
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" });
}
await createCurrentSettingsRepository().upsert(
"session_recording_retention_days",
String(retentionDays),
);
void pruneOldLogs();
res.json({ retentionDays });
},
);
/**
* @openapi
* /session_logs/{id}:
* get:
* summary: Get session log metadata
* description: Returns metadata for a single session recording.
* tags:
* - Session Logs
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Session recording metadata.
* 403:
* description: Not authorized.
* 404:
* description: Session log not found.
* 500:
* description: Failed to fetch session log.
*/
router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
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 createCurrentSessionRecordingRepository().findByIdForUser(
userId,
id,
);
if (!row) return res.status(404).json({ error: "Not found" });
res.json({ log: row });
} catch (error) {
apiLogger.error("Failed to fetch session log", error, {
operation: "session_log_get",
userId,
id,
});
res.status(500).json({ error: "Failed to fetch session log" });
}
});
/**
* @openapi
* /session_logs/{id}/content:
* get:
* summary: Get session log content
* description: Returns the raw text content of a session log file.
* tags:
* - Session Logs
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Raw log text.
* 403:
* description: Not authorized.
* 404:
* description: Session log or file not found.
* 500:
* description: Failed to read session log.
*/
router.get(
"/:id/content",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
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 createCurrentSessionRecordingRepository().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);
if (!isAllowedRecordingPath(resolvedPath)) {
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 as { format?: string | null }).format ??
(row.recordingPath?.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) {
apiLogger.error("Failed to read session log content", error, {
operation: "session_log_content",
userId,
id,
});
res.status(500).json({ error: "Failed to read session log" });
}
},
);
/**
* @openapi
* /session_logs/{id}:
* delete:
* summary: Delete session log
* description: Deletes a session recording and its log file.
* tags:
* - Session Logs
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Session log deleted.
* 403:
* description: Not authorized.
* 404:
* description: Session log not found.
* 500:
* description: Failed to delete session log.
*/
router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
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 sessionRecordingRepository =
createCurrentSessionRecordingRepository();
const row = await sessionRecordingRepository.findPathByIdForUser(
userId,
id,
);
if (!row) return res.status(404).json({ error: "Not found" });
const filePath = row.recordingPath;
await sessionRecordingRepository.deleteForUser(userId, id);
if (filePath) {
const resolvedPath = path.resolve(filePath);
if (isAllowedRecordingPath(resolvedPath) && fs.existsSync(resolvedPath)) {
await fs.promises.unlink(resolvedPath).catch(() => {});
}
}
res.json({ success: true });
} catch (error) {
apiLogger.error("Failed to delete session log", error, {
operation: "session_log_delete",
userId,
id,
});
res.status(500).json({ error: "Failed to delete session log" });
}
});
export default router;
+18
View File
@@ -250,6 +250,20 @@ export function createPluginContext(
{ action: "kv_list" },
);
const filesDataDir = guarded(
manifest,
"files:own",
async () => {
const fs = await import("node:fs/promises");
const path = await import("node:path");
const base = process.env.DATA_DIR ?? "./db/data";
const dir = path.join(base, "plugins", pluginId);
await fs.mkdir(dir, { recursive: true });
return dir;
},
{ action: "files_data_dir" },
);
const dbDefine = guarded(
manifest,
"db:own",
@@ -422,6 +436,10 @@ export function createPluginContext(
list: () => kvList(),
},
files: {
dataDir: () => filesDataDir(),
},
registry: {
provide: (key, value) => {
registry.provide(key, value);
+7
View File
@@ -47,6 +47,13 @@ export const TOPICS = {
* the row is not gone, only unrecoverable, and should go with it.
*/
userDataWiped: "user.data_wiped",
/**
* A user account was deleted. For a plugin whose rows should outlive the
* account (evidence, audit trail) rather than cascade with a refUser()
* column: it keeps a plain userId column and anonymizes its own rows here,
* the way core's own session recordings did before this event existed.
*/
userDeleted: "user.deleted",
} as const;
export type EventListener = (payload: unknown) => void;
+4
View File
@@ -373,6 +373,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runSessionSharingSettingsMigration } =
await import("./utils/crypto-migration/session-sharing-settings-migration.js");
await runSessionSharingSettingsMigration();
const { runSessionRecordingSettingsMigration } =
await import("./utils/crypto-migration/session-recording-settings-migration.js");
await runSessionRecordingSettingsMigration();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init",
@@ -7,10 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* `migrateSchema()` carried a `SELECT id FROM <table> LIMIT 1` probe for a
* number of tables that the primary bootstrap already creates. The probe never
* threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran — and two
* threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran — and one
* of those unreachable copies had drifted away from the real definition
* (`sessions` had lost `ON DELETE CASCADE`; `session_recordings` still had the
* pre-#1128 `user_id NOT NULL` with `ON DELETE CASCADE` and no `username`).
* (`sessions` had lost `ON DELETE CASCADE`).
*
* They are gone now. What has to stay true is that the bootstrap alone
* produces every one of those tables, from an empty database and from a
@@ -27,7 +26,6 @@ describe("bootstrap creates the tables the removed probes covered", () => {
"roles",
"user_roles",
"audit_logs",
"session_recordings",
"api_keys",
];
@@ -99,16 +97,6 @@ describe("bootstrap creates the tables the removed probes covered", () => {
await db.initializeDatabase();
const sqlite = db.getSqlite();
// session_recordings: nullable user_id with the attribution kept, per
// "audit trails survive the account" — not the NOT NULL + CASCADE the
// dead copy still carried.
const columns = sqlite
.prepare("PRAGMA table_info(session_recordings)")
.all() as Array<{ name: string; notnull: number }>;
const userId = columns.find((c) => c.name === "user_id");
expect(userId?.notnull).toBe(0);
expect(columns.some((c) => c.name === "username")).toBe(true);
// sessions: the dead copy had dropped ON DELETE CASCADE.
const sql = sqlite
.prepare(
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more