Files
claudetools/projects/msp-tools/guru-rmm/dashboard/src/pages/History.tsx
azcomputerguru 3fec95af0a feat(dashboard): UI refinements - density, flat agents table, history log
- Reduce layout density ~20% (tighter padding, margins, fonts)
- Flatten Agents table view with Client/Site columns (no grouping)
- Add version info to sidebar footer (UI v0.2.0, API v0.1.0)
- Replace Commands nav with sidebar History log
- Add /history page with full command list
- Add /history/:id detail view with output display
- Apply Mission Control styling to all new components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 08:12:31 -07:00

282 lines
10 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import { Link, useParams, useNavigate } from "react-router-dom";
import { RefreshCw, CheckCircle, XCircle, Clock, Loader2, ArrowLeft, Terminal } from "lucide-react";
import { commandsApi, Command } from "../api/client";
import { Card, CardContent } from "../components/Card";
import { Button } from "../components/Button";
function StatusBadge({ status }: { status: Command["status"] }) {
const config = {
pending: {
icon: Clock,
label: "Pending",
className: "bg-amber-500/10 text-amber-400 border-amber-500/30",
},
running: {
icon: Loader2,
label: "Running",
className: "bg-cyan-500/10 text-cyan-400 border-cyan-500/30",
spin: true,
},
completed: {
icon: CheckCircle,
label: "Completed",
className: "bg-emerald-500/10 text-emerald-400 border-emerald-500/30",
},
failed: {
icon: XCircle,
label: "Failed",
className: "bg-rose-500/10 text-rose-400 border-rose-500/30",
},
};
const { icon: Icon, label, className, spin } = config[status] as {
icon: typeof Clock;
label: string;
className: string;
spin?: boolean;
};
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-mono font-medium border ${className}`}>
<Icon className={`h-3 w-3 ${spin ? "animate-spin" : ""}`} />
{label}
</span>
);
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleString();
}
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) return "Just now";
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
return `${Math.floor(diffInSeconds / 86400)}d ago`;
}
export function History() {
const { data: commands = [], isLoading, refetch } = useQuery({
queryKey: ["commands"],
queryFn: () => commandsApi.list().then((res) => res.data),
refetchInterval: 10000,
});
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-mono font-bold text-[var(--text-primary)]">History</h1>
<p className="text-[var(--text-muted)] text-sm">Command execution log</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
className="border-[var(--border-accent)] text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan-muted)]"
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{/* History List */}
<Card className="glass-card bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-xl overflow-hidden">
<CardContent className="p-0">
{isLoading ? (
<div className="p-8 text-center">
<Loader2 className="h-6 w-6 animate-spin text-[var(--accent-cyan)] mx-auto mb-2" />
<p className="text-[var(--text-muted)] font-mono text-sm">Loading history...</p>
</div>
) : commands.length === 0 ? (
<div className="p-8 text-center">
<Terminal className="h-8 w-8 text-[var(--text-muted)] mx-auto mb-2 opacity-50" />
<p className="text-[var(--text-muted)] font-mono text-sm">No commands executed yet</p>
</div>
) : (
<div className="divide-y divide-[var(--border-secondary)]">
{commands.map((cmd: Command) => (
<Link
key={cmd.id}
to={`/history/${cmd.id}`}
className="flex items-center justify-between p-3 hover:bg-[rgba(6,182,212,0.05)] transition-colors group"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<StatusBadge status={cmd.status} />
<div className="min-w-0 flex-1">
<p className="font-mono text-sm text-[var(--text-primary)] truncate group-hover:text-[var(--accent-cyan)] transition-colors">
{cmd.command_text}
</p>
<p className="text-xs text-[var(--text-muted)] mt-0.5">
{cmd.command_type} | Agent: {cmd.agent_id.slice(0, 8)}...
</p>
</div>
</div>
<div className="text-right pl-4 shrink-0">
<p className="font-mono text-xs text-[var(--text-muted)]">
{formatRelativeTime(cmd.created_at)}
</p>
{cmd.exit_code !== null && (
<p className={`text-xs font-mono ${cmd.exit_code === 0 ? "text-emerald-500" : "text-rose-500"}`}>
exit: {cmd.exit_code}
</p>
)}
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
export function HistoryDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: commands = [], isLoading } = useQuery({
queryKey: ["commands"],
queryFn: () => commandsApi.list().then((res) => res.data),
});
const command = commands.find((cmd: Command) => cmd.id === id);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<Loader2 className="h-8 w-8 animate-spin text-[var(--accent-cyan)]" />
</div>
);
}
if (!command) {
return (
<div className="space-y-4">
<Button
variant="ghost"
onClick={() => navigate("/history")}
className="text-[var(--text-muted)] hover:text-[var(--accent-cyan)]"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to History
</Button>
<Card className="glass-card">
<CardContent className="p-8 text-center">
<p className="text-[var(--text-muted)]">Command not found</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => navigate("/history")}
className="text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan-muted)]"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex-1">
<div className="flex items-center gap-3">
<h1 className="text-xl font-mono font-bold text-[var(--text-primary)]">Command Detail</h1>
<StatusBadge status={command.status} />
</div>
<p className="text-[var(--text-muted)] text-sm mt-0.5">
{formatDate(command.created_at)}
</p>
</div>
</div>
{/* Command Info */}
<Card className="glass-card bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-xl">
<CardContent className="p-4 space-y-4">
{/* Command */}
<div>
<label className="text-xs font-mono font-semibold text-[var(--text-muted)] uppercase tracking-wider">
Command
</label>
<div className="mt-1 p-3 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-secondary)] font-mono text-sm text-[var(--accent-cyan)]">
{command.command_text}
</div>
</div>
{/* Meta info */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label className="text-xs font-mono font-semibold text-[var(--text-muted)] uppercase tracking-wider">
Type
</label>
<p className="mt-1 font-mono text-sm text-[var(--text-secondary)]">{command.command_type}</p>
</div>
<div>
<label className="text-xs font-mono font-semibold text-[var(--text-muted)] uppercase tracking-wider">
Agent
</label>
<Link
to={`/agents/${command.agent_id}`}
className="mt-1 block font-mono text-sm text-[var(--accent-cyan)] hover:underline"
>
{command.agent_id.slice(0, 12)}...
</Link>
</div>
<div>
<label className="text-xs font-mono font-semibold text-[var(--text-muted)] uppercase tracking-wider">
Exit Code
</label>
<p className={`mt-1 font-mono text-sm ${command.exit_code === 0 ? "text-emerald-400" : command.exit_code !== null ? "text-rose-400" : "text-[var(--text-muted)]"}`}>
{command.exit_code !== null ? command.exit_code : "-"}
</p>
</div>
<div>
<label className="text-xs font-mono font-semibold text-[var(--text-muted)] uppercase tracking-wider">
Executed
</label>
<p className="mt-1 font-mono text-sm text-[var(--text-secondary)]">
{formatRelativeTime(command.created_at)}
</p>
</div>
</div>
{/* Output */}
{command.stdout && (
<div>
<label className="text-xs font-mono font-semibold text-[var(--text-muted)] uppercase tracking-wider">
Output
</label>
<pre className="mt-1 p-3 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-secondary)] font-mono text-xs text-[var(--text-secondary)] overflow-x-auto max-h-64 overflow-y-auto">
{command.stdout}
</pre>
</div>
)}
{/* Error */}
{command.stderr && (
<div>
<label className="text-xs font-mono font-semibold text-rose-400 uppercase tracking-wider">
Error Output
</label>
<pre className="mt-1 p-3 rounded-lg bg-rose-500/5 border border-rose-500/20 font-mono text-xs text-rose-300 overflow-x-auto max-h-64 overflow-y-auto">
{command.stderr}
</pre>
</div>
)}
</CardContent>
</Card>
</div>
);
}