claude revamped the ui
This commit is contained in:
@@ -18,11 +18,13 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-compiler-runtime": "^1.0.0",
|
"react-compiler-runtime": "^1.0.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"shadcn": "^4.2.0",
|
"shadcn": "^4.2.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
|
|||||||
Generated
+55
@@ -25,6 +25,9 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.8.0
|
specifier: ^1.8.0
|
||||||
version: 1.8.0([email protected])
|
version: 1.8.0([email protected])
|
||||||
|
next-themes:
|
||||||
|
specifier: ^0.4.6
|
||||||
|
version: 0.4.6([email protected]([email protected]))([email protected])
|
||||||
react:
|
react:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
@@ -40,6 +43,9 @@ importers:
|
|||||||
shadcn:
|
shadcn:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.2.0(@types/[email protected])([email protected])
|
version: 4.2.0(@types/[email protected])([email protected])
|
||||||
|
sonner:
|
||||||
|
specifier: ^2.0.7
|
||||||
|
version: 2.0.7([email protected]([email protected]))([email protected])
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
@@ -843,6 +849,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -851,6 +858,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -859,6 +867,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -867,6 +876,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -875,6 +885,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -883,6 +894,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -891,6 +903,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -899,6 +912,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -907,6 +921,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -915,6 +930,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -923,6 +939,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -931,6 +948,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -939,6 +957,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -1060,6 +1079,7 @@ packages:
|
|||||||
engines: { node: ">= 20" }
|
engines: { node: ">= 20" }
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@tailwindcss/[email protected]":
|
"@tailwindcss/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -1069,6 +1089,7 @@ packages:
|
|||||||
engines: { node: ">= 20" }
|
engines: { node: ">= 20" }
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
"@tailwindcss/[email protected]":
|
"@tailwindcss/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -1078,6 +1099,7 @@ packages:
|
|||||||
engines: { node: ">= 20" }
|
engines: { node: ">= 20" }
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@tailwindcss/[email protected]":
|
"@tailwindcss/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -1087,6 +1109,7 @@ packages:
|
|||||||
engines: { node: ">= 20" }
|
engines: { node: ">= 20" }
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
"@tailwindcss/[email protected]":
|
"@tailwindcss/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -2365,6 +2388,7 @@ packages:
|
|||||||
engines: { node: ">= 12.0.0" }
|
engines: { node: ">= 12.0.0" }
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution:
|
resolution:
|
||||||
@@ -2374,6 +2398,7 @@ packages:
|
|||||||
engines: { node: ">= 12.0.0" }
|
engines: { node: ">= 12.0.0" }
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution:
|
resolution:
|
||||||
@@ -2383,6 +2408,7 @@ packages:
|
|||||||
engines: { node: ">= 12.0.0" }
|
engines: { node: ">= 12.0.0" }
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution:
|
resolution:
|
||||||
@@ -2392,6 +2418,7 @@ packages:
|
|||||||
engines: { node: ">= 12.0.0" }
|
engines: { node: ">= 12.0.0" }
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution:
|
resolution:
|
||||||
@@ -2581,6 +2608,15 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">= 0.6" }
|
engines: { node: ">= 0.6" }
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==,
|
||||||
|
}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@@ -3086,6 +3122,15 @@ packages:
|
|||||||
integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==,
|
integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==,
|
||||||
|
}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@@ -4862,6 +4907,11 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]([email protected]([email protected]))([email protected]):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1([email protected])
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -5221,6 +5271,11 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]([email protected]([email protected]))([email protected]):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1([email protected])
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|||||||
+27
-6
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type Agent,
|
type Agent,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
revokeAgent,
|
revokeAgent,
|
||||||
setAgentNickname,
|
setAgentNickname,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { AppLayout } from "@/layout/AppLayout";
|
import { AppLayout } from "@/layout/AppLayout";
|
||||||
import { AgentsPage } from "@/pages/AgentsPage";
|
import { AgentsPage } from "@/pages/AgentsPage";
|
||||||
import { AlertsPage } from "@/pages/AlertsPage";
|
import { AlertsPage } from "@/pages/AlertsPage";
|
||||||
@@ -20,8 +22,9 @@ import { AuditPage } from "@/pages/AuditPage";
|
|||||||
import { CommandsPage } from "@/pages/CommandsPage";
|
import { CommandsPage } from "@/pages/CommandsPage";
|
||||||
import { DashboardPage } from "@/pages/DashboardPage";
|
import { DashboardPage } from "@/pages/DashboardPage";
|
||||||
import { DevicesPage } from "@/pages/DevicesPage";
|
import { DevicesPage } from "@/pages/DevicesPage";
|
||||||
import { ObservationsPage } from "@/pages/ObservationsPage";
|
|
||||||
import { TokensPage } from "@/pages/TokensPage";
|
import { TokensPage } from "@/pages/TokensPage";
|
||||||
|
import { WakeToolsPage } from "@/pages/WakeToolsPage";
|
||||||
|
import { ObservationsPage } from "@/pages/ObservationsPage";
|
||||||
|
|
||||||
type LoadState = "idle" | "loading" | "ready" | "error";
|
type LoadState = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -33,11 +36,9 @@ export function App() {
|
|||||||
const [selectedAgentId, setSelectedAgentId] = useState("");
|
const [selectedAgentId, setSelectedAgentId] = useState("");
|
||||||
|
|
||||||
const [state, setState] = useState<LoadState>("idle");
|
const [state, setState] = useState<LoadState>("idle");
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
setState("loading");
|
setState("loading");
|
||||||
setError("");
|
|
||||||
try {
|
try {
|
||||||
const [nextAgents, nextAlerts, nextHistory, nextAudit] =
|
const [nextAgents, nextAlerts, nextHistory, nextAudit] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -65,13 +66,16 @@ export function App() {
|
|||||||
setState("ready");
|
setState("ready");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState("error");
|
setState("error");
|
||||||
setError(String(err));
|
toast.error("Failed to load data", { description: String(err) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onRevokeAgent(agentId: string): Promise<boolean> {
|
async function onRevokeAgent(agentId: string): Promise<boolean> {
|
||||||
const result = await revokeAgent(agentId);
|
const result = await revokeAgent(agentId);
|
||||||
await loadAll();
|
await loadAll();
|
||||||
|
if (result.revoked) {
|
||||||
|
toast.success(`Revoked ${agentId}`);
|
||||||
|
}
|
||||||
return result.revoked;
|
return result.revoked;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +85,11 @@ export function App() {
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const result = await setAgentNickname(agentId, nickname);
|
const result = await setAgentNickname(agentId, nickname);
|
||||||
await loadAll();
|
await loadAll();
|
||||||
|
if (result.updated) {
|
||||||
|
toast.success(`Nickname updated for ${agentId}`, {
|
||||||
|
description: nickname || "(cleared)",
|
||||||
|
});
|
||||||
|
}
|
||||||
return result.updated;
|
return result.updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +135,6 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{error && <pre className="error">{error}</pre>}
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<AppLayout />}>
|
<Route path="/" element={<AppLayout />}>
|
||||||
<Route
|
<Route
|
||||||
@@ -140,6 +148,17 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="wake"
|
||||||
|
element={
|
||||||
|
<WakeToolsPage
|
||||||
|
agents={agents}
|
||||||
|
selectedAgentId={selectedAgentId}
|
||||||
|
onSelectAgent={setSelectedAgentId}
|
||||||
|
onAfterWake={loadAll}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="dashboard"
|
path="dashboard"
|
||||||
element={
|
element={
|
||||||
@@ -147,12 +166,12 @@ export function App() {
|
|||||||
agents={agents}
|
agents={agents}
|
||||||
alerts={alerts}
|
alerts={alerts}
|
||||||
transitions={history}
|
transitions={history}
|
||||||
|
audit={audit}
|
||||||
loading={state === "loading"}
|
loading={state === "loading"}
|
||||||
onRefresh={loadAll}
|
onRefresh={loadAll}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="observations" element={<ObservationsPage />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="agents"
|
path="agents"
|
||||||
element={
|
element={
|
||||||
@@ -196,9 +215,11 @@ export function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="tokens" element={<TokensPage />} />
|
<Route path="tokens" element={<TokensPage />} />
|
||||||
|
<Route path="observations" element={<ObservationsPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<Toaster richColors position="bottom-right" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { Agent } from "@/api";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agents: Agent[];
|
||||||
|
value: string;
|
||||||
|
onChange: (agentId: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function displayAgentLabel(agent: Agent): string {
|
||||||
|
const nickname = agent.nickname?.trim();
|
||||||
|
return nickname ? nickname : agent.agent_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentSelector({
|
||||||
|
agents,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const selected = agents.find((a) => a.agent_id === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) onChange(v);
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={className ?? "w-full min-w-0"}>
|
||||||
|
<span className="flex min-w-0 flex-1 items-center gap-2 text-start">
|
||||||
|
{selected ? (
|
||||||
|
<span
|
||||||
|
className={`size-2 shrink-0 rounded-full ${selected.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{value
|
||||||
|
? selected
|
||||||
|
? displayAgentLabel(selected)
|
||||||
|
: value
|
||||||
|
: "Select agent"}
|
||||||
|
</span>
|
||||||
|
{selected ? (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{selected.connected ? "connected" : "offline"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
className="max-w-[min(92vw,30rem)]"
|
||||||
|
alignItemWithTrigger={false}
|
||||||
|
>
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<SelectItem
|
||||||
|
key={agent.agent_id}
|
||||||
|
value={agent.agent_id}
|
||||||
|
className="pe-10"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||||
|
import {
|
||||||
|
CircleCheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
OctagonXIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme="system"
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: <CircleCheckIcon className="size-4" />,
|
||||||
|
info: <InfoIcon className="size-4" />,
|
||||||
|
warning: <TriangleAlertIcon className="size-4" />,
|
||||||
|
error: <OctagonXIcon className="size-4" />,
|
||||||
|
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
"--border-radius": "var(--radius)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast: "cn-toast",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Toaster };
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delay = 0,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Provider.Props) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delay={delay}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
side = "top",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
TooltipPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<TooltipPrimitive.Popup
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pe-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-start-2 data-[side=inline-start]:slide-in-from-end-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-start-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-end-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||||
|
</TooltipPrimitive.Popup>
|
||||||
|
</TooltipPrimitive.Positioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
+187
-68
@@ -1,28 +1,66 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, Outlet } from "react-router-dom";
|
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
import { ChevronDown, Moon, Sun } from "lucide-react";
|
import {
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
Monitor,
|
||||||
|
Zap,
|
||||||
|
BarChart3,
|
||||||
|
Bell,
|
||||||
|
Bot,
|
||||||
|
FileText,
|
||||||
|
Key,
|
||||||
|
Eye,
|
||||||
|
Terminal,
|
||||||
|
PanelLeftClose,
|
||||||
|
PanelLeft,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
Tooltip,
|
||||||
DropdownMenuContent,
|
TooltipContent,
|
||||||
DropdownMenuItem,
|
TooltipTrigger,
|
||||||
DropdownMenuTrigger,
|
} from "@/components/ui/tooltip";
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
|
|
||||||
const navItems = [
|
const SIDEBAR_KEY = "wakey-sidebar-collapsed";
|
||||||
["/", "Devices"],
|
|
||||||
["/dashboard", "Fleet Health"],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const adminItems = [
|
type NavSection = {
|
||||||
["/agents", "Agents"],
|
label: string;
|
||||||
["/tokens", "Tokens"],
|
items: readonly {
|
||||||
["/alerts", "Alerts"],
|
to: string;
|
||||||
["/observations", "Raw Observations"],
|
label: string;
|
||||||
["/commands", "Command Runner"],
|
icon: React.ElementType;
|
||||||
["/audit", "Audit"],
|
end?: boolean;
|
||||||
] as const;
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const navSections: NavSection[] = [
|
||||||
|
{
|
||||||
|
label: "Operations",
|
||||||
|
items: [
|
||||||
|
{ to: "/", label: "Devices", icon: Monitor, end: true },
|
||||||
|
{ to: "/wake", label: "Wake Tools", icon: Zap },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Monitoring",
|
||||||
|
items: [
|
||||||
|
{ to: "/dashboard", label: "Fleet Health", icon: BarChart3 },
|
||||||
|
{ to: "/alerts", label: "Alerts", icon: Bell },
|
||||||
|
{ to: "/observations", label: "Observations", icon: Eye },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Admin",
|
||||||
|
items: [
|
||||||
|
{ to: "/agents", label: "Agents", icon: Bot },
|
||||||
|
{ to: "/audit", label: "Audit", icon: FileText },
|
||||||
|
{ to: "/tokens", label: "Tokens", icon: Key },
|
||||||
|
{ to: "/commands", label: "Commands", icon: Terminal },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
type Theme = "light" | "dark";
|
type Theme = "light" | "dark";
|
||||||
|
|
||||||
@@ -37,8 +75,26 @@ function resolveInitialTheme(): Theme {
|
|||||||
: "light";
|
: "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function usePageTitle() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
useEffect(() => {
|
||||||
|
const flat = navSections.flatMap((s) => s.items);
|
||||||
|
const match = flat.find((item) =>
|
||||||
|
item.end
|
||||||
|
? pathname === "/ui" || pathname === "/ui/" || pathname === "/"
|
||||||
|
: pathname.startsWith(item.to) && item.to !== "/",
|
||||||
|
);
|
||||||
|
document.title = match ? `${match.label} — Wakey` : "Wakey Operator UI";
|
||||||
|
}, [pathname]);
|
||||||
|
}
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const [theme, setTheme] = useState<Theme>(() => resolveInitialTheme());
|
const [theme, setTheme] = useState<Theme>(() => resolveInitialTheme());
|
||||||
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
|
return window.localStorage.getItem(SIDEBAR_KEY) === "true";
|
||||||
|
});
|
||||||
|
|
||||||
|
usePageTitle();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@@ -46,24 +102,89 @@ export function AppLayout() {
|
|||||||
window.localStorage.setItem(THEME_KEY, theme);
|
window.localStorage.setItem(THEME_KEY, theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem(SIDEBAR_KEY, String(collapsed));
|
||||||
|
}, [collapsed]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl p-4">
|
<div className="app-shell">
|
||||||
<header className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<aside
|
||||||
<div>
|
className={`sidebar ${collapsed ? "sidebar--collapsed" : ""}`}
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Wakey</h1>
|
data-collapsed={collapsed}
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
>
|
||||||
Fleet devices, presence, and wake controls
|
<div className="sidebar-brand">
|
||||||
</p>
|
<div className="sidebar-logo">
|
||||||
|
<Zap className="size-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="sidebar-brand-text">
|
||||||
|
<span className="text-sm font-semibold tracking-tight">
|
||||||
|
Wakey
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.65rem] leading-none text-muted-foreground">
|
||||||
|
Operator UI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
{navSections.map((section) => (
|
||||||
|
<div key={section.label} className="sidebar-section">
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="sidebar-section-label">{section.label}</div>
|
||||||
|
)}
|
||||||
|
{section.items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const link = (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
[
|
||||||
|
"sidebar-link",
|
||||||
|
isActive ? "sidebar-link--active" : "",
|
||||||
|
collapsed ? "sidebar-link--collapsed" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="sidebar-link-icon" />
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="sidebar-link-label">{item.label}</span>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<Tooltip key={item.to}>
|
||||||
|
<TooltipTrigger>{link}</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return link;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="fixed right-3 top-3 z-50 size-9 rounded-full p-0 sm:static sm:size-auto sm:rounded-md sm:px-3 sm:py-2"
|
className="sidebar-footer-btn"
|
||||||
aria-label={theme === "dark" ? "Switch to light" : "Switch to dark"}
|
aria-label={
|
||||||
title={theme === "dark" ? "Switch to light" : "Switch to dark"}
|
theme === "dark" ? "Switch to light" : "Switch to dark"
|
||||||
|
}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setTheme((current) => (current === "dark" ? "light" : "dark"))
|
setTheme((c) => (c === "dark" ? "light" : "dark"))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{theme === "dark" ? (
|
{theme === "dark" ? (
|
||||||
@@ -71,48 +192,46 @@ export function AppLayout() {
|
|||||||
) : (
|
) : (
|
||||||
<Moon className="size-4" aria-hidden />
|
<Moon className="size-4" aria-hidden />
|
||||||
)}
|
)}
|
||||||
<span className="hidden sm:inline">
|
{!collapsed && (
|
||||||
{theme === "dark" ? "Switch to light" : "Switch to dark"}
|
<span className="sidebar-link-label">
|
||||||
|
{theme === "dark" ? "Light mode" : "Dark mode"}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
{theme === "dark" ? "Switch to light" : "Switch to dark"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<nav className="mb-3 flex flex-wrap items-center gap-2">
|
<Tooltip>
|
||||||
{navItems.map(([to, label]) => (
|
<TooltipTrigger>
|
||||||
<NavLink
|
<Button
|
||||||
key={to}
|
type="button"
|
||||||
to={to}
|
variant="ghost"
|
||||||
end={to === "/"}
|
size="sm"
|
||||||
className={({ isActive }) =>
|
className="sidebar-footer-btn"
|
||||||
[
|
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
"rounded-md border px-3 py-1.5 text-sm transition-colors",
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
isActive
|
|
||||||
? "border-primary/60 bg-primary/10 text-foreground"
|
|
||||||
: "border-border bg-card text-muted-foreground hover:text-foreground",
|
|
||||||
].join(" ")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{label}
|
{collapsed ? (
|
||||||
</NavLink>
|
<PanelLeft className="size-4" aria-hidden />
|
||||||
))}
|
) : (
|
||||||
<DropdownMenu>
|
<PanelLeftClose className="size-4" aria-hidden />
|
||||||
<DropdownMenuTrigger className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground">
|
)}
|
||||||
Admin / Debug
|
{!collapsed && (
|
||||||
<ChevronDown className="size-4" aria-hidden />
|
<span className="sidebar-link-label">Collapse</span>
|
||||||
</DropdownMenuTrigger>
|
)}
|
||||||
<DropdownMenuContent className="w-48">
|
</Button>
|
||||||
{adminItems.map(([to, label]) => (
|
</TooltipTrigger>
|
||||||
<DropdownMenuItem key={to}>
|
<TooltipContent side="right">
|
||||||
<NavLink className="w-full" to={to}>
|
{collapsed ? "Expand" : "Collapse"}
|
||||||
{label}
|
</TooltipContent>
|
||||||
</NavLink>
|
</Tooltip>
|
||||||
</DropdownMenuItem>
|
</div>
|
||||||
))}
|
</aside>
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main>
|
<main className="app-main">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { App } from "@/App";
|
import { App } from "@/App";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter basename="/ui">
|
<BrowserRouter basename="/ui">
|
||||||
|
<TooltipProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</TooltipProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
+68
-65
@@ -2,7 +2,11 @@ import type { Agent } from "@/api";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Bot, Inbox } from "lucide-react";
|
||||||
|
import { displayAgentLabel } from "@/components/AgentSelector";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
@@ -23,29 +27,17 @@ export function AgentsPage({
|
|||||||
onSetAgentNickname,
|
onSetAgentNickname,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [status, setStatus] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
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) {
|
async function onRevoke(agentId: string) {
|
||||||
if (!window.confirm(`Revoke agent credentials for ${agentId}?`)) {
|
if (!window.confirm(`Revoke agent credentials for ${agentId}?`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStatus("");
|
|
||||||
setError("");
|
|
||||||
try {
|
try {
|
||||||
const revoked = await onRevokeAgent(agentId);
|
await onRevokeAgent(agentId);
|
||||||
setStatus(
|
|
||||||
revoked ? `Revoked ${agentId}` : `${agentId} was already absent`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
toast.error("Revoke failed", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -54,17 +46,10 @@ export function AgentsPage({
|
|||||||
async function onSaveNickname(agent: Agent) {
|
async function onSaveNickname(agent: Agent) {
|
||||||
const draft = drafts[agent.agent_id] ?? agent.nickname ?? "";
|
const draft = drafts[agent.agent_id] ?? agent.nickname ?? "";
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStatus("");
|
|
||||||
setError("");
|
|
||||||
try {
|
try {
|
||||||
const updated = await onSetAgentNickname(agent.agent_id, draft);
|
await onSetAgentNickname(agent.agent_id, draft);
|
||||||
setStatus(
|
|
||||||
updated
|
|
||||||
? `Updated nickname for ${agent.agent_id}`
|
|
||||||
: `${agent.agent_id} not found`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
toast.error("Failed to update nickname", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -73,49 +58,49 @@ export function AgentsPage({
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Agents</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Bot className="size-5 text-primary" />
|
||||||
|
Agents
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{agents.map((agent) => (
|
{agents.map((agent) => (
|
||||||
<div
|
<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}
|
key={agent.agent_id}
|
||||||
>
|
>
|
||||||
<Button
|
<div className="flex items-start justify-between gap-3">
|
||||||
variant={
|
<button
|
||||||
selectedAgentId === agent.agent_id ? "secondary" : "outline"
|
type="button"
|
||||||
}
|
className="flex min-w-0 flex-1 flex-col items-start gap-1 text-left"
|
||||||
className="flex h-auto flex-1 items-center justify-between gap-3 px-3 py-2 text-left"
|
|
||||||
onClick={() => onSelectAgent(agent.agent_id)}
|
onClick={() => onSelectAgent(agent.agent_id)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 truncate">{displayLabel(agent)}</span>
|
<span className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span
|
||||||
{agent.connected ? "connected" : "offline"}
|
className={`size-2 shrink-0 rounded-full ${agent.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
|
||||||
</span>
|
aria-hidden
|
||||||
</Button>
|
|
||||||
<div className="grid min-w-52 gap-2">
|
|
||||||
<Input
|
|
||||||
value={drafts[agent.agent_id] ?? agent.nickname ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDrafts((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[agent.agent_id]: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="nickname (optional)"
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<span className="text-sm font-medium">
|
||||||
<Button
|
{displayAgentLabel(agent)}
|
||||||
variant="outline"
|
</span>
|
||||||
size="sm"
|
<Badge
|
||||||
onClick={() => void onSaveNickname(agent)}
|
variant={agent.connected ? "secondary" : "outline"}
|
||||||
disabled={busy}
|
className="text-[0.6rem] px-1.5 py-0"
|
||||||
>
|
>
|
||||||
Save Name
|
{agent.connected ? "connected" : "offline"}
|
||||||
</Button>
|
</Badge>
|
||||||
|
</span>
|
||||||
|
<code className="text-[0.65rem] text-muted-foreground/70 font-mono truncate max-w-full">
|
||||||
|
{agent.agent_id}
|
||||||
|
</code>
|
||||||
|
</button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -126,25 +111,43 @@ export function AgentsPage({
|
|||||||
Revoke
|
Revoke
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={drafts[agent.agent_id] ?? agent.nickname ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDrafts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[agent.agent_id]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="nickname (optional)"
|
||||||
|
disabled={busy}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void onSaveNickname(agent)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!agents.length && (
|
{!agents.length && (
|
||||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
<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
|
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>
|
||||||
)}
|
)}
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Terminal } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
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 { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -11,6 +19,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { AgentSelector } from "@/components/AgentSelector";
|
||||||
import { runCommand, type Agent, type CommandKind } from "@/api";
|
import { runCommand, type Agent, type CommandKind } from "@/api";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -26,11 +35,6 @@ export function CommandsPage({
|
|||||||
onSelectAgent,
|
onSelectAgent,
|
||||||
onAfterCommand,
|
onAfterCommand,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
function displayAgentLabel(agent: Agent): string {
|
|
||||||
const nickname = agent.nickname?.trim();
|
|
||||||
return nickname ? nickname : agent.agent_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [kind, setKind] = useState<CommandKind>("devs");
|
const [kind, setKind] = useState<CommandKind>("devs");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [output, setOutput] = useState("Select agent and run a command");
|
const [output, setOutput] = useState("Select agent and run a command");
|
||||||
@@ -44,9 +48,11 @@ export function CommandsPage({
|
|||||||
try {
|
try {
|
||||||
const out = await runCommand(selectedAgentId, kind, query.trim());
|
const out = await runCommand(selectedAgentId, kind, query.trim());
|
||||||
setOutput(JSON.stringify(out, null, 2));
|
setOutput(JSON.stringify(out, null, 2));
|
||||||
|
toast.success(`Command "${kind}" completed`);
|
||||||
if (onAfterCommand) await onAfterCommand();
|
if (onAfterCommand) await onAfterCommand();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setOutput(String(err));
|
setOutput(String(err));
|
||||||
|
toast.error("Command failed", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
}
|
}
|
||||||
@@ -55,44 +61,23 @@ export function CommandsPage({
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="grid gap-3" onSubmit={submit}>
|
<form className="grid gap-3" onSubmit={submit}>
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||||
<span>Agent</span>
|
<span>Agent</span>
|
||||||
<Select
|
<AgentSelector
|
||||||
|
agents={agents}
|
||||||
value={selectedAgentId}
|
value={selectedAgentId}
|
||||||
onValueChange={(value) => {
|
onChange={onSelectAgent}
|
||||||
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>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||||
|
|||||||
+271
-38
@@ -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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -7,66 +18,288 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
alerts: Alert[];
|
alerts: Alert[];
|
||||||
transitions: AlertTransition[];
|
transitions: AlertTransition[];
|
||||||
|
audit: AuditEvent[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onRefresh: () => void;
|
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({
|
export function DashboardPage({
|
||||||
agents,
|
agents,
|
||||||
alerts,
|
alerts,
|
||||||
transitions,
|
transitions,
|
||||||
|
audit,
|
||||||
loading,
|
loading,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const connected = agents.filter((a) => a.connected).length;
|
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 (
|
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">
|
||||||
<Card>
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription>Agents</CardDescription>
|
<Skeleton className="h-4 w-20" />
|
||||||
<CardTitle className="text-2xl">{agents.length}</CardTitle>
|
<Skeleton className="mt-2 h-8 w-12" />
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardDescription>Connected</CardDescription>
|
|
||||||
<CardTitle className="text-2xl">{connected}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardDescription>Active Alerts</CardDescription>
|
|
||||||
<CardTitle className="text-2xl">{alerts.length}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardDescription>Transitions</CardDescription>
|
|
||||||
<CardTitle className="text-2xl">{transitions.length}</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
))}
|
||||||
<Card className="sm:col-span-2 lg:col-span-4">
|
<Card className="sm:col-span-2 lg:col-span-4">
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
<CardHeader>
|
||||||
<CardTitle>Overview</CardTitle>
|
<Skeleton className="h-5 w-32" />
|
||||||
<Button
|
|
||||||
onClick={onRefresh}
|
|
||||||
disabled={loading}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{loading ? "Refreshing..." : "Refresh"}
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
Use tabs to run commands, inspect audits, and monitor alerts in real
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
time.
|
))}
|
||||||
</p>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{/* Fleet Status */}
|
||||||
|
<Card>
|
||||||
|
<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="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="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="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">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`size-3.5 ${loading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+45
-406
@@ -1,37 +1,26 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Copy,
|
Inbox,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
attachDeviceIdentifier,
|
|
||||||
createKnownDevice,
|
|
||||||
fetchFleetDevices,
|
fetchFleetDevices,
|
||||||
fetchKnownDevices,
|
fetchKnownDevices,
|
||||||
mergeKnownDevice,
|
|
||||||
refreshFleetDevices,
|
refreshFleetDevices,
|
||||||
wakeFleetDevice,
|
wakeFleetDevice,
|
||||||
type Agent,
|
type Agent,
|
||||||
type FleetDevice,
|
type FleetDevice,
|
||||||
type FleetWakeRoute,
|
|
||||||
type KnownDevice,
|
type KnownDevice,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -45,7 +34,24 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from "@/components/ui/select";
|
} 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 = {
|
type Props = {
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
@@ -54,20 +60,6 @@ type Props = {
|
|||||||
onAfterWake?: () => Promise<void>;
|
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) {
|
export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||||
const [devices, setDevices] = useState<FleetDevice[]>([]);
|
const [devices, setDevices] = useState<FleetDevice[]>([]);
|
||||||
const [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]);
|
const [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]);
|
||||||
@@ -117,14 +109,15 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
try {
|
try {
|
||||||
const result = await refreshFleetDevices();
|
const result = await refreshFleetDevices();
|
||||||
const failed = result.agents.filter((agent) => agent.status !== "ok");
|
const failed = result.agents.filter((agent) => agent.status !== "ok");
|
||||||
setStatus(
|
const msg = failed.length
|
||||||
failed.length
|
|
||||||
? `Stored ${result.total_accepted} observations; ${failed.length} agent refresh failed`
|
? `Stored ${result.total_accepted} observations; ${failed.length} agent refresh failed`
|
||||||
: `Stored ${result.total_accepted} observations from ${result.agents.length} connected agents`,
|
: `Stored ${result.total_accepted} observations from ${result.agents.length} connected agents`;
|
||||||
);
|
setStatus(msg);
|
||||||
|
toast.success(msg);
|
||||||
await loadFleet();
|
await loadFleet();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
setError(String(err));
|
||||||
|
toast.error("Fleet refresh failed", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
@@ -140,13 +133,14 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
deviceKey: device.device_key,
|
deviceKey: device.device_key,
|
||||||
routeId,
|
routeId,
|
||||||
});
|
});
|
||||||
setStatus(
|
const msg = `Wake sent via ${agentLabel(result.route.agent_id, result.route.nickname)} (${result.route.mac ?? "no mac"})`;
|
||||||
`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();
|
if (onAfterWake) await onAfterWake();
|
||||||
await loadFleet();
|
await loadFleet();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
setError(String(err));
|
||||||
|
toast.error("Wake failed", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setWakeBusyKey("");
|
setWakeBusyKey("");
|
||||||
}
|
}
|
||||||
@@ -158,6 +152,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
setCopied(label);
|
setCopied(label);
|
||||||
|
toast.success(`Copied ${label}`);
|
||||||
} catch {
|
} catch {
|
||||||
const area = document.createElement("textarea");
|
const area = document.createElement("textarea");
|
||||||
area.value = text;
|
area.value = text;
|
||||||
@@ -168,6 +163,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
document.execCommand("copy");
|
document.execCommand("copy");
|
||||||
document.body.removeChild(area);
|
document.body.removeChild(area);
|
||||||
setCopied(label);
|
setCopied(label);
|
||||||
|
toast.success(`Copied ${label}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +333,14 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
) : (
|
||||||
<div className="grid gap-2">
|
<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">
|
<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>Device</span>
|
||||||
@@ -358,11 +362,15 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{!sortedDevices.length && (
|
{!sortedDevices.length && (
|
||||||
<div className="rounded-md border bg-card p-4 text-sm text-muted-foreground">
|
<div className="flex flex-col items-center justify-center rounded-md border bg-card py-12 text-center">
|
||||||
No devices match the current filters.
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -471,372 +479,3 @@ function FleetDeviceRow({
|
|||||||
</div>
|
</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}`;
|
|
||||||
}
|
|
||||||
|
|||||||
+156
-43
@@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Key, Copy, Inbox } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type EnrollTokenStatus,
|
type EnrollTokenStatus,
|
||||||
@@ -8,19 +10,36 @@ import {
|
|||||||
} from "@/api";
|
} from "@/api";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
function formatUnix(unix: number): string {
|
function formatUnix(unix: number): string {
|
||||||
return new Date(unix * 1000).toLocaleString();
|
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() {
|
export function TokensPage() {
|
||||||
const [tokens, setTokens] = useState<EnrollTokenStatus[]>([]);
|
const [tokens, setTokens] = useState<EnrollTokenStatus[]>([]);
|
||||||
const [ttlSeconds, setTtlSeconds] = useState("86400");
|
const [ttlSeconds, setTtlSeconds] = useState(86400);
|
||||||
const [status, setStatus] = useState("");
|
const [customTtl, setCustomTtl] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [useCustom, setUseCustom] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [lastIssued, setLastIssued] = useState<{
|
||||||
|
token: string;
|
||||||
|
expires: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const activeCount = useMemo(
|
const activeCount = useMemo(
|
||||||
() => tokens.filter((t) => !t.expired).length,
|
() => tokens.filter((t) => !t.expired).length,
|
||||||
@@ -29,12 +48,11 @@ export function TokensPage() {
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
|
||||||
try {
|
try {
|
||||||
const next = await fetchEnrollTokens();
|
const next = await fetchEnrollTokens();
|
||||||
setTokens(next);
|
setTokens(next);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
toast.error("Failed to load tokens", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -42,17 +60,23 @@ export function TokensPage() {
|
|||||||
|
|
||||||
async function onIssue() {
|
async function onIssue() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
|
||||||
setStatus("");
|
|
||||||
try {
|
try {
|
||||||
const ttl = Number.parseInt(ttlSeconds, 10);
|
const effectiveTtl = useCustom
|
||||||
const issued = await issueEnrollToken(Number.isFinite(ttl) ? ttl : 86400);
|
? Number.parseInt(customTtl, 10) || 86400
|
||||||
setStatus(
|
: ttlSeconds;
|
||||||
`Issued ${issued.enroll_token} (expires ${formatUnix(issued.expires_at_unix)})`,
|
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();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
toast.error("Failed to issue token", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -60,30 +84,50 @@ export function TokensPage() {
|
|||||||
|
|
||||||
async function onRevoke(token: string) {
|
async function onRevoke(token: string) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
|
||||||
setStatus("");
|
|
||||||
try {
|
try {
|
||||||
const result = await revokeEnrollToken(token);
|
const result = await revokeEnrollToken(token);
|
||||||
setStatus(
|
if (result.revoked) {
|
||||||
result.revoked ? `Revoked ${token}` : `${token} was already absent`,
|
toast.success(`Revoked ${token}`);
|
||||||
);
|
} else {
|
||||||
|
toast.info(`${token} was already absent`);
|
||||||
|
}
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
toast.error("Failed to revoke token", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(text: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success("Copied to clipboard");
|
||||||
|
} catch {
|
||||||
|
toast.error("Copy failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const controlPlaneUrl =
|
||||||
|
window.location.origin.replace(/:\d+$/, "") || "https://cp.example.com";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="grid gap-3 xl:grid-cols-2">
|
<section className="grid gap-4 xl:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -94,17 +138,16 @@ export function TokensPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{activeCount} active, {tokens.length} shown
|
|
||||||
</p>
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{tokens.map((token) => (
|
{tokens.map((token) => (
|
||||||
<div
|
<div
|
||||||
className="flex items-start justify-between gap-3 rounded-md border bg-card px-3 py-2"
|
className="flex items-start justify-between gap-3 rounded-md border bg-card px-3 py-2"
|
||||||
key={token.enroll_token}
|
key={token.enroll_token}
|
||||||
>
|
>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<strong>{token.enroll_token}</strong>
|
<strong className="block truncate text-sm">
|
||||||
|
{token.enroll_token}
|
||||||
|
</strong>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
expires: {formatUnix(token.expires_at_unix)}
|
expires: {formatUnix(token.expires_at_unix)}
|
||||||
</div>
|
</div>
|
||||||
@@ -125,43 +168,113 @@ export function TokensPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!tokens.length && (
|
{!tokens.length && (
|
||||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
<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
|
No tokens found
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4 content-start">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Issue Token</CardTitle>
|
<CardTitle>Issue Token</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create a new enrollment token for agents
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3">
|
<CardContent className="grid gap-4">
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<div>
|
||||||
<span>TTL seconds</span>
|
<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
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={ttlSeconds}
|
value={customTtl}
|
||||||
onChange={(e) => setTtlSeconds(e.target.value)}
|
onChange={(e) => setCustomTtl(e.target.value)}
|
||||||
|
placeholder="86400"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Button onClick={() => void onIssue()} disabled={busy}>
|
<Button onClick={() => void onIssue()} disabled={busy}>
|
||||||
Issue
|
Issue Token
|
||||||
</Button>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</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)}
|
{summarize(row.macs)}
|
||||||
</span>
|
</span>
|
||||||
<span className="device-cell" data-label="Presence">
|
<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>
|
||||||
<span className="device-cell device-action" data-label="">
|
<span className="device-cell device-action" data-label="">
|
||||||
<Button
|
<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}`;
|
||||||
|
}
|
||||||
+302
-66
@@ -8,36 +8,42 @@
|
|||||||
:root {
|
:root {
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--muted: oklch(0.96 0.004 145);
|
--muted: oklch(0.96 0.004 145);
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.985 0.002 200);
|
||||||
--foreground: oklch(0.23 0.02 155);
|
--foreground: oklch(0.18 0.02 240);
|
||||||
--card-foreground: oklch(0.23 0.02 155);
|
--card-foreground: oklch(0.18 0.02 240);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.23 0.02 155);
|
--popover-foreground: oklch(0.18 0.02 240);
|
||||||
--primary: oklch(0.56 0.12 156);
|
--primary: oklch(0.55 0.14 155);
|
||||||
--primary-foreground: oklch(0.985 0.003 95);
|
--primary-foreground: oklch(0.985 0.005 155);
|
||||||
--secondary: oklch(0.965 0.01 155);
|
--secondary: oklch(0.96 0.01 155);
|
||||||
--secondary-foreground: oklch(0.3 0.035 156);
|
--secondary-foreground: oklch(0.3 0.035 156);
|
||||||
--muted-foreground: oklch(0.5 0.02 155);
|
--muted-foreground: oklch(0.48 0.02 240);
|
||||||
--accent: oklch(0.945 0.015 156);
|
--accent: oklch(0.945 0.018 155);
|
||||||
--accent-foreground: oklch(0.3 0.035 156);
|
--accent-foreground: oklch(0.3 0.035 156);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.9 0.01 155);
|
--border: oklch(0.91 0.008 240);
|
||||||
--input: oklch(0.9 0.01 155);
|
--input: oklch(0.91 0.008 240);
|
||||||
--ring: oklch(0.62 0.08 156);
|
--ring: oklch(0.55 0.14 155);
|
||||||
--chart-1: oklch(0.62 0.12 156);
|
--chart-1: oklch(0.55 0.14 155);
|
||||||
--chart-2: oklch(0.56 0.11 188);
|
--chart-2: oklch(0.56 0.11 188);
|
||||||
--chart-3: oklch(0.67 0.12 120);
|
--chart-3: oklch(0.67 0.12 120);
|
||||||
--chart-4: oklch(0.72 0.15 85);
|
--chart-4: oklch(0.72 0.15 85);
|
||||||
--chart-5: oklch(0.58 0.18 35);
|
--chart-5: oklch(0.58 0.18 35);
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.975 0.005 155);
|
||||||
--sidebar-foreground: oklch(0.23 0.02 155);
|
--sidebar-foreground: oklch(0.18 0.02 240);
|
||||||
--sidebar-primary: oklch(0.56 0.12 156);
|
--sidebar-primary: oklch(0.55 0.14 155);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0.003 95);
|
--sidebar-primary-foreground: oklch(0.985 0.005 155);
|
||||||
--sidebar-accent: oklch(0.945 0.015 156);
|
--sidebar-accent: oklch(0.94 0.02 155);
|
||||||
--sidebar-accent-foreground: oklch(0.3 0.035 156);
|
--sidebar-accent-foreground: oklch(0.25 0.03 155);
|
||||||
--sidebar-border: oklch(0.9 0.01 155);
|
--sidebar-border: oklch(0.92 0.008 240);
|
||||||
--sidebar-ring: oklch(0.62 0.08 156);
|
--sidebar-ring: oklch(0.55 0.14 155);
|
||||||
|
|
||||||
|
/* Semantic presence colors */
|
||||||
|
--presence-online: oklch(0.62 0.17 145);
|
||||||
|
--presence-likely: oklch(0.75 0.15 75);
|
||||||
|
--presence-unknown: oklch(0.65 0.02 240);
|
||||||
|
--presence-offline: oklch(0.62 0.2 25);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -84,67 +90,297 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--muted: oklch(0.25 0.01 156);
|
--muted: oklch(0.22 0.015 240);
|
||||||
--background: oklch(0.19 0.02 156);
|
--background: oklch(0.14 0.02 250);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.93 0.005 240);
|
||||||
--card: oklch(0.24 0.015 156);
|
--card: oklch(0.18 0.018 245);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.93 0.005 240);
|
||||||
--popover: oklch(0.24 0.015 156);
|
--popover: oklch(0.18 0.018 245);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.93 0.005 240);
|
||||||
--primary: oklch(0.68 0.11 156);
|
--primary: oklch(0.7 0.14 155);
|
||||||
--primary-foreground: oklch(0.17 0.02 156);
|
--primary-foreground: oklch(0.14 0.02 155);
|
||||||
--secondary: oklch(0.28 0.02 156);
|
--secondary: oklch(0.24 0.02 240);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.93 0.005 240);
|
||||||
--muted-foreground: oklch(0.75 0.02 156);
|
--muted-foreground: oklch(0.65 0.015 240);
|
||||||
--accent: oklch(0.3 0.02 156);
|
--accent: oklch(0.24 0.025 245);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.93 0.005 240);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 8%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 10%);
|
||||||
--ring: oklch(0.65 0.08 156);
|
--ring: oklch(0.7 0.14 155);
|
||||||
--chart-1: oklch(0.68 0.11 156);
|
--chart-1: oklch(0.7 0.14 155);
|
||||||
--chart-2: oklch(0.63 0.11 188);
|
--chart-2: oklch(0.63 0.11 188);
|
||||||
--chart-3: oklch(0.73 0.12 120);
|
--chart-3: oklch(0.73 0.12 120);
|
||||||
--chart-4: oklch(0.78 0.14 85);
|
--chart-4: oklch(0.78 0.14 85);
|
||||||
--chart-5: oklch(0.67 0.16 35);
|
--chart-5: oklch(0.67 0.16 35);
|
||||||
--sidebar: oklch(0.24 0.015 156);
|
--sidebar: oklch(0.16 0.02 248);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.93 0.005 240);
|
||||||
--sidebar-primary: oklch(0.68 0.11 156);
|
--sidebar-primary: oklch(0.7 0.14 155);
|
||||||
--sidebar-primary-foreground: oklch(0.17 0.02 156);
|
--sidebar-primary-foreground: oklch(0.14 0.02 155);
|
||||||
--sidebar-accent: oklch(0.3 0.02 156);
|
--sidebar-accent: oklch(0.22 0.025 245);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.93 0.005 240);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 8%);
|
||||||
--sidebar-ring: oklch(0.65 0.08 156);
|
--sidebar-ring: oklch(0.7 0.14 155);
|
||||||
|
|
||||||
|
--presence-online: oklch(0.72 0.18 145);
|
||||||
|
--presence-likely: oklch(0.8 0.14 75);
|
||||||
|
--presence-unknown: oklch(0.6 0.015 240);
|
||||||
|
--presence-offline: oklch(0.7 0.2 25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Base ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
background-color: var(--background);
|
|
||||||
background-image:
|
|
||||||
radial-gradient(
|
|
||||||
1200px 700px at 15% -10%,
|
|
||||||
color-mix(in oklab, var(--primary) 15%, var(--background)) 0%,
|
|
||||||
transparent 60%
|
|
||||||
),
|
|
||||||
radial-gradient(
|
|
||||||
900px 600px at 85% 0%,
|
|
||||||
color-mix(in oklab, var(--accent) 12%, var(--background)) 0%,
|
|
||||||
transparent 58%
|
|
||||||
),
|
|
||||||
linear-gradient(
|
|
||||||
180deg,
|
|
||||||
color-mix(in oklab, var(--background) 85%, var(--foreground) 15%) 0%,
|
|
||||||
var(--background) 45%
|
|
||||||
);
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: 100% 100%;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar shell ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 30;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 15rem;
|
||||||
|
height: 100dvh;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--sidebar);
|
||||||
|
border-right: 1px solid var(--sidebar-border);
|
||||||
|
transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed {
|
||||||
|
width: 3.5rem;
|
||||||
|
padding: 0.75rem 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
1200px 700px at 15% -10%,
|
||||||
|
color-mix(in oklab, var(--primary) 8%, var(--background)) 0%,
|
||||||
|
transparent 60%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
900px 600px at 85% 0%,
|
||||||
|
color-mix(in oklab, var(--accent) 6%, var(--background)) 0%,
|
||||||
|
transparent 58%
|
||||||
|
),
|
||||||
|
var(--background);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar internals ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.3rem 0.35rem 0.75rem;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section-label {
|
||||||
|
padding: 0.5rem 0.55rem 0.25rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 450;
|
||||||
|
color: var(--sidebar-foreground);
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
background-color 120ms ease,
|
||||||
|
color 120ms ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link:hover {
|
||||||
|
background: var(--sidebar-accent);
|
||||||
|
color: var(--sidebar-accent-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link--active {
|
||||||
|
background: color-mix(in oklab, var(--sidebar-primary) 14%, transparent);
|
||||||
|
color: var(--sidebar-primary);
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link--collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link-icon {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link--active .sidebar-link-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
border-top: 1px solid var(--sidebar-border);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 450;
|
||||||
|
color: var(--sidebar-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .sidebar-footer-btn {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Presence badges ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.presence-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-dot--online {
|
||||||
|
background: var(--presence-online);
|
||||||
|
}
|
||||||
|
.presence-dot--likely_online {
|
||||||
|
background: var(--presence-likely);
|
||||||
|
}
|
||||||
|
.presence-dot--unknown {
|
||||||
|
background: var(--presence-unknown);
|
||||||
|
}
|
||||||
|
.presence-dot--offline {
|
||||||
|
background: var(--presence-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-badge--online {
|
||||||
|
border-color: color-mix(in oklab, var(--presence-online) 40%, transparent);
|
||||||
|
color: var(--presence-online);
|
||||||
|
}
|
||||||
|
.presence-badge--likely_online {
|
||||||
|
border-color: color-mix(in oklab, var(--presence-likely) 40%, transparent);
|
||||||
|
color: var(--presence-likely);
|
||||||
|
}
|
||||||
|
.presence-badge--unknown {
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.presence-badge--offline {
|
||||||
|
border-color: color-mix(in oklab, var(--presence-offline) 40%, transparent);
|
||||||
|
color: var(--presence-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
box-shadow: 0 4px 24px oklch(0 0 0 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed {
|
||||||
|
width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed * {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user