- 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>
438 lines
20 KiB
TypeScript
438 lines
20 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Link, useSearchParams } from "react-router-dom";
|
|
import { Trash2, Terminal, RefreshCw, MoveRight, X } from "lucide-react";
|
|
import { agentsApi, sitesApi, Agent, Site } from "../api/client";
|
|
import { Card, CardContent } from "../components/Card";
|
|
import { Button } from "../components/Button";
|
|
import { Input } from "../components/Input";
|
|
|
|
function AgentStatusBadge({ status }: { status: Agent["status"] }) {
|
|
const statusConfig = {
|
|
online: {
|
|
dotClass: "bg-[var(--accent-green)] shadow-[0_0_8px_var(--accent-green)]",
|
|
badgeClass: "bg-[var(--accent-green-muted)] text-[var(--accent-green-light)] border border-[rgba(16,185,129,0.3)]",
|
|
animate: true,
|
|
},
|
|
offline: {
|
|
dotClass: "bg-[var(--text-muted)]",
|
|
badgeClass: "bg-[rgba(100,116,139,0.2)] text-[var(--text-muted)] border border-[rgba(100,116,139,0.3)]",
|
|
animate: false,
|
|
},
|
|
error: {
|
|
dotClass: "bg-[var(--accent-rose)] shadow-[0_0_8px_var(--accent-rose)]",
|
|
badgeClass: "bg-[var(--accent-rose-muted)] text-[var(--accent-rose-light)] border border-[rgba(244,63,94,0.3)]",
|
|
animate: true,
|
|
},
|
|
};
|
|
|
|
const config = statusConfig[status];
|
|
|
|
return (
|
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full font-mono text-xs font-semibold uppercase tracking-wider ${config.badgeClass}`}>
|
|
<span className={`w-2 h-2 rounded-full ${config.dotClass} ${config.animate ? "animate-pulse" : ""}`} />
|
|
{status}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function MoveAgentModal({
|
|
agent,
|
|
sites,
|
|
onClose,
|
|
onMove,
|
|
isLoading,
|
|
}: {
|
|
agent: Agent;
|
|
sites: Site[];
|
|
onClose: () => void;
|
|
onMove: (siteId: string | null) => void;
|
|
isLoading: boolean;
|
|
}) {
|
|
const [selectedSiteId, setSelectedSiteId] = useState<string>(agent.site_id || "");
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50">
|
|
<div className="glass-card bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-xl p-6 w-full max-w-md shadow-xl">
|
|
<h2 className="text-xl font-mono font-bold text-[var(--text-primary)] mb-2">Move Agent</h2>
|
|
<p className="text-[var(--text-secondary)] mb-6">
|
|
Move <strong className="text-[var(--accent-cyan)]">{agent.hostname}</strong> to a different site
|
|
</p>
|
|
|
|
<div className="mb-6">
|
|
<label className="block text-xs font-mono font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-2">
|
|
Select Site
|
|
</label>
|
|
<select
|
|
value={selectedSiteId}
|
|
onChange={(e) => setSelectedSiteId(e.target.value)}
|
|
className="w-full px-3 py-2.5 rounded-lg border border-[var(--border-primary)] bg-[var(--bg-secondary)] text-[var(--text-primary)] font-mono text-sm focus:border-[var(--accent-cyan)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-cyan-muted)] transition-all"
|
|
>
|
|
<option value="">-- Unassigned --</option>
|
|
{sites.map((site) => (
|
|
<option key={site.id} value={site.id}>
|
|
{site.client_name} → {site.name} ({site.site_code})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-secondary)]">
|
|
<Button type="button" variant="ghost" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => onMove(selectedSiteId || null)}
|
|
disabled={isLoading || selectedSiteId === (agent.site_id || "")}
|
|
className="btn-mission-control"
|
|
>
|
|
{isLoading ? "Moving..." : "Move Agent"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Agents() {
|
|
const [search, setSearch] = useState("");
|
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
|
const [movingAgent, setMovingAgent] = useState<Agent | null>(null);
|
|
const [filterClient, setFilterClient] = useState<string>("");
|
|
const [filterSite, setFilterSite] = useState<string>("");
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const statusFilter = searchParams.get("status") || "";
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data: agents = [], isLoading, refetch } = useQuery({
|
|
queryKey: ["agents"],
|
|
queryFn: () => agentsApi.list().then((res) => res.data),
|
|
refetchInterval: 30000,
|
|
});
|
|
|
|
const { data: sites = [] } = useQuery({
|
|
queryKey: ["sites"],
|
|
queryFn: () => sitesApi.list().then((res) => res.data),
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: string) => agentsApi.delete(id),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["agents"] });
|
|
setDeleteConfirm(null);
|
|
},
|
|
onError: (error: Error) => {
|
|
console.error("Failed to delete agent:", error);
|
|
alert(`Failed to delete agent: ${error.message}`);
|
|
},
|
|
});
|
|
|
|
const moveMutation = useMutation({
|
|
mutationFn: ({ agentId, siteId }: { agentId: string; siteId: string | null }) =>
|
|
agentsApi.move(agentId, siteId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["agents"] });
|
|
queryClient.invalidateQueries({ queryKey: ["sites"] });
|
|
setMovingAgent(null);
|
|
},
|
|
onError: (error: Error) => {
|
|
console.error("Failed to move agent:", error);
|
|
alert(`Failed to move agent: ${error.message}`);
|
|
},
|
|
});
|
|
|
|
// Get unique clients from agents
|
|
const clients = [...new Set(agents.filter((a: Agent) => a.client_name).map((a: Agent) => a.client_name))];
|
|
|
|
// Filter agents
|
|
const filteredAgents = agents.filter((agent: Agent) => {
|
|
const matchesSearch =
|
|
agent.hostname.toLowerCase().includes(search.toLowerCase()) ||
|
|
agent.os_type.toLowerCase().includes(search.toLowerCase()) ||
|
|
(agent.client_name && agent.client_name.toLowerCase().includes(search.toLowerCase())) ||
|
|
(agent.site_name && agent.site_name.toLowerCase().includes(search.toLowerCase()));
|
|
|
|
// Handle special __unassigned__ filter value
|
|
let matchesClient: boolean;
|
|
if (filterClient === "__unassigned__") {
|
|
// Show only agents without a site/client assignment
|
|
matchesClient = !agent.site_id;
|
|
} else if (filterClient) {
|
|
matchesClient = agent.client_name === filterClient;
|
|
} else {
|
|
matchesClient = true;
|
|
}
|
|
|
|
const matchesSite = !filterSite || agent.site_id === filterSite;
|
|
const matchesStatus = !statusFilter || agent.status === statusFilter;
|
|
|
|
return matchesSearch && matchesClient && matchesSite && matchesStatus;
|
|
});
|
|
|
|
const clearStatusFilter = () => {
|
|
searchParams.delete("status");
|
|
setSearchParams(searchParams);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-3xl font-mono font-bold text-[var(--text-primary)]">Agents</h1>
|
|
{statusFilter && (
|
|
<button
|
|
onClick={clearStatusFilter}
|
|
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-mono font-semibold bg-[var(--accent-cyan-muted)] text-[var(--accent-cyan)] border border-[var(--border-accent)] hover:bg-[var(--accent-cyan)] hover:text-[var(--bg-primary)] transition-all"
|
|
>
|
|
Status: {statusFilter}
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p className="text-[var(--text-secondary)] mt-1">
|
|
Manage your monitored endpoints
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => refetch()}
|
|
className="border-[var(--border-accent)] text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan-muted)] hover:shadow-[var(--glow-cyan)] transition-all"
|
|
>
|
|
<RefreshCw className="h-4 w-4 mr-2" />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="glass-card bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-xl p-4">
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<Input
|
|
placeholder="Search agents..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="max-w-sm bg-[var(--bg-secondary)] border-[var(--border-primary)] text-[var(--text-primary)] font-mono placeholder:text-[var(--text-muted)] focus:border-[var(--accent-cyan)] focus:ring-[var(--accent-cyan-muted)]"
|
|
/>
|
|
<select
|
|
value={filterClient}
|
|
onChange={(e) => {
|
|
setFilterClient(e.target.value);
|
|
setFilterSite("");
|
|
}}
|
|
className="px-3 py-2 rounded-lg border border-[var(--border-primary)] bg-[var(--bg-secondary)] text-[var(--text-primary)] font-mono text-sm focus:border-[var(--accent-cyan)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-cyan-muted)] transition-all"
|
|
>
|
|
<option value="">All Clients</option>
|
|
<option value="__unassigned__">Unassigned Only</option>
|
|
{clients.map((client) => (
|
|
<option key={client} value={client || ""}>
|
|
{client}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{filterClient && filterClient !== "__unassigned__" && (
|
|
<select
|
|
value={filterSite}
|
|
onChange={(e) => setFilterSite(e.target.value)}
|
|
className="px-3 py-2 rounded-lg border border-[var(--border-primary)] bg-[var(--bg-secondary)] text-[var(--text-primary)] font-mono text-sm focus:border-[var(--accent-cyan)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-cyan-muted)] transition-all"
|
|
>
|
|
<option value="">All Sites</option>
|
|
{sites
|
|
.filter((s: Site) => s.client_name === filterClient)
|
|
.map((site: Site) => (
|
|
<option key={site.id} value={site.id}>
|
|
{site.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => {
|
|
if (e.target.value) {
|
|
searchParams.set("status", e.target.value);
|
|
} else {
|
|
searchParams.delete("status");
|
|
}
|
|
setSearchParams(searchParams);
|
|
}}
|
|
className="px-3 py-2 rounded-lg border border-[var(--border-primary)] bg-[var(--bg-secondary)] text-[var(--text-primary)] font-mono text-sm focus:border-[var(--accent-cyan)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-cyan-muted)] transition-all"
|
|
>
|
|
<option value="">All Statuses</option>
|
|
<option value="online">Online</option>
|
|
<option value="offline">Offline</option>
|
|
<option value="error">Error</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Flat Table View */}
|
|
{filteredAgents.length > 0 && (
|
|
<Card className="glass-card bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-xl overflow-hidden">
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full data-grid">
|
|
<thead>
|
|
<tr className="border-b border-[var(--border-primary)]">
|
|
<th className="text-left py-2 px-4 font-mono text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider bg-[var(--bg-tertiary)]">Hostname</th>
|
|
<th className="text-left py-2 px-4 font-mono text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider bg-[var(--bg-tertiary)]">Client</th>
|
|
<th className="text-left py-2 px-4 font-mono text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider bg-[var(--bg-tertiary)]">Site</th>
|
|
<th className="text-left py-2 px-4 font-mono text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider bg-[var(--bg-tertiary)]">OS</th>
|
|
<th className="text-left py-2 px-4 font-mono text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider bg-[var(--bg-tertiary)]">Status</th>
|
|
<th className="text-left py-2 px-4 font-mono text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider bg-[var(--bg-tertiary)]">Last Seen</th>
|
|
<th className="text-left py-2 px-4 font-mono text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider bg-[var(--bg-tertiary)]">Version</th>
|
|
<th className="text-right py-2 px-4 font-mono text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider bg-[var(--bg-tertiary)]">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredAgents.map((agent: Agent) => (
|
|
<tr
|
|
key={agent.id}
|
|
className="border-b border-[var(--border-secondary)] hover:bg-[rgba(6,182,212,0.05)] transition-colors"
|
|
>
|
|
<td className="py-2 px-4">
|
|
<Link
|
|
to={`/agents/${agent.id}`}
|
|
className="font-mono font-medium text-[var(--accent-cyan)] hover:text-[var(--accent-cyan-light)] hover:underline transition-colors"
|
|
>
|
|
{agent.hostname}
|
|
</Link>
|
|
</td>
|
|
<td className="py-2 px-4 font-mono text-sm text-[var(--text-secondary)]">
|
|
{agent.client_name || <span className="text-[var(--text-muted)]">-</span>}
|
|
</td>
|
|
<td className="py-2 px-4 font-mono text-sm text-[var(--text-secondary)]">
|
|
{agent.site_name || <span className="text-[var(--text-muted)]">-</span>}
|
|
</td>
|
|
<td className="py-2 px-4 font-mono text-sm text-[var(--text-secondary)]">
|
|
{agent.os_type}
|
|
{agent.os_version && (
|
|
<span className="text-[var(--text-muted)]">
|
|
{" "}({agent.os_version})
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="py-2 px-4">
|
|
<AgentStatusBadge status={agent.status} />
|
|
</td>
|
|
<td className="py-2 px-4 font-mono text-sm text-[var(--text-muted)]">
|
|
{agent.last_seen
|
|
? new Date(agent.last_seen).toLocaleString()
|
|
: "Never"}
|
|
</td>
|
|
<td className="py-2 px-4 font-mono text-sm text-[var(--text-muted)]">
|
|
{agent.agent_version || "-"}
|
|
</td>
|
|
<td className="py-2 px-4">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Move to different site"
|
|
onClick={() => setMovingAgent(agent)}
|
|
className="text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan-muted)] transition-all"
|
|
>
|
|
<MoveRight className="h-4 w-4" />
|
|
</Button>
|
|
<Link to={`/agents/${agent.id}`}>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Open terminal"
|
|
className="text-[var(--text-muted)] hover:text-[var(--accent-green)] hover:bg-[var(--accent-green-muted)] transition-all"
|
|
>
|
|
<Terminal className="h-4 w-4" />
|
|
</Button>
|
|
</Link>
|
|
{deleteConfirm === agent.id ? (
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => deleteMutation.mutate(agent.id)}
|
|
disabled={deleteMutation.isPending}
|
|
className="bg-[var(--accent-rose)] hover:bg-[var(--accent-rose-light)] hover:shadow-[var(--glow-rose)]"
|
|
>
|
|
Confirm
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setDeleteConfirm(null)}
|
|
className="text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Delete agent"
|
|
onClick={() => setDeleteConfirm(agent.id)}
|
|
className="text-[var(--text-muted)] hover:text-[var(--accent-rose)] hover:bg-[var(--accent-rose-muted)] transition-all"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{isLoading && (
|
|
<div className="glass-card bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-xl p-8 text-center">
|
|
<div className="inline-flex items-center gap-3 text-[var(--text-secondary)]">
|
|
<RefreshCw className="h-5 w-5 animate-spin text-[var(--accent-cyan)]" />
|
|
<span className="font-mono">Loading agents...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && filteredAgents.length === 0 && (
|
|
<Card className="glass-card bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-xl">
|
|
<CardContent className="py-12 text-center">
|
|
<p className="text-[var(--text-muted)] font-mono">
|
|
{search || filterClient || statusFilter
|
|
? "No agents match your filters."
|
|
: "No agents registered yet."}
|
|
</p>
|
|
{(search || filterClient || statusFilter) && (
|
|
<button
|
|
onClick={() => {
|
|
setSearch("");
|
|
setFilterClient("");
|
|
setFilterSite("");
|
|
clearStatusFilter();
|
|
}}
|
|
className="mt-4 text-[var(--accent-cyan)] hover:text-[var(--accent-cyan-light)] font-mono text-sm underline transition-colors"
|
|
>
|
|
Clear all filters
|
|
</button>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{movingAgent && (
|
|
<MoveAgentModal
|
|
agent={movingAgent}
|
|
sites={sites}
|
|
onClose={() => setMovingAgent(null)}
|
|
onMove={(siteId) =>
|
|
moveMutation.mutate({ agentId: movingAgent.id, siteId })
|
|
}
|
|
isLoading={moveMutation.isPending}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|