mobile + style cleanup
This commit is contained in:
@@ -92,7 +92,15 @@ export function AppLayout() {
|
|||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<aside
|
<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}
|
data-collapsed={collapsed}
|
||||||
>
|
>
|
||||||
<div className="sidebar-brand">
|
<div className="sidebar-brand">
|
||||||
@@ -210,7 +218,29 @@ export function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</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 />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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 {
|
.sidebar {
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 30;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 15rem;
|
width: 15rem;
|
||||||
@@ -163,7 +160,6 @@
|
|||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: var(--sidebar);
|
background: var(--sidebar);
|
||||||
border-right: 1px solid var(--sidebar-border);
|
border-right: 1px solid var(--sidebar-border);
|
||||||
transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -358,29 +354,3 @@
|
|||||||
border-color: color-mix(in oklab, var(--presence-offline) 40%, transparent);
|
border-color: color-mix(in oklab, var(--presence-offline) 40%, transparent);
|
||||||
color: var(--presence-offline);
|
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