misc changes

This commit is contained in:
lda
2026-05-15 12:11:45 +07:00 Verified
parent 3074fbb41a
commit 6fc3dee12d
18 changed files with 115 additions and 90 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ start_service() {
procd_open_instance
procd_set_param command "$BIN" serve --config "$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 stdout 1
procd_set_param stderr 1
+20 -19
View File
@@ -77,7 +77,7 @@ pub fn merge_devices_with_observations(
let key = row
.mac
.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);
}
for lease in leases {
@@ -99,30 +99,31 @@ pub fn merge_devices_with_observations(
Device::from_parts_with_observations(neighbors, leases, observations)
})
.collect();
if !query.is_empty() {
let mut texts = Vec::new();
let mut devs = Vec::new();
let mut ips = Vec::new();
let mut macs = Vec::new();
let mut nuds = Vec::new();
let mut texts = Vec::new();
let mut devs = Vec::new();
let mut ips = Vec::new();
let mut macs = Vec::new();
let mut nuds = Vec::new();
for term in query {
match term {
Query::Text(v) => texts.push(v.as_str()),
Query::Interface(v) => devs.push(v.as_str()),
Query::Ip(v) => ips.push(*v),
Query::Mac(v) => macs.push(*v),
Query::NeighborState(v) => nuds.push(*v),
};
}
for term in query {
match term {
Query::Text(v) => texts.push(v.as_str()),
Query::Interface(v) => devs.push(v.as_str()),
Query::Ip(v) => ips.push(*v),
Query::Mac(v) => macs.push(*v),
Query::NeighborState(v) => nuds.push(*v),
};
}
devices.retain(|device| {
(texts.is_empty() || device.names.iter().any(|n| texts.contains(&n.as_str())))
devices.retain(|device| {
(texts.is_empty() || device.names.iter().any(|n| texts.contains(&n.as_str())))
&& (devs.is_empty() || device.interfaces.iter().any(|i| devs.contains(&i.as_str()))) // same pattern as iter().any
&& (ips.is_empty() || device.ips.iter().any(|ip| ips.contains(ip)))
&& (macs.is_empty() || device.macs.iter().any(|mac| macs.contains(mac)))
&& (nuds.is_empty() || device.neighbors.iter().any(|n| nuds.contains(&n.state)))
});
});
}
devices.sort_by(|a, b| {
presence_rank(b.presence)
.cmp(&presence_rank(a.presence))
+14
View File
@@ -4,6 +4,20 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
<body>
<div id="root"></div>
+1
View File
@@ -144,6 +144,7 @@ export function App() {
selectedAgentId={selectedAgentId}
onSelectAgent={setSelectedAgentId}
onAfterWake={loadAll}
onRefresh={loadAll}
/>
}
/>
+7 -1
View File
@@ -93,7 +93,7 @@ export function AppLayout() {
className={[
"sidebar",
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",
collapsed ? "-translate-x-full md:translate-x-0" : "translate-x-0",
]
@@ -219,8 +219,14 @@ export function AppLayout() {
{/* Mobile backdrop */}
{!collapsed && (
<div
role="button"
tabIndex={0}
className="fixed inset-0 z-40 bg-black/20 md:hidden animate-in fade-in duration-200"
onClick={() => setCollapsed(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setCollapsed(true);
}}
aria-label="Close menu"
/>
)}
+8 -7
View File
@@ -72,7 +72,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
</CardHeader>
<CardContent className="space-y-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
<Select
value={severity}
@@ -80,7 +80,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
if (value) setSeverity(value);
}}
>
<SelectTrigger className="w-full">
<SelectTrigger id="alert-severity" className="w-full">
<SelectValue placeholder="Severity" />
</SelectTrigger>
<SelectContent>
@@ -93,7 +93,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
</Select>
</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
<Select
value={status}
@@ -101,7 +101,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
if (value) setStatus(value);
}}
>
<SelectTrigger className="w-full">
<SelectTrigger id="alert-status" className="w-full">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
@@ -114,7 +114,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
</Select>
</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
<Select
value={kind}
@@ -122,7 +122,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
if (value) setKind(value);
}}
>
<SelectTrigger className="w-full">
<SelectTrigger id="alert-kind" className="w-full">
<SelectValue placeholder="Kind" />
</SelectTrigger>
<SelectContent>
@@ -170,9 +170,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
</span>
</CardHeader>
<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
<Input
id="alert-transition-search"
value={transitionQ}
onChange={(e) => setTransitionQ(e.target.value)}
placeholder="kind, status, message, agent"
+6 -5
View File
@@ -70,7 +70,7 @@ export function AuditPage({ events, onRefresh }: Props) {
<CardContent className="space-y-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
<Select
value={eventType}
@@ -78,7 +78,7 @@ export function AuditPage({ events, onRefresh }: Props) {
if (value) setEventType(value);
}}
>
<SelectTrigger className="w-full">
<SelectTrigger id="audit-event-type" className="w-full">
<SelectValue placeholder="Event type" />
</SelectTrigger>
<SelectContent>
@@ -91,7 +91,7 @@ export function AuditPage({ events, onRefresh }: Props) {
</Select>
</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
<Select
value={outcome}
@@ -99,7 +99,7 @@ export function AuditPage({ events, onRefresh }: Props) {
if (value) setOutcome(value);
}}
>
<SelectTrigger className="w-full">
<SelectTrigger id="audit-outcome" className="w-full">
<SelectValue placeholder="Outcome" />
</SelectTrigger>
<SelectContent>
@@ -112,9 +112,10 @@ export function AuditPage({ events, onRefresh }: Props) {
</Select>
</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
<Input
id="audit-search"
value={needle}
onChange={(e) => setNeedle(e.target.value)}
placeholder="message, agent, actor"
+6 -5
View File
@@ -66,12 +66,12 @@ export function CommandsPage({
Command Runner
</CardTitle>
<CardDescription>
Debug tool send raw commands to agents and inspect JSON responses
Debug tool: send raw commands to agents and inspect JSON responses
</CardDescription>
</CardHeader>
<CardContent>
<form className="grid gap-3" onSubmit={submit}>
<label className="grid gap-1 text-sm text-muted-foreground">
<label htmlFor="cmd-agent" className="grid gap-1 text-sm text-muted-foreground">
<span>Agent</span>
<AgentSelector
agents={agents}
@@ -80,13 +80,13 @@ export function CommandsPage({
/>
</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>
<Select
value={kind}
onValueChange={(value) => setKind(value as CommandKind)}
>
<SelectTrigger className="w-full">
<SelectTrigger id="cmd-kind" className="w-full">
<SelectValue placeholder="Pick command" />
</SelectTrigger>
<SelectContent>
@@ -98,9 +98,10 @@ export function CommandsPage({
</Select>
</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>
<Input
id="cmd-query"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="optional"
+5 -5
View File
@@ -115,7 +115,7 @@ export function DashboardPage({
<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">
<CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
<CardDescription className="flex items-center gap-1.5">
<Bot className="size-3.5" />
Fleet Status
@@ -152,7 +152,7 @@ export function DashboardPage({
{/* Active Alerts */}
<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">
<Bell className="size-3.5" />
Active Alerts
@@ -188,7 +188,7 @@ export function DashboardPage({
{/* Transitions */}
<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">
<Activity className="size-3.5" />
Transitions
@@ -206,7 +206,7 @@ export function DashboardPage({
{/* Token Health */}
<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">
<Key className="size-3.5" />
Enroll Tokens
@@ -228,7 +228,7 @@ export function DashboardPage({
{/* 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">
<CardHeader className="flex flex-row items-start justify-between gap-3 gap-y-0">
<div>
<CardTitle>Recent Activity</CardTitle>
<CardDescription className="mt-1">
+11 -6
View File
@@ -51,9 +51,10 @@ type Props = {
selectedAgentId: string;
onSelectAgent: (agentId: string) => 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 [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]);
const [query, setQuery] = useState("");
@@ -137,8 +138,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
} catch {
const area = document.createElement("textarea");
area.value = text;
area.style.position = "fixed";
area.style.opacity = "0";
area.style.cssText = "position:fixed;opacity:0";
document.body.appendChild(area);
area.select();
document.execCommand("copy");
@@ -201,7 +201,10 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
<Button
variant="outline"
size="sm"
onClick={() => void loadFleet()}
onClick={() => {
void loadFleet();
if (onRefresh) void onRefresh();
}}
disabled={loading}
>
<RefreshCw className="size-4" aria-hidden />
@@ -222,11 +225,12 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
</CardHeader>
<CardContent className="space-y-3">
<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>
<div className="relative">
<Search className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
<Input
id="fleet-search"
className="pl-8"
value={query}
onChange={(event) => setQuery(event.target.value)}
@@ -246,7 +250,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
values={knownFilters}
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>
<Select
value={agentId}
@@ -334,6 +338,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
</Card>
<FleetDeviceDetailsDialog
key={details?.device_key ?? "__none"}
device={details}
knownDevices={knownDevices}
open={Boolean(details)}
+3 -2
View File
@@ -118,7 +118,7 @@ export function TokensPage() {
return (
<section className="grid gap-4 xl:grid-cols-2">
<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>
<CardTitle className="flex items-center gap-2">
<Key className="size-5 text-primary" />
@@ -219,9 +219,10 @@ export function TokensPage() {
</Button>
</div>
{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>
<Input
id="token-custom-ttl"
type="number"
min={1}
value={customTtl}
+7 -7
View File
@@ -101,7 +101,7 @@ export function WakeToolsPage({
</CardDescription>
</CardHeader>
<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>
<AgentSelector
agents={agents}
@@ -111,14 +111,14 @@ export function WakeToolsPage({
</label>
<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>
<Input
id="wake-target"
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
@@ -134,7 +134,7 @@ export function WakeToolsPage({
</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>
<Button
variant="outline"
@@ -147,10 +147,10 @@ export function WakeToolsPage({
<CardContent>
{recentWakes.length > 0 ? (
<div className="grid gap-0">
{recentWakes.map((event, idx) => (
{recentWakes.map((event, i) => (
<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}`}
className={`flex items-start justify-between gap-3 py-2.5 ${i < recentWakes.length - 1 ? "border-b border-border/50" : ""}`}
key={`${event.ts}-${event.target}-${event.agentId}`}
>
<div className="min-w-0">
<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 { toast } from "sonner";
@@ -52,24 +52,14 @@ export function FleetDeviceDetailsDialog({
onCopy,
onChanged,
}: Props) {
const [displayName, setDisplayName] = useState("");
const [displayName, setDisplayName] = useState(device?.display_name ?? "");
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 [addValue, setAddValue] = useState("");
const [error, setError] = useState("");
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) : [];
// 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>
{fullKnown.notes && (
<span className="text-muted-foreground">
{fullKnown.notes}
: {fullKnown.notes}
</span>
)}
</div>
+2 -1
View File
@@ -135,7 +135,7 @@
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground transition-colors duration-100 ease-in-out;
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -189,6 +189,7 @@
background-size: 100% 100%;
overflow-y: auto;
max-width: 100%;
transition: background 100ms ease-in-out;
}
/* ── 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 wakeable = status.connected && !device_offline;
entry.routes.insert(
// one per mac... when is one per mac/ip pair? this a regression.
rid.clone(),
FleetWakeRoute {
route_id: rid,
@@ -85,12 +85,9 @@ pub async fn refresh_fleet_devices(
});
continue;
};
match serde_json::from_value::<Vec<wakey_core::Device>>(
result
.get("devices")
.cloned()
.unwrap_or(serde_json::Value::Array(vec![])),
) {
match serde_json::from_value::<wakey_core::DeviceInventory>(result)
.map(|i| i.devices)
{
Ok(devices) => {
match state
.store
@@ -276,6 +273,7 @@ async fn load_fleet_devices(
let mut devices = build_fleet_devices(known_devices, agent_devices, &context);
filter_fleet_devices(&mut devices, query);
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);
Ok(devices)
}
+7 -4
View File
@@ -74,8 +74,8 @@ pub enum DeviceId {
/// One raw source fact used while building a device aggregate.
///
/// 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
/// online, stale, or unknown without reverse-engineering flattened fields.
/// from hooks (arp, dhcp, etc) so higher layers can explain why a device looks
/// online, stale, or unknown.
#[skip_serializing_none]
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
pub struct DeviceObservationFact {
@@ -145,13 +145,14 @@ impl Device {
}
presence = std::cmp::max(presence, Presence::from(neighbor.state));
}
let mut observed_non_remove = false;
for observation in &observations {
if let Some(name) = observation.hostname.as_deref() {
names.insert(name);
}
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 {
macs.insert(mac);
@@ -196,11 +197,13 @@ fn observation_presence(observation: &DeviceObservationFact) -> Presence {
(_, "remove") => Presence::Offline,
("neigh", "add" | "update" | "old") => Presence::LikelyOnline,
_ => 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.
#[derive(Debug, Default, Clone, Serialize)]
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct DeviceInventory {
#[serde(default)]
pub devices: Vec<Device>,
}
+8 -7
View File
@@ -235,11 +235,11 @@ pub async fn observe_dhcp_event(
row.hostname = hostname.clone();
row.last_action = action.to_string();
row.last_seen_unix = now;
changed = true;
changed = true; // update row
}
})
.or_insert_with(|| {
changed = true;
changed = true; // insert new row
ObservedDhcpClient {
mac: mac_s.clone(),
ip,
@@ -258,7 +258,7 @@ pub async fn observe_dhcp_event(
if cache.get(&mac_s).map(|v| v != &hostname).unwrap_or(true) {
cache.insert(mac_s, hostname);
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))
})
.unwrap_or_else(|| {
changed = true;
changed = true; // append
ObservedNeighbor {
key: key.clone(),
mac: mac.clone(),
@@ -304,7 +304,7 @@ pub async fn observe_neighbor_event(
});
for alias in alias_keys {
if store.neighbors.remove(&alias).is_some() {
changed = true;
changed = true; // remove old alias
}
}
if row.key != key
@@ -318,7 +318,7 @@ pub async fn observe_neighbor_event(
row.ip = ip;
row.last_action = action.clone();
row.last_seen_unix = now;
changed = true;
changed = true; // something changed
}
store.neighbors.insert(key, row);
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 {
continue;
};
// found another ip for same mac, mark it as removed
if row.mac.as_deref() == Some(mac)
&& row_ip != current_ip
&& same_ip_family(row_ip, current_ip)
@@ -412,7 +413,7 @@ mod tests {
use serial_test::serial;
struct EnvGuard {
keys: Vec<&'static str>,
keys: Vec<&'static str>, // vec of 1 key
}
impl EnvGuard {