claude revamped the ui
This commit is contained in:
20 files changed
+2029
-770
No files matched your search
+73
-70
@@ -2,7 +2,11 @@ import type { Agent } from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Bot, Inbox } from "lucide-react";
|
||||
import { displayAgentLabel } from "@/components/AgentSelector";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
@@ -23,29 +27,17 @@ export function AgentsPage({
|
||||
onSetAgentNickname,
|
||||
}: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
|
||||
function displayLabel(agent: Agent): string {
|
||||
const nickname = agent.nickname?.trim();
|
||||
return nickname ? nickname : agent.agent_id;
|
||||
}
|
||||
|
||||
async function onRevoke(agentId: string) {
|
||||
if (!window.confirm(`Revoke agent credentials for ${agentId}?`)) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setStatus("");
|
||||
setError("");
|
||||
try {
|
||||
const revoked = await onRevokeAgent(agentId);
|
||||
setStatus(
|
||||
revoked ? `Revoked ${agentId}` : `${agentId} was already absent`,
|
||||
);
|
||||
await onRevokeAgent(agentId);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
toast.error("Revoke failed", { description: String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -54,17 +46,10 @@ export function AgentsPage({
|
||||
async function onSaveNickname(agent: Agent) {
|
||||
const draft = drafts[agent.agent_id] ?? agent.nickname ?? "";
|
||||
setBusy(true);
|
||||
setStatus("");
|
||||
setError("");
|
||||
try {
|
||||
const updated = await onSetAgentNickname(agent.agent_id, draft);
|
||||
setStatus(
|
||||
updated
|
||||
? `Updated nickname for ${agent.agent_id}`
|
||||
: `${agent.agent_id} not found`,
|
||||
);
|
||||
await onSetAgentNickname(agent.agent_id, draft);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
toast.error("Failed to update nickname", { description: String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -73,29 +58,61 @@ export function AgentsPage({
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agents</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bot className="size-5 text-primary" />
|
||||
Agents
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2">
|
||||
{agents.map((agent) => (
|
||||
<div
|
||||
className="flex items-start justify-between gap-3 rounded-md border bg-card px-3 py-2"
|
||||
className={`rounded-lg border px-4 py-3 transition-colors ${
|
||||
selectedAgentId === agent.agent_id
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "bg-card"
|
||||
}`}
|
||||
key={agent.agent_id}
|
||||
>
|
||||
<Button
|
||||
variant={
|
||||
selectedAgentId === agent.agent_id ? "secondary" : "outline"
|
||||
}
|
||||
className="flex h-auto flex-1 items-center justify-between gap-3 px-3 py-2 text-left"
|
||||
onClick={() => onSelectAgent(agent.agent_id)}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="min-w-0 truncate">{displayLabel(agent)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{agent.connected ? "connected" : "offline"}
|
||||
</span>
|
||||
</Button>
|
||||
<div className="grid min-w-52 gap-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 flex-col items-start gap-1 text-left"
|
||||
onClick={() => onSelectAgent(agent.agent_id)}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${agent.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{displayAgentLabel(agent)}
|
||||
</span>
|
||||
<Badge
|
||||
variant={agent.connected ? "secondary" : "outline"}
|
||||
className="text-[0.6rem] px-1.5 py-0"
|
||||
>
|
||||
{agent.connected ? "connected" : "offline"}
|
||||
</Badge>
|
||||
</span>
|
||||
<code className="text-[0.65rem] text-muted-foreground/70 font-mono truncate max-w-full">
|
||||
{agent.agent_id}
|
||||
</code>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => void onRevoke(agent.agent_id)}
|
||||
disabled={busy}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input
|
||||
value={drafts[agent.agent_id] ?? agent.nickname ?? ""}
|
||||
onChange={(e) =>
|
||||
@@ -106,45 +123,31 @@ export function AgentsPage({
|
||||
}
|
||||
placeholder="nickname (optional)"
|
||||
disabled={busy}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void onSaveNickname(agent)}
|
||||
disabled={busy}
|
||||
>
|
||||
Save Name
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => void onRevoke(agent.agent_id)}
|
||||
disabled={busy}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void onSaveNickname(agent)}
|
||||
disabled={busy}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!agents.length && (
|
||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
||||
No agents enrolled
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Inbox className="size-10 text-muted-foreground/30" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
No agents enrolled
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Issue a token and enroll an agent to get started
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{status && (
|
||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 text-xs">
|
||||
{status}
|
||||
</pre>
|
||||
)}
|
||||
{error && (
|
||||
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { Terminal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -11,6 +19,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AgentSelector } from "@/components/AgentSelector";
|
||||
import { runCommand, type Agent, type CommandKind } from "@/api";
|
||||
|
||||
type Props = {
|
||||
@@ -26,11 +35,6 @@ export function CommandsPage({
|
||||
onSelectAgent,
|
||||
onAfterCommand,
|
||||
}: Props) {
|
||||
function displayAgentLabel(agent: Agent): string {
|
||||
const nickname = agent.nickname?.trim();
|
||||
return nickname ? nickname : agent.agent_id;
|
||||
}
|
||||
|
||||
const [kind, setKind] = useState<CommandKind>("devs");
|
||||
const [query, setQuery] = useState("");
|
||||
const [output, setOutput] = useState("Select agent and run a command");
|
||||
@@ -44,9 +48,11 @@ export function CommandsPage({
|
||||
try {
|
||||
const out = await runCommand(selectedAgentId, kind, query.trim());
|
||||
setOutput(JSON.stringify(out, null, 2));
|
||||
toast.success(`Command "${kind}" completed`);
|
||||
if (onAfterCommand) await onAfterCommand();
|
||||
} catch (err) {
|
||||
setOutput(String(err));
|
||||
toast.error("Command failed", { description: String(err) });
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
@@ -55,44 +61,23 @@ export function CommandsPage({
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Command Runner</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Terminal className="size-5 text-primary" />
|
||||
Command Runner
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Debug tool — send raw commands to agents and inspect JSON responses
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="grid gap-3" onSubmit={submit}>
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>Agent</span>
|
||||
<Select
|
||||
<AgentSelector
|
||||
agents={agents}
|
||||
value={selectedAgentId}
|
||||
onValueChange={(value) => {
|
||||
if (value) onSelectAgent(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agents.map((agent) => (
|
||||
<SelectItem
|
||||
key={agent.agent_id}
|
||||
value={agent.agent_id}
|
||||
disabled={!agent.connected}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${agent.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 truncate">
|
||||
{displayAgentLabel(agent)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{agent.connected ? "connected" : "offline"}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
onChange={onSelectAgent}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
|
||||
+253
-20
@@ -1,4 +1,15 @@
|
||||
import type { Agent, Alert, AlertTransition } from "@/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Activity, Bell, Bot, Key, RefreshCw, ArrowRight } from "lucide-react";
|
||||
|
||||
import {
|
||||
type Agent,
|
||||
type Alert,
|
||||
type AlertTransition,
|
||||
type AuditEvent,
|
||||
type EnrollTokenStatus,
|
||||
fetchEnrollTokens,
|
||||
} from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -7,66 +18,288 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
alerts: Alert[];
|
||||
transitions: AlertTransition[];
|
||||
audit: AuditEvent[];
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
function relativeTime(unixMs: number): string {
|
||||
const diff = Date.now() - unixMs;
|
||||
if (diff < 60_000) return "just now";
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
function severityColor(severity: string): string {
|
||||
switch (severity.toLowerCase()) {
|
||||
case "critical":
|
||||
return "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/30";
|
||||
case "warning":
|
||||
return "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30";
|
||||
case "info":
|
||||
return "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function eventIcon(eventType: string): string {
|
||||
if (eventType.includes("wake")) return "⚡";
|
||||
if (eventType.includes("enroll")) return "🔑";
|
||||
if (eventType.includes("command")) return "▶";
|
||||
if (eventType.includes("connect")) return "🔗";
|
||||
if (eventType.includes("disconnect")) return "⛓️💥";
|
||||
return "•";
|
||||
}
|
||||
|
||||
export function DashboardPage({
|
||||
agents,
|
||||
alerts,
|
||||
transitions,
|
||||
audit,
|
||||
loading,
|
||||
onRefresh,
|
||||
}: Props) {
|
||||
const connected = agents.filter((a) => a.connected).length;
|
||||
const total = agents.length;
|
||||
|
||||
const severityBuckets = alerts.reduce<Record<string, number>>((acc, a) => {
|
||||
acc[a.severity] = (acc[a.severity] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const [tokens, setTokens] = useState<EnrollTokenStatus[]>([]);
|
||||
useEffect(() => {
|
||||
void fetchEnrollTokens()
|
||||
.then(setTokens)
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
const activeTokens = tokens.filter((t) => !t.expired).length;
|
||||
|
||||
const recentAudit = audit.slice(0, 8);
|
||||
|
||||
if (loading && !agents.length) {
|
||||
return (
|
||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="mt-2 h-8 w-12" />
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
<Card className="sm:col-span-2 lg:col-span-4">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Fleet Status */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Agents</CardDescription>
|
||||
<CardTitle className="text-2xl">{agents.length}</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardDescription className="flex items-center gap-1.5">
|
||||
<Bot className="size-3.5" />
|
||||
Fleet Status
|
||||
</CardDescription>
|
||||
<Link
|
||||
to="/agents"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">
|
||||
{connected}
|
||||
<span className="text-base font-normal text-muted-foreground">
|
||||
/{total}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{connected === total
|
||||
? "All agents connected"
|
||||
: `${total - connected} offline`}
|
||||
</p>
|
||||
{total > 0 && (
|
||||
<div className="mt-2 flex h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${(connected / total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Alerts */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Connected</CardDescription>
|
||||
<CardTitle className="text-2xl">{connected}</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardDescription className="flex items-center gap-1.5">
|
||||
<Bell className="size-3.5" />
|
||||
Active Alerts
|
||||
</CardDescription>
|
||||
<Link
|
||||
to="/alerts"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{alerts.length}</div>
|
||||
{Object.keys(severityBuckets).length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{Object.entries(severityBuckets)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([sev, count]) => (
|
||||
<Badge
|
||||
key={sev}
|
||||
variant="outline"
|
||||
className={`text-[0.65rem] ${severityColor(sev)}`}
|
||||
>
|
||||
{count} {sev}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-muted-foreground">All clear</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transitions */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Active Alerts</CardDescription>
|
||||
<CardTitle className="text-2xl">{alerts.length}</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardDescription className="flex items-center gap-1.5">
|
||||
<Activity className="size-3.5" />
|
||||
Transitions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">
|
||||
{transitions.length}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Recent state changes
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Token Health */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Transitions</CardDescription>
|
||||
<CardTitle className="text-2xl">{transitions.length}</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardDescription className="flex items-center gap-1.5">
|
||||
<Key className="size-3.5" />
|
||||
Enroll Tokens
|
||||
</CardDescription>
|
||||
<Link
|
||||
to="/tokens"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{activeTokens}</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{activeTokens === 1 ? "active token" : "active tokens"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity Feed */}
|
||||
<Card className="sm:col-span-2 lg:col-span-4">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<CardTitle>Overview</CardTitle>
|
||||
<div>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Latest audit events across the fleet
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{loading ? "Refreshing..." : "Refresh"}
|
||||
<RefreshCw
|
||||
className={`size-3.5 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use tabs to run commands, inspect audits, and monitor alerts in real
|
||||
time.
|
||||
</p>
|
||||
{recentAudit.length > 0 ? (
|
||||
<div className="grid gap-0">
|
||||
{recentAudit.map((event, i) => (
|
||||
<div
|
||||
key={event.event_id}
|
||||
className={`flex items-start gap-3 py-3 ${
|
||||
i < recentAudit.length - 1
|
||||
? "border-b border-border/50"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-sm"
|
||||
aria-hidden
|
||||
>
|
||||
{eventIcon(event.event_type)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{event.event_type.replace(/_/g, " ")}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
event.outcome === "success"
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
}
|
||||
className="text-[0.6rem] px-1.5 py-0"
|
||||
>
|
||||
{event.outcome}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{event.message}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{relativeTime(event.ts_unix * 1000)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Activity className="size-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No recent activity
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
+68
-429
@@ -1,37 +1,26 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Inbox,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
attachDeviceIdentifier,
|
||||
createKnownDevice,
|
||||
fetchFleetDevices,
|
||||
fetchKnownDevices,
|
||||
mergeKnownDevice,
|
||||
refreshFleetDevices,
|
||||
wakeFleetDevice,
|
||||
type Agent,
|
||||
type FleetDevice,
|
||||
type FleetWakeRoute,
|
||||
type KnownDevice,
|
||||
} from "@/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -45,7 +34,24 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
FilterSelect,
|
||||
MobileLabel,
|
||||
PresenceBadge,
|
||||
} from "@/pages/fleet/FleetComponents";
|
||||
import { FleetDeviceDetailsDialog } from "@/pages/fleet/FleetDeviceDetailsDialog";
|
||||
import {
|
||||
agentLabel,
|
||||
formatSeen,
|
||||
knownFilters,
|
||||
presenceFilters,
|
||||
presenceRank,
|
||||
summarize,
|
||||
type KnownFilter,
|
||||
type SortDir,
|
||||
type SortKey,
|
||||
} from "@/pages/fleet/utils";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
@@ -54,20 +60,6 @@ type Props = {
|
||||
onAfterWake?: () => Promise<void>;
|
||||
};
|
||||
|
||||
type SortKey = "name" | "presence" | "last_seen" | "agents" | "ip" | "mac";
|
||||
type SortDir = "asc" | "desc";
|
||||
type KnownFilter = "all" | "known" | "unknown";
|
||||
|
||||
const presenceFilters = [
|
||||
"all",
|
||||
"online",
|
||||
"likely_online",
|
||||
"unknown",
|
||||
"offline",
|
||||
] as const;
|
||||
|
||||
const knownFilters: KnownFilter[] = ["all", "known", "unknown"];
|
||||
|
||||
export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
const [devices, setDevices] = useState<FleetDevice[]>([]);
|
||||
const [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]);
|
||||
@@ -117,14 +109,15 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
try {
|
||||
const result = await refreshFleetDevices();
|
||||
const failed = result.agents.filter((agent) => agent.status !== "ok");
|
||||
setStatus(
|
||||
failed.length
|
||||
? `Stored ${result.total_accepted} observations; ${failed.length} agent refresh failed`
|
||||
: `Stored ${result.total_accepted} observations from ${result.agents.length} connected agents`,
|
||||
);
|
||||
const msg = failed.length
|
||||
? `Stored ${result.total_accepted} observations; ${failed.length} agent refresh failed`
|
||||
: `Stored ${result.total_accepted} observations from ${result.agents.length} connected agents`;
|
||||
setStatus(msg);
|
||||
toast.success(msg);
|
||||
await loadFleet();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
toast.error("Fleet refresh failed", { description: String(err) });
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
@@ -140,13 +133,14 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
deviceKey: device.device_key,
|
||||
routeId,
|
||||
});
|
||||
setStatus(
|
||||
`Wake sent via ${agentLabel(result.route.agent_id, result.route.nickname)} (${result.route.mac ?? "no mac"})`,
|
||||
);
|
||||
const msg = `Wake sent via ${agentLabel(result.route.agent_id, result.route.nickname)} (${result.route.mac ?? "no mac"})`;
|
||||
toast.success(msg);
|
||||
setStatus(msg);
|
||||
if (onAfterWake) await onAfterWake();
|
||||
await loadFleet();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
toast.error("Wake failed", { description: String(err) });
|
||||
} finally {
|
||||
setWakeBusyKey("");
|
||||
}
|
||||
@@ -158,6 +152,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(label);
|
||||
toast.success(`Copied ${label}`);
|
||||
} catch {
|
||||
const area = document.createElement("textarea");
|
||||
area.value = text;
|
||||
@@ -168,6 +163,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(area);
|
||||
setCopied(label);
|
||||
toast.success(`Copied ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,32 +333,44 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="hidden grid-cols-[minmax(10rem,1.3fr)_8rem_minmax(9rem,1fr)_minmax(10rem,1fr)_minmax(10rem,1fr)_8rem_8rem] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm font-medium xl:grid">
|
||||
<span>Device</span>
|
||||
<span>Presence</span>
|
||||
<span>IPs</span>
|
||||
<span>MACs</span>
|
||||
<span>Agents</span>
|
||||
<span>Last seen</span>
|
||||
<span className="text-right">Actions</span>
|
||||
{/* Skeleton loading */}
|
||||
{loading && !devices.length ? (
|
||||
<div className="grid gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
{sortedDevices.map((device) => (
|
||||
<FleetDeviceRow
|
||||
key={device.device_key}
|
||||
device={device}
|
||||
busy={wakeBusyKey === device.device_key}
|
||||
onWake={() => void wakeDevice(device)}
|
||||
onDetails={() => setDetails(device)}
|
||||
onCopy={(label, value) => void copyValue(label, value)}
|
||||
/>
|
||||
))}
|
||||
{!sortedDevices.length && (
|
||||
<div className="rounded-md border bg-card p-4 text-sm text-muted-foreground">
|
||||
No devices match the current filters.
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
<div className="hidden grid-cols-[minmax(10rem,1.3fr)_8rem_minmax(9rem,1fr)_minmax(10rem,1fr)_minmax(10rem,1fr)_8rem_8rem] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm font-medium xl:grid">
|
||||
<span>Device</span>
|
||||
<span>Presence</span>
|
||||
<span>IPs</span>
|
||||
<span>MACs</span>
|
||||
<span>Agents</span>
|
||||
<span>Last seen</span>
|
||||
<span className="text-right">Actions</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{sortedDevices.map((device) => (
|
||||
<FleetDeviceRow
|
||||
key={device.device_key}
|
||||
device={device}
|
||||
busy={wakeBusyKey === device.device_key}
|
||||
onWake={() => void wakeDevice(device)}
|
||||
onDetails={() => setDetails(device)}
|
||||
onCopy={(label, value) => void copyValue(label, value)}
|
||||
/>
|
||||
))}
|
||||
{!sortedDevices.length && (
|
||||
<div className="flex flex-col items-center justify-center rounded-md border bg-card py-12 text-center">
|
||||
<Inbox className="size-10 text-muted-foreground/30" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
No devices match the current filters
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -471,372 +479,3 @@ function FleetDeviceRow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FleetDeviceDetailsDialog({
|
||||
device,
|
||||
knownDevices,
|
||||
open,
|
||||
busy,
|
||||
onOpenChange,
|
||||
onWake,
|
||||
onCopy,
|
||||
onChanged,
|
||||
}: {
|
||||
device: FleetDevice | null;
|
||||
knownDevices: KnownDevice[];
|
||||
open: boolean;
|
||||
busy: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onWake: (routeId?: string | null) => void;
|
||||
onCopy: (label: string, value: string) => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [targetDeviceId, setTargetDeviceId] = useState("");
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(device?.display_name ?? "");
|
||||
setTargetDeviceId("");
|
||||
setRouteId(device?.recommended_route?.route_id ?? "");
|
||||
setError("");
|
||||
}, [device]);
|
||||
|
||||
if (!device) return null;
|
||||
|
||||
const currentDevice = device;
|
||||
const identifiers = identifiersFor(currentDevice);
|
||||
const otherKnownDevices = knownDevices.filter(
|
||||
(known) => known.device_id !== currentDevice.known_device?.device_id,
|
||||
);
|
||||
|
||||
async function createRemembered() {
|
||||
setError("");
|
||||
try {
|
||||
await createKnownDevice({
|
||||
display_name: displayName.trim() || currentDevice.display_name,
|
||||
pinned: true,
|
||||
identifiers,
|
||||
});
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function attachToExisting() {
|
||||
if (!targetDeviceId) return;
|
||||
setError("");
|
||||
try {
|
||||
for (const identifier of identifiers) {
|
||||
await attachDeviceIdentifier(targetDeviceId, identifier);
|
||||
}
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function mergeIntoExisting() {
|
||||
if (!targetDeviceId || !currentDevice.known_device) return;
|
||||
setError("");
|
||||
try {
|
||||
await mergeKnownDevice(
|
||||
targetDeviceId,
|
||||
currentDevice.known_device.device_id,
|
||||
);
|
||||
onChanged();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
const selectedRoute =
|
||||
device.route_candidates.find((route) => route.route_id === routeId) ??
|
||||
device.recommended_route;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[92vh] overflow-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="truncate">
|
||||
{device.display_name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{device.known_device
|
||||
? "Remembered fleet device"
|
||||
: "Observed fleet device"}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<PresenceBadge presence={device.presence} />
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{error && (
|
||||
<pre className="max-h-40 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<DetailBlock label="IPs" values={device.ips} onCopy={onCopy} />
|
||||
<DetailBlock label="MACs" values={device.macs} onCopy={onCopy} />
|
||||
<DetailBlock
|
||||
label="Hostnames"
|
||||
values={device.hostnames}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
<DetailBlock
|
||||
label="Agents"
|
||||
values={device.agents.map((agent) =>
|
||||
agentLabel(agent.agent_id, agent.nickname),
|
||||
)}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">Wake route</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onWake(selectedRoute?.route_id ?? null)}
|
||||
disabled={busy || !selectedRoute?.wakeable}
|
||||
>
|
||||
<Zap className="size-4" aria-hidden />
|
||||
{busy ? "Waking" : "Wake"}
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
value={routeId}
|
||||
onValueChange={(value) => setRouteId(value ?? "")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{selectedRoute
|
||||
? routeLabel(selectedRoute)
|
||||
: "No connected MAC-backed route"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{device.route_candidates.map((route) => (
|
||||
<SelectItem key={route.route_id} value={route.route_id}>
|
||||
{routeLabel(route)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-3">
|
||||
<h3 className="text-sm font-medium">Remember / merge</h3>
|
||||
{!device.known_device && (
|
||||
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<Input
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder="Device name"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void createRemembered()}
|
||||
disabled={!identifiers.length}
|
||||
>
|
||||
Remember device
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto_auto]">
|
||||
<Select
|
||||
value={targetDeviceId}
|
||||
onValueChange={(value) => setTargetDeviceId(value ?? "")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{targetDeviceId
|
||||
? knownDevices.find(
|
||||
(known) => known.device_id === targetDeviceId,
|
||||
)?.display_name
|
||||
: "Select known device"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
className="max-w-[min(92vw,30rem)]"
|
||||
alignItemWithTrigger={false}
|
||||
>
|
||||
{otherKnownDevices.map((known) => (
|
||||
<SelectItem key={known.device_id} value={known.device_id}>
|
||||
{known.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void attachToExisting()}
|
||||
disabled={!targetDeviceId || !identifiers.length}
|
||||
>
|
||||
Attach IDs
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void mergeIntoExisting()}
|
||||
disabled={!targetDeviceId || !device.known_device}
|
||||
>
|
||||
Merge
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Proposed identifiers:{" "}
|
||||
{identifiers.map((id) => `${id.kind}:${id.value}`).join(", ") ||
|
||||
"-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSelect({
|
||||
label,
|
||||
value,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
values: readonly string[];
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<Select value={value} onValueChange={(next) => next && onChange(next)}>
|
||||
<SelectTrigger>
|
||||
<span>{value.replace("_", " ")}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{values.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item.replace("_", " ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailBlock({
|
||||
label,
|
||||
values,
|
||||
onCopy,
|
||||
}: {
|
||||
label: string;
|
||||
values: string[];
|
||||
onCopy: (label: string, value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<div className="mb-2 text-xs font-medium uppercase text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
{values.length ? (
|
||||
values.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className="flex min-w-0 items-center justify-between gap-2 rounded px-1 py-0.5 text-left hover:bg-accent"
|
||||
onClick={() => onCopy(label, value)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{value}</span>
|
||||
<Copy
|
||||
className="size-3.5 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PresenceBadge({ presence }: { presence: string }) {
|
||||
return (
|
||||
<Badge variant={presence === "offline" ? "outline" : "secondary"}>
|
||||
{presence.replace("_", " ")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileLabel({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="mb-1 block text-xs font-medium text-muted-foreground xl:hidden">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function summarize(values: string[]): string {
|
||||
if (!values.length) return "-";
|
||||
if (values.length <= 2) return values.join(", ");
|
||||
return `${values[0]}, ${values[1]} (+${values.length - 2})`;
|
||||
}
|
||||
|
||||
function formatSeen(value: number | null): string {
|
||||
if (!value) return "-";
|
||||
const seconds = Math.max(0, Math.floor(Date.now() / 1000) - value);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 48) return `${hours}h ago`;
|
||||
return new Date(value * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function presenceRank(presence: string): number {
|
||||
if (presence === "online") return 4;
|
||||
if (presence === "likely_online") return 3;
|
||||
if (presence === "unknown") return 2;
|
||||
if (presence === "offline") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function identifiersFor(
|
||||
device: FleetDevice,
|
||||
): { kind: string; value: string }[] {
|
||||
return [
|
||||
...device.macs.map((value) => ({ kind: "mac", value })),
|
||||
...device.ips.map((value) => ({ kind: "ip", value })),
|
||||
];
|
||||
}
|
||||
|
||||
function agentLabel(agentId: string, nickname?: string | null): string {
|
||||
const trimmed = nickname?.trim();
|
||||
return trimmed ? trimmed : agentId;
|
||||
}
|
||||
|
||||
function routeLabel(route: FleetWakeRoute): string {
|
||||
const status = route.connected ? "connected" : "offline";
|
||||
const target = route.mac
|
||||
? `${route.mac}${route.ip ? ` / ${route.ip}` : ""}`
|
||||
: (route.ip ?? "-");
|
||||
return `${agentLabel(route.agent_id, route.nickname)} · ${target} · ${route.source} · ${status}`;
|
||||
}
|
||||
+170
-57
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Key, Copy, Inbox } from "lucide-react";
|
||||
|
||||
import {
|
||||
type EnrollTokenStatus,
|
||||
@@ -8,19 +10,36 @@ import {
|
||||
} from "@/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
function formatUnix(unix: number): string {
|
||||
return new Date(unix * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
const TTL_PRESETS = [
|
||||
{ label: "1 hour", seconds: 3600 },
|
||||
{ label: "12 hours", seconds: 43200 },
|
||||
{ label: "1 day", seconds: 86400 },
|
||||
{ label: "7 days", seconds: 604800 },
|
||||
] as const;
|
||||
|
||||
export function TokensPage() {
|
||||
const [tokens, setTokens] = useState<EnrollTokenStatus[]>([]);
|
||||
const [ttlSeconds, setTtlSeconds] = useState("86400");
|
||||
const [status, setStatus] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [ttlSeconds, setTtlSeconds] = useState(86400);
|
||||
const [customTtl, setCustomTtl] = useState("");
|
||||
const [useCustom, setUseCustom] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [lastIssued, setLastIssued] = useState<{
|
||||
token: string;
|
||||
expires: string;
|
||||
} | null>(null);
|
||||
|
||||
const activeCount = useMemo(
|
||||
() => tokens.filter((t) => !t.expired).length,
|
||||
@@ -29,12 +48,11 @@ export function TokensPage() {
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const next = await fetchEnrollTokens();
|
||||
setTokens(next);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
toast.error("Failed to load tokens", { description: String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -42,17 +60,23 @@ export function TokensPage() {
|
||||
|
||||
async function onIssue() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setStatus("");
|
||||
try {
|
||||
const ttl = Number.parseInt(ttlSeconds, 10);
|
||||
const issued = await issueEnrollToken(Number.isFinite(ttl) ? ttl : 86400);
|
||||
setStatus(
|
||||
`Issued ${issued.enroll_token} (expires ${formatUnix(issued.expires_at_unix)})`,
|
||||
const effectiveTtl = useCustom
|
||||
? Number.parseInt(customTtl, 10) || 86400
|
||||
: ttlSeconds;
|
||||
const issued = await issueEnrollToken(
|
||||
Number.isFinite(effectiveTtl) ? effectiveTtl : 86400,
|
||||
);
|
||||
setLastIssued({
|
||||
token: issued.enroll_token,
|
||||
expires: formatUnix(issued.expires_at_unix),
|
||||
});
|
||||
toast.success("Token issued", {
|
||||
description: `Expires ${formatUnix(issued.expires_at_unix)}`,
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
toast.error("Failed to issue token", { description: String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -60,30 +84,50 @@ export function TokensPage() {
|
||||
|
||||
async function onRevoke(token: string) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setStatus("");
|
||||
try {
|
||||
const result = await revokeEnrollToken(token);
|
||||
setStatus(
|
||||
result.revoked ? `Revoked ${token}` : `${token} was already absent`,
|
||||
);
|
||||
if (result.revoked) {
|
||||
toast.success(`Revoked ${token}`);
|
||||
} else {
|
||||
toast.info(`${token} was already absent`);
|
||||
}
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
toast.error("Failed to revoke token", { description: String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success("Copied to clipboard");
|
||||
} catch {
|
||||
toast.error("Copy failed");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const controlPlaneUrl =
|
||||
window.location.origin.replace(/:\d+$/, "") || "https://cp.example.com";
|
||||
|
||||
return (
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
<section className="grid gap-4 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<CardTitle>Enroll Tokens</CardTitle>
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="size-5 text-primary" />
|
||||
Enroll Tokens
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
{activeCount} active, {tokens.length} shown
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -94,17 +138,16 @@ export function TokensPage() {
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeCount} active, {tokens.length} shown
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
{tokens.map((token) => (
|
||||
<div
|
||||
className="flex items-start justify-between gap-3 rounded-md border bg-card px-3 py-2"
|
||||
key={token.enroll_token}
|
||||
>
|
||||
<div>
|
||||
<strong>{token.enroll_token}</strong>
|
||||
<div className="min-w-0">
|
||||
<strong className="block truncate text-sm">
|
||||
{token.enroll_token}
|
||||
</strong>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
expires: {formatUnix(token.expires_at_unix)}
|
||||
</div>
|
||||
@@ -125,43 +168,113 @@ export function TokensPage() {
|
||||
</div>
|
||||
))}
|
||||
{!tokens.length && (
|
||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
||||
No tokens found
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Inbox className="size-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No tokens found
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Issue Token</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>TTL seconds</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={ttlSeconds}
|
||||
onChange={(e) => setTtlSeconds(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button onClick={() => void onIssue()} disabled={busy}>
|
||||
Issue
|
||||
</Button>
|
||||
{status && (
|
||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 text-xs">
|
||||
{status}
|
||||
</pre>
|
||||
)}
|
||||
{error && (
|
||||
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-4 content-start">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Issue Token</CardTitle>
|
||||
<CardDescription>
|
||||
Create a new enrollment token for agents
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
Token lifetime
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TTL_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.seconds}
|
||||
variant={
|
||||
!useCustom && ttlSeconds === preset.seconds
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTtlSeconds(preset.seconds);
|
||||
setUseCustom(false);
|
||||
}}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant={useCustom ? "secondary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setUseCustom(true)}
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
</div>
|
||||
{useCustom && (
|
||||
<label className="mt-2 grid gap-1 text-sm text-muted-foreground">
|
||||
<span>TTL (seconds)</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={customTtl}
|
||||
onChange={(e) => setCustomTtl(e.target.value)}
|
||||
placeholder="86400"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={() => void onIssue()} disabled={busy}>
|
||||
Issue Token
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Install command after issuing */}
|
||||
{lastIssued && (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Agent Installation</CardTitle>
|
||||
<CardDescription>
|
||||
Token: <code className="text-xs">{lastIssued.token}</code>
|
||||
<br />
|
||||
Expires: {lastIssued.expires}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run this on the target device to enroll:
|
||||
</p>
|
||||
<div className="relative">
|
||||
<pre className="overflow-auto rounded-md border bg-muted/60 p-3 pr-10 font-mono text-xs">
|
||||
{`wakey-agent enroll \\
|
||||
--server-url ${controlPlaneUrl} \\
|
||||
--token ${lastIssued.token}`}
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1.5 top-1.5 size-7 p-0"
|
||||
onClick={() =>
|
||||
void copyToClipboard(
|
||||
`wakey-agent enroll --server-url ${controlPlaneUrl} --token ${lastIssued.token}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Zap, Inbox } from "lucide-react";
|
||||
|
||||
import { runCommand, type Agent } from "@/api";
|
||||
import { AgentSelector } from "@/components/AgentSelector";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type WakeEvent = {
|
||||
ts: number;
|
||||
target: string;
|
||||
agentId: string;
|
||||
outcome: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
selectedAgentId: string;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onAfterWake?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function WakeToolsPage({
|
||||
agents,
|
||||
selectedAgentId,
|
||||
onSelectAgent,
|
||||
onAfterWake,
|
||||
}: Props) {
|
||||
const [target, setTarget] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [recentWakes, setRecentWakes] = useState<WakeEvent[]>([]);
|
||||
|
||||
async function handleWake(e?: React.FormEvent) {
|
||||
e?.preventDefault();
|
||||
const trimmed = target.trim();
|
||||
if (!trimmed || !selectedAgentId || busy) return;
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await runCommand(selectedAgentId, "wake", trimmed);
|
||||
const detail = JSON.stringify(response);
|
||||
const ok = detail.includes('"ok"') || detail.includes('"success"');
|
||||
setRecentWakes((prev) =>
|
||||
[
|
||||
{
|
||||
ts: Date.now(),
|
||||
target: trimmed,
|
||||
agentId: selectedAgentId,
|
||||
outcome: ok ? "ok" : "fail",
|
||||
detail,
|
||||
},
|
||||
...prev,
|
||||
].slice(0, 20),
|
||||
);
|
||||
if (ok) {
|
||||
toast.success(`Woke ${trimmed}`);
|
||||
} else {
|
||||
toast.error(`Wake failed: ${trimmed}`, { description: detail });
|
||||
}
|
||||
if (onAfterWake) await onAfterWake();
|
||||
setTarget("");
|
||||
} catch (err) {
|
||||
setRecentWakes((prev) =>
|
||||
[
|
||||
{
|
||||
ts: Date.now(),
|
||||
target: trimmed,
|
||||
agentId: selectedAgentId,
|
||||
outcome: "error",
|
||||
detail: String(err),
|
||||
},
|
||||
...prev,
|
||||
].slice(0, 20),
|
||||
);
|
||||
toast.error("Wake failed", { description: String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(20rem,1fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Zap className="size-5 text-primary" />
|
||||
Wake Target
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Send a Wake-on-LAN packet by hostname, IP, or MAC address
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<label className="grid gap-1.5 text-sm text-muted-foreground">
|
||||
<span>Agent</span>
|
||||
<AgentSelector
|
||||
agents={agents}
|
||||
value={selectedAgentId}
|
||||
onChange={onSelectAgent}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<form onSubmit={handleWake} className="grid gap-3">
|
||||
<label className="grid gap-1.5 text-sm text-muted-foreground">
|
||||
<span>Target</span>
|
||||
<Input
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
placeholder="bedroom-pc, 192.168.1.100, or aa:bb:cc:dd:ee:ff"
|
||||
disabled={busy}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!selectedAgentId || !target.trim() || busy}
|
||||
className="gap-2"
|
||||
>
|
||||
<Zap className="size-4" />
|
||||
{busy ? "Sending..." : "Wake"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle>Recent Wakes</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRecentWakes([])}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentWakes.length > 0 ? (
|
||||
<div className="grid gap-0">
|
||||
{recentWakes.map((event, idx) => (
|
||||
<div
|
||||
className={`flex items-start justify-between gap-3 py-2.5 ${idx < recentWakes.length - 1 ? "border-b border-border/50" : ""}`}
|
||||
key={`${event.ts}-${idx}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{event.target}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
event.outcome === "ok" ? "secondary" : "destructive"
|
||||
}
|
||||
className="text-[0.6rem] px-1.5 py-0"
|
||||
>
|
||||
{event.outcome}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-[0.65rem] text-muted-foreground/70">
|
||||
{new Date(event.ts).toLocaleString()} via {event.agentId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Inbox className="size-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No wake actions yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -139,7 +139,16 @@ export function DeviceInventoryTable({
|
||||
{summarize(row.macs)}
|
||||
</span>
|
||||
<span className="device-cell" data-label="Presence">
|
||||
<Badge variant="outline">{row.presence}</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`presence-badge--${row.presence}`}
|
||||
>
|
||||
<span
|
||||
className={`presence-dot presence-dot--${row.presence}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{row.presence.replace("_", " ")}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="device-cell device-action" data-label="">
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export function PresenceBadge({ presence }: { presence: string }) {
|
||||
return (
|
||||
<Badge variant="outline" className={`presence-badge--${presence}`}>
|
||||
<span className={`presence-dot presence-dot--${presence}`} aria-hidden />
|
||||
{presence.replace("_", " ")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileLabel({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="mb-1 block text-xs font-medium text-muted-foreground xl:hidden">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterSelect({
|
||||
label,
|
||||
value,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
values: readonly string[];
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<Select value={value} onValueChange={(next) => next && onChange(next)}>
|
||||
<SelectTrigger>
|
||||
<span>{value.replace("_", " ")}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{values.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item.replace("_", " ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailBlock({
|
||||
label,
|
||||
values,
|
||||
onCopy,
|
||||
}: {
|
||||
label: string;
|
||||
values: string[];
|
||||
onCopy: (label: string, value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<div className="mb-2 text-xs font-medium uppercase text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
{values.length ? (
|
||||
values.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className="flex min-w-0 items-center justify-between gap-2 rounded px-1 py-0.5 text-left hover:bg-accent"
|
||||
onClick={() => onCopy(label, value)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{value}</span>
|
||||
<CopyIcon />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-3.5 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Zap } from "lucide-react";
|
||||
|
||||
import {
|
||||
attachDeviceIdentifier,
|
||||
createKnownDevice,
|
||||
mergeKnownDevice,
|
||||
type FleetDevice,
|
||||
type KnownDevice,
|
||||
} from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DetailBlock, PresenceBadge } from "@/pages/fleet/FleetComponents";
|
||||
import { agentLabel, identifiersFor, routeLabel } from "@/pages/fleet/utils";
|
||||
|
||||
type Props = {
|
||||
device: FleetDevice | null;
|
||||
knownDevices: KnownDevice[];
|
||||
open: boolean;
|
||||
busy: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onWake: (routeId?: string | null) => void;
|
||||
onCopy: (label: string, value: string) => void;
|
||||
onChanged: () => void;
|
||||
};
|
||||
|
||||
export function FleetDeviceDetailsDialog({
|
||||
device,
|
||||
knownDevices,
|
||||
open,
|
||||
busy,
|
||||
onOpenChange,
|
||||
onWake,
|
||||
onCopy,
|
||||
onChanged,
|
||||
}: Props) {
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [targetDeviceId, setTargetDeviceId] = useState("");
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(device?.display_name ?? "");
|
||||
setTargetDeviceId("");
|
||||
setRouteId(device?.recommended_route?.route_id ?? "");
|
||||
setError("");
|
||||
}, [device]);
|
||||
|
||||
if (!device) return null;
|
||||
|
||||
const currentDevice = device;
|
||||
const identifiers = identifiersFor(currentDevice);
|
||||
const otherKnownDevices = knownDevices.filter(
|
||||
(known) => known.device_id !== currentDevice.known_device?.device_id,
|
||||
);
|
||||
|
||||
async function createRemembered() {
|
||||
setError("");
|
||||
try {
|
||||
await createKnownDevice({
|
||||
display_name: displayName.trim() || currentDevice.display_name,
|
||||
pinned: true,
|
||||
identifiers,
|
||||
});
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function attachToExisting() {
|
||||
if (!targetDeviceId) return;
|
||||
setError("");
|
||||
try {
|
||||
for (const identifier of identifiers) {
|
||||
await attachDeviceIdentifier(targetDeviceId, identifier);
|
||||
}
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function mergeIntoExisting() {
|
||||
if (!targetDeviceId || !currentDevice.known_device) return;
|
||||
setError("");
|
||||
try {
|
||||
await mergeKnownDevice(
|
||||
targetDeviceId,
|
||||
currentDevice.known_device.device_id,
|
||||
);
|
||||
onChanged();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
const selectedRoute =
|
||||
device.route_candidates.find((route) => route.route_id === routeId) ??
|
||||
device.recommended_route;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[92vh] overflow-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="truncate">
|
||||
{device.display_name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{device.known_device
|
||||
? "Remembered fleet device"
|
||||
: "Observed fleet device"}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<PresenceBadge presence={device.presence} />
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{error && (
|
||||
<pre className="max-h-40 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<DetailBlock label="IPs" values={device.ips} onCopy={onCopy} />
|
||||
<DetailBlock label="MACs" values={device.macs} onCopy={onCopy} />
|
||||
<DetailBlock
|
||||
label="Hostnames"
|
||||
values={device.hostnames}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
<DetailBlock
|
||||
label="Agents"
|
||||
values={device.agents.map((agent) =>
|
||||
agentLabel(agent.agent_id, agent.nickname),
|
||||
)}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">Wake route</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onWake(selectedRoute?.route_id ?? null)}
|
||||
disabled={busy || !selectedRoute?.wakeable}
|
||||
>
|
||||
<Zap className="size-4" aria-hidden />
|
||||
{busy ? "Waking" : "Wake"}
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
value={routeId}
|
||||
onValueChange={(value) => setRouteId(value ?? "")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{selectedRoute
|
||||
? routeLabel(selectedRoute)
|
||||
: "No connected MAC-backed route"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{device.route_candidates.map((route) => (
|
||||
<SelectItem key={route.route_id} value={route.route_id}>
|
||||
{routeLabel(route)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-3">
|
||||
<h3 className="text-sm font-medium">Remember / merge</h3>
|
||||
{!device.known_device && (
|
||||
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<Input
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder="Device name"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void createRemembered()}
|
||||
disabled={!identifiers.length}
|
||||
>
|
||||
Remember device
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto_auto]">
|
||||
<Select
|
||||
value={targetDeviceId}
|
||||
onValueChange={(value) => setTargetDeviceId(value ?? "")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{targetDeviceId
|
||||
? knownDevices.find(
|
||||
(known) => known.device_id === targetDeviceId,
|
||||
)?.display_name
|
||||
: "Select known device"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
className="max-w-[min(92vw,30rem)]"
|
||||
alignItemWithTrigger={false}
|
||||
>
|
||||
{otherKnownDevices.map((known) => (
|
||||
<SelectItem key={known.device_id} value={known.device_id}>
|
||||
{known.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void attachToExisting()}
|
||||
disabled={!targetDeviceId || !identifiers.length}
|
||||
>
|
||||
Attach IDs
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void mergeIntoExisting()}
|
||||
disabled={!targetDeviceId || !device.known_device}
|
||||
>
|
||||
Merge
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Proposed identifiers:{" "}
|
||||
{identifiers.map((id) => `${id.kind}:${id.value}`).join(", ") ||
|
||||
"-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { FleetDevice, FleetWakeRoute } from "@/api";
|
||||
|
||||
export type SortKey =
|
||||
| "name"
|
||||
| "presence"
|
||||
| "last_seen"
|
||||
| "agents"
|
||||
| "ip"
|
||||
| "mac";
|
||||
export type SortDir = "asc" | "desc";
|
||||
export type KnownFilter = "all" | "known" | "unknown";
|
||||
|
||||
export const presenceFilters = [
|
||||
"all",
|
||||
"online",
|
||||
"likely_online",
|
||||
"unknown",
|
||||
"offline",
|
||||
] as const;
|
||||
|
||||
export const knownFilters: KnownFilter[] = ["all", "known", "unknown"];
|
||||
|
||||
export function summarize(values: string[]): string {
|
||||
if (!values.length) return "-";
|
||||
if (values.length <= 2) return values.join(", ");
|
||||
return `${values[0]}, ${values[1]} (+${values.length - 2})`;
|
||||
}
|
||||
|
||||
export function formatSeen(value: number | null): string {
|
||||
if (!value) return "-";
|
||||
const seconds = Math.max(0, Math.floor(Date.now() / 1000) - value);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 48) return `${hours}h ago`;
|
||||
return new Date(value * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
export function presenceRank(presence: string): number {
|
||||
if (presence === "online") return 4;
|
||||
if (presence === "likely_online") return 3;
|
||||
if (presence === "unknown") return 2;
|
||||
if (presence === "offline") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function identifiersFor(
|
||||
device: FleetDevice,
|
||||
): { kind: string; value: string }[] {
|
||||
return [
|
||||
...device.macs.map((value) => ({ kind: "mac", value })),
|
||||
...device.ips.map((value) => ({ kind: "ip", value })),
|
||||
];
|
||||
}
|
||||
|
||||
export function agentLabel(agentId: string, nickname?: string | null): string {
|
||||
const trimmed = nickname?.trim();
|
||||
return trimmed ? trimmed : agentId;
|
||||
}
|
||||
|
||||
export function routeLabel(route: FleetWakeRoute): string {
|
||||
const status = route.connected ? "connected" : "offline";
|
||||
const target = route.mac
|
||||
? `${route.mac}${route.ip ? ` / ${route.ip}` : ""}`
|
||||
: (route.ip ?? "-");
|
||||
return `${agentLabel(route.agent_id, route.nickname)} · ${target} · ${route.source} · ${status}`;
|
||||
}
|
||||
Reference in new issue
Block a user