From e8808294afd84a776ca26367be652f9e6fc5b46d Mon Sep 17 00:00:00 2001 From: Samuel Cardoso Date: Sun, 21 Sep 2025 14:30:18 +0200 Subject: [PATCH] feat(frontend): add recording dialog component --- .../src/components/log/RecordedSessions.tsx | 206 ++++++++++++++++++ .../src/components/log/RecordingDialog.tsx | 172 +++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 frontend/src/components/log/RecordedSessions.tsx create mode 100644 frontend/src/components/log/RecordingDialog.tsx diff --git a/frontend/src/components/log/RecordedSessions.tsx b/frontend/src/components/log/RecordedSessions.tsx new file mode 100644 index 000000000..bd0d8f0d2 --- /dev/null +++ b/frontend/src/components/log/RecordedSessions.tsx @@ -0,0 +1,206 @@ +import { useState } from "react"; +import { Types } from "komodo_client"; +import { useRead, useWrite } from "@lib/hooks"; +import { Button } from "@ui/button"; +import { Badge } from "@ui/badge"; +import { Download, Trash2, Square, Eye } from "lucide-react"; +import { Log } from "../log"; +import { format } from "date-fns"; +import { useToast } from "@ui/use-toast"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@ui/dialog"; + +export const RecordedSessions = ({ + target, +}: { + target: Types.ResourceTarget; +}) => { + const { toast } = useToast(); + const [selectedRecording, setSelectedRecording] = useState(null); + const [viewDialogOpen, setViewDialogOpen] = useState(false); + + const { data: recordings, refetch } = useRead("ListLogRecordings", { + target, + active_only: false, + }); + + const { data: recordedLogs } = useRead( + "GetRecordedLogs", + { + recording_id: selectedRecording || "", + tail: 1000, + timestamps: false, + }, + { + enabled: !!selectedRecording, + } + ); + + const { mutate: deleteRecording } = useWrite("DeleteLogRecording", { + onSuccess: () => { + toast({ title: "Recording deleted" }); + setSelectedRecording(null); + setViewDialogOpen(false); + refetch(); + }, + }); + + const { mutate: stopRecording } = useWrite("StopLogRecording", { + onSuccess: () => { + toast({ title: "Recording stopped" }); + refetch(); + }, + }); + + const formatDate = (ts: number) => { + return format(new Date(ts), "MMM d, HH:mm"); + }; + + const calculateDuration = (start: number, end?: number | null) => { + const endTime = end || Date.now(); + const hours = Math.floor((endTime - start) / (1000 * 60 * 60)); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days} day${days > 1 ? 's' : ''}`; + if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''}`; + return "< 1 hour"; + }; + + const downloadLogs = (recordingId: string) => { + if (!recordedLogs || selectedRecording !== recordingId) return; + + const combined = `${recordedLogs.stdout}\n\n--- STDERR ---\n${recordedLogs.stderr}`; + const blob = new Blob([combined], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `logs-${recordingId}-${Date.now()}.txt`; + a.click(); + URL.revokeObjectURL(url); + }; + + const viewRecording = (id: string) => { + setSelectedRecording(id); + setViewDialogOpen(true); + }; + + if (!recordings || recordings.length === 0) { + return ( +
+ No recorded sessions. Click "Record" to start capturing logs. +
+ ); + } + + return ( + <> +
+
+ + + + + + + + + + + {recordings.map((recording) => ( + + + + + + + ))} + +
DateDurationStatusActions
{formatDate(recording.start_ts)} + {calculateDuration(recording.start_ts, recording.expire_ts)} + + {recording.status === Types.LogRecordingStatus.Recording ? ( + Recording + ) : recording.status === Types.LogRecordingStatus.Stopped ? ( + Complete + ) : ( + Expired + )} + +
+ {recording.status === Types.LogRecordingStatus.Recording ? ( + + ) : null} + + {selectedRecording === recording.id && recordedLogs && ( + + )} + +
+
+
+
+ + {/* Log viewer dialog */} + + + + + Recorded Logs + {selectedRecording && recordedLogs && ( + + )} + + +
+ +
+
+
+ + ); +}; \ No newline at end of file diff --git a/frontend/src/components/log/RecordingDialog.tsx b/frontend/src/components/log/RecordingDialog.tsx new file mode 100644 index 000000000..d12ff2fe1 --- /dev/null +++ b/frontend/src/components/log/RecordingDialog.tsx @@ -0,0 +1,172 @@ +import { useState, useMemo } from "react"; +import { Types } from "komodo_client"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@ui/dialog"; +import { Button } from "@ui/button"; +import { Input } from "@ui/input"; +import { ToggleGroup, ToggleGroupItem } from "@ui/toggle-group"; +import { Label } from "@ui/label"; +import { useWrite } from "@lib/hooks"; +import { useToast } from "@ui/use-toast"; + +type DurationUnit = "hours" | "days" | "infinite"; + +export const RecordingDialog = ({ + open, + onOpenChange, + target, + services = [], + onRecordingStarted, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + target: Types.ResourceTarget; + services?: string[]; + onRecordingStarted: (recording: Types.LogRecording) => void; +}) => { + const { toast } = useToast(); + const [name, setName] = useState(""); + const [durationValue, setDurationValue] = useState(1); + const [durationUnit, setDurationUnit] = useState("hours"); + + // Generate default name placeholder + const placeholderName = useMemo(() => { + const date = new Date(); + const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + const timeStr = date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); + const baseName = `${target.id.slice(0, 8)}_${dateStr}_${timeStr}`; + + if (services && services.length > 0) { + const serviceList = services.length <= 2 + ? services.join(', ') + : `${services.slice(0, 2).join(', ')}...+${services.length - 2}`; + return `${baseName} (${serviceList})`; + } + + return baseName; + }, [target, services]); + + const { mutate: startRecording, isPending } = useWrite("StartLogRecording", { + onSuccess: (recording) => { + const durationText = durationUnit === "infinite" + ? "until manually stopped" + : `${durationValue} ${durationUnit}`; + + toast({ + title: "Recording started", + description: `Logs will be recorded for ${durationText}`, + }); + onRecordingStarted(recording); + onOpenChange(false); + }, + onError: (error: any) => { + toast({ + title: "Failed to start recording", + description: error?.message || "An error occurred", + variant: "destructive", + }); + }, + }); + + const handleStart = () => { + let durationDays: number | undefined = undefined; + + if (durationUnit === "hours") { + durationDays = durationValue / 24; + } else if (durationUnit === "days") { + durationDays = durationValue; + } + // durationDays stays undefined for "infinite" + + startRecording({ + target, + name: name || undefined, + duration_days: durationDays, + services, + }); + }; + + return ( + + + + Start Log Recording + +
+ {/* Name field */} +
+ + setName(e.target.value)} + /> +

+ Optional - defaults to auto-generated name if not specified +

+
+ + {/* Duration selector */} +
+ +
+ setDurationValue(parseInt(e.target.value) || 1)} + disabled={durationUnit === "infinite"} + className="w-20" + /> +
+ value && setDurationUnit(value as DurationUnit)} + > + + Hours + + + Days + + + Infinite + + +
+
+ {durationUnit === "infinite" && ( +

+ Recording will continue until manually stopped +

+ )} +
+ + {services && services.length > 0 && ( +
+ +
+ {services.join(", ")} +
+
+ )} +
+ + + + +
+
+ ); +}; \ No newline at end of file