feat: per-node custom color styling (border, background, icon)

- Add resolveNodeColors() utility merging type defaults with per-node overrides
- Default colors per node type (cyan=isp/router/lxc/ap, green=switch/nas,
  purple=server/vm, orange=proxmox, amber=iot, gray=generic)
- Remove glowColor prop from BaseNode — colors now come from node data
- ProxmoxGroupNode uses resolveNodeColors for group border/header/icon
- NodeModal: Appearance section with 3 color swatches (border, background, icon)
  — click swatch to open native color picker; Reset to defaults button
- custom_colors persisted as JSON in DB (backend model + schemas + migration)
- 7 new unit tests for resolveNodeColors covering all node types + partial overrides
This commit is contained in:
Pouzor
2026-03-07 14:31:05 +01:00
parent 0fe3c6390a
commit 37c963cf96
12 changed files with 174 additions and 32 deletions
@@ -1,6 +1,7 @@
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'
import { type LucideIcon } from 'lucide-react'
import type { NodeData, NodeStatus } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
const STATUS_COLORS: Record<NodeStatus, string> = {
online: '#39d353',
@@ -11,10 +12,10 @@ const STATUS_COLORS: Record<NodeStatus, string> = {
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
icon: LucideIcon
glowColor?: string
}
export function BaseNode({ data, selected, icon: Icon, glowColor = '#00d4ff' }: BaseNodeProps) {
export function BaseNode({ data, selected, icon: Icon }: BaseNodeProps) {
const colors = resolveNodeColors(data)
const statusColor = STATUS_COLORS[data.status]
const isOnline = data.status === 'online'
@@ -22,12 +23,12 @@ export function BaseNode({ data, selected, icon: Icon, glowColor = '#00d4ff' }:
<div
className="relative flex flex-row items-center gap-2.5 px-2.5 py-2 rounded-lg border transition-all duration-200"
style={{
background: '#21262d',
borderColor: selected ? glowColor : '#30363d',
background: colors.background,
borderColor: selected ? colors.border : '#30363d',
boxShadow: isOnline
? `0 0 10px ${glowColor}2e, 0 0 3px ${glowColor}1a`
? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
: selected
? `0 0 8px ${glowColor}44`
? `0 0 8px ${colors.border}44`
: 'none',
opacity: data.status === 'offline' ? 0.55 : 1,
minWidth: 140,
@@ -38,7 +39,7 @@ export function BaseNode({ data, selected, icon: Icon, glowColor = '#00d4ff' }:
{/* Icon */}
<div
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
style={{ color: isOnline ? glowColor : '#8b949e', background: '#161b22' }}
style={{ color: isOnline ? colors.icon : '#8b949e', background: '#161b22' }}
>
<Icon size={15} />
</div>
@@ -1,6 +1,7 @@
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { Layers } from 'lucide-react'
import type { NodeData, NodeStatus } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { BaseNode } from './BaseNode'
const STATUS_COLORS: Record<NodeStatus, string> = {
@@ -10,18 +11,18 @@ const STATUS_COLORS: Record<NodeStatus, string> = {
unknown: '#8b949e',
}
const GLOW = '#ff6e00'
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
const { data, selected } = props
const colors = resolveNodeColors(data)
// Render as a regular node when container mode is disabled
if (data.container_mode === false) {
return <BaseNode {...props} icon={Layers} glowColor={GLOW} />
return <BaseNode {...props} icon={Layers} />
}
const statusColor = STATUS_COLORS[data.status]
const isOnline = data.status === 'online'
const glow = colors.border
return (
<>
@@ -29,36 +30,36 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
minWidth={220}
minHeight={160}
isVisible={selected}
lineStyle={{ borderColor: GLOW, opacity: 0.6 }}
handleStyle={{ borderColor: GLOW, backgroundColor: '#21262d' }}
lineStyle={{ borderColor: glow, opacity: 0.6 }}
handleStyle={{ borderColor: glow, backgroundColor: '#21262d' }}
/>
{/* Group border */}
<div
className="w-full h-full rounded-xl border-2 flex flex-col overflow-hidden"
style={{
borderColor: selected ? GLOW : isOnline ? `${GLOW}66` : '#30363d',
background: isOnline ? `${GLOW}08` : '#0d111766',
borderColor: selected ? glow : isOnline ? `${glow}66` : '#30363d',
background: isOnline ? `${colors.background}cc` : `${colors.background}aa`,
boxShadow: isOnline
? `0 0 20px ${GLOW}1a, inset 0 0 40px ${GLOW}08`
? `0 0 20px ${glow}1a, inset 0 0 40px ${glow}08`
: selected
? `0 0 12px ${GLOW}33`
? `0 0 12px ${glow}33`
: 'none',
}}
>
{/* Header bar */}
<div
className="flex items-center gap-2 px-2.5 py-1.5 shrink-0"
style={{ background: isOnline ? `${GLOW}18` : '#161b2288', borderBottom: `1px solid ${isOnline ? `${GLOW}33` : '#30363d'}` }}
style={{ background: isOnline ? `${glow}18` : '#161b2288', borderBottom: `1px solid ${isOnline ? `${glow}33` : '#30363d'}` }}
>
<div
className="flex items-center justify-center w-5 h-5 rounded-md shrink-0"
style={{ color: isOnline ? GLOW : '#8b949e', background: '#161b22' }}
style={{ color: isOnline ? colors.icon : '#8b949e', background: '#161b22' }}
>
<Layers size={12} />
</div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[11px] font-semibold leading-tight truncate" style={{ color: isOnline ? GLOW : '#c9d1d9' }}>
<span className="text-[11px] font-semibold leading-tight truncate" style={{ color: isOnline ? glow : '#c9d1d9' }}>
{data.label}
</span>
{data.ip && (
+11 -12
View File
@@ -8,15 +8,14 @@ import type { NodeData } from '@/types'
type N = NodeProps<Node<NodeData>>
export const IspNode = (props: N) => <BaseNode {...props} icon={Globe} glowColor="#00d4ff" />
export const RouterNode = (props: N) => <BaseNode {...props} icon={Router} glowColor="#00d4ff" />
export const SwitchNode = (props: N) => <BaseNode {...props} icon={Network} glowColor="#39d353" />
export const ServerNode = (props: N) => <BaseNode {...props} icon={Server} glowColor="#a855f7" />
export const ProxmoxNode = (props: N) => <BaseNode {...props} icon={Layers} glowColor="#ff6e00" />
export const VmNode = (props: N) => <BaseNode {...props} icon={Box} glowColor="#a855f7" />
export const LxcNode = (props: N) => <BaseNode {...props} icon={Container} glowColor="#00d4ff" />
export const NasNode = (props: N) => <BaseNode {...props} icon={HardDrive} glowColor="#39d353" />
export const IotNode = (props: N) => <BaseNode {...props} icon={Cpu} glowColor="#e3b341" />
export const ApNode = (props: N) => <BaseNode {...props} icon={Wifi} glowColor="#00d4ff" />
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} glowColor="#8b949e" />
export const IspNode = (props: N) => <BaseNode {...props} icon={Globe} />
export const RouterNode = (props: N) => <BaseNode {...props} icon={Router} />
export const SwitchNode = (props: N) => <BaseNode {...props} icon={Network} />
export const ServerNode = (props: N) => <BaseNode {...props} icon={Server} />
export const ProxmoxNode = (props: N) => <BaseNode {...props} icon={Layers} />
export const VmNode = (props: N) => <BaseNode {...props} icon={Box} />
export const LxcNode = (props: N) => <BaseNode {...props} icon={Container} />
export const NasNode = (props: N) => <BaseNode {...props} icon={HardDrive} />
export const IotNode = (props: N) => <BaseNode {...props} icon={Cpu} />
export const ApNode = (props: N) => <BaseNode {...props} icon={Wifi} />
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
@@ -1,10 +1,12 @@
import { useState } from 'react'
import { RotateCcw } 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'
const NODE_TYPES = Object.entries(NODE_TYPE_LABELS) as [NodeType, string][]
@@ -19,6 +21,7 @@ const DEFAULT_DATA: Partial<NodeData> = {
check_method: 'ping',
services: [],
container_mode: true,
custom_colors: undefined,
}
interface NodeModalProps {
@@ -177,6 +180,50 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</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>