Banger? idk

This commit is contained in:
lda
2026-04-14 03:18:41 +07:00 Unverified
parent f52b7c18d9
commit e219dcc724
20 changed files with 4834 additions and 1480 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles.css",
"baseColor": "mauve",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": true,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+11 -1
View File
@@ -12,10 +12,20 @@
"format:check": "prettier --check ." "format:check": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.0",
"@fontsource-variable/inter": "^5.2.8",
"@tailwindcss/vite": "^4.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-compiler-runtime": "^1.0.0", "react-compiler-runtime": "^1.0.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.28.0" "react-router-dom": "^6.28.0",
"shadcn": "^4.2.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
+3479 -970
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+199
View File
@@ -0,0 +1,199 @@
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-start", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pe-2 ps-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 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",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-start-2 data-[side=inline-start]:slide-in-from-end-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute end-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+34
View File
@@ -1,5 +1,8 @@
import { useEffect, useState } from "react";
import { NavLink, Outlet } from "react-router-dom"; import { NavLink, Outlet } from "react-router-dom";
import { Button } from "@/components/ui/button";
const navItems = [ const navItems = [
["/", "Devices"], ["/", "Devices"],
["/commands", "Wake Tools"], ["/commands", "Wake Tools"],
@@ -10,7 +13,28 @@ const navItems = [
["/tokens", "Tokens"], ["/tokens", "Tokens"],
] as const; ] as const;
type Theme = "light" | "dark";
const THEME_KEY = "wakey-ui-theme";
function resolveInitialTheme(): Theme {
const stored = window.localStorage.getItem(THEME_KEY);
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
export function AppLayout() { export function AppLayout() {
const [theme, setTheme] = useState<Theme>(() => resolveInitialTheme());
useEffect(() => {
const root = document.documentElement;
root.classList.toggle("dark", theme === "dark");
window.localStorage.setItem(THEME_KEY, theme);
}, [theme]);
return ( return (
<div className="app"> <div className="app">
<header className="topbar"> <header className="topbar">
@@ -18,6 +42,16 @@ export function AppLayout() {
<h1>Wakey Operator UI</h1> <h1>Wakey Operator UI</h1>
<p>Find devices fast and wake them reliably</p> <p>Find devices fast and wake them reliably</p>
</div> </div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setTheme((current) => (current === "dark" ? "light" : "dark"))
}
>
{theme === "dark" ? "Switch to light" : "Switch to dark"}
</Button>
</header> </header>
<nav className="tabs"> <nav className="tabs">
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+74 -46
View File
@@ -1,5 +1,16 @@
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { runCommand, type Agent, type CommandKind } from "@/api"; import { runCommand, type Agent, type CommandKind } from "@/api";
type Props = { type Props = {
@@ -37,51 +48,68 @@ export function CommandsPage({
} }
return ( return (
<section className="card"> <Card>
<h2>Command Runner</h2> <CardHeader>
<form className="form" onSubmit={submit}> <CardTitle>Command Runner</CardTitle>
<label> </CardHeader>
Agent <CardContent>
<select <form className="form" onSubmit={submit}>
value={selectedAgentId} <label>
onChange={(e) => onSelectAgent(e.target.value)} Agent
required <Select
> value={selectedAgentId}
<option value="" disabled> onValueChange={(value) => {
Select agent if (value) onSelectAgent(value);
</option> }}
{agents.map((agent) => ( >
<option key={agent.agent_id} value={agent.agent_id}> <SelectTrigger className="w-full">
{agent.agent_id} ({agent.connected ? "connected" : "offline"}) <SelectValue placeholder="Select agent" />
</option> </SelectTrigger>
))} <SelectContent>
</select> {agents.map((agent) => (
</label> <SelectItem key={agent.agent_id} value={agent.agent_id}>
<label> {agent.agent_id} (
Command {agent.connected ? "connected" : "offline"})
<select </SelectItem>
value={kind} ))}
onChange={(e) => setKind(e.target.value as CommandKind)} </SelectContent>
> </Select>
<option value="devs">devs</option> </label>
<option value="leases">leases</option>
<option value="inventory">inventory</option> <label>
<option value="wake">wake</option> Command
</select> <Select
</label> value={kind}
<label> onValueChange={(value) => setKind(value as CommandKind)}
Query >
<input <SelectTrigger className="w-full">
value={query} <SelectValue placeholder="Pick command" />
onChange={(e) => setQuery(e.target.value)} </SelectTrigger>
placeholder="optional" <SelectContent>
/> <SelectItem value="devs">devs</SelectItem>
</label> <SelectItem value="leases">leases</SelectItem>
<button type="submit" disabled={running || !selectedAgentId}> <SelectItem value="inventory">inventory</SelectItem>
{running ? "Running..." : "Run command"} <SelectItem value="wake">wake</SelectItem>
</button> </SelectContent>
</form> </Select>
<pre className="output">{output}</pre> </label>
</section>
<label>
Query
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="optional"
/>
</label>
<Button type="submit" disabled={running || !selectedAgentId}>
{running ? "Running..." : "Run command"}
</Button>
</form>
<Textarea className="output" value={output} readOnly />
</CardContent>
</Card>
); );
} }
+156 -444
View File
@@ -1,21 +1,35 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { 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),
};
type SortKey = keyof typeof sorters;
type SortDir = "asc" | "desc";
type PresenceFilter = "all" | "online" | "likely_online" | "unknown";
import { runCommand, type Agent } from "@/api"; import { runCommand, type Agent } from "@/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DeviceInventoryTable } from "@/pages/devices/DeviceInventoryTable";
import { WakeHistoryCard } from "@/pages/devices/WakeHistoryCard";
import type {
DeviceRow,
PresenceFilter,
SortDir,
WakeEvent,
} from "@/pages/devices/types";
import {
chooseWakeTarget,
loadHistory,
parseInventoryRows,
parseWakeSummary,
PRESENCE_FILTERS,
saveHistory,
sorters,
type SortKey,
} from "@/pages/devices/utils";
type Props = { type Props = {
agents: Agent[]; agents: Agent[];
@@ -24,169 +38,6 @@ type Props = {
onAfterWake?: () => Promise<void>; onAfterWake?: () => Promise<void>;
}; };
type DeviceRow = {
id: string;
name: string;
ips: string[];
macs: string[];
interfaces: string[];
presence: string;
};
type WakeEvent = {
ts: number;
target: string;
agentId: string;
outcome: string;
requestId: string;
detail: string;
};
const WAKE_HISTORY_KEY = "wakey_recent_wakes_v1";
const PRESENCE_FILTERS: PresenceFilter[] = [
"all",
"online",
"likely_online",
"unknown",
];
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 id = macs[0] || ips[0] || names[0] || `row-${idx}`;
return {
id,
name: names[0] || "(unnamed)",
ips,
macs,
interfaces,
presence,
};
})
.filter((v): v is DeviceRow => Boolean(v));
}
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 };
}
function chooseWakeTarget(device: DeviceRow): string {
return device.name !== "(unnamed)"
? device.name
: device.macs[0] || device.ips[0] || "";
}
function summarize(values: string[]): string {
if (!values.length) return "-";
if (values.length === 1) return values[0];
return `${values[0]} (+${values.length - 1})`;
}
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 [];
}
}
function saveHistory(history: WakeEvent[]) {
window.localStorage.setItem(
WAKE_HISTORY_KEY,
JSON.stringify(history.slice(0, 20)),
);
}
export function DevicesPage({ export function DevicesPage({
agents, agents,
selectedAgentId, selectedAgentId,
@@ -437,292 +288,153 @@ export function DevicesPage({
return ( return (
<section className="two-col"> <section className="two-col">
<div className="card"> <Card>
<div className="row-head"> <CardHeader className="row-head">
<h2>Devices</h2> <CardTitle>Devices</CardTitle>
<button <Button
variant="outline"
size="sm"
onClick={() => void loadInventory()} onClick={() => void loadInventory()}
disabled={loading || !selectedAgentId} disabled={loading || !selectedAgentId}
> >
{loading ? "Refreshing..." : "Refresh"} {loading ? "Refreshing..." : "Refresh"}
</button> </Button>
</div> </CardHeader>
<div className="grid-3 compact"> <CardContent>
<label> <div className="grid-3 compact">
Agent <label>
<select Agent
value={selectedAgentId} <Select
onChange={(e) => onSelectAgent(e.target.value)} value={selectedAgentId}
required onValueChange={(value) => {
if (value) onSelectAgent(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select agent" />
</SelectTrigger>
<SelectContent>
{agents.map((agent) => (
<SelectItem key={agent.agent_id} value={agent.agent_id}>
{agent.agent_id} (
{agent.connected ? "connected" : "offline"})
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="span-2">
Search
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="name, ip, mac, interface"
/>
</label>
</div>
<div className="quick-wake">
<label>
Quick Wake (name, IP, or MAC)
<Input
value={quickWake}
onChange={(e) => setQuickWake(e.target.value)}
placeholder="bedroom-pc or aa:bb:cc:dd:ee:ff"
/>
</label>
<Button
onClick={() => void wakeTarget(quickWake.trim(), "quick")}
disabled={
!selectedAgentId || !quickWake.trim() || wakeBusyId === "quick"
}
> >
<option value="" disabled> {wakeBusyId === "quick" ? "Waking..." : "Wake target"}
Select agent </Button>
</option> </div>
{agents.map((agent) => (
<option key={agent.agent_id} value={agent.agent_id}>
{agent.agent_id} ({agent.connected ? "connected" : "offline"})
</option>
))}
</select>
</label>
<label className="span-2">
Search
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="name, ip, mac, interface"
/>
</label>
</div>
<div className="quick-wake"> <div
<label> className="presence-filters"
Quick Wake (name, IP, or MAC) role="group"
<input aria-label="Filter by presence"
value={quickWake}
onChange={(e) => setQuickWake(e.target.value)}
placeholder="bedroom-pc or aa:bb:cc:dd:ee:ff"
/>
</label>
<button
onClick={() => void wakeTarget(quickWake.trim(), "quick")}
disabled={
!selectedAgentId || !quickWake.trim() || wakeBusyId === "quick"
}
> >
{wakeBusyId === "quick" ? "Waking..." : "Wake target"} {PRESENCE_FILTERS.map((value) => (
</button> <Button
</div> key={value}
variant={presenceFilter === value ? "secondary" : "outline"}
size="xs"
onClick={() => setPresenceFilter(value)}
type="button"
>
{value === "all" ? "all" : value.replace("_", " ")}
</Button>
))}
</div>
<div <div className="bulk-actions">
className="presence-filters" <label className="select-visible">
role="group"
aria-label="Filter by presence"
>
{PRESENCE_FILTERS.map((value) => (
<button
key={value}
className={`filter-chip ${presenceFilter === value ? "active" : ""}`}
onClick={() => setPresenceFilter(value)}
type="button"
>
{value === "all" ? "all" : value.replace("_", " ")}
</button>
))}
</div>
<div className="bulk-actions">
<label className="select-visible">
<input
type="checkbox"
checked={allVisibleSelected}
onChange={(e) => toggleAllVisible(e.target.checked)}
disabled={!filtered.length}
/>
Select visible
</label>
<button
onClick={() => void wakeSelectedDevices()}
disabled={!selectedVisibleCount || !selectedAgentId || bulkWakeBusy}
type="button"
>
{bulkWakeBusy
? "Waking selected..."
: `Wake selected (${selectedVisibleCount})`}
</button>
<button
onClick={() => setSelectedIds([])}
disabled={!selectedIds.length}
type="button"
>
Clear selection
</button>
{copyStatus && <span className="muted">{copyStatus}</span>}
</div>
<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">
<span className="device-cell device-select">
<input <input
type="checkbox" type="checkbox"
checked={allVisibleSelected} checked={allVisibleSelected}
onChange={(e) => toggleAllVisible(e.target.checked)} onChange={(e) => toggleAllVisible(e.target.checked)}
disabled={!filtered.length} disabled={!filtered.length}
aria-label="Select all visible devices"
/> />
</span> Select visible
<span </label>
className="sortable-col device-cell" <Button
onClick={() => onClick={() => void wakeSelectedDevices()}
setSort((s) => ({ disabled={
key: "name", !selectedVisibleCount || !selectedAgentId || bulkWakeBusy
dir: s.key === "name" && s.dir === "asc" ? "desc" : "asc",
}))
} }
type="button"
size="sm"
> >
Name {sort.key === "name" ? (sort.dir === "asc" ? "▲" : "▼") : ""} {bulkWakeBusy
</span> ? "Waking selected..."
<span : `Wake selected (${selectedVisibleCount})`}
className="sortable-col device-cell" </Button>
onClick={() => <Button
setSort((s) => ({ onClick={() => setSelectedIds([])}
key: "ip", disabled={!selectedIds.length}
dir: s.key === "ip" && s.dir === "asc" ? "desc" : "asc", type="button"
})) variant="outline"
} size="sm"
> >
IP {sort.key === "ip" ? (sort.dir === "asc" ? "▲" : "▼") : ""} Clear selection
</span> </Button>
<span {copyStatus && <span className="muted">{copyStatus}</span>}
className="sortable-col device-cell"
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",
}))
}
>
Presence{" "}
{sort.key === "presence" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
</span>
<span className="device-cell">Interfaces</span>
<span className="device-cell device-action">Actions</span>
</div> </div>
{filtered.map((row) => (
<div className="row plain device-row" key={row.id}>
<span className="device-cell device-select" data-label="Pick">
<input
type="checkbox"
checked={selectedIds.includes(row.id)}
onChange={(e) => toggleRowSelection(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 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
}
>
{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>
</span>
</div>
))}
{!filtered.length && <div className="empty">No devices found</div>}
</div>
</div>
<div className="card"> <p className="muted">
<div className="row-head"> Showing {filtered.length} of {rows.length}
<h2>Recent Wake Actions</h2> </p>
<button {error && <pre className="error">{error}</pre>}
onClick={() => {
setRecentWakes([]); <DeviceInventoryTable
saveHistory([]); rows={filtered}
}} selectedIds={selectedIds}
> sort={sort}
Clear allVisibleSelected={allVisibleSelected}
</button> wakeBusyId={wakeBusyId}
</div> selectedAgentId={selectedAgentId}
<div className="list"> bulkWakeBusy={bulkWakeBusy}
{recentWakes.map((event, idx) => ( onToggleAllVisible={toggleAllVisible}
<div onToggleRow={toggleRowSelection}
className="row plain" onSortChange={setSort}
key={`${event.ts}-${event.target}-${idx}`} onWakeDevice={(device) => void wakeDevice(device)}
> onCopyValue={(label, value) => void copyValue(label, value)}
<div> />
<strong>{event.target}</strong> </CardContent>
<div className="muted"> </Card>
{new Date(event.ts).toLocaleString()} on {event.agentId}
</div> <WakeHistoryCard
<div className="muted">{event.detail}</div> events={recentWakes}
{event.requestId && ( onClear={() => {
<Link setRecentWakes([]);
className="muted linkish" saveHistory([]);
to={`/audit?q=${encodeURIComponent(event.requestId)}`} }}
> />
View related audit ({event.requestId})
</Link>
)}
</div>
<span
className={`pill ${event.outcome === "ok" ? "ready" : "error"}`}
>
{event.outcome}
</span>
</div>
))}
{!recentWakes.length && (
<div className="empty">No wake actions yet</div>
)}
</div>
</div>
</section> </section>
); );
} }
@@ -0,0 +1,163 @@
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
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;
};
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,
}: Props) {
return (
<div className="list device-list">
<div className="row plain device-row device-header">
<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">Interfaces</span>
<span className="device-cell device-action">Actions</span>
</div>
{rows.map((row) => (
<div className="row plain device-row" key={row.id}>
<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 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">
<Badge variant="outline">{row.presence}</Badge>
</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={() => onWakeDevice(row)}
disabled={
wakeBusyId === row.id || !selectedAgentId || bulkWakeBusy
}
size="sm"
>
{wakeBusyId === row.id ? "Waking..." : "Wake"}
</Button>
<Button
type="button"
variant="outline"
size="xs"
onClick={() => onCopyValue("name", chooseWakeTarget(row))}
>
Copy name
</Button>
<Button
type="button"
variant="outline"
size="xs"
onClick={() => onCopyValue("ip", row.ips[0] || "")}
>
Copy IP
</Button>
<Button
type="button"
variant="outline"
size="xs"
onClick={() => onCopyValue("mac", row.macs[0] || "")}
>
Copy MAC
</Button>
</span>
</div>
))}
{!rows.length && <div className="empty">No devices found</div>}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
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="row-head">
<CardTitle>Recent Wake Actions</CardTitle>
<Button variant="outline" size="sm" onClick={onClear}>
Clear
</Button>
</CardHeader>
<CardContent>
<div className="list">
{events.map((event, 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">{event.detail}</div>
{event.requestId && (
<Link
className="muted linkish"
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="empty">No wake actions yet</div>}
</div>
</CardContent>
</Card>
);
}
+20
View File
@@ -0,0 +1,20 @@
export type DeviceRow = {
id: string;
name: string;
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
@@ -0,0 +1,174 @@
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 id = macs[0] || ips[0] || names[0] || `row-${idx}`;
return {
id,
name: names[0] || "(unnamed)",
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.name !== "(unnamed)"
? device.name
: device.macs[0] || device.ips[0] || "";
}
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",
};
}
+159 -19
View File
@@ -1,9 +1,53 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";
@custom-variant dark (&:is(.dark *));
:root { :root {
--bg: #0a111f; --bg: #f5f8ff;
--card: #111a2d; --bg-accent: #e8f0ff;
--line: #2c3b57; --card: oklch(1 0 0);
--text: #e7edf7; --line: #c9d3e4;
--muted: #9bb0d1; --text: #1b2a42;
--muted: #5b6f8f;
--surface: #ffffff;
--surface-muted: #f4f7fc;
--surface-strong: #eaf0fb;
--card-bg-a: rgba(255, 255, 255, 0.98);
--card-bg-b: rgba(245, 249, 255, 0.9);
--tab-active-border: #7d96bb;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0.008 326);
--card-foreground: oklch(0.145 0.008 326);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0.008 326);
--primary: oklch(0.457 0.24 277.023);
--primary-foreground: oklch(0.962 0.018 272.314);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted-foreground: oklch(0.542 0.034 322.5);
--accent: oklch(0.96 0.003 325.6);
--accent-foreground: oklch(0.212 0.019 322.12);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.005 325.62);
--input: oklch(0.922 0.005 325.62);
--ring: oklch(0.711 0.019 323.02);
--chart-1: oklch(0.81 0.117 11.638);
--chart-2: oklch(0.645 0.246 16.439);
--chart-3: oklch(0.586 0.253 17.585);
--chart-4: oklch(0.514 0.222 16.935);
--chart-5: oklch(0.455 0.188 13.697);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0.008 326);
--sidebar-primary: oklch(0.511 0.262 276.966);
--sidebar-primary-foreground: oklch(0.962 0.018 272.314);
--sidebar-accent: oklch(0.96 0.003 325.6);
--sidebar-accent-foreground: oklch(0.212 0.019 322.12);
--sidebar-border: oklch(0.922 0.005 325.62);
--sidebar-ring: oklch(0.711 0.019 323.02);
} }
* { * {
@@ -12,7 +56,7 @@
body { body {
margin: 0; margin: 0;
color: var(--text); color: var(--text);
background: radial-gradient(circle at 20% 10%, #1a2741 0%, var(--bg) 45%); background: radial-gradient(circle at 20% 10%, var(--bg-accent) 0%, var(--bg) 45%);
font-family: "IBM Plex Sans", "Segoe UI", sans-serif; font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
} }
@@ -47,12 +91,12 @@ body {
padding: 0.35rem 0.65rem; padding: 0.35rem 0.65rem;
text-decoration: none; text-decoration: none;
color: var(--muted); color: var(--muted);
background: #0b1528; background: var(--surface-muted);
} }
.tab.active { .tab.active {
color: var(--text); color: var(--text);
border-color: #6b84ad; border-color: var(--tab-active-border);
} }
.pill { .pill {
@@ -109,11 +153,7 @@ body {
.card { .card {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; border-radius: 12px;
background: linear-gradient( background: linear-gradient(180deg, var(--card-bg-a), var(--card-bg-b));
180deg,
rgba(17, 26, 45, 0.92),
rgba(17, 26, 45, 0.72)
);
padding: 0.8rem; padding: 0.8rem;
} }
.span-2 { .span-2 {
@@ -137,7 +177,7 @@ input,
select { select {
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #0b1528; background: var(--surface);
color: var(--text); color: var(--text);
} }
button { button {
@@ -145,7 +185,7 @@ button {
cursor: pointer; cursor: pointer;
} }
button:hover { button:hover {
border-color: #6b84ad; border-color: var(--tab-active-border);
} }
input, input,
select { select {
@@ -251,13 +291,13 @@ select {
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.6rem;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #0b1528; background: var(--surface);
border-radius: 8px; border-radius: 8px;
padding: 0.45rem 0.55rem; padding: 0.45rem 0.55rem;
text-align: left; text-align: left;
} }
.row.selected { .row.selected {
border-color: #6b84ad; border-color: var(--tab-active-border);
} }
.row.plain { .row.plain {
cursor: default; cursor: default;
@@ -286,7 +326,7 @@ select {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 2; z-index: 2;
background: #091429; background: var(--surface-strong);
} }
.device-cell { .device-cell {
@@ -394,7 +434,7 @@ select {
.output { .output {
margin: 0.65rem 0 0; margin: 0.65rem 0 0;
background: #020814; background: var(--surface-muted);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
padding: 0.7rem; padding: 0.7rem;
@@ -412,3 +452,103 @@ select {
padding: 0.7rem; padding: 0.7rem;
overflow: auto; overflow: auto;
} }
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Inter Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
.dark {
--bg: #0a111f;
--bg-accent: #1a2741;
--line: #2c3b57;
--text: #e7edf7;
--muted: #9bb0d1;
--surface: #0b1528;
--surface-muted: #0d1a30;
--surface-strong: #091429;
--card-bg-a: rgba(17, 26, 45, 0.92);
--card-bg-b: rgba(17, 26, 45, 0.72);
--tab-active-border: #6b84ad;
--background: oklch(0.145 0.008 326);
--foreground: oklch(0.985 0 0);
--card: oklch(0.212 0.019 322.12);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.212 0.019 322.12);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.398 0.195 277.366);
--primary-foreground: oklch(0.962 0.018 272.314);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.263 0.024 320.12);
--muted-foreground: oklch(0.711 0.019 323.02);
--accent: oklch(0.263 0.024 320.12);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.542 0.034 322.5);
--chart-1: oklch(0.81 0.117 11.638);
--chart-2: oklch(0.645 0.246 16.439);
--chart-3: oklch(0.586 0.253 17.585);
--chart-4: oklch(0.514 0.222 16.935);
--chart-5: oklch(0.455 0.188 13.697);
--sidebar: oklch(0.212 0.019 322.12);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.585 0.233 277.117);
--sidebar-primary-foreground: oklch(0.962 0.018 272.314);
--sidebar-accent: oklch(0.263 0.024 320.12);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.542 0.034 322.5);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+2
View File
@@ -2,6 +2,7 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import tailwindcss from "@tailwindcss/vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -31,6 +32,7 @@ export default defineConfig({
} }
: undefined, : undefined,
), ),
tailwindcss(),
], ],
base: "/ui/", base: "/ui/",
resolve: { resolve: {