df3b7a8cb0
Silent return when label was empty made the Add button appear broken. Native browser required tooltip doesn't render in Radix Dialog portals. Now shows inline red error message and highlights the field.
347 lines
16 KiB
TypeScript
347 lines
16 KiB
TypeScript
import { createElement, useState } from 'react'
|
|
import { RotateCcw, ChevronDown } from 'lucide-react'
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Select, SelectContent, SelectItem, 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 } from '@/utils/nodeIcons'
|
|
|
|
const NODE_TYPES = Object.entries(NODE_TYPE_LABELS) as [NodeType, string][]
|
|
|
|
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
|
|
|
const DEFAULT_DATA: Partial<NodeData> = {
|
|
type: 'server',
|
|
label: '',
|
|
hostname: '',
|
|
ip: '',
|
|
status: 'unknown',
|
|
check_method: 'ping',
|
|
services: [],
|
|
container_mode: true,
|
|
custom_colors: undefined,
|
|
custom_icon: undefined,
|
|
}
|
|
|
|
interface NodeModalProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
onSubmit: (data: Partial<NodeData>) => void
|
|
initial?: Partial<NodeData>
|
|
title?: string
|
|
proxmoxNodes?: { id: string; label: string }[]
|
|
}
|
|
|
|
const CHILD_TYPES: NodeType[] = ['vm', 'lxc']
|
|
|
|
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
|
// initial value is enough — no need for a reset effect.
|
|
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) {
|
|
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
|
const [iconSearch, setIconSearch] = useState('')
|
|
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
|
const [labelError, setLabelError] = useState(false)
|
|
|
|
const set = (key: keyof NodeData, value: unknown) =>
|
|
setForm((f) => ({ ...f, [key]: value }))
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!form.label?.trim()) {
|
|
setLabelError(true)
|
|
return
|
|
}
|
|
setLabelError(false)
|
|
onSubmit(form)
|
|
onClose()
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{/* Type */}
|
|
<div className="flex flex-col gap-1.5 col-span-2">
|
|
<Label className="text-xs text-muted-foreground">Type</Label>
|
|
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
|
|
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
|
{NODE_TYPES.map(([value, label]) => (
|
|
<SelectItem key={value} value={value} className="text-sm">
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Icon */}
|
|
<div className="flex flex-col gap-1.5 col-span-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs text-muted-foreground">Icon</Label>
|
|
{form.custom_icon && (
|
|
<button
|
|
type="button"
|
|
onClick={() => { set('custom_icon', undefined); setIconPickerOpen(false) }}
|
|
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
|
>
|
|
<RotateCcw size={10} /> Reset
|
|
</button>
|
|
)}
|
|
</div>
|
|
{/* Trigger button */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setIconPickerOpen((o) => !o)}
|
|
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
{(() => {
|
|
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
|
|
if (entry) {
|
|
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff]' })}<span className="text-foreground">{entry.label}</span></>
|
|
}
|
|
return <span className="text-muted-foreground">Default (from type)</span>
|
|
})()}
|
|
</span>
|
|
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
|
</button>
|
|
{/* Inline picker panel */}
|
|
{iconPickerOpen && (
|
|
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d]">
|
|
<Input
|
|
value={iconSearch}
|
|
onChange={(e) => setIconSearch(e.target.value)}
|
|
placeholder="Search icons…"
|
|
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
|
autoFocus
|
|
/>
|
|
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
|
|
{ICON_CATEGORIES.map((cat) => {
|
|
const entries = ICON_REGISTRY.filter(
|
|
(e) => e.category === cat &&
|
|
(iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase()))
|
|
)
|
|
if (entries.length === 0) return null
|
|
return (
|
|
<div key={cat}>
|
|
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{entries.map((entry) => {
|
|
const isSelected = form.custom_icon === entry.key
|
|
return (
|
|
<button
|
|
key={entry.key}
|
|
type="button"
|
|
title={entry.label}
|
|
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
|
|
className="flex items-center justify-center w-7 h-7 rounded transition-colors"
|
|
style={{
|
|
background: isSelected ? '#00d4ff22' : 'transparent',
|
|
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
|
color: isSelected ? '#00d4ff' : '#8b949e',
|
|
}}
|
|
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
|
|
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
|
>
|
|
{createElement(entry.icon, { size: 13 })}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Label */}
|
|
<div className="flex flex-col gap-1.5 col-span-2">
|
|
<Label className="text-xs text-muted-foreground">Label *</Label>
|
|
<Input
|
|
value={form.label ?? ''}
|
|
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
|
|
placeholder="My Server"
|
|
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'}`}
|
|
/>
|
|
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
|
|
</div>
|
|
|
|
{/* Hostname */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label className="text-xs text-muted-foreground">Hostname</Label>
|
|
<Input
|
|
value={form.hostname ?? ''}
|
|
onChange={(e) => set('hostname', e.target.value)}
|
|
placeholder="server.lan"
|
|
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
|
/>
|
|
</div>
|
|
|
|
{/* IP */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label className="text-xs text-muted-foreground">IP Address</Label>
|
|
<Input
|
|
value={form.ip ?? ''}
|
|
onChange={(e) => set('ip', e.target.value)}
|
|
placeholder="192.168.1.x"
|
|
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
|
/>
|
|
</div>
|
|
|
|
{/* Check method */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label className="text-xs text-muted-foreground">Check Method</Label>
|
|
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
|
|
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
|
{CHECK_METHODS.map((m) => (
|
|
<SelectItem key={m} value={m} className="text-sm font-mono">{m}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Check target */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label className="text-xs text-muted-foreground">Check Target</Label>
|
|
<Input
|
|
value={form.check_target ?? ''}
|
|
onChange={(e) => set('check_target', e.target.value)}
|
|
placeholder="http://..."
|
|
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
|
/>
|
|
</div>
|
|
|
|
{/* Parent Proxmox (VM / LXC only) */}
|
|
{CHILD_TYPES.includes(form.type as NodeType) && proxmoxNodes.length > 0 && (
|
|
<div className="flex flex-col gap-1.5 col-span-2">
|
|
<Label className="text-xs text-muted-foreground">Parent Proxmox</Label>
|
|
<Select
|
|
value={form.parent_id ?? 'none'}
|
|
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
|
|
>
|
|
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
|
<SelectValue placeholder="None (standalone)" />
|
|
</SelectTrigger>
|
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
|
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
|
|
{proxmoxNodes.map((n) => (
|
|
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Container mode (proxmox only) */}
|
|
{form.type === 'proxmox' && (
|
|
<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">Show VM/LXC nodes nested inside</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 items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
|
|
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
|
|
>
|
|
<span
|
|
className="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
|
|
style={{ transform: form.container_mode ? 'translateX(16px)' : 'translateX(0)' }}
|
|
/>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Appearance */}
|
|
<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 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => set('custom_colors', 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
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{(['border', 'background', 'icon'] as const).map((key) => {
|
|
const resolved = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
|
|
const currentValue = resolved[key]
|
|
const isCustom = !!form.custom_colors?.[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 transition-all"
|
|
style={{ borderColor: isCustom ? currentValue : '#30363d' }}
|
|
title={`${key.charAt(0).toUpperCase() + key.slice(1)}: ${currentValue}`}
|
|
>
|
|
<input
|
|
type="color"
|
|
value={currentValue}
|
|
onChange={(e) => set('custom_colors', { ...form.custom_colors, [key]: e.target.value })}
|
|
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
|
|
/>
|
|
<div className="w-full h-full rounded-sm" style={{ background: currentValue }} />
|
|
</label>
|
|
<span className="text-[9px] text-muted-foreground/60 capitalize">{key}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</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>
|
|
|
|
{/* Notes */}
|
|
<div className="flex flex-col gap-1.5 col-span-2">
|
|
<Label className="text-xs text-muted-foreground">Notes</Label>
|
|
<Input
|
|
value={form.notes ?? ''}
|
|
onChange={(e) => set('notes', e.target.value)}
|
|
placeholder="Optional notes"
|
|
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-1">
|
|
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
|
>
|
|
{title === 'Add Node' ? 'Add' : 'Save'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|