Merge branch 'Pouzor:main' into fix/zone-styling
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { brandIconUrl, BRAND_ICON_PREFIX } from '@/utils/nodeIcons'
|
||||
import dashboardIcons from '@/data/dashboardIcons.json'
|
||||
|
||||
const SLUGS: string[] = dashboardIcons as string[]
|
||||
const PAGE = 120
|
||||
|
||||
interface BrandIconPickerProps {
|
||||
value?: string
|
||||
onSelect: (key: string) => void
|
||||
}
|
||||
|
||||
export function BrandIconPicker({ value, onSelect }: BrandIconPickerProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [limit, setLimit] = useState(PAGE)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return SLUGS
|
||||
return SLUGS.filter((s) => s.includes(q))
|
||||
}, [query])
|
||||
|
||||
const visible = filtered.slice(0, limit)
|
||||
const selectedSlug = value?.startsWith(BRAND_ICON_PREFIX) ? value.slice(BRAND_ICON_PREFIX.length) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setLimit(PAGE) }}
|
||||
placeholder={`Search ${SLUGS.length} brand icons...`}
|
||||
className="bg-[#0d1117] border-[#30363d] text-xs h-7"
|
||||
aria-label="Brand icon search"
|
||||
/>
|
||||
<div className="text-[10px] text-muted-foreground/60">
|
||||
{filtered.length} match{filtered.length === 1 ? '' : 'es'} · icons served via jsDelivr CDN
|
||||
</div>
|
||||
<div className="max-h-52 overflow-y-auto pr-1">
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{visible.map((slug) => {
|
||||
const selected = slug === selectedSlug
|
||||
return (
|
||||
<button
|
||||
key={slug}
|
||||
type="button"
|
||||
onClick={() => onSelect(`${BRAND_ICON_PREFIX}${slug}`)}
|
||||
title={slug}
|
||||
aria-label={slug}
|
||||
aria-pressed={selected}
|
||||
className={`flex items-center justify-center aspect-square rounded-md border transition-colors cursor-pointer ${
|
||||
selected
|
||||
? 'border-[#00d4ff] bg-[#00d4ff]/10'
|
||||
: 'border-[#30363d] hover:border-[#484f58] bg-[#0d1117]'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={brandIconUrl(slug)}
|
||||
alt={slug}
|
||||
loading="lazy"
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ width: 20, height: 20, objectFit: 'contain' }}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{filtered.length > limit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLimit((l) => l + PAGE)}
|
||||
className="mt-2 w-full text-[11px] text-muted-foreground hover:text-foreground py-1"
|
||||
>
|
||||
Load more ({filtered.length - limit} remaining)
|
||||
</button>
|
||||
)}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center text-[11px] text-muted-foreground py-4">No icons match.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { toast } from 'sonner'
|
||||
import {
|
||||
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
|
||||
Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle, Flame,
|
||||
Radio, Zap, Lightbulb,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
@@ -21,7 +22,8 @@ import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
|
||||
const EDITABLE_NODE_TYPES: NodeType[] = [
|
||||
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
|
||||
'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host',
|
||||
'docker_container', 'generic',
|
||||
'docker_container', 'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice',
|
||||
'generic',
|
||||
]
|
||||
|
||||
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||
@@ -30,7 +32,9 @@ const NODE_ICONS: Record<string, LucideIcon> = {
|
||||
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,
|
||||
vm: Box, lxc: Container, nas: HardDrive, iot: Cpu, ap: Wifi,
|
||||
camera: Camera, printer: Printer, computer: Monitor, cpl: PlugZap,
|
||||
docker_host: Anchor, docker_container: Package, generic: Circle,
|
||||
docker_host: Anchor, docker_container: Package,
|
||||
zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb,
|
||||
generic: Circle,
|
||||
}
|
||||
|
||||
// ── Default style for a node type (from default theme) ─────────────────────
|
||||
|
||||
@@ -8,13 +8,15 @@ import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
|
||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||
import { BrandIconPicker } from './BrandIconPicker'
|
||||
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
|
||||
|
||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
||||
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
|
||||
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
||||
]
|
||||
|
||||
@@ -60,7 +62,15 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
||||
const [iconSearch, setIconSearch] = useState('')
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
const [iconTab, setIconTab] = useState<'generic' | 'brand'>(isBrandIconKey(initial?.custom_icon) ? 'brand' : 'generic')
|
||||
const [labelError, setLabelError] = useState(false)
|
||||
const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
|
||||
const showServicesEnabled = form.custom_colors?.show_services === true
|
||||
const hasAppearanceOverrides = Boolean(
|
||||
form.custom_colors?.border
|
||||
|| form.custom_colors?.background
|
||||
|| form.custom_colors?.icon
|
||||
)
|
||||
|
||||
const set = (key: keyof NodeData, value: unknown) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
@@ -87,7 +97,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -144,6 +154,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{(() => {
|
||||
if (isBrandIconKey(form.custom_icon)) {
|
||||
const slug = brandIconSlug(form.custom_icon!)
|
||||
return <><img src={brandIconUrl(slug)} alt={slug} width={13} height={13} className="shrink-0" style={{ width: 13, height: 13, objectFit: 'contain' }} /><span className="text-foreground truncate">{slug}</span></>
|
||||
}
|
||||
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
|
||||
if (entry) {
|
||||
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
|
||||
@@ -159,6 +173,37 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
{/* Inline icon picker - full width, shown below the type+icon row */}
|
||||
{iconPickerOpen && (
|
||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
|
||||
<div className="flex gap-1 mb-1" role="tablist" aria-label="Icon source">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={iconTab === 'generic'}
|
||||
onClick={() => setIconTab('generic')}
|
||||
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||
iconTab === 'generic' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Generic
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={iconTab === 'brand'}
|
||||
onClick={() => setIconTab('brand')}
|
||||
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||
iconTab === 'brand' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Brand
|
||||
</button>
|
||||
</div>
|
||||
{iconTab === 'brand' ? (
|
||||
<BrandIconPicker
|
||||
value={form.custom_icon}
|
||||
onSelect={(key) => { set('custom_icon', key); setIconPickerOpen(false) }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
value={iconSearch}
|
||||
onChange={(e) => setIconSearch(e.target.value)}
|
||||
@@ -204,6 +249,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -298,21 +345,52 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<div className="flex items-center justify-between col-span-2 py-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Label className="text-xs text-muted-foreground">Container Mode</Label>
|
||||
<span className="text-[10px] text-muted-foreground/60">Allow other nodes to nest inside this node</span>
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
Allow other nodes to nest inside this node
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-label="Container Mode"
|
||||
aria-checked={!!form.container_mode}
|
||||
onClick={() => set('container_mode', !form.container_mode)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none ${modalStyles['modal-interactive']}`}
|
||||
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ease-in-out"
|
||||
style={{
|
||||
transform: form.container_mode ? 'translateX(16px)' : 'translateX(0)'
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service visibility */}
|
||||
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||
<div className="flex items-start justify-between col-span-2 py-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Label className="text-xs text-muted-foreground">Show Services</Label>
|
||||
<span className="text-[10px] text-muted-foreground/60">Display discovered services on the node card</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!!form.container_mode}
|
||||
onClick={() => set('container_mode', !form.container_mode)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none ${modalStyles['modal-interactive']}`}
|
||||
tabIndex={0}
|
||||
aria-label="Toggle container mode"
|
||||
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
|
||||
aria-label="Show Services"
|
||||
aria-checked={showServicesEnabled}
|
||||
onClick={() => set('custom_colors', {
|
||||
...form.custom_colors,
|
||||
show_services: !showServicesEnabled,
|
||||
})}
|
||||
className="relative inline-flex h-5 w-9 mt-1 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none"
|
||||
style={{ background: showServicesEnabled ? resolvedNodeColors.icon : '#30363d' }}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-all"
|
||||
style={{ left: form.container_mode ? 'calc(100% - 18px)' : '2px' }}
|
||||
className="pointer-events-none absolute top-px left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ease-in-out"
|
||||
style={{ transform: showServicesEnabled ? 'translateX(16px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -322,10 +400,20 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<div className="flex flex-col gap-2 col-span-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Appearance</Label>
|
||||
{form.custom_colors && (
|
||||
{hasAppearanceOverrides && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set('custom_colors', undefined)}
|
||||
onClick={() => setForm((f) => {
|
||||
if (!f.custom_colors) return f
|
||||
const { border, background, icon, ...rest } = f.custom_colors
|
||||
void border
|
||||
void background
|
||||
void icon
|
||||
return {
|
||||
...f,
|
||||
custom_colors: Object.keys(rest).length > 0 ? rest : undefined,
|
||||
}
|
||||
})}
|
||||
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<RotateCcw size={10} /> Reset to defaults
|
||||
@@ -359,9 +447,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{!form.custom_colors && (
|
||||
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
|
||||
)}
|
||||
<div className="min-h-3.5">
|
||||
{!hasAppearanceOverrides && (
|
||||
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom connection points (not for group containers) */}
|
||||
|
||||
@@ -12,7 +12,7 @@ interface Service {
|
||||
|
||||
export interface PendingDevice {
|
||||
id: string
|
||||
ip: string
|
||||
ip: string | null
|
||||
mac: string | null
|
||||
hostname: string | null
|
||||
os: string | null
|
||||
@@ -20,6 +20,12 @@ export interface PendingDevice {
|
||||
suggested_type: string | null
|
||||
status: string
|
||||
discovery_source: string | null
|
||||
ieee_address?: string | null
|
||||
friendly_name?: string | null
|
||||
device_subtype?: string | null
|
||||
model?: string | null
|
||||
vendor?: string | null
|
||||
lqi?: number | null
|
||||
discovered_at: string
|
||||
}
|
||||
|
||||
@@ -77,6 +83,8 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
||||
if (!device) return null
|
||||
|
||||
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||
const isZigbee = device.discovery_source === 'zigbee'
|
||||
const titleLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'Pending device'
|
||||
|
||||
const handleApprove = () => { onApprove(device) }
|
||||
const handleHide = () => { onHide(device); onClose() }
|
||||
@@ -88,17 +96,30 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<TypeIcon size={15} className="text-[#00d4ff] shrink-0" />
|
||||
{device.hostname ?? device.ip}
|
||||
{titleLabel}
|
||||
{isZigbee && (
|
||||
<span className="ml-1 text-[9px] font-mono uppercase px-1 py-0.5 rounded bg-[#00d4ff]/15 text-[#00d4ff] border border-[#00d4ff]/30">
|
||||
Zigbee
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
{/* Device info */}
|
||||
<div className="flex flex-col gap-1.5 p-3 rounded-md bg-[#21262d] border border-[#30363d]">
|
||||
<InfoRow label="IP" value={device.ip} />
|
||||
{device.ip && <InfoRow label="IP" value={device.ip} />}
|
||||
{device.hostname && <InfoRow label="Hostname" value={device.hostname} />}
|
||||
{device.mac && <InfoRow label="MAC" value={device.mac} />}
|
||||
{device.os && <InfoRow label="OS" value={device.os} />}
|
||||
{device.ieee_address && <InfoRow label="IEEE" value={device.ieee_address} />}
|
||||
{device.friendly_name && device.friendly_name !== device.hostname && (
|
||||
<InfoRow label="Name" value={device.friendly_name} />
|
||||
)}
|
||||
{device.vendor && <InfoRow label="Vendor" value={device.vendor} />}
|
||||
{device.model && <InfoRow label="Model" value={device.model} />}
|
||||
{device.device_subtype && <InfoRow label="Role" value={device.device_subtype} />}
|
||||
{device.lqi != null && <InfoRow label="LQI" value={String(device.lqi)} />}
|
||||
{device.suggested_type && (
|
||||
<InfoRow label="Type" value={device.suggested_type} />
|
||||
)}
|
||||
@@ -108,8 +129,8 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
||||
<InfoRow label="Discovered" value={new Date(device.discovered_at.endsWith('Z') ? device.discovered_at : device.discovered_at + 'Z').toLocaleString()} />
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<div>
|
||||
{/* Services (skipped for Zigbee devices — they don't have IP services) */}
|
||||
{!isZigbee && <div>
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
Services found ({device.services.length})
|
||||
</p>
|
||||
@@ -138,7 +159,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import {
|
||||
Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network,
|
||||
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2,
|
||||
} from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { scanApi } from '@/api/client'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { toast } from 'sonner'
|
||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
import type { NodeType, ServiceInfo } from '@/types'
|
||||
|
||||
interface PendingDevicesModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
highlightId?: string
|
||||
initialStatus?: 'pending' | 'hidden'
|
||||
}
|
||||
|
||||
const PORT_COLORS: Record<number, string> = {
|
||||
22: '#a855f7', // SSH purple
|
||||
80: '#00d4ff', // HTTP cyan
|
||||
443: '#39d353', // HTTPS green
|
||||
53: '#e3b341', // DNS amber
|
||||
3306: '#a855f7', // MySQL
|
||||
5432: '#a855f7', // Postgres
|
||||
6379: '#f85149', // Redis
|
||||
9090: '#e3b341', // Prometheus
|
||||
3000: '#00d4ff', // Grafana/dev
|
||||
8080: '#00d4ff',
|
||||
8443: '#39d353',
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
hypervisor: '#ff6e00',
|
||||
nas: '#39d353',
|
||||
automation: '#a855f7',
|
||||
containers: '#00d4ff',
|
||||
network: '#39d353',
|
||||
security: '#f85149',
|
||||
monitoring: '#e3b341',
|
||||
database: '#a855f7',
|
||||
web: '#00d4ff',
|
||||
media: '#ff6e00',
|
||||
iot: '#e3b341',
|
||||
}
|
||||
|
||||
function serviceColor(port: number | null | undefined, category?: string | null): string {
|
||||
if (port != null && PORT_COLORS[port]) return PORT_COLORS[port]
|
||||
if (category && CATEGORY_COLORS[category.toLowerCase()]) return CATEGORY_COLORS[category.toLowerCase()]
|
||||
return '#8b949e'
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ElementType> = {
|
||||
isp: Globe,
|
||||
router: Router,
|
||||
server: Server,
|
||||
proxmox: Layers,
|
||||
vm: Box,
|
||||
lxc: Container,
|
||||
nas: HardDrive,
|
||||
iot: Cpu,
|
||||
ap: Wifi,
|
||||
switch: Network,
|
||||
generic: Circle,
|
||||
}
|
||||
|
||||
type SourceFilter = 'all' | 'ip' | 'zigbee'
|
||||
type StatusFilter = 'pending' | 'hidden'
|
||||
|
||||
function inferSource(d: PendingDevice): 'zigbee' | 'ip' {
|
||||
if (d.discovery_source === 'zigbee' || d.ieee_address) return 'zigbee'
|
||||
return 'ip'
|
||||
}
|
||||
|
||||
const COMMON_PORTS = new Set([22, 80, 443])
|
||||
|
||||
function specialServiceName(d: PendingDevice): string | undefined {
|
||||
const candidates = (d.services ?? []).filter(
|
||||
(s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port) && s.service_name,
|
||||
)
|
||||
// Deprioritize generic web category so apps like home assistant / jellyfin win
|
||||
const nonWeb = candidates.find((s) => s.category?.toLowerCase() !== 'web')
|
||||
return (nonWeb ?? candidates[0])?.service_name ?? undefined
|
||||
}
|
||||
|
||||
function deviceLabel(d: PendingDevice): string {
|
||||
return d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
|
||||
}
|
||||
|
||||
function injectAutoEdges(edges: { id: string; source: string; target: string }[] | undefined) {
|
||||
if (!edges || edges.length === 0) return
|
||||
useCanvasStore.setState((state) => ({
|
||||
edges: [
|
||||
...state.edges,
|
||||
...edges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: 'bottom',
|
||||
targetHandle: 'top-t',
|
||||
type: 'iot',
|
||||
data: { type: 'iot' as const },
|
||||
})),
|
||||
],
|
||||
hasUnsavedChanges: true,
|
||||
}))
|
||||
}
|
||||
|
||||
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending' }: PendingDevicesModalProps) {
|
||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [search, setSearch] = useState('')
|
||||
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>(initialStatus)
|
||||
const { addNode, scanEventTs } = useCanvasStore()
|
||||
const highlightRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
|
||||
setDevices(res.data)
|
||||
} catch {
|
||||
toast.error(`Failed to load ${statusFilter} devices`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [statusFilter])
|
||||
|
||||
useEffect(() => { if (open) load() }, [open, load])
|
||||
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])
|
||||
|
||||
// Reset transient state when reopening
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectMode(false)
|
||||
setSelectedIds(new Set())
|
||||
setSearch('')
|
||||
} else {
|
||||
setStatusFilter(initialStatus)
|
||||
}
|
||||
}, [open, initialStatus])
|
||||
|
||||
const distinctTypes = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
devices.forEach((d) => { if (d.suggested_type) set.add(d.suggested_type) })
|
||||
return [...set].sort()
|
||||
}, [devices])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
return devices.filter((d) => {
|
||||
if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false
|
||||
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false
|
||||
if (q) {
|
||||
const hay = [
|
||||
d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model,
|
||||
...d.services.map((s) => s.service_name),
|
||||
].filter(Boolean).join(' ').toLowerCase()
|
||||
if (!hay.includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [devices, search, sourceFilter, typeFilter])
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightId || loading || !open) return
|
||||
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}, [highlightId, loading, open, filtered])
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleCardClick = (d: PendingDevice) => {
|
||||
if (selectMode) { toggleSelect(d.id); return }
|
||||
if (statusFilter === 'hidden') { handleRestore(d); return }
|
||||
setSelected(d)
|
||||
}
|
||||
|
||||
const handleRestore = async (device: PendingDevice) => {
|
||||
try {
|
||||
await scanApi.restore(device.id)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||
toast.success(`Restored ${deviceLabel(device)}`)
|
||||
} catch {
|
||||
toast.error('Failed to restore device')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkRestore = async () => {
|
||||
const ids = [...selectedIds]
|
||||
if (ids.length === 0) return
|
||||
try {
|
||||
const res = await scanApi.bulkRestore(ids)
|
||||
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||
setSelectedIds(new Set())
|
||||
toast.success(`Restored ${res.data.restored} device${res.data.restored !== 1 ? 's' : ''}`)
|
||||
} catch {
|
||||
toast.error('Failed to bulk restore devices')
|
||||
}
|
||||
}
|
||||
|
||||
const enterSelectMode = () => {
|
||||
setSelectMode(true)
|
||||
}
|
||||
|
||||
const exitSelectMode = () => {
|
||||
setSelectMode(false)
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
const selectAllVisible = () => {
|
||||
setSelectedIds(new Set(filtered.map((d) => d.id)))
|
||||
}
|
||||
|
||||
const handleClearAll = async () => {
|
||||
const targets = filtered
|
||||
if (targets.length === 0) return
|
||||
const filtersActive = targets.length !== devices.length
|
||||
try {
|
||||
if (filtersActive) {
|
||||
const results = await Promise.allSettled(targets.map((d) => scanApi.ignore(d.id)))
|
||||
const failed = results.filter((r) => r.status === 'rejected').length
|
||||
const removedIds = new Set(
|
||||
targets.filter((_, i) => results[i].status === 'fulfilled').map((d) => d.id)
|
||||
)
|
||||
setDevices((prev) => prev.filter((d) => !removedIds.has(d.id)))
|
||||
setSelectedIds(new Set())
|
||||
if (failed > 0) toast.error(`Removed ${removedIds.size}, ${failed} failed`)
|
||||
else toast.success(`Removed ${removedIds.size} device${removedIds.size !== 1 ? 's' : ''}`)
|
||||
} else {
|
||||
await scanApi.clearPending()
|
||||
setDevices([])
|
||||
setSelectedIds(new Set())
|
||||
toast.success('Pending devices cleared')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to clear pending devices')
|
||||
}
|
||||
}
|
||||
|
||||
const handleApprove = async (device: PendingDevice) => {
|
||||
try {
|
||||
const fallbackLabel = deviceLabel(device)
|
||||
const nodeData = {
|
||||
label: fallbackLabel,
|
||||
type: (device.suggested_type ?? 'generic') as NodeType,
|
||||
ip: device.ip ?? undefined,
|
||||
hostname: device.hostname ?? undefined,
|
||||
status: 'unknown',
|
||||
services: (device.services ?? []) as ServiceInfo[],
|
||||
}
|
||||
const res = await scanApi.approve(device.id, nodeData)
|
||||
const nodeId = res.data.node_id
|
||||
addNode({
|
||||
id: nodeId,
|
||||
type: nodeData.type,
|
||||
position: { x: 400, y: 300 },
|
||||
data: { ...nodeData, status: 'unknown' as const },
|
||||
})
|
||||
injectAutoEdges(res.data.edges)
|
||||
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
||||
toast.success(`Approved ${nodeData.label}${extra}`)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||
setSelected(null)
|
||||
} catch {
|
||||
toast.error('Failed to approve device')
|
||||
}
|
||||
}
|
||||
|
||||
const handleHide = async (device: PendingDevice) => {
|
||||
try {
|
||||
await scanApi.hide(device.id)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||
setSelected(null)
|
||||
toast.success('Device hidden')
|
||||
} catch {
|
||||
toast.error('Failed to hide device')
|
||||
}
|
||||
}
|
||||
|
||||
const handleIgnore = async (device: PendingDevice) => {
|
||||
try {
|
||||
await scanApi.ignore(device.id)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||
setSelected(null)
|
||||
} catch {
|
||||
toast.error('Failed to remove device')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkApprove = async () => {
|
||||
const ids = [...selectedIds]
|
||||
if (ids.length === 0) return
|
||||
try {
|
||||
const res = await scanApi.bulkApprove(ids)
|
||||
const deviceToNode: Record<string, string> = {}
|
||||
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
|
||||
const approvedDevices = devices.filter((d) => ids.includes(d.id))
|
||||
approvedDevices.forEach((d, i) => {
|
||||
const nodeId = deviceToNode[d.id]
|
||||
if (!nodeId) return
|
||||
addNode({
|
||||
id: nodeId,
|
||||
type: (d.suggested_type ?? 'generic') as NodeType,
|
||||
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
|
||||
data: {
|
||||
label: deviceLabel(d),
|
||||
type: (d.suggested_type ?? 'generic') as NodeType,
|
||||
ip: d.ip ?? undefined,
|
||||
hostname: d.hostname ?? undefined,
|
||||
status: 'unknown' as const,
|
||||
services: (d.services ?? []) as ServiceInfo[],
|
||||
},
|
||||
})
|
||||
})
|
||||
injectAutoEdges(res.data.edges)
|
||||
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||
setSelectedIds(new Set())
|
||||
const linkExtra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
||||
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}${linkExtra}`)
|
||||
} catch {
|
||||
toast.error('Failed to bulk approve devices')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkHide = async () => {
|
||||
const ids = [...selectedIds]
|
||||
if (ids.length === 0) return
|
||||
try {
|
||||
const res = await scanApi.bulkHide(ids)
|
||||
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||
setSelectedIds(new Set())
|
||||
toast.success(`Hidden ${res.data.hidden} device${res.data.hidden !== 1 ? 's' : ''}`)
|
||||
} catch {
|
||||
toast.error('Failed to bulk hide devices')
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts: 's' select-mode, 'a' select-all-visible, Esc clears selection or closes, '/' focuses search
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null
|
||||
const inField = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT')
|
||||
if (e.key === 'Escape') {
|
||||
if (selectMode && selectedIds.size > 0) { e.preventDefault(); setSelectedIds(new Set()) }
|
||||
return
|
||||
}
|
||||
if (inField) return
|
||||
if (e.key === '/') { e.preventDefault(); searchRef.current?.focus() }
|
||||
else if (e.key.toLowerCase() === 's') { e.preventDefault(); if (selectMode) exitSelectMode(); else enterSelectMode() }
|
||||
else if (e.key.toLowerCase() === 'a' && selectMode) { e.preventDefault(); selectAllVisible() }
|
||||
else if (e.key === 'Enter' && selectMode && selectedIds.size > 0) { e.preventDefault(); handleBulkApprove() }
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, selectMode, selectedIds, filtered])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="!max-w-none w-[95vw] h-[90vh] p-0 flex flex-col gap-0 bg-[#0d1117] border-border"
|
||||
>
|
||||
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle className="text-base font-semibold flex items-center gap-2">
|
||||
{statusFilter === 'pending' ? 'Pending Devices' : 'Hidden Devices'}
|
||||
<span className="text-muted-foreground font-normal text-xs">
|
||||
({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Refresh">
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
{statusFilter === 'pending' && devices.length > 0 && (
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="text-muted-foreground hover:text-[#f85149] p-1.5 rounded transition-colors"
|
||||
title={filtered.length !== devices.length ? `Remove ${filtered.length} filtered` : 'Clear all pending'}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Close">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="px-4 py-2 border-b border-border bg-[#161b22] shrink-0 flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search name, IP, MAC, IEEE, service…"
|
||||
className="w-full text-xs bg-[#0d1117] border border-border rounded px-7 py-1.5 outline-none focus:border-[#00d4ff]/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex rounded border border-border overflow-hidden text-xs" role="group" aria-label="Source filter">
|
||||
<button
|
||||
onClick={() => setSourceFilter('all')}
|
||||
className={`px-2.5 py-1.5 transition-colors ${sourceFilter === 'all' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSourceFilter('ip')}
|
||||
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'ip' ? 'bg-[#a855f7]/20 text-[#a855f7]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
IP scan
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSourceFilter('zigbee')}
|
||||
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'zigbee' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Zigbee
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="text-xs bg-[#0d1117] border border-border rounded px-2 py-1.5 outline-none focus:border-[#00d4ff]/50"
|
||||
aria-label="Type filter"
|
||||
>
|
||||
<option value="all">All types</option>
|
||||
{distinctTypes.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<div className="flex rounded border border-border overflow-hidden text-xs">
|
||||
<button
|
||||
onClick={() => setStatusFilter('pending')}
|
||||
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'pending' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Pending
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('hidden')}
|
||||
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'hidden' ? 'bg-[#8b949e]/20 text-foreground' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Hidden
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => selectMode ? exitSelectMode() : enterSelectMode()}
|
||||
className={`text-xs px-2.5 py-1.5 rounded border transition-colors ${selectMode ? 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
|
||||
title="Toggle select mode (s)"
|
||||
>
|
||||
{selectMode ? 'Exit select' : 'Select mode'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 size={20} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-10">
|
||||
{devices.length === 0 ? `No ${statusFilter} devices` : 'No devices match filters'}
|
||||
</p>
|
||||
)}
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3">
|
||||
{filtered.map((d) => (
|
||||
<DeviceCard
|
||||
key={d.id}
|
||||
device={d}
|
||||
selected={selectedIds.has(d.id)}
|
||||
selectMode={selectMode}
|
||||
highlighted={d.id === highlightId}
|
||||
onClick={() => handleCardClick(d)}
|
||||
cardRef={d.id === highlightId ? highlightRef : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection action bar */}
|
||||
{selectMode && (
|
||||
<div className="px-4 py-2.5 border-t border-border bg-[#161b22] shrink-0 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground mr-1">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={selectAllVisible}
|
||||
className="text-xs px-2.5 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Select all visible ({filtered.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
disabled={selectedIds.size === 0}
|
||||
className="text-xs px-2.5 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{statusFilter === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleBulkApprove}
|
||||
disabled={selectedIds.size === 0}
|
||||
className="text-xs px-3 py-1.5 rounded bg-[#39d353]/20 text-[#39d353] hover:bg-[#39d353]/30 disabled:opacity-40 font-medium transition-colors"
|
||||
>
|
||||
Approve ({selectedIds.size})
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkHide}
|
||||
disabled={selectedIds.size === 0}
|
||||
className="text-xs px-3 py-1.5 rounded bg-[#8b949e]/20 text-[#8b949e] hover:bg-[#8b949e]/30 disabled:opacity-40 font-medium transition-colors"
|
||||
>
|
||||
Hide ({selectedIds.size})
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{statusFilter === 'hidden' && (
|
||||
<button
|
||||
onClick={handleBulkRestore}
|
||||
disabled={selectedIds.size === 0}
|
||||
className="text-xs px-3 py-1.5 rounded bg-[#e3b341]/20 text-[#e3b341] hover:bg-[#e3b341]/30 disabled:opacity-40 font-medium transition-colors"
|
||||
>
|
||||
Restore ({selectedIds.size})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<PendingDeviceModal
|
||||
device={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
onApprove={handleApprove}
|
||||
onHide={handleHide}
|
||||
onIgnore={handleIgnore}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface DeviceCardProps {
|
||||
device: PendingDevice
|
||||
selected: boolean
|
||||
selectMode: boolean
|
||||
highlighted: boolean
|
||||
onClick: () => void
|
||||
cardRef?: React.Ref<HTMLButtonElement>
|
||||
}
|
||||
|
||||
function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRef }: DeviceCardProps) {
|
||||
const source = inferSource(device)
|
||||
const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||
const label = deviceLabel(device)
|
||||
const sourceColor = source === 'zigbee' ? '#00d4ff' : '#a855f7'
|
||||
const sourceLabel = source === 'zigbee' ? 'ZIGBEE' : (device.discovery_source ?? 'IP').toUpperCase()
|
||||
const services = device.services ?? []
|
||||
const visibleServices = services.slice(0, 4)
|
||||
const moreServices = services.length - visibleServices.length
|
||||
|
||||
const borderClass = highlighted
|
||||
? 'border-[#e3b341] bg-[#2d3748]'
|
||||
: selected
|
||||
? 'border-[#00d4ff] bg-[#00d4ff]/5 shadow-[0_0_0_1px_rgba(0,212,255,0.4)] scale-[1.02]'
|
||||
: 'border-border bg-[#161b22] hover:border-[#30363d] hover:bg-[#21262d]'
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={cardRef}
|
||||
onClick={onClick}
|
||||
data-testid={`pending-card-${device.id}`}
|
||||
className={`relative text-left rounded-lg border p-3 transition-all duration-150 ${borderClass}`}
|
||||
>
|
||||
{selectMode && selected && (
|
||||
<CheckCircle2
|
||||
size={18}
|
||||
className="absolute top-2 right-2 text-[#00d4ff] fill-[#0d1117]"
|
||||
/>
|
||||
)}
|
||||
{!selectMode && device.status === 'hidden' && (
|
||||
<EyeOff size={14} className="absolute top-2 right-2 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<div className="shrink-0 w-8 h-8 rounded bg-[#21262d] flex items-center justify-center text-foreground">
|
||||
<Icon size={16} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground break-all leading-snug">{label}</div>
|
||||
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
|
||||
<span
|
||||
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||
style={{ background: `${sourceColor}22`, color: sourceColor }}
|
||||
>
|
||||
{sourceLabel}
|
||||
</span>
|
||||
{device.suggested_type && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#21262d] text-muted-foreground">
|
||||
{device.suggested_type}
|
||||
</span>
|
||||
)}
|
||||
{device.lqi != null && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#21262d] text-muted-foreground">
|
||||
LQI {device.lqi}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tech grid */}
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5 text-[11px] mb-2">
|
||||
{device.ip && <InfoLine label="IP" value={device.ip} />}
|
||||
{device.mac && <InfoLine label="MAC" value={device.mac} />}
|
||||
{device.ieee_address && <InfoLine label="IEEE" value={device.ieee_address} />}
|
||||
{device.hostname && <InfoLine label="Host" value={device.hostname} />}
|
||||
{device.vendor && <InfoLine label="Vendor" value={device.vendor} />}
|
||||
{device.model && <InfoLine label="Model" value={device.model} />}
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
{visibleServices.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{visibleServices.map((s, i) => {
|
||||
const color = serviceColor(s.port, s.category)
|
||||
return (
|
||||
<span
|
||||
key={`${s.port}-${s.protocol}-${i}`}
|
||||
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||
style={{ background: `${color}22`, color }}
|
||||
title={`${s.service_name} (${s.protocol}/${s.port})`}
|
||||
>
|
||||
{s.service_name}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{moreServices > 0 && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-[#21262d] text-muted-foreground">
|
||||
+{moreServices}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoLine({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-1.5 min-w-0">
|
||||
<span className="text-muted-foreground shrink-0 w-12">{label}</span>
|
||||
<span className="font-mono text-foreground truncate">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -33,8 +33,10 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
|
||||
).slice(0, 6)
|
||||
|
||||
const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
|
||||
d.ip.toLowerCase().includes(q) ||
|
||||
d.ip?.toLowerCase().includes(q) ||
|
||||
d.hostname?.toLowerCase().includes(q) ||
|
||||
d.friendly_name?.toLowerCase().includes(q) ||
|
||||
d.ieee_address?.toLowerCase().includes(q) ||
|
||||
d.services.some((s) =>
|
||||
s.service_name?.toLowerCase().includes(q) ||
|
||||
s.category?.toLowerCase().includes(q)
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useState } from 'react'
|
||||
import modalStyles from './modal-interactive.module.css'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
|
||||
|
||||
export type TextBorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||
|
||||
export interface TextFormData {
|
||||
text: string
|
||||
font: string
|
||||
text_color: string
|
||||
text_size: number
|
||||
border_color: string
|
||||
border_style: TextBorderStyle
|
||||
border_width: number
|
||||
background_color: string
|
||||
}
|
||||
|
||||
const BORDER_STYLES: { value: TextBorderStyle; label: string; preview: string }[] = [
|
||||
{ value: 'none', label: 'None', preview: ' ' },
|
||||
{ value: 'solid', label: 'Solid', preview: '───' },
|
||||
{ value: 'dashed', label: 'Dashed', preview: '╌╌╌' },
|
||||
{ value: 'dotted', label: 'Dotted', preview: '···' },
|
||||
{ value: 'double', label: 'Double', preview: '═══' },
|
||||
]
|
||||
|
||||
const TEXT_SIZES: { value: number; label: string }[] = [
|
||||
{ value: 10, label: '10' },
|
||||
{ value: 12, label: '12' },
|
||||
{ value: 14, label: '14' },
|
||||
{ value: 18, label: '18' },
|
||||
{ value: 24, label: '24' },
|
||||
{ value: 32, label: '32' },
|
||||
]
|
||||
|
||||
const BORDER_WIDTHS: { value: number; label: string }[] = [
|
||||
{ value: 1, label: '1px' },
|
||||
{ value: 2, label: '2px' },
|
||||
{ value: 3, label: '3px' },
|
||||
{ value: 4, label: '4px' },
|
||||
{ value: 5, label: '5px' },
|
||||
]
|
||||
|
||||
const FONTS = [
|
||||
{ value: 'inter', label: 'Inter (sans-serif)' },
|
||||
{ value: 'mono', label: 'JetBrains Mono' },
|
||||
{ value: 'serif', label: 'Serif' },
|
||||
{ value: 'sans', label: 'System Sans' },
|
||||
]
|
||||
|
||||
const DEFAULT_FORM: TextFormData = {
|
||||
text: '',
|
||||
font: 'inter',
|
||||
text_color: '#e6edf3',
|
||||
text_size: 14,
|
||||
border_color: '#30363d',
|
||||
border_style: 'none',
|
||||
border_width: 1,
|
||||
background_color: '#00000000',
|
||||
}
|
||||
|
||||
interface TextModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: TextFormData) => void
|
||||
onDelete?: () => void
|
||||
initial?: Partial<TextFormData>
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function TextModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Text' }: TextModalProps) {
|
||||
const [form, setForm] = useState<TextFormData>({ ...DEFAULT_FORM, ...initial })
|
||||
|
||||
const set = <K extends keyof TextFormData>(key: K, value: TextFormData[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit(form)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const colorFields = [
|
||||
{ key: 'text_color' as const, label: 'Text' },
|
||||
{ key: 'border_color' as const, label: 'Border' },
|
||||
{ key: 'background_color' as const, label: 'Background' },
|
||||
]
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
||||
{/* Text content */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Text</Label>
|
||||
<textarea
|
||||
value={form.text}
|
||||
onChange={(e) => set('text', e.target.value)}
|
||||
placeholder="Type text…"
|
||||
rows={3}
|
||||
className={`bg-[#21262d] border border-[#30363d] text-sm p-2 resize-y min-h-[60px] focus:outline-none focus:border-[#00d4ff] ${modalStyles['modal-radius']}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Font (Police) */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Police</Label>
|
||||
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
|
||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}>
|
||||
<SelectValue>
|
||||
{FONTS.find((f) => f.value === form.font)?.label ?? form.font}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{FONTS.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value} className="text-sm">
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Colors */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Colors</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{colorFields.map(({ key, label }) => {
|
||||
const { hex6, alpha } = hexToRgba(form[key])
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-1 items-center">
|
||||
<label
|
||||
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
|
||||
style={{ borderColor: '#30363d' }}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={hex6}
|
||||
onChange={(e) => set(key, rgbaToHex8(e.target.value, alpha))}
|
||||
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
|
||||
/>
|
||||
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={alpha}
|
||||
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
|
||||
className="w-full h-1 accent-[#00d4ff] cursor-pointer"
|
||||
title={`Opacity: ${alpha}%`}
|
||||
/>
|
||||
<span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text size */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Size</Label>
|
||||
<div className="grid grid-cols-6 gap-1">
|
||||
{TEXT_SIZES.map(({ value, label }) => {
|
||||
const isSelected = form.text_size === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => set('text_size', value)}
|
||||
className={`flex items-center justify-center h-8 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
fontSize: Math.min(value, 16),
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Border style */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Border Style</Label>
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{BORDER_STYLES.map(({ value, label, preview }) => {
|
||||
const isSelected = form.border_style === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
title={label}
|
||||
onClick={() => set('border_style', value)}
|
||||
className={`flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-[11px] leading-none">{preview}</span>
|
||||
<span className="text-[9px]">{label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Border width (only when style != none) */}
|
||||
{form.border_style !== 'none' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Border Width</Label>
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{BORDER_WIDTHS.map(({ value, label }) => {
|
||||
const isSelected = form.border_width === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => set('border_width', value)}
|
||||
className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between gap-2 pt-1">
|
||||
{onDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
|
||||
onClick={() => { onDelete(); onClose() }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
|
||||
{title === 'Add Text' ? 'Add' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -273,12 +273,45 @@ describe('NodeModal', () => {
|
||||
|
||||
it('toggles container_mode on click', () => {
|
||||
const { onSubmit } = renderModal({ initial: { ...BASE, type: 'proxmox', container_mode: true } })
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Container Mode' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
|
||||
})
|
||||
|
||||
// ── Parent container ──────────────────────────────────────────────────
|
||||
// ── Show services toggle (modal-only) ───────────────────────────────
|
||||
|
||||
it('shows Show Services toggle for regular nodes', () => {
|
||||
renderModal({ initial: BASE })
|
||||
expect(screen.getByText('Show Services')).toBeDefined()
|
||||
expect(screen.getByRole('switch', { name: 'Show Services' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides Show Services toggle for groupRect', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'groupRect' } })
|
||||
expect(screen.queryByText('Show Services')).toBeNull()
|
||||
})
|
||||
|
||||
it('submits custom_colors.show_services=true when toggled on', () => {
|
||||
const { onSubmit } = renderModal({ initial: BASE })
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Show Services' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||
expect(data.custom_colors?.show_services).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps default colors hint visible when Show Services is toggled on', () => {
|
||||
renderModal({ initial: BASE })
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Show Services' }))
|
||||
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not show Appearance reset when only Show Services is set', () => {
|
||||
renderModal({ initial: BASE })
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Show Services' }))
|
||||
expect(screen.queryByText('Reset to defaults')).toBeNull()
|
||||
})
|
||||
|
||||
// ── Parent Proxmox (vm / lxc only) ───────────────────────────────────
|
||||
|
||||
const parentContainerVisibleTypes = ['proxmox', 'vm', 'lxc', 'docker_host', 'isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic'] as const
|
||||
const parentContainerHiddenTypes = ['groupRect', 'group'] as const
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { PendingDevicesModal } from '../PendingDevicesModal'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
|
||||
vi.mock('@/stores/canvasStore')
|
||||
|
||||
const mockBulkApprove = vi.fn()
|
||||
const mockBulkHide = vi.fn()
|
||||
const mockRestore = vi.fn()
|
||||
const mockBulkRestore = vi.fn()
|
||||
const mockApprove = vi.fn()
|
||||
const mockHide = vi.fn()
|
||||
const mockPending = vi.fn()
|
||||
const mockHidden = vi.fn()
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
scanApi: {
|
||||
pending: (...a: unknown[]) => mockPending(...a),
|
||||
hidden: (...a: unknown[]) => mockHidden(...a),
|
||||
clearPending: vi.fn().mockResolvedValue({}),
|
||||
approve: (...a: unknown[]) => mockApprove(...a),
|
||||
hide: (...a: unknown[]) => mockHide(...a),
|
||||
ignore: vi.fn().mockResolvedValue({}),
|
||||
bulkApprove: (...a: unknown[]) => mockBulkApprove(...a),
|
||||
bulkHide: (...a: unknown[]) => mockBulkHide(...a),
|
||||
restore: (...a: unknown[]) => mockRestore(...a),
|
||||
bulkRestore: (...a: unknown[]) => mockBulkRestore(...a),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
vi.mock('@/components/modals/PendingDeviceModal', () => ({
|
||||
PendingDeviceModal: ({ device }: { device: unknown }) =>
|
||||
device ? <div data-testid="approval-modal" /> : null,
|
||||
}))
|
||||
|
||||
const DEVICE_IP = {
|
||||
id: 'dev-a',
|
||||
ip: '192.168.1.10',
|
||||
hostname: 'host-a',
|
||||
mac: 'aa:bb:cc:dd:ee:01',
|
||||
os: null,
|
||||
services: [{ port: 80, protocol: 'tcp', service_name: 'http' }],
|
||||
suggested_type: 'server',
|
||||
status: 'pending',
|
||||
discovery_source: 'arp',
|
||||
discovered_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const DEVICE_ZIGBEE = {
|
||||
id: 'dev-b',
|
||||
ip: null,
|
||||
hostname: null,
|
||||
mac: null,
|
||||
os: null,
|
||||
services: [],
|
||||
suggested_type: 'iot',
|
||||
status: 'pending',
|
||||
discovery_source: 'zigbee',
|
||||
ieee_address: '0x00124b001234abcd',
|
||||
friendly_name: 'living-room-bulb',
|
||||
vendor: 'Philips',
|
||||
model: 'Hue White',
|
||||
discovered_at: '2026-01-02T00:00:00Z',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useCanvasStore).mockReturnValue({
|
||||
addNode: vi.fn(),
|
||||
scanEventTs: 0,
|
||||
} as unknown as ReturnType<typeof useCanvasStore>)
|
||||
// setState is used by injectAutoEdges
|
||||
;(useCanvasStore as unknown as { setState: (fn: unknown) => void }).setState = vi.fn()
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZIGBEE] })
|
||||
mockHidden.mockResolvedValue({ data: [] })
|
||||
mockApprove.mockResolvedValue({ data: { node_id: 'n1', edges: [], edges_created: 0 } })
|
||||
mockHide.mockResolvedValue({ data: {} })
|
||||
mockBulkApprove.mockResolvedValue({
|
||||
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], edges: [], edges_created: 0 },
|
||||
})
|
||||
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
|
||||
mockRestore.mockResolvedValue({ data: { restored: true, device_id: 'dev-a' } })
|
||||
mockBulkRestore.mockResolvedValue({ data: { restored: 1, skipped: 0 } })
|
||||
})
|
||||
|
||||
const baseProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
describe('PendingDevicesModal', () => {
|
||||
it('loads and renders pending devices on open', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
expect(screen.getByText('living-room-bulb')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows source chip ZIGBEE for zigbee device', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
expect(screen.getByText('ZIGBEE')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by search query', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'living' } })
|
||||
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by source (zigbee only)', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zigbee' }))
|
||||
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by suggested type', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.change(screen.getByLabelText('Type filter'), { target: { value: 'server' } })
|
||||
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches to hidden status loads hidden devices', async () => {
|
||||
mockHidden.mockResolvedValue({
|
||||
data: [{ ...DEVICE_IP, id: 'h1', hostname: 'hidden-host', status: 'hidden' }],
|
||||
})
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hidden' }))
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-h1')).toBeInTheDocument())
|
||||
expect(mockHidden).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens approval modal when card is clicked outside select mode', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
expect(screen.getByTestId('approval-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles selection in select mode instead of opening approval', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('1 selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('select all visible selects only filtered devices', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'host-a' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Select all visible/ }))
|
||||
expect(screen.getByText('1 selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('bulk approve calls API with selected ids', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
|
||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
||||
})
|
||||
|
||||
it('bulk hide calls API with selected ids', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Hide \(1\)/ }))
|
||||
await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a']))
|
||||
})
|
||||
|
||||
it('does not load when closed', () => {
|
||||
render(<PendingDevicesModal {...baseProps} open={false} />)
|
||||
expect(mockPending).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('respects initialStatus=hidden', async () => {
|
||||
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, hostname: 'hidden-host', status: 'hidden' }] })
|
||||
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||
await waitFor(() => expect(mockHidden).toHaveBeenCalled())
|
||||
expect(mockPending).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clicking a hidden card restores it instead of opening approval', async () => {
|
||||
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
|
||||
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
await waitFor(() => expect(mockRestore).toHaveBeenCalledWith('dev-a'))
|
||||
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('bulk restore in hidden mode calls API with selected ids', async () => {
|
||||
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
|
||||
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Restore \(1\)/ }))
|
||||
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { TextModal, type TextFormData } from '../TextModal'
|
||||
|
||||
describe('TextModal', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(
|
||||
<TextModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
|
||||
)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders form fields when open', () => {
|
||||
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByPlaceholderText('Type text…')).toBeDefined()
|
||||
expect(screen.getByText('Add Text')).toBeDefined()
|
||||
expect(screen.getByText('Police')).toBeDefined()
|
||||
expect(screen.getByText('Border Style')).toBeDefined()
|
||||
expect(screen.getByText('Size')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders Edit Text title when provided', () => {
|
||||
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Text" />)
|
||||
expect(screen.getByText('Edit Text')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls onSubmit with form data on submit', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<TextModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
const ta = screen.getByPlaceholderText('Type text…')
|
||||
fireEvent.change(ta, { target: { value: 'Hello' } })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(onSubmit).toHaveBeenCalledOnce()
|
||||
const submitted = onSubmit.mock.calls[0][0] as TextFormData
|
||||
expect(submitted.text).toBe('Hello')
|
||||
expect(submitted.font).toBe('inter')
|
||||
expect(submitted.border_style).toBe('none')
|
||||
})
|
||||
|
||||
it('hides Border Width when style is none, shows when not', () => {
|
||||
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByText('Border Width')).toBeNull()
|
||||
fireEvent.click(screen.getByTitle('Solid'))
|
||||
expect(screen.getByText('Border Width')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows Delete button and calls handlers when provided', () => {
|
||||
const onDelete = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(<TextModal open onClose={onClose} onSubmit={vi.fn()} onDelete={onDelete} />)
|
||||
fireEvent.click(screen.getByText('Delete'))
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('pre-fills from initial prop', () => {
|
||||
render(
|
||||
<TextModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
initial={{ text: 'Pre-filled', text_size: 24, font: 'mono' }}
|
||||
/>
|
||||
)
|
||||
const ta = screen.getByPlaceholderText('Type text…') as HTMLTextAreaElement
|
||||
expect(ta.value).toBe('Pre-filled')
|
||||
})
|
||||
|
||||
it('cancel calls onClose', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<TextModal open onClose={onClose} onSubmit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user