misc changes
This commit is contained in:
@@ -12,7 +12,7 @@ start_service() {
|
|||||||
procd_open_instance
|
procd_open_instance
|
||||||
procd_set_param command "$BIN" serve --config "$CONFIG"
|
procd_set_param command "$BIN" serve --config "$CONFIG"
|
||||||
procd_set_param file "$CONFIG"
|
procd_set_param file "$CONFIG"
|
||||||
procd_set_param env RUST_LOG=wakey_agent=debug,wakey=debug
|
# procd_set_param env RUST_LOG=wakey_agent=debug,wakey=debug
|
||||||
procd_set_param respawn 5 1 0
|
procd_set_param respawn 5 1 0
|
||||||
procd_set_param stdout 1
|
procd_set_param stdout 1
|
||||||
procd_set_param stderr 1
|
procd_set_param stderr 1
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ pub fn merge_devices_with_observations(
|
|||||||
let key = row
|
let key = row
|
||||||
.mac
|
.mac
|
||||||
.map(|m| m.to_string())
|
.map(|m| m.to_string())
|
||||||
.unwrap_or_else(|| format!("ip:{}", row.ip));
|
.unwrap_or_else(|| format!("ip:{}", row.ip)); // key by mac or by FAILED ip
|
||||||
by_key.entry(key).or_default().0.push(row);
|
by_key.entry(key).or_default().0.push(row);
|
||||||
}
|
}
|
||||||
for lease in leases {
|
for lease in leases {
|
||||||
@@ -99,7 +99,7 @@ pub fn merge_devices_with_observations(
|
|||||||
Device::from_parts_with_observations(neighbors, leases, observations)
|
Device::from_parts_with_observations(neighbors, leases, observations)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
if !query.is_empty() {
|
||||||
let mut texts = Vec::new();
|
let mut texts = Vec::new();
|
||||||
let mut devs = Vec::new();
|
let mut devs = Vec::new();
|
||||||
let mut ips = Vec::new();
|
let mut ips = Vec::new();
|
||||||
@@ -123,6 +123,7 @@ pub fn merge_devices_with_observations(
|
|||||||
&& (macs.is_empty() || device.macs.iter().any(|mac| macs.contains(mac)))
|
&& (macs.is_empty() || device.macs.iter().any(|mac| macs.contains(mac)))
|
||||||
&& (nuds.is_empty() || device.neighbors.iter().any(|n| nuds.contains(&n.state)))
|
&& (nuds.is_empty() || device.neighbors.iter().any(|n| nuds.contains(&n.state)))
|
||||||
});
|
});
|
||||||
|
}
|
||||||
devices.sort_by(|a, b| {
|
devices.sort_by(|a, b| {
|
||||||
presence_rank(b.presence)
|
presence_rank(b.presence)
|
||||||
.cmp(&presence_rank(a.presence))
|
.cmp(&presence_rank(a.presence))
|
||||||
|
|||||||
@@ -4,6 +4,20 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Wakey Operator UI</title>
|
<title>Wakey Operator UI</title>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem("wakey-ui-theme");
|
||||||
|
if (
|
||||||
|
t === "dark" ||
|
||||||
|
((t === "system" || !t) &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||||
|
) {
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export function App() {
|
|||||||
selectedAgentId={selectedAgentId}
|
selectedAgentId={selectedAgentId}
|
||||||
onSelectAgent={setSelectedAgentId}
|
onSelectAgent={setSelectedAgentId}
|
||||||
onAfterWake={loadAll}
|
onAfterWake={loadAll}
|
||||||
|
onRefresh={loadAll}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function AppLayout() {
|
|||||||
className={[
|
className={[
|
||||||
"sidebar",
|
"sidebar",
|
||||||
collapsed ? "sidebar--collapsed" : "",
|
collapsed ? "sidebar--collapsed" : "",
|
||||||
"fixed inset-y-0 left-0 z-50 shadow-xl transition-all duration-300 ease-in-out",
|
"fixed inset-y-0 left-0 z-50 shadow-xl transition-all duration-100 ease-in-out",
|
||||||
"md:sticky md:top-0 md:z-30 md:shadow-none",
|
"md:sticky md:top-0 md:z-30 md:shadow-none",
|
||||||
collapsed ? "-translate-x-full md:translate-x-0" : "translate-x-0",
|
collapsed ? "-translate-x-full md:translate-x-0" : "translate-x-0",
|
||||||
]
|
]
|
||||||
@@ -219,8 +219,14 @@ export function AppLayout() {
|
|||||||
{/* Mobile backdrop */}
|
{/* Mobile backdrop */}
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
className="fixed inset-0 z-40 bg-black/20 md:hidden animate-in fade-in duration-200"
|
className="fixed inset-0 z-40 bg-black/20 md:hidden animate-in fade-in duration-200"
|
||||||
onClick={() => setCollapsed(true)}
|
onClick={() => setCollapsed(true)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") setCollapsed(true);
|
||||||
|
}}
|
||||||
|
aria-label="Close menu"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label htmlFor="alert-severity" className="grid gap-1 text-sm text-muted-foreground">
|
||||||
Severity
|
Severity
|
||||||
<Select
|
<Select
|
||||||
value={severity}
|
value={severity}
|
||||||
@@ -80,7 +80,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
if (value) setSeverity(value);
|
if (value) setSeverity(value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger id="alert-severity" className="w-full">
|
||||||
<SelectValue placeholder="Severity" />
|
<SelectValue placeholder="Severity" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -93,7 +93,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label htmlFor="alert-status" className="grid gap-1 text-sm text-muted-foreground">
|
||||||
Status
|
Status
|
||||||
<Select
|
<Select
|
||||||
value={status}
|
value={status}
|
||||||
@@ -101,7 +101,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
if (value) setStatus(value);
|
if (value) setStatus(value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger id="alert-status" className="w-full">
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder="Status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -114,7 +114,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label htmlFor="alert-kind" className="grid gap-1 text-sm text-muted-foreground">
|
||||||
Kind
|
Kind
|
||||||
<Select
|
<Select
|
||||||
value={kind}
|
value={kind}
|
||||||
@@ -122,7 +122,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
if (value) setKind(value);
|
if (value) setKind(value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger id="alert-kind" className="w-full">
|
||||||
<SelectValue placeholder="Kind" />
|
<SelectValue placeholder="Kind" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -170,9 +170,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label htmlFor="alert-transition-search" className="grid gap-1 text-sm text-muted-foreground">
|
||||||
Search
|
Search
|
||||||
<Input
|
<Input
|
||||||
|
id="alert-transition-search"
|
||||||
value={transitionQ}
|
value={transitionQ}
|
||||||
onChange={(e) => setTransitionQ(e.target.value)}
|
onChange={(e) => setTransitionQ(e.target.value)}
|
||||||
placeholder="kind, status, message, agent"
|
placeholder="kind, status, message, agent"
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function AuditPage({ events, onRefresh }: Props) {
|
|||||||
|
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label htmlFor="audit-event-type" className="grid gap-1 text-sm text-muted-foreground">
|
||||||
Event type
|
Event type
|
||||||
<Select
|
<Select
|
||||||
value={eventType}
|
value={eventType}
|
||||||
@@ -78,7 +78,7 @@ export function AuditPage({ events, onRefresh }: Props) {
|
|||||||
if (value) setEventType(value);
|
if (value) setEventType(value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger id="audit-event-type" className="w-full">
|
||||||
<SelectValue placeholder="Event type" />
|
<SelectValue placeholder="Event type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -91,7 +91,7 @@ export function AuditPage({ events, onRefresh }: Props) {
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label htmlFor="audit-outcome" className="grid gap-1 text-sm text-muted-foreground">
|
||||||
Outcome
|
Outcome
|
||||||
<Select
|
<Select
|
||||||
value={outcome}
|
value={outcome}
|
||||||
@@ -99,7 +99,7 @@ export function AuditPage({ events, onRefresh }: Props) {
|
|||||||
if (value) setOutcome(value);
|
if (value) setOutcome(value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger id="audit-outcome" className="w-full">
|
||||||
<SelectValue placeholder="Outcome" />
|
<SelectValue placeholder="Outcome" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -112,9 +112,10 @@ export function AuditPage({ events, onRefresh }: Props) {
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label htmlFor="audit-search" className="grid gap-1 text-sm text-muted-foreground">
|
||||||
Search
|
Search
|
||||||
<Input
|
<Input
|
||||||
|
id="audit-search"
|
||||||
value={needle}
|
value={needle}
|
||||||
onChange={(e) => setNeedle(e.target.value)}
|
onChange={(e) => setNeedle(e.target.value)}
|
||||||
placeholder="message, agent, actor"
|
placeholder="message, agent, actor"
|
||||||
|
|||||||
@@ -66,12 +66,12 @@ export function CommandsPage({
|
|||||||
Command Runner
|
Command Runner
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Debug tool — send raw commands to agents and inspect JSON responses
|
Debug tool: send raw commands to agents and inspect JSON responses
|
||||||
</CardDescription>
|
</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 htmlFor="cmd-agent" className="grid gap-1 text-sm text-muted-foreground">
|
||||||
<span>Agent</span>
|
<span>Agent</span>
|
||||||
<AgentSelector
|
<AgentSelector
|
||||||
agents={agents}
|
agents={agents}
|
||||||
@@ -80,13 +80,13 @@ export function CommandsPage({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label htmlFor="cmd-kind" className="grid gap-1 text-sm text-muted-foreground">
|
||||||
<span>Command</span>
|
<span>Command</span>
|
||||||
<Select
|
<Select
|
||||||
value={kind}
|
value={kind}
|
||||||
onValueChange={(value) => setKind(value as CommandKind)}
|
onValueChange={(value) => setKind(value as CommandKind)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger id="cmd-kind" className="w-full">
|
||||||
<SelectValue placeholder="Pick command" />
|
<SelectValue placeholder="Pick command" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -98,9 +98,10 @@ export function CommandsPage({
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label htmlFor="cmd-query" className="grid gap-1 text-sm text-muted-foreground">
|
||||||
<span>Query</span>
|
<span>Query</span>
|
||||||
<Input
|
<Input
|
||||||
|
id="cmd-query"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="optional"
|
placeholder="optional"
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function DashboardPage({
|
|||||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{/* Fleet Status */}
|
{/* Fleet Status */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
|
||||||
<CardDescription className="flex items-center gap-1.5">
|
<CardDescription className="flex items-center gap-1.5">
|
||||||
<Bot className="size-3.5" />
|
<Bot className="size-3.5" />
|
||||||
Fleet Status
|
Fleet Status
|
||||||
@@ -152,7 +152,7 @@ export function DashboardPage({
|
|||||||
|
|
||||||
{/* Active Alerts */}
|
{/* Active Alerts */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
|
||||||
<CardDescription className="flex items-center gap-1.5">
|
<CardDescription className="flex items-center gap-1.5">
|
||||||
<Bell className="size-3.5" />
|
<Bell className="size-3.5" />
|
||||||
Active Alerts
|
Active Alerts
|
||||||
@@ -188,7 +188,7 @@ export function DashboardPage({
|
|||||||
|
|
||||||
{/* Transitions */}
|
{/* Transitions */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
|
||||||
<CardDescription className="flex items-center gap-1.5">
|
<CardDescription className="flex items-center gap-1.5">
|
||||||
<Activity className="size-3.5" />
|
<Activity className="size-3.5" />
|
||||||
Transitions
|
Transitions
|
||||||
@@ -206,7 +206,7 @@ export function DashboardPage({
|
|||||||
|
|
||||||
{/* Token Health */}
|
{/* Token Health */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
|
||||||
<CardDescription className="flex items-center gap-1.5">
|
<CardDescription className="flex items-center gap-1.5">
|
||||||
<Key className="size-3.5" />
|
<Key className="size-3.5" />
|
||||||
Enroll Tokens
|
Enroll Tokens
|
||||||
@@ -228,7 +228,7 @@ export function DashboardPage({
|
|||||||
|
|
||||||
{/* Recent Activity Feed */}
|
{/* Recent Activity Feed */}
|
||||||
<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 className="flex flex-row items-start justify-between gap-3 gap-y-0">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Recent Activity</CardTitle>
|
<CardTitle>Recent Activity</CardTitle>
|
||||||
<CardDescription className="mt-1">
|
<CardDescription className="mt-1">
|
||||||
|
|||||||
@@ -51,9 +51,10 @@ type Props = {
|
|||||||
selectedAgentId: string;
|
selectedAgentId: string;
|
||||||
onSelectAgent: (agentId: string) => void;
|
onSelectAgent: (agentId: string) => void;
|
||||||
onAfterWake?: () => Promise<void>;
|
onAfterWake?: () => Promise<void>;
|
||||||
|
onRefresh?: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DevicesPage({ agents, onAfterWake }: Props) {
|
export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
|
||||||
const [devices, setDevices] = useState<FleetDevice[]>([]);
|
const [devices, setDevices] = useState<FleetDevice[]>([]);
|
||||||
const [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]);
|
const [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -137,8 +138,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
} catch {
|
} catch {
|
||||||
const area = document.createElement("textarea");
|
const area = document.createElement("textarea");
|
||||||
area.value = text;
|
area.value = text;
|
||||||
area.style.position = "fixed";
|
area.style.cssText = "position:fixed;opacity:0";
|
||||||
area.style.opacity = "0";
|
|
||||||
document.body.appendChild(area);
|
document.body.appendChild(area);
|
||||||
area.select();
|
area.select();
|
||||||
document.execCommand("copy");
|
document.execCommand("copy");
|
||||||
@@ -201,7 +201,10 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => void loadFleet()}
|
onClick={() => {
|
||||||
|
void loadFleet();
|
||||||
|
if (onRefresh) void onRefresh();
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<RefreshCw className="size-4" aria-hidden />
|
<RefreshCw className="size-4" aria-hidden />
|
||||||
@@ -222,11 +225,12 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="grid gap-2 lg:grid-cols-[minmax(16rem,1fr)_11rem_10rem_14rem_10rem]">
|
<div className="grid gap-2 lg:grid-cols-[minmax(16rem,1fr)_11rem_10rem_14rem_10rem]">
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label htmlFor="fleet-search" className="grid gap-1 text-sm text-muted-foreground">
|
||||||
<span>Search</span>
|
<span>Search</span>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
<Search className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
id="fleet-search"
|
||||||
className="pl-8"
|
className="pl-8"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
@@ -246,7 +250,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
values={knownFilters}
|
values={knownFilters}
|
||||||
onChange={(value) => setKnown(value as KnownFilter)}
|
onChange={(value) => setKnown(value as KnownFilter)}
|
||||||
/>
|
/>
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label htmlFor="fleet-agent" className="grid gap-1 text-sm text-muted-foreground">
|
||||||
<span>Agent</span>
|
<span>Agent</span>
|
||||||
<Select
|
<Select
|
||||||
value={agentId}
|
value={agentId}
|
||||||
@@ -334,6 +338,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<FleetDeviceDetailsDialog
|
<FleetDeviceDetailsDialog
|
||||||
|
key={details?.device_key ?? "__none"}
|
||||||
device={details}
|
device={details}
|
||||||
knownDevices={knownDevices}
|
knownDevices={knownDevices}
|
||||||
open={Boolean(details)}
|
open={Boolean(details)}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function TokensPage() {
|
|||||||
return (
|
return (
|
||||||
<section className="grid gap-4 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 gap-y-0">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Key className="size-5 text-primary" />
|
<Key className="size-5 text-primary" />
|
||||||
@@ -219,9 +219,10 @@ export function TokensPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{useCustom && (
|
{useCustom && (
|
||||||
<label className="mt-2 grid gap-1 text-sm text-muted-foreground">
|
<label htmlFor="token-custom-ttl" className="mt-2 grid gap-1 text-sm text-muted-foreground">
|
||||||
<span>TTL (seconds)</span>
|
<span>TTL (seconds)</span>
|
||||||
<Input
|
<Input
|
||||||
|
id="token-custom-ttl"
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={customTtl}
|
value={customTtl}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export function WakeToolsPage({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<label className="grid gap-1.5 text-sm text-muted-foreground">
|
<label htmlFor="wake-agent" className="grid gap-1.5 text-sm text-muted-foreground">
|
||||||
<span>Agent</span>
|
<span>Agent</span>
|
||||||
<AgentSelector
|
<AgentSelector
|
||||||
agents={agents}
|
agents={agents}
|
||||||
@@ -111,14 +111,14 @@ export function WakeToolsPage({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<form onSubmit={handleWake} className="grid gap-3">
|
<form onSubmit={handleWake} className="grid gap-3">
|
||||||
<label className="grid gap-1.5 text-sm text-muted-foreground">
|
<label htmlFor="wake-target" className="grid gap-1.5 text-sm text-muted-foreground">
|
||||||
<span>Target</span>
|
<span>Target</span>
|
||||||
<Input
|
<Input
|
||||||
|
id="wake-target"
|
||||||
value={target}
|
value={target}
|
||||||
onChange={(e) => setTarget(e.target.value)}
|
onChange={(e) => setTarget(e.target.value)}
|
||||||
placeholder="bedroom-pc, 192.168.1.100, or aa:bb:cc:dd:ee:ff"
|
placeholder="bedroom-pc, 192.168.1.100, or aa:bb:cc:dd:ee:ff"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
@@ -134,7 +134,7 @@ export function WakeToolsPage({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
<CardHeader className="flex flex-row items-center justify-between gap-2 gap-y-0">
|
||||||
<CardTitle>Recent Wakes</CardTitle>
|
<CardTitle>Recent Wakes</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -147,10 +147,10 @@ export function WakeToolsPage({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{recentWakes.length > 0 ? (
|
{recentWakes.length > 0 ? (
|
||||||
<div className="grid gap-0">
|
<div className="grid gap-0">
|
||||||
{recentWakes.map((event, idx) => (
|
{recentWakes.map((event, i) => (
|
||||||
<div
|
<div
|
||||||
className={`flex items-start justify-between gap-3 py-2.5 ${idx < recentWakes.length - 1 ? "border-b border-border/50" : ""}`}
|
className={`flex items-start justify-between gap-3 py-2.5 ${i < recentWakes.length - 1 ? "border-b border-border/50" : ""}`}
|
||||||
key={`${event.ts}-${idx}`}
|
key={`${event.ts}-${event.target}-${event.agentId}`}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Minus, Plus, Zap } from "lucide-react";
|
import { Minus, Plus, Zap } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -52,24 +52,14 @@ export function FleetDeviceDetailsDialog({
|
|||||||
onCopy,
|
onCopy,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState(device?.display_name ?? "");
|
||||||
const [targetDeviceId, setTargetDeviceId] = useState("");
|
const [targetDeviceId, setTargetDeviceId] = useState("");
|
||||||
const [routeId, setRouteId] = useState("");
|
const [routeId, setRouteId] = useState(device?.recommended_route?.route_id ?? "");
|
||||||
const [addKind, setAddKind] = useState<"mac" | "ip">("mac");
|
const [addKind, setAddKind] = useState<"mac" | "ip">("mac");
|
||||||
const [addValue, setAddValue] = useState("");
|
const [addValue, setAddValue] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [actionBusy, setActionBusy] = useState(false);
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDisplayName(device?.display_name ?? "");
|
|
||||||
setTargetDeviceId("");
|
|
||||||
setRouteId(device?.recommended_route?.route_id ?? "");
|
|
||||||
setAddKind("mac");
|
|
||||||
setAddValue("");
|
|
||||||
setError("");
|
|
||||||
setActionBusy(false);
|
|
||||||
}, [device]);
|
|
||||||
|
|
||||||
const rowIdentifiers = device ? identifiersFor(device) : [];
|
const rowIdentifiers = device ? identifiersFor(device) : [];
|
||||||
|
|
||||||
// Resolve the full KnownDevice object if this fleet row is known
|
// Resolve the full KnownDevice object if this fleet row is known
|
||||||
@@ -279,7 +269,7 @@ export function FleetDeviceDetailsDialog({
|
|||||||
<span className="font-medium">{fullKnown.display_name}</span>
|
<span className="font-medium">{fullKnown.display_name}</span>
|
||||||
{fullKnown.notes && (
|
{fullKnown.notes && (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
— {fullKnown.notes}
|
: {fullKnown.notes}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-1
@@ -135,7 +135,7 @@
|
|||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground transition-colors duration-100 ease-in-out;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
@@ -189,6 +189,7 @@
|
|||||||
background-size: 100% 100%;
|
background-size: 100% 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
transition: background 100ms ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sidebar internals ────────────────────────────────────────────────── */
|
/* ── Sidebar internals ────────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ fn add_agent_device_to_entry(
|
|||||||
let rid = route_id(&agent_id, Some(mac), ip_for_mac.as_ref(), "device");
|
let rid = route_id(&agent_id, Some(mac), ip_for_mac.as_ref(), "device");
|
||||||
let wakeable = status.connected && !device_offline;
|
let wakeable = status.connected && !device_offline;
|
||||||
entry.routes.insert(
|
entry.routes.insert(
|
||||||
|
// one per mac... when is one per mac/ip pair? this a regression.
|
||||||
rid.clone(),
|
rid.clone(),
|
||||||
FleetWakeRoute {
|
FleetWakeRoute {
|
||||||
route_id: rid,
|
route_id: rid,
|
||||||
|
|||||||
@@ -85,12 +85,9 @@ pub async fn refresh_fleet_devices(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
match serde_json::from_value::<Vec<wakey_core::Device>>(
|
match serde_json::from_value::<wakey_core::DeviceInventory>(result)
|
||||||
result
|
.map(|i| i.devices)
|
||||||
.get("devices")
|
{
|
||||||
.cloned()
|
|
||||||
.unwrap_or(serde_json::Value::Array(vec![])),
|
|
||||||
) {
|
|
||||||
Ok(devices) => {
|
Ok(devices) => {
|
||||||
match state
|
match state
|
||||||
.store
|
.store
|
||||||
@@ -276,6 +273,7 @@ async fn load_fleet_devices(
|
|||||||
let mut devices = build_fleet_devices(known_devices, agent_devices, &context);
|
let mut devices = build_fleet_devices(known_devices, agent_devices, &context);
|
||||||
filter_fleet_devices(&mut devices, query);
|
filter_fleet_devices(&mut devices, query);
|
||||||
let limit = query.limit.unwrap_or(500).clamp(1, 1000);
|
let limit = query.limit.unwrap_or(500).clamp(1, 1000);
|
||||||
|
// this is going to be 10 million times harder if i decide to do this lazily
|
||||||
devices.truncate(limit);
|
devices.truncate(limit);
|
||||||
Ok(devices)
|
Ok(devices)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ pub enum DeviceId {
|
|||||||
/// One raw source fact used while building a device aggregate.
|
/// One raw source fact used while building a device aggregate.
|
||||||
///
|
///
|
||||||
/// These are intentionally source-shaped and non-durable. They preserve details
|
/// These are intentionally source-shaped and non-durable. They preserve details
|
||||||
/// from hooks and live inventory so higher layers can explain why a device looks
|
/// from hooks (arp, dhcp, etc) so higher layers can explain why a device looks
|
||||||
/// online, stale, or unknown without reverse-engineering flattened fields.
|
/// online, stale, or unknown.
|
||||||
#[skip_serializing_none]
|
#[skip_serializing_none]
|
||||||
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
|
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
|
||||||
pub struct DeviceObservationFact {
|
pub struct DeviceObservationFact {
|
||||||
@@ -145,13 +145,14 @@ impl Device {
|
|||||||
}
|
}
|
||||||
presence = std::cmp::max(presence, Presence::from(neighbor.state));
|
presence = std::cmp::max(presence, Presence::from(neighbor.state));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut observed_non_remove = false;
|
let mut observed_non_remove = false;
|
||||||
for observation in &observations {
|
for observation in &observations {
|
||||||
if let Some(name) = observation.hostname.as_deref() {
|
if let Some(name) = observation.hostname.as_deref() {
|
||||||
names.insert(name);
|
names.insert(name);
|
||||||
}
|
}
|
||||||
if let Some(ip) = observation.ip {
|
if let Some(ip) = observation.ip {
|
||||||
ips.insert(ip);
|
ips.insert(ip); // should we just add removed IPs? Or a separate map of presence -> IP sets?
|
||||||
}
|
}
|
||||||
if let Some(mac) = observation.mac {
|
if let Some(mac) = observation.mac {
|
||||||
macs.insert(mac);
|
macs.insert(mac);
|
||||||
@@ -196,11 +197,13 @@ fn observation_presence(observation: &DeviceObservationFact) -> Presence {
|
|||||||
(_, "remove") => Presence::Offline,
|
(_, "remove") => Presence::Offline,
|
||||||
("neigh", "add" | "update" | "old") => Presence::LikelyOnline,
|
("neigh", "add" | "update" | "old") => Presence::LikelyOnline,
|
||||||
_ => Presence::Unknown,
|
_ => Presence::Unknown,
|
||||||
|
// dhcp events does not say anything about offline status, but this may shadow neighbor entries. This may be undesirable.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collection of merged discovered devices.
|
/// Collection of merged discovered devices.
|
||||||
#[derive(Debug, Default, Clone, Serialize)]
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||||
pub struct DeviceInventory {
|
pub struct DeviceInventory {
|
||||||
|
#[serde(default)]
|
||||||
pub devices: Vec<Device>,
|
pub devices: Vec<Device>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,11 +235,11 @@ pub async fn observe_dhcp_event(
|
|||||||
row.hostname = hostname.clone();
|
row.hostname = hostname.clone();
|
||||||
row.last_action = action.to_string();
|
row.last_action = action.to_string();
|
||||||
row.last_seen_unix = now;
|
row.last_seen_unix = now;
|
||||||
changed = true;
|
changed = true; // update row
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.or_insert_with(|| {
|
.or_insert_with(|| {
|
||||||
changed = true;
|
changed = true; // insert new row
|
||||||
ObservedDhcpClient {
|
ObservedDhcpClient {
|
||||||
mac: mac_s.clone(),
|
mac: mac_s.clone(),
|
||||||
ip,
|
ip,
|
||||||
@@ -258,7 +258,7 @@ pub async fn observe_dhcp_event(
|
|||||||
if cache.get(&mac_s).map(|v| v != &hostname).unwrap_or(true) {
|
if cache.get(&mac_s).map(|v| v != &hostname).unwrap_or(true) {
|
||||||
cache.insert(mac_s, hostname);
|
cache.insert(mac_s, hostname);
|
||||||
save_mac_name_cache(&cache).await?;
|
save_mac_name_cache(&cache).await?;
|
||||||
changed = true;
|
changed = true; // update mac -> name cache...
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +292,7 @@ pub async fn observe_neighbor_event(
|
|||||||
.find_map(|alias| store.neighbors.remove(alias))
|
.find_map(|alias| store.neighbors.remove(alias))
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
changed = true;
|
changed = true; // append
|
||||||
ObservedNeighbor {
|
ObservedNeighbor {
|
||||||
key: key.clone(),
|
key: key.clone(),
|
||||||
mac: mac.clone(),
|
mac: mac.clone(),
|
||||||
@@ -304,7 +304,7 @@ pub async fn observe_neighbor_event(
|
|||||||
});
|
});
|
||||||
for alias in alias_keys {
|
for alias in alias_keys {
|
||||||
if store.neighbors.remove(&alias).is_some() {
|
if store.neighbors.remove(&alias).is_some() {
|
||||||
changed = true;
|
changed = true; // remove old alias
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if row.key != key
|
if row.key != key
|
||||||
@@ -318,7 +318,7 @@ pub async fn observe_neighbor_event(
|
|||||||
row.ip = ip;
|
row.ip = ip;
|
||||||
row.last_action = action.clone();
|
row.last_action = action.clone();
|
||||||
row.last_seen_unix = now;
|
row.last_seen_unix = now;
|
||||||
changed = true;
|
changed = true; // something changed
|
||||||
}
|
}
|
||||||
store.neighbors.insert(key, row);
|
store.neighbors.insert(key, row);
|
||||||
if let (Some(mac), Some(ip)) = (mac.as_deref(), ip)
|
if let (Some(mac), Some(ip)) = (mac.as_deref(), ip)
|
||||||
@@ -387,6 +387,7 @@ fn mark_replaced_neighbor_ips_removed(
|
|||||||
let Some(row_ip) = row.ip else {
|
let Some(row_ip) = row.ip else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
// found another ip for same mac, mark it as removed
|
||||||
if row.mac.as_deref() == Some(mac)
|
if row.mac.as_deref() == Some(mac)
|
||||||
&& row_ip != current_ip
|
&& row_ip != current_ip
|
||||||
&& same_ip_family(row_ip, current_ip)
|
&& same_ip_family(row_ip, current_ip)
|
||||||
@@ -412,7 +413,7 @@ mod tests {
|
|||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
struct EnvGuard {
|
struct EnvGuard {
|
||||||
keys: Vec<&'static str>,
|
keys: Vec<&'static str>, // vec of 1 key
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EnvGuard {
|
impl EnvGuard {
|
||||||
|
|||||||
Reference in New Issue
Block a user