feat(frontend): add recording dialog component

This commit is contained in:
Samuel Cardoso
2025-09-21 14:30:18 +02:00
parent aadeb965e6
commit e8808294af
2 changed files with 378 additions and 0 deletions
@@ -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<string | null>(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 (
<div className="text-center py-8 text-muted-foreground">
No recorded sessions. Click "Record" to start capturing logs.
</div>
);
}
return (
<>
<div className="space-y-4">
<div className="border rounded-lg">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left p-3 font-medium">Date</th>
<th className="text-left p-3 font-medium">Duration</th>
<th className="text-left p-3 font-medium">Status</th>
<th className="text-right p-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{recordings.map((recording) => (
<tr key={recording.id} className="border-b hover:bg-accent/50">
<td className="p-3">{formatDate(recording.start_ts)}</td>
<td className="p-3">
{calculateDuration(recording.start_ts, recording.expire_ts)}
</td>
<td className="p-3">
{recording.status === Types.LogRecordingStatus.Recording ? (
<Badge variant="destructive" className="text-xs">Recording</Badge>
) : recording.status === Types.LogRecordingStatus.Stopped ? (
<Badge variant="secondary" className="text-xs">Complete</Badge>
) : (
<Badge variant="outline" className="text-xs">Expired</Badge>
)}
</td>
<td className="p-3">
<div className="flex items-center justify-end gap-1">
{recording.status === Types.LogRecordingStatus.Recording ? (
<Button
size="sm"
variant="outline"
className="h-7"
onClick={() => stopRecording({ id: recording.id })}
>
<Square className="w-3 h-3 mr-1" />
Stop
</Button>
) : null}
<Button
size="sm"
variant="outline"
className="h-7"
onClick={() => viewRecording(recording.id)}
>
<Eye className="w-3 h-3 mr-1" />
View
</Button>
{selectedRecording === recording.id && recordedLogs && (
<Button
size="sm"
variant="outline"
className="h-7"
onClick={() => downloadLogs(recording.id)}
>
<Download className="w-3 h-3 mr-1" />
Download
</Button>
)}
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => deleteRecording({ id: recording.id })}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Log viewer dialog */}
<Dialog open={viewDialogOpen} onOpenChange={setViewDialogOpen}>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>Recorded Logs</span>
{selectedRecording && recordedLogs && (
<Button
size="sm"
variant="outline"
onClick={() => downloadLogs(selectedRecording)}
>
<Download className="w-4 h-4 mr-2" />
Download
</Button>
)}
</DialogTitle>
</DialogHeader>
<div className="h-[60vh] overflow-auto">
<Log
log={recordedLogs}
stream="stdout"
/>
</div>
</DialogContent>
</Dialog>
</>
);
};
@@ -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<number>(1);
const [durationUnit, setDurationUnit] = useState<DurationUnit>("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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Start Log Recording</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Name field */}
<div className="space-y-2">
<Label htmlFor="recording-name">Session Name</Label>
<Input
id="recording-name"
placeholder={placeholderName}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Optional - defaults to auto-generated name if not specified
</p>
</div>
{/* Duration selector */}
<div className="space-y-2">
<Label>Recording Duration</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min="1"
value={durationValue}
onChange={(e) => setDurationValue(parseInt(e.target.value) || 1)}
disabled={durationUnit === "infinite"}
className="w-20"
/>
<div className="border rounded-md">
<ToggleGroup
type="single"
value={durationUnit}
onValueChange={(value) => value && setDurationUnit(value as DurationUnit)}
>
<ToggleGroupItem value="hours" className="rounded-r-none">
Hours
</ToggleGroupItem>
<ToggleGroupItem value="days" className="rounded-none">
Days
</ToggleGroupItem>
<ToggleGroupItem value="infinite" className="rounded-l-none">
Infinite
</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
{durationUnit === "infinite" && (
<p className="text-sm text-muted-foreground">
Recording will continue until manually stopped
</p>
)}
</div>
{services && services.length > 0 && (
<div className="space-y-1">
<Label>Services to record</Label>
<div className="text-sm text-muted-foreground font-mono">
{services.join(", ")}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleStart} disabled={isPending}>
Start Recording
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};