Files
claudetools/projects/msp-tools/guru-rmm/dashboard/src/pages/Agents.tsx
azcomputerguru c332f4f48d 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

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} &rarr; {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>
);
}