- 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>
282 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|