Banger? idk

This commit is contained in:
lda committed 2026-04-14 03:18:41 +07:00
1 parent f52b7c18d9
commit e219dcc724
20 files changed
+4834 -1480

No files matched your search

+74 -46
View File
@@ -1,5 +1,16 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { runCommand, type Agent, type CommandKind } from "@/api";
type Props = {
@@ -37,51 +48,68 @@ export function CommandsPage({
}
return (
<section className="card">
<h2>Command Runner</h2>
<form className="form" onSubmit={submit}>
<label>
Agent
<select
value={selectedAgentId}
onChange={(e) => onSelectAgent(e.target.value)}
required
>
<option value="" disabled>
Select agent
</option>
{agents.map((agent) => (
<option key={agent.agent_id} value={agent.agent_id}>
{agent.agent_id} ({agent.connected ? "connected" : "offline"})
</option>
))}
</select>
</label>
<label>
Command
<select
value={kind}
onChange={(e) => setKind(e.target.value as CommandKind)}
>
<option value="devs">devs</option>
<option value="leases">leases</option>
<option value="inventory">inventory</option>
<option value="wake">wake</option>
</select>
</label>
<label>
Query
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="optional"
/>
</label>
<button type="submit" disabled={running || !selectedAgentId}>
{running ? "Running..." : "Run command"}
</button>
</form>
<pre className="output">{output}</pre>
</section>
<Card>
<CardHeader>
<CardTitle>Command Runner</CardTitle>
</CardHeader>
<CardContent>
<form className="form" onSubmit={submit}>
<label>
Agent
<Select
value={selectedAgentId}
onValueChange={(value) => {
if (value) onSelectAgent(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select agent" />
</SelectTrigger>
<SelectContent>
{agents.map((agent) => (
<SelectItem key={agent.agent_id} value={agent.agent_id}>
{agent.agent_id} (
{agent.connected ? "connected" : "offline"})
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label>
Command
<Select
value={kind}
onValueChange={(value) => setKind(value as CommandKind)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pick command" />
</SelectTrigger>
<SelectContent>
<SelectItem value="devs">devs</SelectItem>
<SelectItem value="leases">leases</SelectItem>
<SelectItem value="inventory">inventory</SelectItem>
<SelectItem value="wake">wake</SelectItem>
</SelectContent>
</Select>
</label>
<label>
Query
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="optional"
/>
</label>
<Button type="submit" disabled={running || !selectedAgentId}>
{running ? "Running..." : "Run command"}
</Button>
</form>
<Textarea className="output" value={output} readOnly />
</CardContent>
</Card>
);
}
+156 -444
View File
@@ -1,21 +1,35 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
// Utility for sorting
const sorters = {
name: (a: DeviceRow, b: DeviceRow) => a.name.localeCompare(b.name),
ip: (a: DeviceRow, b: DeviceRow) =>
(a.ips[0] || "").localeCompare(b.ips[0] || ""),
mac: (a: DeviceRow, b: DeviceRow) =>
(a.macs[0] || "").localeCompare(b.macs[0] || ""),
presence: (a: DeviceRow, b: DeviceRow) =>
a.presence.localeCompare(b.presence),
};
type SortKey = keyof typeof sorters;
type SortDir = "asc" | "desc";
type PresenceFilter = "all" | "online" | "likely_online" | "unknown";
import { useLocation, useNavigate } from "react-router-dom";
import { runCommand, type Agent } from "@/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DeviceInventoryTable } from "@/pages/devices/DeviceInventoryTable";
import { WakeHistoryCard } from "@/pages/devices/WakeHistoryCard";
import type {
DeviceRow,
PresenceFilter,
SortDir,
WakeEvent,
} from "@/pages/devices/types";
import {
chooseWakeTarget,
loadHistory,
parseInventoryRows,
parseWakeSummary,
PRESENCE_FILTERS,
saveHistory,
sorters,
type SortKey,
} from "@/pages/devices/utils";
type Props = {
agents: Agent[];
@@ -24,169 +38,6 @@ type Props = {
onAfterWake?: () => Promise<void>;
};
type DeviceRow = {
id: string;
name: string;
ips: string[];
macs: string[];
interfaces: string[];
presence: string;
};
type WakeEvent = {
ts: number;
target: string;
agentId: string;
outcome: string;
requestId: string;
detail: string;
};
const WAKE_HISTORY_KEY = "wakey_recent_wakes_v1";
const PRESENCE_FILTERS: PresenceFilter[] = [
"all",
"online",
"likely_online",
"unknown",
];
function parseInventoryRows(payload: unknown): DeviceRow[] {
if (!payload || typeof payload !== "object") return [];
const obj = payload as Record<string, unknown>;
if (obj.kind !== "inventory") return [];
if (!obj.devices || !Array.isArray(obj.devices)) return [];
return obj.devices
.map((raw, idx) => {
if (!raw || typeof raw !== "object") return null;
const device = raw as Record<string, unknown>;
const names = Array.isArray(device.names)
? (device.names.filter((v) => typeof v === "string") as string[])
: [];
const ips = Array.isArray(device.ips)
? (device.ips.filter((v) => typeof v === "string") as string[])
: [];
const macs = Array.isArray(device.macs)
? (device.macs.filter((v) => typeof v === "string") as string[])
: [];
const interfaces = Array.isArray(device.interfaces)
? (device.interfaces.filter((v) => typeof v === "string") as string[])
: [];
const presence =
typeof device.presence === "string" ? device.presence : "unknown";
const id = macs[0] || ips[0] || names[0] || `row-${idx}`;
return {
id,
name: names[0] || "(unnamed)",
ips,
macs,
interfaces,
presence,
};
})
.filter((v): v is DeviceRow => Boolean(v));
}
function parseWakeSummary(response: unknown): {
outcome: string;
requestId: string;
detail: string;
} {
if (!response || typeof response !== "object") {
return { outcome: "error", requestId: "", detail: "invalid wake response" };
}
const envelope = response as Record<string, unknown>;
const requestId =
typeof envelope.request_id === "string" ? envelope.request_id : "";
const status =
typeof envelope.status === "string" ? envelope.status : "error";
if (status !== "ok") {
const error = envelope.error as Record<string, unknown> | undefined;
const detail =
typeof error?.message === "string" ? error.message : "wake failed";
return { outcome: "error", requestId, detail };
}
const result = envelope.result as Record<string, unknown> | undefined;
if (result?.kind !== "wake") {
return { outcome: "ok", requestId, detail: "wake dispatched" };
}
const entries = Array.isArray(result.result) ? result.result : [];
const targetOutcomes = entries
.map((row) => {
if (!row || typeof row !== "object") return "";
const rowObj = row as Record<string, unknown>;
const statusRaw = rowObj.status;
const status =
typeof statusRaw === "string"
? statusRaw
: typeof (statusRaw as Record<string, unknown> | undefined)?.kind ===
"string"
? String((statusRaw as Record<string, unknown>).kind)
: "unknown";
const ip = typeof rowObj.ip === "string" ? rowObj.ip : "?";
const mac = typeof rowObj.mac === "string" ? rowObj.mac : "?";
return `${status}(${ip}/${mac})`;
})
.filter(Boolean);
const detail = targetOutcomes.length
? targetOutcomes.join(", ")
: "wake dispatched";
const failed = targetOutcomes.some(
(s) =>
s.startsWith("incomplete") ||
s.startsWith("nonexistent_address") ||
s.startsWith("wrong_size"),
);
return { outcome: failed ? "error" : "ok", requestId, detail };
}
function chooseWakeTarget(device: DeviceRow): string {
return device.name !== "(unnamed)"
? device.name
: device.macs[0] || device.ips[0] || "";
}
function summarize(values: string[]): string {
if (!values.length) return "-";
if (values.length === 1) return values[0];
return `${values[0]} (+${values.length - 1})`;
}
function loadHistory(): WakeEvent[] {
try {
const raw = window.localStorage.getItem(WAKE_HISTORY_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter((row): row is WakeEvent => {
if (!row || typeof row !== "object") return false;
const event = row as Record<string, unknown>;
return (
typeof event.ts === "number" &&
typeof event.target === "string" &&
typeof event.agentId === "string" &&
typeof event.outcome === "string" &&
typeof event.requestId === "string" &&
typeof event.detail === "string"
);
});
} catch {
return [];
}
}
function saveHistory(history: WakeEvent[]) {
window.localStorage.setItem(
WAKE_HISTORY_KEY,
JSON.stringify(history.slice(0, 20)),
);
}
export function DevicesPage({
agents,
selectedAgentId,
@@ -437,292 +288,153 @@ export function DevicesPage({
return (
<section className="two-col">
<div className="card">
<div className="row-head">
<h2>Devices</h2>
<button
<Card>
<CardHeader className="row-head">
<CardTitle>Devices</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => void loadInventory()}
disabled={loading || !selectedAgentId}
>
{loading ? "Refreshing..." : "Refresh"}
</button>
</div>
</Button>
</CardHeader>
<div className="grid-3 compact">
<label>
Agent
<select
value={selectedAgentId}
onChange={(e) => onSelectAgent(e.target.value)}
required
<CardContent>
<div className="grid-3 compact">
<label>
Agent
<Select
value={selectedAgentId}
onValueChange={(value) => {
if (value) onSelectAgent(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select agent" />
</SelectTrigger>
<SelectContent>
{agents.map((agent) => (
<SelectItem key={agent.agent_id} value={agent.agent_id}>
{agent.agent_id} (
{agent.connected ? "connected" : "offline"})
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="span-2">
Search
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="name, ip, mac, interface"
/>
</label>
</div>
<div className="quick-wake">
<label>
Quick Wake (name, IP, or MAC)
<Input
value={quickWake}
onChange={(e) => setQuickWake(e.target.value)}
placeholder="bedroom-pc or aa:bb:cc:dd:ee:ff"
/>
</label>
<Button
onClick={() => void wakeTarget(quickWake.trim(), "quick")}
disabled={
!selectedAgentId || !quickWake.trim() || wakeBusyId === "quick"
}
>
<option value="" disabled>
Select agent
</option>
{agents.map((agent) => (
<option key={agent.agent_id} value={agent.agent_id}>
{agent.agent_id} ({agent.connected ? "connected" : "offline"})
</option>
))}
</select>
</label>
<label className="span-2">
Search
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="name, ip, mac, interface"
/>
</label>
</div>
{wakeBusyId === "quick" ? "Waking..." : "Wake target"}
</Button>
</div>
<div className="quick-wake">
<label>
Quick Wake (name, IP, or MAC)
<input
value={quickWake}
onChange={(e) => setQuickWake(e.target.value)}
placeholder="bedroom-pc or aa:bb:cc:dd:ee:ff"
/>
</label>
<button
onClick={() => void wakeTarget(quickWake.trim(), "quick")}
disabled={
!selectedAgentId || !quickWake.trim() || wakeBusyId === "quick"
}
<div
className="presence-filters"
role="group"
aria-label="Filter by presence"
>
{wakeBusyId === "quick" ? "Waking..." : "Wake target"}
</button>
</div>
{PRESENCE_FILTERS.map((value) => (
<Button
key={value}
variant={presenceFilter === value ? "secondary" : "outline"}
size="xs"
onClick={() => setPresenceFilter(value)}
type="button"
>
{value === "all" ? "all" : value.replace("_", " ")}
</Button>
))}
</div>
<div
className="presence-filters"
role="group"
aria-label="Filter by presence"
>
{PRESENCE_FILTERS.map((value) => (
<button
key={value}
className={`filter-chip ${presenceFilter === value ? "active" : ""}`}
onClick={() => setPresenceFilter(value)}
type="button"
>
{value === "all" ? "all" : value.replace("_", " ")}
</button>
))}
</div>
<div className="bulk-actions">
<label className="select-visible">
<input
type="checkbox"
checked={allVisibleSelected}
onChange={(e) => toggleAllVisible(e.target.checked)}
disabled={!filtered.length}
/>
Select visible
</label>
<button
onClick={() => void wakeSelectedDevices()}
disabled={!selectedVisibleCount || !selectedAgentId || bulkWakeBusy}
type="button"
>
{bulkWakeBusy
? "Waking selected..."
: `Wake selected (${selectedVisibleCount})`}
</button>
<button
onClick={() => setSelectedIds([])}
disabled={!selectedIds.length}
type="button"
>
Clear selection
</button>
{copyStatus && <span className="muted">{copyStatus}</span>}
</div>
<p className="muted">
Showing {filtered.length} of {rows.length}
</p>
{error && <pre className="error">{error}</pre>}
<div className="list device-list">
<div className="row plain device-row device-header">
<span className="device-cell device-select">
<div className="bulk-actions">
<label className="select-visible">
<input
type="checkbox"
checked={allVisibleSelected}
onChange={(e) => toggleAllVisible(e.target.checked)}
disabled={!filtered.length}
aria-label="Select all visible devices"
/>
</span>
<span
className="sortable-col device-cell"
onClick={() =>
setSort((s) => ({
key: "name",
dir: s.key === "name" && s.dir === "asc" ? "desc" : "asc",
}))
Select visible
</label>
<Button
onClick={() => void wakeSelectedDevices()}
disabled={
!selectedVisibleCount || !selectedAgentId || bulkWakeBusy
}
type="button"
size="sm"
>
Name {sort.key === "name" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
</span>
<span
className="sortable-col device-cell"
onClick={() =>
setSort((s) => ({
key: "ip",
dir: s.key === "ip" && s.dir === "asc" ? "desc" : "asc",
}))
}
{bulkWakeBusy
? "Waking selected..."
: `Wake selected (${selectedVisibleCount})`}
</Button>
<Button
onClick={() => setSelectedIds([])}
disabled={!selectedIds.length}
type="button"
variant="outline"
size="sm"
>
IP {sort.key === "ip" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
</span>
<span
className="sortable-col device-cell"
onClick={() =>
setSort((s) => ({
key: "mac",
dir: s.key === "mac" && s.dir === "asc" ? "desc" : "asc",
}))
}
>
MAC {sort.key === "mac" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
</span>
<span
className="sortable-col device-cell"
onClick={() =>
setSort((s) => ({
key: "presence",
dir: s.key === "presence" && s.dir === "asc" ? "desc" : "asc",
}))
}
>
Presence{" "}
{sort.key === "presence" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
</span>
<span className="device-cell">Interfaces</span>
<span className="device-cell device-action">Actions</span>
Clear selection
</Button>
{copyStatus && <span className="muted">{copyStatus}</span>}
</div>
{filtered.map((row) => (
<div className="row plain device-row" key={row.id}>
<span className="device-cell device-select" data-label="Pick">
<input
type="checkbox"
checked={selectedIds.includes(row.id)}
onChange={(e) => toggleRowSelection(row.id, e.target.checked)}
aria-label={`Select ${row.name}`}
/>
</span>
<span className="device-cell" data-label="Name" title={row.name}>
{row.name}
</span>
<span
className="device-cell muted"
data-label="IP"
title={row.ips.join(", ") || "-"}
>
{summarize(row.ips)}
</span>
<span
className="device-cell muted"
data-label="MAC"
title={row.macs.join(", ") || "-"}
>
{summarize(row.macs)}
</span>
<span className="device-cell" data-label="Presence">
<span className="pill">{row.presence}</span>
</span>
<span
className="device-cell"
data-label="Interfaces"
title={row.interfaces.join(", ") || "-"}
>
{summarize(row.interfaces)}
</span>
<span className="device-cell device-action" data-label="">
<button
onClick={() => void wakeDevice(row)}
disabled={
wakeBusyId === row.id || !selectedAgentId || bulkWakeBusy
}
>
{wakeBusyId === row.id ? "Waking..." : "Wake"}
</button>
<button
type="button"
className="mini-btn"
onClick={() => void copyValue("name", chooseWakeTarget(row))}
>
Copy name
</button>
<button
type="button"
className="mini-btn"
onClick={() => void copyValue("ip", row.ips[0] || "")}
>
Copy IP
</button>
<button
type="button"
className="mini-btn"
onClick={() => void copyValue("mac", row.macs[0] || "")}
>
Copy MAC
</button>
</span>
</div>
))}
{!filtered.length && <div className="empty">No devices found</div>}
</div>
</div>
<div className="card">
<div className="row-head">
<h2>Recent Wake Actions</h2>
<button
onClick={() => {
setRecentWakes([]);
saveHistory([]);
}}
>
Clear
</button>
</div>
<div className="list">
{recentWakes.map((event, idx) => (
<div
className="row plain"
key={`${event.ts}-${event.target}-${idx}`}
>
<div>
<strong>{event.target}</strong>
<div className="muted">
{new Date(event.ts).toLocaleString()} on {event.agentId}
</div>
<div className="muted">{event.detail}</div>
{event.requestId && (
<Link
className="muted linkish"
to={`/audit?q=${encodeURIComponent(event.requestId)}`}
>
View related audit ({event.requestId})
</Link>
)}
</div>
<span
className={`pill ${event.outcome === "ok" ? "ready" : "error"}`}
>
{event.outcome}
</span>
</div>
))}
{!recentWakes.length && (
<div className="empty">No wake actions yet</div>
)}
</div>
</div>
<p className="muted">
Showing {filtered.length} of {rows.length}
</p>
{error && <pre className="error">{error}</pre>}
<DeviceInventoryTable
rows={filtered}
selectedIds={selectedIds}
sort={sort}
allVisibleSelected={allVisibleSelected}
wakeBusyId={wakeBusyId}
selectedAgentId={selectedAgentId}
bulkWakeBusy={bulkWakeBusy}
onToggleAllVisible={toggleAllVisible}
onToggleRow={toggleRowSelection}
onSortChange={setSort}
onWakeDevice={(device) => void wakeDevice(device)}
onCopyValue={(label, value) => void copyValue(label, value)}
/>
</CardContent>
</Card>
<WakeHistoryCard
events={recentWakes}
onClear={() => {
setRecentWakes([]);
saveHistory([]);
}}
/>
</section>
);
}
@@ -0,0 +1,163 @@
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import type { DeviceRow, SortDir } from "@/pages/devices/types";
import {
chooseWakeTarget,
nextSort,
summarize,
type SortKey,
} from "@/pages/devices/utils";
type Props = {
rows: DeviceRow[];
selectedIds: string[];
sort: { key: SortKey; dir: SortDir };
allVisibleSelected: boolean;
wakeBusyId: string;
selectedAgentId: string;
bulkWakeBusy: boolean;
onToggleAllVisible: (checked: boolean) => void;
onToggleRow: (id: string, checked: boolean) => void;
onSortChange: (next: { key: SortKey; dir: SortDir }) => void;
onWakeDevice: (device: DeviceRow) => void;
onCopyValue: (label: string, value: string) => void;
};
function sortArrow(active: boolean, dir: SortDir) {
if (!active) return "";
return dir === "asc" ? "▲" : "▼";
}
export function DeviceInventoryTable({
rows,
selectedIds,
sort,
allVisibleSelected,
wakeBusyId,
selectedAgentId,
bulkWakeBusy,
onToggleAllVisible,
onToggleRow,
onSortChange,
onWakeDevice,
onCopyValue,
}: Props) {
return (
<div className="list device-list">
<div className="row plain device-row device-header">
<span className="device-cell device-select">
<input
type="checkbox"
checked={allVisibleSelected}
onChange={(e) => onToggleAllVisible(e.target.checked)}
disabled={!rows.length}
aria-label="Select all visible devices"
/>
</span>
<span
className="sortable-col device-cell"
onClick={() => onSortChange(nextSort(sort, "name"))}
>
Name {sortArrow(sort.key === "name", sort.dir)}
</span>
<span
className="sortable-col device-cell"
onClick={() => onSortChange(nextSort(sort, "ip"))}
>
IP {sortArrow(sort.key === "ip", sort.dir)}
</span>
<span
className="sortable-col device-cell"
onClick={() => onSortChange(nextSort(sort, "mac"))}
>
MAC {sortArrow(sort.key === "mac", sort.dir)}
</span>
<span
className="sortable-col device-cell"
onClick={() => onSortChange(nextSort(sort, "presence"))}
>
Presence {sortArrow(sort.key === "presence", sort.dir)}
</span>
<span className="device-cell">Interfaces</span>
<span className="device-cell device-action">Actions</span>
</div>
{rows.map((row) => (
<div className="row plain device-row" key={row.id}>
<span className="device-cell device-select" data-label="Pick">
<input
type="checkbox"
checked={selectedIds.includes(row.id)}
onChange={(e) => onToggleRow(row.id, e.target.checked)}
aria-label={`Select ${row.name}`}
/>
</span>
<span className="device-cell" data-label="Name" title={row.name}>
{row.name}
</span>
<span
className="device-cell muted"
data-label="IP"
title={row.ips.join(", ") || "-"}
>
{summarize(row.ips)}
</span>
<span
className="device-cell muted"
data-label="MAC"
title={row.macs.join(", ") || "-"}
>
{summarize(row.macs)}
</span>
<span className="device-cell" data-label="Presence">
<Badge variant="outline">{row.presence}</Badge>
</span>
<span
className="device-cell"
data-label="Interfaces"
title={row.interfaces.join(", ") || "-"}
>
{summarize(row.interfaces)}
</span>
<span className="device-cell device-action" data-label="">
<Button
onClick={() => onWakeDevice(row)}
disabled={
wakeBusyId === row.id || !selectedAgentId || bulkWakeBusy
}
size="sm"
>
{wakeBusyId === row.id ? "Waking..." : "Wake"}
</Button>
<Button
type="button"
variant="outline"
size="xs"
onClick={() => onCopyValue("name", chooseWakeTarget(row))}
>
Copy name
</Button>
<Button
type="button"
variant="outline"
size="xs"
onClick={() => onCopyValue("ip", row.ips[0] || "")}
>
Copy IP
</Button>
<Button
type="button"
variant="outline"
size="xs"
onClick={() => onCopyValue("mac", row.macs[0] || "")}
>
Copy MAC
</Button>
</span>
</div>
))}
{!rows.length && <div className="empty">No devices found</div>}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { WakeEvent } from "@/pages/devices/types";
type Props = {
events: WakeEvent[];
onClear: () => void;
};
export function WakeHistoryCard({ events, onClear }: Props) {
return (
<Card>
<CardHeader className="row-head">
<CardTitle>Recent Wake Actions</CardTitle>
<Button variant="outline" size="sm" onClick={onClear}>
Clear
</Button>
</CardHeader>
<CardContent>
<div className="list">
{events.map((event, idx) => (
<div
className="row plain"
key={`${event.ts}-${event.target}-${idx}`}
>
<div>
<strong>{event.target}</strong>
<div className="muted">
{new Date(event.ts).toLocaleString()} on {event.agentId}
</div>
<div className="muted">{event.detail}</div>
{event.requestId && (
<Link
className="muted linkish"
to={`/audit?q=${encodeURIComponent(event.requestId)}`}
>
View related audit ({event.requestId})
</Link>
)}
</div>
<Badge
variant={event.outcome === "ok" ? "secondary" : "destructive"}
>
{event.outcome}
</Badge>
</div>
))}
{!events.length && <div className="empty">No wake actions yet</div>}
</div>
</CardContent>
</Card>
);
}
+20
View File
@@ -0,0 +1,20 @@
export type DeviceRow = {
id: string;
name: string;
ips: string[];
macs: string[];
interfaces: string[];
presence: string;
};
export type WakeEvent = {
ts: number;
target: string;
agentId: string;
outcome: string;
requestId: string;
detail: string;
};
export type SortDir = "asc" | "desc";
export type PresenceFilter = "all" | "online" | "likely_online" | "unknown";
+174
View File
@@ -0,0 +1,174 @@
import type {
PresenceFilter,
SortDir,
DeviceRow,
WakeEvent,
} from "@/pages/devices/types";
export const WAKE_HISTORY_KEY = "wakey_recent_wakes_v1";
export const PRESENCE_FILTERS: PresenceFilter[] = [
"all",
"online",
"likely_online",
"unknown",
];
export const sorters = {
name: (a: DeviceRow, b: DeviceRow) => a.name.localeCompare(b.name),
ip: (a: DeviceRow, b: DeviceRow) =>
(a.ips[0] || "").localeCompare(b.ips[0] || ""),
mac: (a: DeviceRow, b: DeviceRow) =>
(a.macs[0] || "").localeCompare(b.macs[0] || ""),
presence: (a: DeviceRow, b: DeviceRow) =>
a.presence.localeCompare(b.presence),
};
export type SortKey = keyof typeof sorters;
export function parseInventoryRows(payload: unknown): DeviceRow[] {
if (!payload || typeof payload !== "object") return [];
const obj = payload as Record<string, unknown>;
if (obj.kind !== "inventory") return [];
if (!obj.devices || !Array.isArray(obj.devices)) return [];
return obj.devices
.map((raw, idx) => {
if (!raw || typeof raw !== "object") return null;
const device = raw as Record<string, unknown>;
const names = Array.isArray(device.names)
? (device.names.filter((v) => typeof v === "string") as string[])
: [];
const ips = Array.isArray(device.ips)
? (device.ips.filter((v) => typeof v === "string") as string[])
: [];
const macs = Array.isArray(device.macs)
? (device.macs.filter((v) => typeof v === "string") as string[])
: [];
const interfaces = Array.isArray(device.interfaces)
? (device.interfaces.filter((v) => typeof v === "string") as string[])
: [];
const presence =
typeof device.presence === "string" ? device.presence : "unknown";
const id = macs[0] || ips[0] || names[0] || `row-${idx}`;
return {
id,
name: names[0] || "(unnamed)",
ips,
macs,
interfaces,
presence,
};
})
.filter((v): v is DeviceRow => Boolean(v));
}
export function parseWakeSummary(response: unknown): {
outcome: string;
requestId: string;
detail: string;
} {
if (!response || typeof response !== "object") {
return { outcome: "error", requestId: "", detail: "invalid wake response" };
}
const envelope = response as Record<string, unknown>;
const requestId =
typeof envelope.request_id === "string" ? envelope.request_id : "";
const status =
typeof envelope.status === "string" ? envelope.status : "error";
if (status !== "ok") {
const error = envelope.error as Record<string, unknown> | undefined;
const detail =
typeof error?.message === "string" ? error.message : "wake failed";
return { outcome: "error", requestId, detail };
}
const result = envelope.result as Record<string, unknown> | undefined;
if (result?.kind !== "wake") {
return { outcome: "ok", requestId, detail: "wake dispatched" };
}
const entries = Array.isArray(result.result) ? result.result : [];
const targetOutcomes = entries
.map((row) => {
if (!row || typeof row !== "object") return "";
const rowObj = row as Record<string, unknown>;
const statusRaw = rowObj.status;
const status =
typeof statusRaw === "string"
? statusRaw
: typeof (statusRaw as Record<string, unknown> | undefined)?.kind ===
"string"
? String((statusRaw as Record<string, unknown>).kind)
: "unknown";
const ip = typeof rowObj.ip === "string" ? rowObj.ip : "?";
const mac = typeof rowObj.mac === "string" ? rowObj.mac : "?";
return `${status}(${ip}/${mac})`;
})
.filter(Boolean);
const detail = targetOutcomes.length
? targetOutcomes.join(", ")
: "wake dispatched";
const failed = targetOutcomes.some(
(s) =>
s.startsWith("incomplete") ||
s.startsWith("nonexistent_address") ||
s.startsWith("wrong_size"),
);
return { outcome: failed ? "error" : "ok", requestId, detail };
}
export function chooseWakeTarget(device: DeviceRow): string {
return device.name !== "(unnamed)"
? device.name
: device.macs[0] || device.ips[0] || "";
}
export function summarize(values: string[]): string {
if (!values.length) return "-";
if (values.length === 1) return values[0];
return `${values[0]} (+${values.length - 1})`;
}
export function loadHistory(): WakeEvent[] {
try {
const raw = window.localStorage.getItem(WAKE_HISTORY_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter((row): row is WakeEvent => {
if (!row || typeof row !== "object") return false;
const event = row as Record<string, unknown>;
return (
typeof event.ts === "number" &&
typeof event.target === "string" &&
typeof event.agentId === "string" &&
typeof event.outcome === "string" &&
typeof event.requestId === "string" &&
typeof event.detail === "string"
);
});
} catch {
return [];
}
}
export function saveHistory(history: WakeEvent[]) {
window.localStorage.setItem(
WAKE_HISTORY_KEY,
JSON.stringify(history.slice(0, 20)),
);
}
export function nextSort(
current: { key: SortKey; dir: SortDir },
key: SortKey,
): { key: SortKey; dir: SortDir } {
return {
key,
dir: current.key === key && current.dir === "asc" ? "desc" : "asc",
};
}