small ahh change But Format

This commit is contained in:
lda committed 2026-04-14 02:18:07 +07:00
1 parent 458123428e
commit f52b7c18d9
12 files changed
+1409 -699

No files matched your search

+219 -70
View File
@@ -1,12 +1,14 @@
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),
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;
@@ -41,7 +43,12 @@ type WakeEvent = {
};
const WAKE_HISTORY_KEY = "wakey_recent_wakes_v1";
const PRESENCE_FILTERS: PresenceFilter[] = ["all", "online", "likely_online", "unknown"];
const PRESENCE_FILTERS: PresenceFilter[] = [
"all",
"online",
"likely_online",
"unknown",
];
function parseInventoryRows(payload: unknown): DeviceRow[] {
if (!payload || typeof payload !== "object") return [];
@@ -53,13 +60,20 @@ function parseInventoryRows(payload: unknown): DeviceRow[] {
.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 names = Array.isArray(device.names)
? (device.names.filter((v) => typeof v === "string") as string[])
: [];
const presence = typeof device.presence === "string" ? device.presence : "unknown";
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 {
@@ -74,17 +88,24 @@ function parseInventoryRows(payload: unknown): DeviceRow[] {
.filter((v): v is DeviceRow => Boolean(v));
}
function parseWakeSummary(response: unknown): { outcome: string; requestId: string; detail: string } {
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";
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";
const detail =
typeof error?.message === "string" ? error.message : "wake failed";
return { outcome: "error", requestId, detail };
}
@@ -102,24 +123,32 @@ function parseWakeSummary(response: unknown): { outcome: string; requestId: stri
const status =
typeof statusRaw === "string"
? statusRaw
: (typeof (statusRaw as Record<string, unknown> | undefined)?.kind === "string"
? String((statusRaw as Record<string, unknown>).kind)
: "unknown");
: 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"));
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] || "");
: device.macs[0] || device.ips[0] || "";
}
function summarize(values: string[]): string {
@@ -138,12 +167,12 @@ function loadHistory(): 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"
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 {
@@ -152,10 +181,18 @@ function loadHistory(): WakeEvent[] {
}
function saveHistory(history: WakeEvent[]) {
window.localStorage.setItem(WAKE_HISTORY_KEY, JSON.stringify(history.slice(0, 20)));
window.localStorage.setItem(
WAKE_HISTORY_KEY,
JSON.stringify(history.slice(0, 20)),
);
}
export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWake }: Props) {
export function DevicesPage({
agents,
selectedAgentId,
onSelectAgent,
onAfterWake,
}: Props) {
const location = useLocation();
const navigate = useNavigate();
@@ -173,11 +210,16 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
function getPresenceFromUrl(): PresenceFilter {
const params = new URLSearchParams(location.search);
const raw = params.get("presence");
return PRESENCE_FILTERS.includes(raw as PresenceFilter) ? (raw as PresenceFilter) : "all";
return PRESENCE_FILTERS.includes(raw as PresenceFilter)
? (raw as PresenceFilter)
: "all";
}
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>(getSortFromUrl());
const [presenceFilter, setPresenceFilter] = useState<PresenceFilter>(getPresenceFromUrl());
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>(
getSortFromUrl(),
);
const [presenceFilter, setPresenceFilter] =
useState<PresenceFilter>(getPresenceFromUrl());
// Keep view state in sync with URL
useEffect(() => {
@@ -221,7 +263,6 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
});
}
useEffect(() => {
setRecentWakes(loadHistory());
}, []);
@@ -257,7 +298,14 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
await wakeTarget(target, device.id);
}
async function wakeTarget(target: string, busyId: string, opts: { refresh: boolean; notify: boolean } = { refresh: true, notify: true }) {
async function wakeTarget(
target: string,
busyId: string,
opts: { refresh: boolean; notify: boolean } = {
refresh: true,
notify: true,
},
) {
if (!target || !selectedAgentId) return;
setWakeBusyId(busyId);
try {
@@ -297,7 +345,10 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
for (const row of selectedRows) {
const target = chooseWakeTarget(row);
if (!target) continue;
await wakeTarget(target, `bulk:${row.id}`, { refresh: false, notify: false });
await wakeTarget(target, `bulk:${row.id}`, {
refresh: false,
notify: false,
});
}
await loadInventory();
if (onAfterWake) await onAfterWake();
@@ -323,7 +374,9 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
}, [selectedAgentId]);
useEffect(() => {
setSelectedIds((prev) => prev.filter((id) => rows.some((row) => row.id === id)));
setSelectedIds((prev) =>
prev.filter((id) => rows.some((row) => row.id === id)),
);
}, [rows]);
useEffect(() => {
@@ -339,12 +392,13 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
result = result.filter((row) => row.presence === presenceFilter);
}
if (q) {
result = result.filter((row) =>
row.name.toLowerCase().includes(q)
|| row.presence.toLowerCase().includes(q)
|| row.ips.some((v) => v.toLowerCase().includes(q))
|| row.macs.some((v) => v.toLowerCase().includes(q))
|| row.interfaces.some((v) => v.toLowerCase().includes(q)),
result = result.filter(
(row) =>
row.name.toLowerCase().includes(q) ||
row.presence.toLowerCase().includes(q) ||
row.ips.some((v) => v.toLowerCase().includes(q)) ||
row.macs.some((v) => v.toLowerCase().includes(q)) ||
row.interfaces.some((v) => v.toLowerCase().includes(q)),
);
}
// Sort
@@ -354,8 +408,11 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
return result;
}, [rows, query, sort, presenceFilter]);
const selectedVisibleCount = filtered.filter((row) => selectedIds.includes(row.id)).length;
const allVisibleSelected = filtered.length > 0 && selectedVisibleCount === filtered.length;
const selectedVisibleCount = filtered.filter((row) =>
selectedIds.includes(row.id),
).length;
const allVisibleSelected =
filtered.length > 0 && selectedVisibleCount === filtered.length;
function toggleRowSelection(id: string, checked: boolean) {
setSelectedIds((prev) => {
@@ -366,7 +423,9 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
function toggleAllVisible(checked: boolean) {
if (!checked) {
setSelectedIds((prev) => prev.filter((id) => !filtered.some((row) => row.id === id)));
setSelectedIds((prev) =>
prev.filter((id) => !filtered.some((row) => row.id === id)),
);
return;
}
setSelectedIds((prev) => {
@@ -381,7 +440,10 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
<div className="card">
<div className="row-head">
<h2>Devices</h2>
<button onClick={() => void loadInventory()} disabled={loading || !selectedAgentId}>
<button
onClick={() => void loadInventory()}
disabled={loading || !selectedAgentId}
>
{loading ? "Refreshing..." : "Refresh"}
</button>
</div>
@@ -394,7 +456,9 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
onChange={(e) => onSelectAgent(e.target.value)}
required
>
<option value="" disabled>Select agent</option>
<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"})
@@ -423,13 +487,19 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
</label>
<button
onClick={() => void wakeTarget(quickWake.trim(), "quick")}
disabled={!selectedAgentId || !quickWake.trim() || wakeBusyId === "quick"}
disabled={
!selectedAgentId || !quickWake.trim() || wakeBusyId === "quick"
}
>
{wakeBusyId === "quick" ? "Waking..." : "Wake target"}
</button>
</div>
<div className="presence-filters" role="group" aria-label="Filter by presence">
<div
className="presence-filters"
role="group"
aria-label="Filter by presence"
>
{PRESENCE_FILTERS.map((value) => (
<button
key={value}
@@ -457,7 +527,9 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
disabled={!selectedVisibleCount || !selectedAgentId || bulkWakeBusy}
type="button"
>
{bulkWakeBusy ? "Waking selected..." : `Wake selected (${selectedVisibleCount})`}
{bulkWakeBusy
? "Waking selected..."
: `Wake selected (${selectedVisibleCount})`}
</button>
<button
onClick={() => setSelectedIds([])}
@@ -469,7 +541,9 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
{copyStatus && <span className="muted">{copyStatus}</span>}
</div>
<p className="muted">Showing {filtered.length} of {rows.length}</p>
<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">
@@ -484,27 +558,48 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
</span>
<span
className="sortable-col device-cell"
onClick={() => setSort((s) => ({ key: "name", dir: s.key === "name" && s.dir === "asc" ? "desc" : "asc" }))}
onClick={() =>
setSort((s) => ({
key: "name",
dir: s.key === "name" && s.dir === "asc" ? "desc" : "asc",
}))
}
>
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" }))}
onClick={() =>
setSort((s) => ({
key: "ip",
dir: s.key === "ip" && s.dir === "asc" ? "desc" : "asc",
}))
}
>
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" }))}
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" }))}
onClick={() =>
setSort((s) => ({
key: "presence",
dir: s.key === "presence" && s.dir === "asc" ? "desc" : "asc",
}))
}
>
Presence {sort.key === "presence" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
Presence{" "}
{sort.key === "presence" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
</span>
<span className="device-cell">Interfaces</span>
<span className="device-cell device-action">Actions</span>
@@ -519,18 +614,63 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
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" 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}>
<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>
<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>
))}
@@ -552,10 +692,15 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
</div>
<div className="list">
{recentWakes.map((event, idx) => (
<div className="row plain" key={`${event.ts}-${event.target}-${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">
{new Date(event.ts).toLocaleString()} on {event.agentId}
</div>
<div className="muted">{event.detail}</div>
{event.requestId && (
<Link
@@ -566,14 +711,18 @@ export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWak
</Link>
)}
</div>
<span className={`pill ${event.outcome === "ok" ? "ready" : "error"}`}>
<span
className={`pill ${event.outcome === "ok" ? "ready" : "error"}`}
>
{event.outcome}
</span>
</div>
))}
{!recentWakes.length && <div className="empty">No wake actions yet</div>}
{!recentWakes.length && (
<div className="empty">No wake actions yet</div>
)}
</div>
</div>
</section>
);
}
}