mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: turn session recording into a plugin (B13)
This commit is contained in:
1 parent
a276f6898c
commit
0af2c94983
147 files changed
+25915
-2074
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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": "依主機篩選..."
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
useTranslation,
|
||||
useToast,
|
||||
usePluginApi,
|
||||
usePermission,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Input,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
copyToClipboard,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Loader2,
|
||||
ScrollText,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { SessionRecordingPlayer } from "./SessionRecordingPlayer";
|
||||
import { asciicastToPlainText, parseAsciicast } from "./asciicast";
|
||||
import {
|
||||
createSessionRecordingApi,
|
||||
type SessionLogRecord,
|
||||
} from "./session-recording-api";
|
||||
|
||||
function useAdaptivePolling(
|
||||
fn: () => Promise<boolean | void>,
|
||||
options: { minIntervalMs: number; maxIntervalMs: number },
|
||||
enabled: boolean,
|
||||
) {
|
||||
const fnRef = useRef(fn);
|
||||
fnRef.current = fn;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let interval = options.minIntervalMs;
|
||||
let stable = 0;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const changed = await fnRef.current();
|
||||
if (changed) {
|
||||
interval = options.minIntervalMs;
|
||||
stable = 0;
|
||||
} else {
|
||||
stable++;
|
||||
if (stable >= 3) {
|
||||
interval = Math.min(interval * 1.5, options.maxIntervalMs);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// keep polling
|
||||
}
|
||||
if (!cancelled) timer = setTimeout(tick, interval);
|
||||
};
|
||||
|
||||
timer = setTimeout(tick, interval);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [enabled, options.minIntervalMs, options.maxIntervalMs]);
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (seconds == null) return "--";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null): string {
|
||||
if (bytes == null || bytes === 0) return "--";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function buildFilename(log: SessionLogRecord): string {
|
||||
const host = (log.hostName ?? log.hostIp ?? "session")
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.slice(0, 40);
|
||||
const d = new Date(log.startedAt);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const h = String(d.getHours()).padStart(2, "0");
|
||||
const min = String(d.getMinutes()).padStart(2, "0");
|
||||
const s = String(d.getSeconds()).padStart(2, "0");
|
||||
const extension =
|
||||
log.format === "guacamole"
|
||||
? "guac"
|
||||
: log.format === "asciicast"
|
||||
? "cast"
|
||||
: "log";
|
||||
return `${host}_${y}-${m}-${day}_${h}-${min}-${s}.${extension}`;
|
||||
}
|
||||
|
||||
function buildTextFilename(log: SessionLogRecord): string {
|
||||
return buildFilename(log).replace(/\.(cast|guac|log)$/, ".txt");
|
||||
}
|
||||
|
||||
async function extractPlainText(
|
||||
log: SessionLogRecord,
|
||||
blob: Blob,
|
||||
): Promise<string | null> {
|
||||
if (log.format === "guacamole") return null;
|
||||
const source = await blob.text();
|
||||
if (log.format === "text") return source;
|
||||
return asciicastToPlainText(parseAsciicast(source));
|
||||
}
|
||||
|
||||
function SectionHeader({ label, count }: { label: string; count: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/20">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60 flex-1">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold text-muted-foreground/40 bg-muted/60 px-1.5 py-0.5">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogRow({
|
||||
log,
|
||||
onView,
|
||||
onDownload,
|
||||
onDownloadText,
|
||||
onDelete,
|
||||
}: {
|
||||
log: SessionLogRecord;
|
||||
onView: () => void;
|
||||
onDownload: () => void;
|
||||
onDownloadText: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const hostLabel = log.hostName ?? log.hostIp ?? `Host ${log.hostId}`;
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 last:border-b-0 hover:bg-muted/40 transition-colors">
|
||||
<div className="shrink-0 flex items-center justify-center size-7 bg-muted/60 text-muted-foreground">
|
||||
<ScrollText className="size-3.5" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
|
||||
<span className="text-xs font-semibold truncate text-foreground">
|
||||
{hostLabel}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/60 truncate">
|
||||
{formatDate(log.startedAt)}
|
||||
{" · "}
|
||||
{(log.protocol ?? "ssh").toUpperCase()}
|
||||
{log.username ? ` · ${log.username}` : ""}
|
||||
{" · "}
|
||||
{formatDuration(log.duration)}
|
||||
{" · "}
|
||||
{formatBytes(log.sizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onView}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<Eye className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">View log</TooltipContent>
|
||||
</Tooltip>
|
||||
{log.format === "asciicast" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onDownloadText}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{t("sessionLogs.downloadAsText")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onDownload}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<Download className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Download</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionLogsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const api = createSessionRecordingApi(usePluginApi());
|
||||
const canView = usePermission("view");
|
||||
const [logs, setLogs] = useState<SessionLogRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [viewLog, setViewLog] = useState<SessionLogRecord | null>(null);
|
||||
const [viewContent, setViewContent] = useState<string>("");
|
||||
const [viewBlob, setViewBlob] = useState<Blob | null>(null);
|
||||
const [viewText, setViewText] = useState<string | null>(null);
|
||||
const [viewLoading, setViewLoading] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SessionLogRecord | null>(
|
||||
null,
|
||||
);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const logsRef = useRef(logs);
|
||||
logsRef.current = logs;
|
||||
|
||||
const load = useCallback(
|
||||
async (initial = false) => {
|
||||
if (initial) setLoading(true);
|
||||
try {
|
||||
const fresh = await api.list();
|
||||
const changed =
|
||||
logsRef.current.length !== fresh.length ||
|
||||
logsRef.current.some((log, i) => log.id !== fresh[i]?.id);
|
||||
if (changed) {
|
||||
logsRef.current = fresh;
|
||||
setLogs(fresh);
|
||||
}
|
||||
return changed;
|
||||
} catch (error) {
|
||||
if (initial) {
|
||||
toast.error(t("sessionLogs.loadError"));
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (initial) setLoading(false);
|
||||
}
|
||||
},
|
||||
[api, t, toast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canView) return;
|
||||
void load(true);
|
||||
}, [canView, load]);
|
||||
|
||||
useAdaptivePolling(
|
||||
() => load(false),
|
||||
{
|
||||
minIntervalMs: 5_000,
|
||||
maxIntervalMs: 30_000,
|
||||
},
|
||||
canView,
|
||||
);
|
||||
|
||||
const q = filter.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? logs.filter((l) =>
|
||||
`${l.hostName ?? ""} ${l.hostIp ?? ""} ${l.username ?? ""} ${l.protocol}`
|
||||
.toLowerCase()
|
||||
.includes(q),
|
||||
)
|
||||
: logs;
|
||||
|
||||
const handleView = async (log: SessionLogRecord) => {
|
||||
setViewLog(log);
|
||||
setViewContent("");
|
||||
setViewBlob(null);
|
||||
setViewText(null);
|
||||
setViewLoading(true);
|
||||
try {
|
||||
if (log.format === "text") {
|
||||
const text = await api.getContent(log.id);
|
||||
setViewContent(text);
|
||||
setViewText(text);
|
||||
} else {
|
||||
const blob = await api.getBlob(log.id);
|
||||
setViewBlob(blob);
|
||||
setViewText(await extractPlainText(log, blob));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("sessionLogs.loadError"));
|
||||
} finally {
|
||||
setViewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleDownload = async (log: SessionLogRecord) => {
|
||||
try {
|
||||
downloadBlob(await api.getBlob(log.id), buildFilename(log));
|
||||
} catch {
|
||||
toast.error(t("sessionLogs.loadError"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadText = async (log: SessionLogRecord) => {
|
||||
try {
|
||||
const text =
|
||||
log.id === viewLog?.id && viewText != null
|
||||
? viewText
|
||||
: await extractPlainText(log, await api.getBlob(log.id));
|
||||
if (text == null) {
|
||||
toast.error(t("sessionLogs.loadError"));
|
||||
return;
|
||||
}
|
||||
downloadBlob(
|
||||
new Blob([text], { type: "text/plain" }),
|
||||
buildTextFilename(log),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("sessionLogs.loadError"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.delete(deleteTarget.id);
|
||||
setLogs((prev) => prev.filter((l) => l.id !== deleteTarget.id));
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
toast.error(t("sessionLogs.deleteError"));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!viewText) return;
|
||||
const ok = await copyToClipboard(viewText);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
toast.error(t("common.copyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center flex-1 p-8">
|
||||
<Loader2 className="size-5 text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline log viewer
|
||||
if (viewLog) {
|
||||
const hostLabel =
|
||||
viewLog.hostName ?? viewLog.hostIp ?? `Host ${viewLog.hostId}`;
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{/* Viewer header */}
|
||||
<div className="flex items-center gap-2 px-2 py-2 border-b border-border/60 bg-muted/20 shrink-0">
|
||||
<button
|
||||
onClick={() => setViewLog(null)}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</button>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="text-xs font-semibold truncate text-foreground">
|
||||
{hostLabel}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{formatDate(viewLog.startedAt)}
|
||||
{` · ${viewLog.protocol.toUpperCase()}`}
|
||||
{viewLog.duration != null
|
||||
? ` · ${formatDuration(viewLog.duration)}`
|
||||
: ""}
|
||||
{" · "}
|
||||
{formatBytes(viewLog.sizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{viewText != null && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{copied
|
||||
? t("sessionLogs.copied")
|
||||
: t("sessionLogs.copyContent")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{viewLog.format === "asciicast" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => handleDownloadText(viewLog)}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{t("sessionLogs.downloadAsText")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => handleDownload(viewLog)}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<Download className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{t("sessionLogs.downloadLog")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Log content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{viewLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : viewBlob ? (
|
||||
<SessionRecordingPlayer log={viewLog} blob={viewBlob} />
|
||||
) : (
|
||||
<pre className="p-3 text-[11px] font-mono whitespace-pre-wrap break-all text-foreground/80 leading-relaxed">
|
||||
{viewContent || "(empty)"}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
|
||||
<div className="size-10 bg-muted/40 flex items-center justify-center">
|
||||
<ScrollText className="size-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t("sessionLogs.noLogs")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/40">
|
||||
{t("sessionLogs.noLogsDesc")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative px-3 py-2 border-b border-border/60">
|
||||
<Search className="absolute left-5.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50 pointer-events-none" />
|
||||
<Input
|
||||
placeholder={t("sessionLogs.filterByHost")}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="pl-8 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
|
||||
<span className="text-xs text-muted-foreground/50">
|
||||
No results for "{filter}"
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<SectionHeader
|
||||
label={t("sessionLogs.title")}
|
||||
count={filtered.length}
|
||||
/>
|
||||
{filtered.map((log) => (
|
||||
<LogRow
|
||||
key={log.id}
|
||||
log={log}
|
||||
onView={() => handleView(log)}
|
||||
onDownload={() => handleDownload(log)}
|
||||
onDownloadText={() => handleDownloadText(log)}
|
||||
onDelete={() => setDeleteTarget(log)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline delete confirmation - positioned against the relative parent in AppShell */}
|
||||
{deleteTarget && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
|
||||
<p className="text-sm text-foreground">
|
||||
{t("sessionLogs.confirmDelete")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{deleteTarget.hostName ??
|
||||
deleteTarget.hostIp ??
|
||||
`Session ${deleteTarget.id}`}
|
||||
{" · "}
|
||||
{formatDate(deleteTarget.startedAt)}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
{deleting && <Loader2 className="size-3 animate-spin" />}
|
||||
{t("sessionLogs.deleteLog")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Pause, Play } from "lucide-react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { Select2 } from "@termix/plugin-sdk/ui";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { parseAsciicast, type Asciicast } from "./asciicast";
|
||||
import type { SessionLogRecord } from "./session-recording-api";
|
||||
|
||||
const SPEEDS = [0.5, 1, 2, 4];
|
||||
|
||||
function formatPosition(seconds: number) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
function PlaybackControls({
|
||||
playing,
|
||||
position,
|
||||
duration,
|
||||
speed,
|
||||
onToggle,
|
||||
onSeek,
|
||||
onSpeed,
|
||||
}: {
|
||||
playing: boolean;
|
||||
position: number;
|
||||
duration: number;
|
||||
speed: number;
|
||||
onToggle: () => void;
|
||||
onSeek: (position: number) => void;
|
||||
onSpeed: (speed: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-t border-border/60 bg-muted/20 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex size-7 items-center justify-center hover:bg-muted"
|
||||
aria-label={playing ? "Pause recording" : "Play recording"}
|
||||
>
|
||||
{playing ? (
|
||||
<Pause className="size-3.5" />
|
||||
) : (
|
||||
<Play className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<span className="w-10 text-[10px] tabular-nums text-muted-foreground">
|
||||
{formatPosition(position)}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(duration, 0.01)}
|
||||
step={0.01}
|
||||
value={Math.min(position, duration)}
|
||||
onChange={(event) => onSeek(Number(event.target.value))}
|
||||
className="min-w-0 flex-1 accent-primary"
|
||||
aria-label="Recording timeline"
|
||||
/>
|
||||
<span className="w-10 text-right text-[10px] tabular-nums text-muted-foreground">
|
||||
{formatPosition(duration)}
|
||||
</span>
|
||||
<Select2
|
||||
value={speed}
|
||||
onChange={(event) => onSpeed(Number(event.target.value))}
|
||||
className="h-7 border border-border bg-background px-1 text-[10px]"
|
||||
aria-label="Playback speed"
|
||||
>
|
||||
{SPEEDS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}×
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AsciicastPlayer({ blob }: { blob: Blob }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const recordingRef = useRef<Asciicast | null>(null);
|
||||
const positionRef = useRef(0);
|
||||
const eventIndexRef = useRef(0);
|
||||
const [position, setPosition] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [speed, setSpeed] = useState(1);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const renderAt = useCallback((nextPosition: number) => {
|
||||
const terminal = terminalRef.current;
|
||||
const recording = recordingRef.current;
|
||||
if (!terminal || !recording) return;
|
||||
if (nextPosition < positionRef.current) {
|
||||
terminal.reset();
|
||||
terminal.resize(recording.width, recording.height);
|
||||
eventIndexRef.current = 0;
|
||||
}
|
||||
while (eventIndexRef.current < recording.events.length) {
|
||||
const [time, type, data] = recording.events[eventIndexRef.current];
|
||||
if (time > nextPosition) break;
|
||||
if (type === "o") terminal.write(data);
|
||||
if (type === "r") {
|
||||
const [cols, rows] = data.split("x").map(Number);
|
||||
if (cols > 0 && rows > 0) terminal.resize(cols, rows);
|
||||
}
|
||||
eventIndexRef.current++;
|
||||
}
|
||||
positionRef.current = nextPosition;
|
||||
setPosition(nextPosition);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
convertEol: false,
|
||||
fontSize: 12,
|
||||
theme: { background: "#09090b" },
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(containerRef.current);
|
||||
terminalRef.current = terminal;
|
||||
const observer = new ResizeObserver(() => fitAddon.fit());
|
||||
observer.observe(containerRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
blob
|
||||
.text()
|
||||
.then((source) => {
|
||||
const recording = parseAsciicast(source);
|
||||
recordingRef.current = recording;
|
||||
setDuration(recording.duration);
|
||||
terminal.resize(recording.width, recording.height);
|
||||
renderAt(0);
|
||||
})
|
||||
.catch((reason) => setError(getErrorMessage(reason, String(reason))));
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
terminal.dispose();
|
||||
terminalRef.current = null;
|
||||
};
|
||||
}, [blob, renderAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing || !recordingRef.current) return;
|
||||
let frame = 0;
|
||||
let previous = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const next = Math.min(
|
||||
duration,
|
||||
positionRef.current + ((now - previous) / 1000) * speed,
|
||||
);
|
||||
previous = now;
|
||||
renderAt(next);
|
||||
if (next >= duration) setPlaying(false);
|
||||
else frame = requestAnimationFrame(tick);
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [duration, playing, renderAt, speed]);
|
||||
|
||||
if (error) return <div className="p-4 text-xs text-destructive">{error}</div>;
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div ref={containerRef} className="min-h-0 flex-1 bg-[#09090b] p-2" />
|
||||
<PlaybackControls
|
||||
playing={playing}
|
||||
position={position}
|
||||
duration={duration}
|
||||
speed={speed}
|
||||
onToggle={() => {
|
||||
if (!playing && position >= duration) renderAt(0);
|
||||
setPlaying((value) => !value);
|
||||
}}
|
||||
onSeek={renderAt}
|
||||
onSpeed={setSpeed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GuacamolePlayer({ blob }: { blob: Blob }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const recordingRef = useRef<Guacamole.SessionRecording | null>(null);
|
||||
const positionRef = useRef(0);
|
||||
const [position, setPosition] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [speed, setSpeed] = useState(1);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const recording = new Guacamole.SessionRecording(blob, 100);
|
||||
recordingRef.current = recording;
|
||||
const display = recording.getDisplay();
|
||||
const element = display.getElement();
|
||||
containerRef.current.replaceChildren(element);
|
||||
const fit = () => {
|
||||
const width = display.getWidth();
|
||||
if (width && containerRef.current) {
|
||||
display.scale(containerRef.current.clientWidth / width);
|
||||
}
|
||||
};
|
||||
const observer = new ResizeObserver(fit);
|
||||
observer.observe(containerRef.current);
|
||||
recording.onload = () => {
|
||||
setDuration(recording.getDuration() / 1000);
|
||||
fit();
|
||||
};
|
||||
recording.onprogress = (nextDuration) => setDuration(nextDuration / 1000);
|
||||
recording.onseek = (nextPosition) => {
|
||||
positionRef.current = nextPosition / 1000;
|
||||
setPosition(positionRef.current);
|
||||
};
|
||||
recording.onerror = setError;
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
recording.abort();
|
||||
recordingRef.current = null;
|
||||
};
|
||||
}, [blob]);
|
||||
|
||||
useEffect(() => {
|
||||
const recording = recordingRef.current;
|
||||
if (!recording || !playing) return;
|
||||
recording.pause();
|
||||
let previous = performance.now();
|
||||
let nextPosition = positionRef.current;
|
||||
const interval = window.setInterval(() => {
|
||||
const now = performance.now();
|
||||
nextPosition = Math.min(
|
||||
duration,
|
||||
nextPosition + ((now - previous) / 1000) * speed,
|
||||
);
|
||||
previous = now;
|
||||
recording.seek(nextPosition * 1000);
|
||||
if (nextPosition >= duration) setPlaying(false);
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, [duration, playing, speed]);
|
||||
|
||||
if (error) return <div className="p-4 text-xs text-destructive">{error}</div>;
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="min-h-0 flex-1 overflow-hidden bg-black"
|
||||
/>
|
||||
<PlaybackControls
|
||||
playing={playing}
|
||||
position={position}
|
||||
duration={duration}
|
||||
speed={speed}
|
||||
onToggle={() => {
|
||||
const recording = recordingRef.current;
|
||||
if (!recording) return;
|
||||
if (!playing && position >= duration) recording.seek(0);
|
||||
setPlaying((value) => !value);
|
||||
}}
|
||||
onSeek={(nextPosition) => {
|
||||
recordingRef.current?.seek(nextPosition * 1000);
|
||||
positionRef.current = nextPosition;
|
||||
setPosition(nextPosition);
|
||||
}}
|
||||
onSpeed={setSpeed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionRecordingPlayer({
|
||||
log,
|
||||
blob,
|
||||
}: {
|
||||
log: SessionLogRecord;
|
||||
blob: Blob;
|
||||
}) {
|
||||
return log.format === "guacamole" ? (
|
||||
<GuacamolePlayer blob={blob} />
|
||||
) : (
|
||||
<AsciicastPlayer blob={blob} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
export type AsciicastEvent = [
|
||||
time: number,
|
||||
type: "i" | "o" | "r",
|
||||
data: string,
|
||||
];
|
||||
|
||||
export type Asciicast = {
|
||||
width: number;
|
||||
height: number;
|
||||
duration: number;
|
||||
events: AsciicastEvent[];
|
||||
};
|
||||
|
||||
export function parseAsciicast(source: string): Asciicast {
|
||||
const lines = source.trim().split("\n");
|
||||
const header = JSON.parse(lines.shift() || "{}") as {
|
||||
version?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
if (header.version !== 2) throw new Error("Unsupported asciicast version");
|
||||
|
||||
const events = lines
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as AsciicastEvent)
|
||||
.filter(
|
||||
([time, type, data]) =>
|
||||
Number.isFinite(time) &&
|
||||
(type === "i" || type === "o" || type === "r") &&
|
||||
typeof data === "string",
|
||||
);
|
||||
|
||||
return {
|
||||
width: header.width || 80,
|
||||
height: header.height || 24,
|
||||
duration: events.at(-1)?.[0] || 0,
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
function writeAsync(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise((resolve) => terminal.write(data, resolve));
|
||||
}
|
||||
|
||||
// Replays the recording into an offscreen terminal and reads back its
|
||||
// rendered buffer, so cursor movement/overwrites resolve the same way
|
||||
// they would on screen instead of leaving raw ANSI escapes in the text.
|
||||
export async function asciicastToPlainText(
|
||||
recording: Asciicast,
|
||||
): Promise<string> {
|
||||
const terminal = new Terminal({
|
||||
cols: recording.width,
|
||||
rows: recording.height,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
try {
|
||||
for (const [, type, data] of recording.events) {
|
||||
if (type === "o") await writeAsync(terminal, data);
|
||||
if (type === "r") {
|
||||
const [cols, rows] = data.split("x").map(Number);
|
||||
if (cols > 0 && rows > 0) terminal.resize(cols, rows);
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = terminal.buffer.active;
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
lines.push(buffer.getLine(i)?.translateToString(true) ?? "");
|
||||
}
|
||||
while (lines.length > 0 && lines.at(-1) === "") lines.pop();
|
||||
return lines.join("\n");
|
||||
} finally {
|
||||
terminal.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ScrollText } from "lucide-react";
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { SessionLogsPanel } from "./SessionLogsPanel";
|
||||
import { RecordingStatus } from "./RecordingStatus";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerRailItem({
|
||||
id: "session-logs",
|
||||
icon: ScrollText,
|
||||
titleKey: "nav.sessionLogs",
|
||||
kind: "panel",
|
||||
promotable: true,
|
||||
rightDockable: true,
|
||||
separatorAfter: true,
|
||||
permission: "view",
|
||||
});
|
||||
|
||||
app.registerPanel("session-logs", () => <SessionLogsPanel />);
|
||||
|
||||
app.registerTab("session-logs", () => <SessionLogsPanel />, {
|
||||
icon: ScrollText,
|
||||
titleKey: "nav.sessionLogs",
|
||||
hostless: true,
|
||||
});
|
||||
|
||||
app.registerSlotContribution("terminal.toolbarStatus", {
|
||||
actionId: "session-recording.terminalStatus",
|
||||
titleKey: "nav.sessionLogs",
|
||||
kind: "component",
|
||||
component: RecordingStatus,
|
||||
when: (context) => {
|
||||
const host = context.host as
|
||||
| { pluginSettings?: Record<string, Record<string, unknown>> }
|
||||
| undefined;
|
||||
const enabled =
|
||||
host?.pluginSettings?.["session-recording"]?.enableSessionRecording;
|
||||
return enabled !== false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivate(): void {}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export type SessionLogRecord = {
|
||||
id: number;
|
||||
hostId: number;
|
||||
userId: string | null;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
duration: number | null;
|
||||
recordingPath: string | null;
|
||||
hostName: string | null;
|
||||
hostIp: string | null;
|
||||
sizeBytes: number | null;
|
||||
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
format: "text" | "asciicast" | "guacamole";
|
||||
username: string | null;
|
||||
};
|
||||
|
||||
export function createSessionRecordingApi(api: PluginApiClient) {
|
||||
return {
|
||||
async list(): Promise<SessionLogRecord[]> {
|
||||
const response = await api.get<{ logs: SessionLogRecord[] }>("/");
|
||||
return response.data.logs;
|
||||
},
|
||||
|
||||
async getBlob(id: number): Promise<Blob> {
|
||||
const response = await api.get<Blob>(`/${id}/content`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getContent(id: number): Promise<string> {
|
||||
const response = await api.get<string>(`/${id}/content`, {
|
||||
responseType: "text",
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async delete(id: number): Promise<void> {
|
||||
await api.delete(`/${id}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseAsciicast } from "../../src/frontend/asciicast";
|
||||
|
||||
describe("parseAsciicast", () => {
|
||||
it("parses terminal dimensions and timed input/output", () => {
|
||||
const recording = parseAsciicast(
|
||||
'{"version":2,"width":120,"height":30}\n[0.1,"o","hello"]\n[1.5,"i","ls\\r"]\n[2,"r","100x40"]\n',
|
||||
);
|
||||
expect(recording.width).toBe(120);
|
||||
expect(recording.height).toBe(30);
|
||||
expect(recording.duration).toBe(2);
|
||||
expect(recording.events).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("rejects unsupported recording versions", () => {
|
||||
expect(() => parseAsciicast('{"version":1}\n')).toThrow(
|
||||
"Unsupported asciicast version",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user