Compare commits
6
Commits
No files matched your search
@@ -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": "zinc",
|
||||
"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
@@ -12,10 +12,20 @@
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"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-compiler-runtime": "^1.0.0",
|
||||
"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": {
|
||||
"@types/node": "^22.10.2",
|
||||
|
||||
Generated
+3479
-970
File diff suppressed because it is too large.
Load diff
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-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:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:ps-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="rtl:rotate-180 ms-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "inline-end",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-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 )}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex 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 focus:**:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute end-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex 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 focus:**:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute end-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ms-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 min-w-0 flex-1 truncate 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 min-w-0 flex-1 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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const navItems = [
|
||||
["/", "Devices"],
|
||||
["/commands", "Wake Tools"],
|
||||
@@ -10,23 +13,65 @@ const navItems = [
|
||||
["/tokens", "Tokens"],
|
||||
] 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() {
|
||||
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 (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<div className="mx-auto max-w-7xl p-4">
|
||||
<header className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1>Wakey Operator UI</h1>
|
||||
<p>Find devices fast and wake them reliably</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Wakey Operator UI
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Find devices fast and wake them reliably
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<nav className="tabs">
|
||||
<nav className="mb-3 flex flex-wrap gap-2">
|
||||
{navItems.map(([to, label]) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) => `tab ${isActive ? "active" : ""}`}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"rounded-full border px-3 py-1.5 text-sm transition-colors",
|
||||
isActive
|
||||
? "border-primary/60 bg-primary/10 text-foreground"
|
||||
: "border-border bg-card text-muted-foreground hover:text-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Agent } from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
@@ -8,21 +10,34 @@ type Props = {
|
||||
|
||||
export function AgentsPage({ agents, selectedAgentId, onSelectAgent }: Props) {
|
||||
return (
|
||||
<section className="card">
|
||||
<h2>Agents</h2>
|
||||
<div className="list">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agents</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2">
|
||||
{agents.map((agent) => (
|
||||
<button
|
||||
<Button
|
||||
key={agent.agent_id}
|
||||
className={`row ${selectedAgentId === agent.agent_id ? "selected" : ""}`}
|
||||
variant={
|
||||
selectedAgentId === agent.agent_id ? "secondary" : "outline"
|
||||
}
|
||||
className="flex h-auto w-full items-center justify-between px-3 py-2 text-left"
|
||||
onClick={() => onSelectAgent(agent.agent_id)}
|
||||
>
|
||||
<span>{agent.agent_id}</span>
|
||||
<span>{agent.connected ? "connected" : "offline"}</span>
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{agent.connected ? "connected" : "offline"}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
{!agents.length && <div className="empty">No agents enrolled</div>}
|
||||
{!agents.length && (
|
||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
||||
No agents enrolled
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+98
-40
@@ -1,6 +1,17 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { Alert, AlertTransition } 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 { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type Props = {
|
||||
alerts: Alert[];
|
||||
@@ -51,83 +62,130 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
}, [transitions, transitionQ]);
|
||||
|
||||
return (
|
||||
<section className="two-col">
|
||||
<div className="card">
|
||||
<div className="row-head">
|
||||
<h2>Active Alerts</h2>
|
||||
<button onClick={() => void onRefresh()}>Refresh</button>
|
||||
</div>
|
||||
<div className="grid-3 compact">
|
||||
<label>
|
||||
<section className="grid gap-3 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex items-center justify-between gap-2">
|
||||
<CardTitle>Active Alerts</CardTitle>
|
||||
<Button size="sm" variant="outline" onClick={() => void onRefresh()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
Severity
|
||||
<select
|
||||
<Select
|
||||
value={severity}
|
||||
onChange={(e) => setSeverity(e.target.value)}
|
||||
onValueChange={(value) => {
|
||||
if (value) setSeverity(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Severity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{severities.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
Status
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(value) => {
|
||||
if (value) setStatus(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statuses.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
Kind
|
||||
<select value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||
<Select
|
||||
value={kind}
|
||||
onValueChange={(value) => {
|
||||
if (value) setKind(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Kind" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{kinds.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted">
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {filteredAlerts.length} of {alerts.length}
|
||||
</p>
|
||||
<div className="list">
|
||||
|
||||
<div className="grid gap-2">
|
||||
{filteredAlerts.map((alert) => (
|
||||
<div className="row plain" key={alert.alert_id}>
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
key={alert.alert_id}
|
||||
>
|
||||
<span>
|
||||
{alert.kind}
|
||||
{alert.agent_id ? ` (${alert.agent_id})` : ""}
|
||||
</span>
|
||||
<span>{alert.severity}</span>
|
||||
<span className="text-muted-foreground">{alert.severity}</span>
|
||||
</div>
|
||||
))}
|
||||
{!filteredAlerts.length && (
|
||||
<div className="empty">No active alerts</div>
|
||||
<div className="px-1 text-sm text-muted-foreground">
|
||||
No active alerts
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="card">
|
||||
<div className="row-head">
|
||||
<h2>Transition History</h2>
|
||||
<span className="muted">{filteredTransitions.length} shown</span>
|
||||
</div>
|
||||
<label>
|
||||
<Card>
|
||||
<CardHeader className="flex items-center justify-between gap-2">
|
||||
<CardTitle>Transition History</CardTitle>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{filteredTransitions.length} shown
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
Search
|
||||
<input
|
||||
<Input
|
||||
value={transitionQ}
|
||||
onChange={(e) => setTransitionQ(e.target.value)}
|
||||
placeholder="kind, status, message, agent"
|
||||
/>
|
||||
</label>
|
||||
<pre className="output small">
|
||||
{JSON.stringify(filteredTransitions, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
className="min-h-[230px] font-mono text-xs"
|
||||
readOnly
|
||||
value={JSON.stringify(filteredTransitions, null, 2)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+63
-22
@@ -2,6 +2,17 @@ import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import type { AuditEvent } 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 { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type Props = {
|
||||
events: AuditEvent[];
|
||||
@@ -49,48 +60,78 @@ export function AuditPage({ events, onRefresh }: Props) {
|
||||
}, [events, eventType, outcome, needle]);
|
||||
|
||||
return (
|
||||
<section className="card span-full">
|
||||
<div className="row-head">
|
||||
<h2>Recent Audit Events</h2>
|
||||
<button onClick={() => void onRefresh()}>Refresh</button>
|
||||
</div>
|
||||
<div className="grid-3 compact">
|
||||
<label>
|
||||
<Card size="sm">
|
||||
<CardHeader className="flex items-center justify-between gap-2">
|
||||
<CardTitle>Recent Audit Events</CardTitle>
|
||||
<Button size="sm" variant="outline" onClick={() => void onRefresh()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
Event type
|
||||
<select
|
||||
<Select
|
||||
value={eventType}
|
||||
onChange={(e) => setEventType(e.target.value)}
|
||||
onValueChange={(value) => {
|
||||
if (value) setEventType(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Event type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eventTypes.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
Outcome
|
||||
<select value={outcome} onChange={(e) => setOutcome(e.target.value)}>
|
||||
<Select
|
||||
value={outcome}
|
||||
onValueChange={(value) => {
|
||||
if (value) setOutcome(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Outcome" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{outcomes.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
Search
|
||||
<input
|
||||
<Input
|
||||
value={needle}
|
||||
onChange={(e) => setNeedle(e.target.value)}
|
||||
placeholder="message, agent, actor"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted">
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {filtered.length} of {events.length}
|
||||
</p>
|
||||
<pre className="output">{JSON.stringify(filtered, null, 2)}</pre>
|
||||
</section>
|
||||
|
||||
<Textarea
|
||||
className="min-h-[360px] font-mono text-xs"
|
||||
readOnly
|
||||
value={JSON.stringify(filtered, null, 2)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
@@ -37,51 +48,72 @@ export function CommandsPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<h2>Command Runner</h2>
|
||||
<form className="form" onSubmit={submit}>
|
||||
<label>
|
||||
Agent
|
||||
<select
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Command Runner</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="grid gap-3" onSubmit={submit}>
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>Agent</span>
|
||||
<Select
|
||||
value={selectedAgentId}
|
||||
onChange={(e) => onSelectAgent(e.target.value)}
|
||||
required
|
||||
onValueChange={(value) => {
|
||||
if (value) onSelectAgent(value);
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select agent
|
||||
</option>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.agent_id} value={agent.agent_id}>
|
||||
{agent.agent_id} ({agent.connected ? "connected" : "offline"})
|
||||
</option>
|
||||
<SelectItem key={agent.agent_id} value={agent.agent_id}>
|
||||
{agent.agent_id} (
|
||||
{agent.connected ? "connected" : "offline"})
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label>
|
||||
Command
|
||||
<select
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>Command</span>
|
||||
<Select
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value as CommandKind)}
|
||||
onValueChange={(value) => setKind(value as CommandKind)}
|
||||
>
|
||||
<option value="devs">devs</option>
|
||||
<option value="leases">leases</option>
|
||||
<option value="inventory">inventory</option>
|
||||
<option value="wake">wake</option>
|
||||
</select>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pick command" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="devs">devs</SelectItem>
|
||||
<SelectItem value="leases">leases</SelectItem>
|
||||
<SelectItem value="inventory">inventory</SelectItem>
|
||||
<SelectItem value="wake">wake</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label>
|
||||
Query
|
||||
<input
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>Query</span>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="optional"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={running || !selectedAgentId}>
|
||||
|
||||
<Button type="submit" disabled={running || !selectedAgentId}>
|
||||
{running ? "Running..." : "Run command"}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
<pre className="output">{output}</pre>
|
||||
</section>
|
||||
|
||||
<Textarea
|
||||
className="mt-3 max-h-80 min-h-48 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs"
|
||||
value={output}
|
||||
readOnly
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { Agent, Alert, AlertTransition } from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
@@ -17,35 +25,50 @@ export function DashboardPage({
|
||||
}: Props) {
|
||||
const connected = agents.filter((a) => a.connected).length;
|
||||
return (
|
||||
<section className="card-grid">
|
||||
<div className="card stat">
|
||||
<h3>Agents</h3>
|
||||
<strong>{agents.length}</strong>
|
||||
</div>
|
||||
<div className="card stat">
|
||||
<h3>Connected</h3>
|
||||
<strong>{connected}</strong>
|
||||
</div>
|
||||
<div className="card stat">
|
||||
<h3>Active Alerts</h3>
|
||||
<strong>{alerts.length}</strong>
|
||||
</div>
|
||||
<div className="card stat">
|
||||
<h3>Transitions</h3>
|
||||
<strong>{transitions.length}</strong>
|
||||
</div>
|
||||
<div className="card span-full">
|
||||
<div className="row-head">
|
||||
<h2>Overview</h2>
|
||||
<button onClick={onRefresh} disabled={loading}>
|
||||
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Agents</CardDescription>
|
||||
<CardTitle className="text-2xl">{agents.length}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Connected</CardDescription>
|
||||
<CardTitle className="text-2xl">{connected}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Active Alerts</CardDescription>
|
||||
<CardTitle className="text-2xl">{alerts.length}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Transitions</CardDescription>
|
||||
<CardTitle className="text-2xl">{transitions.length}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="sm:col-span-2 lg:col-span-4">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<CardTitle>Overview</CardTitle>
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{loading ? "Refreshing..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use tabs to run commands, inspect audits, and monitor alerts in real
|
||||
time.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+149
-387
@@ -1,21 +1,35 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, 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 { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
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,
|
||||
} 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";
|
||||
import "@/pages/devices/devices.css";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
@@ -24,167 +38,9 @@ type Props = {
|
||||
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)),
|
||||
);
|
||||
function middleEllipsis(value: string, head = 16, tail = 10): string {
|
||||
if (value.length <= head + tail + 3) return value;
|
||||
return `${value.slice(0, head)}...${value.slice(-tail)}`;
|
||||
}
|
||||
|
||||
export function DevicesPage({
|
||||
@@ -361,13 +217,39 @@ export function DevicesPage({
|
||||
async function copyValue(label: string, value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
async function copyWithFallback(text: string) {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "0";
|
||||
textarea.style.left = "0";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
if (!ok) throw new Error("fallback-copy-failed");
|
||||
}
|
||||
|
||||
try {
|
||||
if (window.isSecureContext && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(trimmed);
|
||||
} else {
|
||||
await copyWithFallback(trimmed);
|
||||
}
|
||||
setCopyStatus(`Copied ${label}`);
|
||||
} catch {
|
||||
try {
|
||||
await copyWithFallback(trimmed);
|
||||
setCopyStatus(`Copied ${label}`);
|
||||
} catch {
|
||||
setCopyStatus(`Copy failed for ${label}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadInventory();
|
||||
@@ -436,39 +318,58 @@ export function DevicesPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="two-col">
|
||||
<div className="card">
|
||||
<div className="row-head">
|
||||
<h2>Devices</h2>
|
||||
<button
|
||||
<section className="grid gap-3 xl:grid-cols-[minmax(0,2fr)_minmax(20rem,1fr)]">
|
||||
<Card className="min-w-0">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle>Devices</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void loadInventory()}
|
||||
disabled={loading || !selectedAgentId}
|
||||
>
|
||||
{loading ? "Refreshing..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<div className="grid-3 compact">
|
||||
<label>
|
||||
Agent
|
||||
<select
|
||||
<CardContent>
|
||||
<div className="mt-2 grid gap-2 md:grid-cols-3">
|
||||
<label className="grid min-w-0 gap-1 text-sm text-muted-foreground">
|
||||
<span>Agent</span>
|
||||
<Select
|
||||
value={selectedAgentId}
|
||||
onChange={(e) => onSelectAgent(e.target.value)}
|
||||
required
|
||||
onValueChange={(value) => {
|
||||
if (value) onSelectAgent(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate text-start">
|
||||
{selectedAgentId
|
||||
? middleEllipsis(selectedAgentId)
|
||||
: "Select agent"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
className="max-w-[min(92vw,30rem)]"
|
||||
alignItemWithTrigger={false}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select agent
|
||||
</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.agent_id} value={agent.agent_id}>
|
||||
{agent.agent_id} ({agent.connected ? "connected" : "offline"})
|
||||
</option>
|
||||
<SelectItem
|
||||
key={agent.agent_id}
|
||||
value={agent.agent_id}
|
||||
className="pe-10"
|
||||
>
|
||||
{middleEllipsis(agent.agent_id, 14, 8)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="span-2">
|
||||
Search
|
||||
<input
|
||||
|
||||
<label className="grid min-w-0 gap-1 text-sm text-muted-foreground md:col-span-2">
|
||||
<span>Search</span>
|
||||
<Input
|
||||
className="w-full min-w-0"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="name, ip, mac, interface"
|
||||
@@ -479,20 +380,20 @@ export function DevicesPage({
|
||||
<div className="quick-wake">
|
||||
<label>
|
||||
Quick Wake (name, IP, or MAC)
|
||||
<input
|
||||
<Input
|
||||
value={quickWake}
|
||||
onChange={(e) => setQuickWake(e.target.value)}
|
||||
placeholder="bedroom-pc or aa:bb:cc:dd:ee:ff"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
<Button
|
||||
onClick={() => void wakeTarget(quickWake.trim(), "quick")}
|
||||
disabled={
|
||||
!selectedAgentId || !quickWake.trim() || wakeBusyId === "quick"
|
||||
}
|
||||
>
|
||||
{wakeBusyId === "quick" ? "Waking..." : "Wake target"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -501,14 +402,15 @@ export function DevicesPage({
|
||||
aria-label="Filter by presence"
|
||||
>
|
||||
{PRESENCE_FILTERS.map((value) => (
|
||||
<button
|
||||
<Button
|
||||
key={value}
|
||||
className={`filter-chip ${presenceFilter === value ? "active" : ""}`}
|
||||
variant={presenceFilter === value ? "secondary" : "outline"}
|
||||
size="xs"
|
||||
onClick={() => setPresenceFilter(value)}
|
||||
type="button"
|
||||
>
|
||||
{value === "all" ? "all" : value.replace("_", " ")}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -522,207 +424,67 @@ export function DevicesPage({
|
||||
/>
|
||||
Select visible
|
||||
</label>
|
||||
<button
|
||||
<Button
|
||||
onClick={() => void wakeSelectedDevices()}
|
||||
disabled={!selectedVisibleCount || !selectedAgentId || bulkWakeBusy}
|
||||
disabled={
|
||||
!selectedVisibleCount || !selectedAgentId || bulkWakeBusy
|
||||
}
|
||||
type="button"
|
||||
size="sm"
|
||||
>
|
||||
{bulkWakeBusy
|
||||
? "Waking selected..."
|
||||
: `Wake selected (${selectedVisibleCount})`}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setSelectedIds([])}
|
||||
disabled={!selectedIds.length}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
{copyStatus && <span className="muted">{copyStatus}</span>}
|
||||
</Button>
|
||||
{copyStatus && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{copyStatus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="muted">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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
|
||||
type="checkbox"
|
||||
checked={allVisibleSelected}
|
||||
onChange={(e) => toggleAllVisible(e.target.checked)}
|
||||
disabled={!filtered.length}
|
||||
aria-label="Select all visible devices"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="sortable-col device-cell"
|
||||
onClick={() =>
|
||||
setSort((s) => ({
|
||||
key: "name",
|
||||
dir: s.key === "name" && s.dir === "asc" ? "desc" : "asc",
|
||||
}))
|
||||
}
|
||||
>
|
||||
Name {sort.key === "name" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
|
||||
</span>
|
||||
<span
|
||||
className="sortable-col device-cell"
|
||||
onClick={() =>
|
||||
setSort((s) => ({
|
||||
key: "ip",
|
||||
dir: s.key === "ip" && s.dir === "asc" ? "desc" : "asc",
|
||||
}))
|
||||
}
|
||||
>
|
||||
IP {sort.key === "ip" ? (sort.dir === "asc" ? "▲" : "▼") : ""}
|
||||
</span>
|
||||
<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>
|
||||
{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>
|
||||
{error && (
|
||||
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="row-head">
|
||||
<h2>Recent Wake Actions</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
<DeviceInventoryTable
|
||||
rows={filtered}
|
||||
selectedIds={selectedIds}
|
||||
sort={sort}
|
||||
allVisibleSelected={allVisibleSelected}
|
||||
wakeBusyId={wakeBusyId}
|
||||
selectedAgentId={selectedAgentId}
|
||||
bulkWakeBusy={bulkWakeBusy}
|
||||
onToggleAllVisible={toggleAllVisible}
|
||||
onToggleRow={toggleRowSelection}
|
||||
onSortChange={setSort}
|
||||
onWakeDevice={(device) => void wakeDevice(device)}
|
||||
onCopyValue={(label, value) => void copyValue(label, value)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<WakeHistoryCard
|
||||
events={recentWakes}
|
||||
onClear={() => {
|
||||
setRecentWakes([]);
|
||||
saveHistory([]);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div className="list">
|
||||
{recentWakes.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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+80
-40
@@ -6,6 +6,17 @@ import {
|
||||
issueEnrollToken,
|
||||
revokeEnrollToken,
|
||||
} from "@/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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";
|
||||
|
||||
function formatUnix(unix: number): string {
|
||||
return new Date(unix * 1000).toLocaleString();
|
||||
@@ -77,74 +88,103 @@ export function TokensPage() {
|
||||
}, [includeExpired]);
|
||||
|
||||
return (
|
||||
<section className="two-col">
|
||||
<div className="card">
|
||||
<div className="row-head">
|
||||
<h2>Enroll Tokens</h2>
|
||||
<button onClick={() => void load()} disabled={busy}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="form">
|
||||
<label>
|
||||
Include expired
|
||||
<select
|
||||
value={includeExpired ? "yes" : "no"}
|
||||
onChange={(e) => setIncludeExpired(e.target.value === "yes")}
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<CardTitle>Enroll Tokens</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void load()}
|
||||
disabled={busy}
|
||||
>
|
||||
<option value="no">No</option>
|
||||
<option value="yes">Yes</option>
|
||||
</select>
|
||||
Refresh
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>Include expired</span>
|
||||
<Select
|
||||
value={includeExpired ? "yes" : "no"}
|
||||
onValueChange={(value) => setIncludeExpired(value === "yes")}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Choose option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="no">No</SelectItem>
|
||||
<SelectItem value="yes">Yes</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeCount} active, {tokens.length} shown
|
||||
</p>
|
||||
<div className="list">
|
||||
<div className="grid gap-2">
|
||||
{tokens.map((token) => (
|
||||
<div className="row" key={token.enroll_token}>
|
||||
<div
|
||||
className="flex items-start justify-between gap-3 rounded-md border bg-card px-3 py-2"
|
||||
key={token.enroll_token}
|
||||
>
|
||||
<div>
|
||||
<strong>{token.enroll_token}</strong>
|
||||
<div className="muted">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
expires: {formatUnix(token.expires_at_unix)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="token-actions">
|
||||
<span className={`pill ${token.expired ? "error" : "ready"}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={token.expired ? "destructive" : "secondary"}>
|
||||
{token.expired ? "expired" : "active"}
|
||||
</span>
|
||||
<button
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void onRevoke(token.enroll_token)}
|
||||
disabled={busy}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!tokens.length && <div className="empty">No tokens found</div>}
|
||||
{!tokens.length && (
|
||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
||||
No tokens found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="card">
|
||||
<h2>Issue Token</h2>
|
||||
<div className="form">
|
||||
<label>
|
||||
TTL seconds
|
||||
<input
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Issue Token</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>TTL seconds</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={ttlSeconds}
|
||||
onChange={(e) => setTtlSeconds(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button onClick={() => void onIssue()} disabled={busy}>
|
||||
<Button onClick={() => void onIssue()} disabled={busy}>
|
||||
Issue
|
||||
</button>
|
||||
</div>
|
||||
{status && <pre className="output">{status}</pre>}
|
||||
{error && <pre className="error">{error}</pre>}
|
||||
</div>
|
||||
</Button>
|
||||
{status && (
|
||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 text-xs">
|
||||
{status}
|
||||
</pre>
|
||||
)}
|
||||
{error && (
|
||||
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
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;
|
||||
};
|
||||
|
||||
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="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">Interfaces</span>
|
||||
<span className="device-cell device-action">Actions</span>
|
||||
</div>
|
||||
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
className="device-row rounded-md border bg-card px-3 py-2 text-sm"
|
||||
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 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">{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"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
.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(100px, 0.9fr) minmax(170px, max-content);
|
||||
align-items: center;
|
||||
column-gap: 0.4rem;
|
||||
row-gap: 0.55rem;
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.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"
|
||||
"interfaces interfaces";
|
||||
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;
|
||||
}
|
||||
|
||||
.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-cell[data-label="Interfaces"] {
|
||||
grid-area: interfaces;
|
||||
}
|
||||
|
||||
.device-row .device-action {
|
||||
grid-area: action;
|
||||
justify-self: end;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quick-wake {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
+138
-402
@@ -1,414 +1,150 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/inter";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--bg: #0a111f;
|
||||
--card: #111a2d;
|
||||
--line: #2c3b57;
|
||||
--text: #e7edf7;
|
||||
--muted: #9bb0d1;
|
||||
--card: oklch(1 0 0);
|
||||
--muted: oklch(0.96 0.004 145);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.23 0.02 155);
|
||||
--card-foreground: oklch(0.23 0.02 155);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.23 0.02 155);
|
||||
--primary: oklch(0.56 0.12 156);
|
||||
--primary-foreground: oklch(0.985 0.003 95);
|
||||
--secondary: oklch(0.965 0.01 155);
|
||||
--secondary-foreground: oklch(0.3 0.035 156);
|
||||
--muted-foreground: oklch(0.5 0.02 155);
|
||||
--accent: oklch(0.945 0.015 156);
|
||||
--accent-foreground: oklch(0.3 0.035 156);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.9 0.01 155);
|
||||
--input: oklch(0.9 0.01 155);
|
||||
--ring: oklch(0.62 0.08 156);
|
||||
--chart-1: oklch(0.62 0.12 156);
|
||||
--chart-2: oklch(0.56 0.11 188);
|
||||
--chart-3: oklch(0.67 0.12 120);
|
||||
--chart-4: oklch(0.72 0.15 85);
|
||||
--chart-5: oklch(0.58 0.18 35);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.23 0.02 155);
|
||||
--sidebar-primary: oklch(0.56 0.12 156);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.003 95);
|
||||
--sidebar-accent: oklch(0.945 0.015 156);
|
||||
--sidebar-accent-foreground: oklch(0.3 0.035 156);
|
||||
--sidebar-border: oklch(0.9 0.01 155);
|
||||
--sidebar-ring: oklch(0.62 0.08 156);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
background: radial-gradient(circle at 20% 10%, #1a2741 0%, var(--bg) 45%);
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
@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);
|
||||
}
|
||||
|
||||
.app {
|
||||
padding: 1rem;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
}
|
||||
.topbar p {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--muted);
|
||||
.dark {
|
||||
--muted: oklch(0.25 0.01 156);
|
||||
--background: oklch(0.19 0.02 156);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.24 0.015 156);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.24 0.015 156);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.68 0.11 156);
|
||||
--primary-foreground: oklch(0.17 0.02 156);
|
||||
--secondary: oklch(0.28 0.02 156);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted-foreground: oklch(0.75 0.02 156);
|
||||
--accent: oklch(0.3 0.02 156);
|
||||
--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.65 0.08 156);
|
||||
--chart-1: oklch(0.68 0.11 156);
|
||||
--chart-2: oklch(0.63 0.11 188);
|
||||
--chart-3: oklch(0.73 0.12 120);
|
||||
--chart-4: oklch(0.78 0.14 85);
|
||||
--chart-5: oklch(0.67 0.16 35);
|
||||
--sidebar: oklch(0.24 0.015 156);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.68 0.11 156);
|
||||
--sidebar-primary-foreground: oklch(0.17 0.02 156);
|
||||
--sidebar-accent: oklch(0.3 0.02 156);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.65 0.08 156);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.65rem;
|
||||
text-decoration: none;
|
||||
color: var(--muted);
|
||||
background: #0b1528;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--text);
|
||||
border-color: #6b84ad;
|
||||
}
|
||||
|
||||
.pill {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.pill.ready {
|
||||
color: #86efac;
|
||||
border-color: #14532d;
|
||||
}
|
||||
.pill.loading {
|
||||
color: #fde68a;
|
||||
border-color: #713f12;
|
||||
}
|
||||
.pill.error {
|
||||
color: #fca5a5;
|
||||
border-color: #7f1d1d;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
.stat h3 {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.stat strong {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
|
||||
}
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
background-color: var(--background);
|
||||
background-image:
|
||||
radial-gradient(
|
||||
1200px 700px at 15% -10%,
|
||||
color-mix(in oklab, var(--primary) 15%, var(--background)) 0%,
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(
|
||||
900px 600px at 85% 0%,
|
||||
color-mix(in oklab, var(--accent) 12%, var(--background)) 0%,
|
||||
transparent 58%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(17, 26, 45, 0.92),
|
||||
rgba(17, 26, 45, 0.72)
|
||||
color-mix(in oklab, var(--background) 85%, var(--foreground) 15%) 0%,
|
||||
var(--background) 45%
|
||||
);
|
||||
padding: 0.8rem;
|
||||
}
|
||||
.span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.span-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.row-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: #0b1528;
|
||||
color: var(--text);
|
||||
}
|
||||
button {
|
||||
padding: 0.45rem 0.65rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #6b84ad;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.55rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.form label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
.grid-3.compact {
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.grid-3 label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.presence-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: #0b1528;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.filter-chip.active {
|
||||
border-color: #6b84ad;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.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);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.linkish {
|
||||
display: inline-block;
|
||||
margin-top: 0.25rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
border: 1px solid var(--line);
|
||||
background: #0b1528;
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.55rem;
|
||||
text-align: left;
|
||||
}
|
||||
.row.selected {
|
||||
border-color: #6b84ad;
|
||||
}
|
||||
.row.plain {
|
||||
cursor: default;
|
||||
}
|
||||
.token-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.device-list {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.device-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(110px, 1.1fr) minmax(160px, 2fr) minmax(
|
||||
130px,
|
||||
1.5fr
|
||||
) minmax(110px, 0.9fr) minmax(120px, 1fr) minmax(220px, auto);
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.device-header {
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #091429;
|
||||
}
|
||||
|
||||
.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: wrap;
|
||||
gap: 0.35rem;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.device-select {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mini-btn {
|
||||
padding: 0.28rem 0.45rem;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.device-header {
|
||||
display: none;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.device-row {
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-areas:
|
||||
"select action"
|
||||
"name name"
|
||||
"presence presence"
|
||||
"ip ip"
|
||||
"mac mac"
|
||||
"interfaces interfaces";
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.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);
|
||||
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;
|
||||
}
|
||||
|
||||
.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-cell[data-label="Interfaces"] {
|
||||
grid-area: interfaces;
|
||||
}
|
||||
|
||||
.device-row .device-action {
|
||||
grid-area: action;
|
||||
justify-self: end;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.quick-wake {
|
||||
grid-template-columns: 1fr;
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
padding: 0.35rem 0.2rem;
|
||||
}
|
||||
|
||||
.output {
|
||||
margin: 0.65rem 0 0;
|
||||
background: #020814;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.output.small {
|
||||
max-height: 230px;
|
||||
}
|
||||
.error {
|
||||
border: 1px solid #7f1d1d;
|
||||
background: rgba(127, 29, 29, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 0.7rem;
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -31,6 +32,7 @@ export default defineConfig({
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
tailwindcss(),
|
||||
],
|
||||
base: "/ui/",
|
||||
resolve: {
|
||||
|
||||
Reference in new issue
Block a user