will this be reverted?

This commit is contained in:
lda
2026-04-18 00:53:12 +07:00 Verified
parent 150588d834
commit b4152e84c4
8 changed files with 283 additions and 31 deletions
+7 -7
View File
@@ -1,7 +1,7 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"; import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
@@ -37,8 +37,8 @@ const buttonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
}, }
); )
function Button({ function Button({
className, className,
@@ -52,7 +52,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
); )
} }
export { Button, buttonVariants }; export { Button, buttonVariants }
+158
View File
@@ -0,0 +1,158 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 start-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 end-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+22 -4
View File
@@ -12,6 +12,7 @@ import {
SelectTrigger, SelectTrigger,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { DeviceInventoryTable } from "@/pages/devices/DeviceInventoryTable"; import { DeviceInventoryTable } from "@/pages/devices/DeviceInventoryTable";
import { DeviceDetailsDialog } from "@/pages/devices/DeviceDetailsDialog";
import { WakeHistoryCard } from "@/pages/devices/WakeHistoryCard"; import { WakeHistoryCard } from "@/pages/devices/WakeHistoryCard";
import type { import type {
DeviceRow, DeviceRow,
@@ -112,6 +113,7 @@ export function DevicesPage({
const [bulkWakeBusy, setBulkWakeBusy] = useState(false); const [bulkWakeBusy, setBulkWakeBusy] = useState(false);
const [quickWake, setQuickWake] = useState(""); const [quickWake, setQuickWake] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [detailsDevice, setDetailsDevice] = useState<DeviceRow | null>(null);
const [copyStatus, setCopyStatus] = useState(""); const [copyStatus, setCopyStatus] = useState("");
const [recentWakes, setRecentWakes] = useState<WakeEvent[]>([]); const [recentWakes, setRecentWakes] = useState<WakeEvent[]>([]);
function appendWakeEvent(event: WakeEvent) { function appendWakeEvent(event: WakeEvent) {
@@ -288,9 +290,17 @@ export function DevicesPage({
); );
} }
// Sort // Sort
const sorter = sorters[sort.key]; if (sort.key === "name") {
result = [...result].sort(sorter); result = [...result].sort((a, b) => {
if (sort.dir === "desc") result.reverse(); if (a.isUnnamed !== b.isUnnamed) return a.isUnnamed ? 1 : -1;
const cmp = a.name.localeCompare(b.name);
return sort.dir === "desc" ? -cmp : cmp;
});
} else {
const sorter = sorters[sort.key];
result = [...result].sort(sorter);
if (sort.dir === "desc") result.reverse();
}
return result; return result;
}, [rows, query, sort, presenceFilter]); }, [rows, query, sort, presenceFilter]);
@@ -342,7 +352,7 @@ export function DevicesPage({
<span>Agent</span> <span>Agent</span>
<Select <Select
value={selectedAgentId} value={selectedAgentId}
onValueChange={(value) => { onValueChange={(value: string) => {
if (value) onSelectAgent(value); if (value) onSelectAgent(value);
}} }}
> >
@@ -505,6 +515,14 @@ export function DevicesPage({
onSortChange={setSort} onSortChange={setSort}
onWakeDevice={(device) => void wakeDevice(device)} onWakeDevice={(device) => void wakeDevice(device)}
onCopyValue={(label, value) => void copyValue(label, value)} onCopyValue={(label, value) => void copyValue(label, value)}
onOpenDetails={setDetailsDevice}
/>
<DeviceDetailsDialog
device={detailsDevice}
open={Boolean(detailsDevice)}
onOpenChange={(open) => {
if (!open) setDetailsDevice(null);
}}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -0,0 +1,68 @@
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>
);
}
+21 -9
View File
@@ -28,6 +28,7 @@ type Props = {
onSortChange: (next: { key: SortKey; dir: SortDir }) => void; onSortChange: (next: { key: SortKey; dir: SortDir }) => void;
onWakeDevice: (device: DeviceRow) => void; onWakeDevice: (device: DeviceRow) => void;
onCopyValue: (label: string, value: string) => void; onCopyValue: (label: string, value: string) => void;
onOpenDetails: (device: DeviceRow) => void;
}; };
function sortArrow(active: boolean, dir: SortDir) { function sortArrow(active: boolean, dir: SortDir) {
@@ -48,7 +49,13 @@ export function DeviceInventoryTable({
onSortChange, onSortChange,
onWakeDevice, onWakeDevice,
onCopyValue, onCopyValue,
onOpenDetails,
}: Props) { }: Props) {
function shouldIgnoreRowClick(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
return Boolean(target.closest("button, input, a, [role='menuitem']"));
}
return ( return (
<div className="device-list grid gap-2"> <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"> <div className="device-row device-header rounded-md border bg-muted/60 px-3 py-2 text-sm">
@@ -85,14 +92,26 @@ export function DeviceInventoryTable({
> >
Presence {sortArrow(sort.key === "presence", sort.dir)} Presence {sortArrow(sort.key === "presence", sort.dir)}
</span> </span>
<span className="device-cell">Interfaces</span>
<span className="device-cell device-action">Actions</span> <span className="device-cell device-action">Actions</span>
</div> </div>
{rows.map((row) => ( {rows.map((row) => (
<div <div
className="device-row rounded-md border bg-card px-3 py-2 text-sm" 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} 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"> <span className="device-cell device-select" data-label="Pick">
<input <input
@@ -122,13 +141,6 @@ export function DeviceInventoryTable({
<span className="device-cell" data-label="Presence"> <span className="device-cell" data-label="Presence">
<Badge variant="outline">{row.presence}</Badge> <Badge variant="outline">{row.presence}</Badge>
</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=""> <span className="device-cell device-action" data-label="">
<Button <Button
onClick={() => onWakeDevice(row)} onClick={() => onWakeDevice(row)}
+3 -8
View File
@@ -52,11 +52,11 @@
display: grid; display: grid;
grid-template-columns: grid-template-columns:
36px minmax(110px, 1.1fr) minmax(160px, 2fr) minmax(130px, 1.5fr) 36px minmax(110px, 1.1fr) minmax(160px, 2fr) minmax(130px, 1.5fr)
minmax(100px, 0.85fr) minmax(100px, 0.9fr) minmax(170px, max-content); minmax(100px, 0.85fr) minmax(170px, max-content);
align-items: center; align-items: center;
column-gap: 0.4rem; column-gap: 0.4rem;
row-gap: 0.55rem; row-gap: 0.55rem;
min-width: 980px; min-width: 860px;
} }
.device-header { .device-header {
@@ -111,8 +111,7 @@
"name name" "name name"
"presence presence" "presence presence"
"ip ip" "ip ip"
"mac mac" "mac mac";
"interfaces interfaces";
align-items: start; align-items: start;
min-width: 0; min-width: 0;
} }
@@ -162,10 +161,6 @@
grid-area: mac; grid-area: mac;
} }
.device-row .device-cell[data-label="Interfaces"] {
grid-area: interfaces;
}
.device-row .device-action { .device-row .device-action {
grid-area: action; grid-area: action;
justify-self: end; justify-self: end;
+1
View File
@@ -1,6 +1,7 @@
export type DeviceRow = { export type DeviceRow = {
id: string; id: string;
name: string; name: string;
isUnnamed: boolean;
ips: string[]; ips: string[];
macs: string[]; macs: string[];
interfaces: string[]; interfaces: string[];
+3 -3
View File
@@ -50,11 +50,13 @@ export function parseInventoryRows(payload: unknown): DeviceRow[] {
: []; : [];
const presence = const presence =
typeof device.presence === "string" ? device.presence : "unknown"; typeof device.presence === "string" ? device.presence : "unknown";
const isUnnamed = names.length === 0;
const id = macs[0] || ips[0] || names[0] || `row-${idx}`; const id = macs[0] || ips[0] || names[0] || `row-${idx}`;
return { return {
id, id,
name: names[0] || "(unnamed)", name: names[0] || "(unnamed)",
isUnnamed,
ips, ips,
macs, macs,
interfaces, interfaces,
@@ -122,9 +124,7 @@ export function parseWakeSummary(response: unknown): {
} }
export function chooseWakeTarget(device: DeviceRow): string { export function chooseWakeTarget(device: DeviceRow): string {
return device.name !== "(unnamed)" return device.isUnnamed ? device.macs[0] || device.ips[0] || "" : device.name;
? device.name
: device.macs[0] || device.ips[0] || "";
} }
export function summarize(values: string[]): string { export function summarize(values: string[]): string {