fleet api and the most good looking (in terms of functional) UI

This commit is contained in:
lda
2026-04-29 00:16:02 +07:00 Verified
parent 806cc25655
commit 051c6d8900
9 changed files with 1869 additions and 483 deletions
+134
View File
@@ -122,6 +122,63 @@ export type AgentDeviceObservationEvent = {
known_device: KnownDeviceSummary | null;
};
export type FleetDeviceAgent = {
agent_id: string;
nickname?: string | null;
connected: boolean;
last_seen_unix: number;
};
export type FleetWakeRoute = {
route_id: string;
agent_id: string;
nickname?: string | null;
connected: boolean;
mac: string | null;
ip: string | null;
hostname: string | null;
source: string;
last_seen_unix: number;
wakeable: boolean;
};
export type FleetDevice = {
device_key: string;
display_name: string;
known_device: KnownDeviceSummary | null;
pinned: boolean;
ips: string[];
macs: string[];
hostnames: string[];
agents: FleetDeviceAgent[];
sources: string[];
first_seen_unix: number | null;
last_seen_unix: number | null;
presence: string;
route_candidates: FleetWakeRoute[];
recommended_route: FleetWakeRoute | null;
};
export type RefreshFleetDevicesResponse = {
total_accepted: number;
agents: {
agent_id: string;
status: string;
accepted: number;
error: string | null;
}[];
};
export type WakeFleetDeviceResponse = {
route: FleetWakeRoute;
command: {
request_id: string;
status: string;
result?: unknown;
error?: { code: string; message: string; retryable?: boolean | null };
};
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
@@ -208,6 +265,57 @@ export function fetchKnownDevices(): Promise<KnownDevice[]> {
return request<KnownDevice[]>("/api/v1/control/devices");
}
export function fetchFleetDevices(opts?: {
query?: string;
presence?: string;
known?: string;
agentId?: string;
limit?: number;
}): Promise<FleetDevice[]> {
const params = new URLSearchParams();
if (opts?.query) params.set("query", opts.query);
if (opts?.presence && opts.presence !== "all") {
params.set("presence", opts.presence);
}
if (opts?.known && opts.known !== "all") params.set("known", opts.known);
if (opts?.agentId) params.set("agent_id", opts.agentId);
params.set("limit", String(opts?.limit ?? 500));
return request<FleetDevice[]>(
`/api/v1/control/fleet/devices?${params.toString()}`,
);
}
export function refreshFleetDevices(input?: {
agentIds?: string[];
timeoutMs?: number;
}): Promise<RefreshFleetDevicesResponse> {
return request<RefreshFleetDevicesResponse>(
"/api/v1/control/fleet/devices/refresh",
{
method: "POST",
body: JSON.stringify({
agent_ids: input?.agentIds ?? [],
timeout_ms: input?.timeoutMs ?? null,
}),
},
);
}
export function wakeFleetDevice(input: {
deviceKey: string;
routeId?: string | null;
timeoutMs?: number;
}): Promise<WakeFleetDeviceResponse> {
return request<WakeFleetDeviceResponse>("/api/v1/control/fleet/wake", {
method: "POST",
body: JSON.stringify({
device_key: input.deviceKey,
route_id: input.routeId ?? null,
timeout_ms: input.timeoutMs ?? null,
}),
});
}
export function fetchObservations(opts?: {
agentId?: string;
limit?: number;
@@ -270,6 +378,32 @@ export function attachObservationIdentifier(
);
}
export function attachDeviceIdentifier(
deviceId: string,
identifier: { kind: string; value: string },
): Promise<KnownDevice> {
return request<KnownDevice>(
`/api/v1/control/devices/${encodeURIComponent(deviceId)}/identifiers`,
{
method: "POST",
body: JSON.stringify(identifier),
},
);
}
export function mergeKnownDevice(
targetDeviceId: string,
sourceDeviceId: string,
): Promise<KnownDevice> {
return request<KnownDevice>(
`/api/v1/control/devices/${encodeURIComponent(targetDeviceId)}/merge`,
{
method: "POST",
body: JSON.stringify({ source_device_id: sourceDeviceId }),
},
);
}
export function runCommand(
agentId: string,
kind: CommandKind,
+33 -9
View File
@@ -1,18 +1,27 @@
import { useEffect, useState } from "react";
import { NavLink, Outlet } from "react-router-dom";
import { Moon, Sun } from "lucide-react";
import { ChevronDown, Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const navItems = [
["/", "Devices"],
["/observations", "Observed"],
["/commands", "Wake Tools"],
["/dashboard", "Fleet Health"],
] as const;
const adminItems = [
["/agents", "Agents"],
["/audit", "Audit"],
["/alerts", "Alerts"],
["/tokens", "Tokens"],
["/alerts", "Alerts"],
["/observations", "Raw Observations"],
["/commands", "Command Runner"],
["/audit", "Audit"],
] as const;
type Theme = "light" | "dark";
@@ -42,10 +51,10 @@ export function AppLayout() {
<header className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Wakey Operator UI
Wakey
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Find devices fast and wake them reliably
Fleet devices, presence, and wake controls
</p>
</div>
<Button
@@ -70,7 +79,7 @@ export function AppLayout() {
</Button>
</header>
<nav className="mb-3 flex flex-wrap gap-2">
<nav className="mb-3 flex flex-wrap items-center gap-2">
{navItems.map(([to, label]) => (
<NavLink
key={to}
@@ -78,7 +87,7 @@ export function AppLayout() {
end={to === "/"}
className={({ isActive }) =>
[
"rounded-full border px-3 py-1.5 text-sm transition-colors",
"rounded-md border px-3 py-1.5 text-sm transition-colors",
isActive
? "border-primary/60 bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:text-foreground",
@@ -88,6 +97,21 @@ export function AppLayout() {
{label}
</NavLink>
))}
<DropdownMenu>
<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
<ChevronDown className="size-4" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
{adminItems.map(([to, label]) => (
<DropdownMenuItem key={to}>
<NavLink className="w-full" to={to}>
{label}
</NavLink>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</nav>
<main>
File diff suppressed because it is too large Load Diff