mobile + style cleanup

This commit is contained in:
lda
2026-05-01 01:17:21 +07:00 Verified
parent 83145a501f
commit 63e7e4246b
8 changed files with 32 additions and 736 deletions
+32 -2
View File
@@ -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>
);
}
-62
View File
@@ -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>
);
}
-174
View File
@@ -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;
}
}
-21
View File
@@ -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";
-174
View File
@@ -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",
};
}
-30
View File
@@ -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;
}
}