mobile + style cleanup
This commit is contained in:
@@ -92,7 +92,15 @@ export function AppLayout() {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside
|
||||
className={`sidebar ${collapsed ? "sidebar--collapsed" : ""}`}
|
||||
className={[
|
||||
"sidebar",
|
||||
collapsed ? "sidebar--collapsed" : "",
|
||||
"fixed inset-y-0 left-0 z-50 shadow-xl transition-all duration-300 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",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
data-collapsed={collapsed}
|
||||
>
|
||||
<div className="sidebar-brand">
|
||||
@@ -210,7 +218,29 @@ export function AppLayout() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="app-main">
|
||||
{/* Mobile backdrop */}
|
||||
{!collapsed && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/20 md:hidden animate-in fade-in duration-200"
|
||||
onClick={() => setCollapsed(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile floating button when sidebar is collapsed/hidden */}
|
||||
{collapsed && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="fixed bottom-3 left-3 z-40 md:hidden"
|
||||
aria-label="Open menu"
|
||||
onClick={() => setCollapsed(false)}
|
||||
>
|
||||
<PanelLeft className="size-4" aria-hidden />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<main className="app-main @container">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { DeviceRow } from "@/pages/devices/types";
|
||||
|
||||
type Props = {
|
||||
device: DeviceRow | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function summarizeAll(values: string[]): string {
|
||||
if (!values.length) return "-";
|
||||
return values.join(", ");
|
||||
}
|
||||
|
||||
export function DeviceDetailsDialog({ device, open, onOpenChange }: Props) {
|
||||
if (!device) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl p-0">
|
||||
<div className="grid gap-4 p-4 text-sm">
|
||||
<DialogHeader className="pr-8">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="truncate">{device.name}</DialogTitle>
|
||||
<DialogDescription>Device details</DialogDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{device.presence}</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 font-medium">IP addresses</p>
|
||||
<p className="text-muted-foreground">{summarizeAll(device.ips)}</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<p className="mb-1 font-medium">MAC addresses</p>
|
||||
<p className="text-muted-foreground">{summarizeAll(device.macs)}</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<p className="mb-1 font-medium">Interfaces</p>
|
||||
<p className="text-muted-foreground">
|
||||
{summarizeAll(device.interfaces)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
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;
|
||||
onOpenDetails: (device: DeviceRow) => 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,
|
||||
onOpenDetails,
|
||||
}: Props) {
|
||||
function shouldIgnoreRowClick(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return false;
|
||||
return Boolean(target.closest("button, input, a, [role='menuitem']"));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="device-list grid gap-2">
|
||||
<div className="device-row device-header rounded-md border bg-muted/60 px-3 py-2 text-sm">
|
||||
<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 device-action">Actions</span>
|
||||
</div>
|
||||
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
className="device-row cursor-pointer rounded-md border bg-card px-3 py-2 text-sm transition-colors hover:bg-accent/30"
|
||||
key={row.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
if (shouldIgnoreRowClick(event.target)) return;
|
||||
onOpenDetails(row);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (shouldIgnoreRowClick(event.target)) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onOpenDetails(row);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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 text-muted-foreground"
|
||||
data-label="IP"
|
||||
title={row.ips.join(", ") || "-"}
|
||||
>
|
||||
{summarize(row.ips)}
|
||||
</span>
|
||||
<span
|
||||
className="device-cell text-muted-foreground"
|
||||
data-label="MAC"
|
||||
title={row.macs.join(", ") || "-"}
|
||||
>
|
||||
{summarize(row.macs)}
|
||||
</span>
|
||||
<span className="device-cell" data-label="Presence">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`presence-badge--${row.presence}`}
|
||||
>
|
||||
<span
|
||||
className={`presence-dot presence-dot--${row.presence}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{row.presence.replace("_", " ")}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="device-cell device-action" data-label="">
|
||||
<Button
|
||||
onClick={() => onWakeDevice(row)}
|
||||
disabled={
|
||||
wakeBusyId === row.id || !selectedAgentId || bulkWakeBusy
|
||||
}
|
||||
size="sm"
|
||||
className="device-btn"
|
||||
>
|
||||
{wakeBusyId === row.id ? "Waking..." : "Wake"}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"device-copy-trigger",
|
||||
)}
|
||||
>
|
||||
Copy
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-32">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onCopyValue("target", chooseWakeTarget(row))}
|
||||
>
|
||||
Copy target
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onCopyValue("name", row.name)}>
|
||||
Copy name
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onCopyValue("ip", row.ips[0] || "")}
|
||||
>
|
||||
Copy IP
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onCopyValue("mac", row.macs[0] || "")}
|
||||
>
|
||||
Copy MAC
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!rows.length && (
|
||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
||||
No devices found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle>Recent Wake Actions</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={onClear}>
|
||||
Clear
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2">
|
||||
{events.map((event, idx) => (
|
||||
<div
|
||||
className="flex items-start justify-between gap-3 rounded-md border bg-card px-3 py-2"
|
||||
key={`${event.ts}-${event.target}-${idx}`}
|
||||
>
|
||||
<div>
|
||||
<strong>{event.target}</strong>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(event.ts).toLocaleString()} on {event.agentId}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{event.detail}
|
||||
</div>
|
||||
{event.requestId && (
|
||||
<Link
|
||||
className="linkish text-xs text-muted-foreground"
|
||||
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="px-1 py-2 text-sm text-muted-foreground">
|
||||
No wake actions yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
.quick-wake {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.6rem;
|
||||
align-items: end;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.quick-wake label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.presence-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.select-visible {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.linkish {
|
||||
display: inline-block;
|
||||
margin-top: 0.25rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.device-list {
|
||||
margin-top: 0.8rem;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
|
||||
.device-row {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
36px minmax(110px, 1.1fr) minmax(160px, 2fr) minmax(130px, 1.5fr)
|
||||
minmax(100px, 0.85fr) minmax(170px, max-content);
|
||||
align-items: center;
|
||||
column-gap: 0.4rem;
|
||||
row-gap: 0.55rem;
|
||||
min-width: 860px;
|
||||
}
|
||||
|
||||
.device-header {
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.device-cell {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sortable-col {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.device-action {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.35rem;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.device-btn {
|
||||
min-width: 4.25rem;
|
||||
}
|
||||
|
||||
.device-copy-trigger {
|
||||
min-width: 6.2rem;
|
||||
}
|
||||
|
||||
.device-select {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.device-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.device-row {
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-areas:
|
||||
"select action"
|
||||
"name name"
|
||||
"presence presence"
|
||||
"ip ip"
|
||||
"mac mac";
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-row .device-cell {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.device-row .device-cell[data-label]::before {
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.device-row .device-cell[data-label="Name"] {
|
||||
grid-area: name;
|
||||
}
|
||||
|
||||
.device-row .device-cell[data-label="Pick"] {
|
||||
grid-area: select;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
.device-row .device-cell[data-label="Pick"]::before {
|
||||
display: none;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.device-row .device-cell[data-label="Presence"] {
|
||||
grid-area: presence;
|
||||
}
|
||||
|
||||
.device-row .device-cell[data-label="IP"] {
|
||||
grid-area: ip;
|
||||
}
|
||||
|
||||
.device-row .device-cell[data-label="MAC"] {
|
||||
grid-area: mac;
|
||||
}
|
||||
|
||||
.device-row .device-action {
|
||||
grid-area: action;
|
||||
justify-self: end;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quick-wake {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
export type DeviceRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
isUnnamed: boolean;
|
||||
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";
|
||||
@@ -1,174 +0,0 @@
|
||||
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 isUnnamed = names.length === 0;
|
||||
|
||||
const id = macs[0] || ips[0] || names[0] || `row-${idx}`;
|
||||
return {
|
||||
id,
|
||||
name: names[0] || "(unnamed)",
|
||||
isUnnamed,
|
||||
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.isUnnamed ? device.macs[0] || device.ips[0] || "" : device.name;
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
@@ -153,9 +153,6 @@
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 15rem;
|
||||
@@ -163,7 +160,6 @@
|
||||
padding: 0.75rem;
|
||||
background: var(--sidebar);
|
||||
border-right: 1px solid var(--sidebar-border);
|
||||
transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -358,29 +354,3 @@
|
||||
border-color: color-mix(in oklab, var(--presence-offline) 40%, transparent);
|
||||
color: var(--presence-offline);
|
||||
}
|
||||
|
||||
/* ── Mobile ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
box-shadow: 0 4px 24px oklch(0 0 0 / 12%);
|
||||
}
|
||||
|
||||
.sidebar--collapsed {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.sidebar--collapsed * {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user