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:
@@ -20,9 +20,11 @@ class Base(DeclarativeBase):
|
|||||||
async def init_db() -> None:
|
async def init_db() -> None:
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
# Add container_mode column if not present (existing databases)
|
# Add columns introduced after initial schema (idempotent)
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN container_mode BOOLEAN NOT NULL DEFAULT 0")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN container_mode BOOLEAN NOT NULL DEFAULT 0")
|
||||||
|
with suppress(Exception):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_colors JSON")
|
||||||
|
|
||||||
|
|
||||||
async def get_db():
|
async def get_db():
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class Node(Base):
|
|||||||
pos_y: Mapped[float] = mapped_column(Float, default=0)
|
pos_y: Mapped[float] = mapped_column(Float, default=0)
|
||||||
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id"))
|
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id"))
|
||||||
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
|
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
custom_colors: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class NodeSave(BaseModel):
|
|||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
parent_id: str | None = None
|
parent_id: str | None = None
|
||||||
container_mode: bool = False
|
container_mode: bool = False
|
||||||
|
custom_colors: dict | None = None
|
||||||
pos_x: float = 0
|
pos_x: float = 0
|
||||||
pos_y: float = 0
|
pos_y: float = 0
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class NodeBase(BaseModel):
|
|||||||
pos_y: float = 0
|
pos_y: float = 0
|
||||||
parent_id: str | None = None
|
parent_id: str | None = None
|
||||||
container_mode: bool = False
|
container_mode: bool = False
|
||||||
|
custom_colors: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
class NodeCreate(NodeBase):
|
class NodeCreate(NodeBase):
|
||||||
@@ -41,6 +42,7 @@ class NodeUpdate(BaseModel):
|
|||||||
pos_x: float | None = None
|
pos_x: float | None = None
|
||||||
pos_y: float | None = None
|
pos_y: float | None = None
|
||||||
container_mode: bool | None = None
|
container_mode: bool | None = None
|
||||||
|
custom_colors: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
class NodeResponse(NodeBase):
|
class NodeResponse(NodeBase):
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export default function App() {
|
|||||||
notes: n.data.notes ?? null,
|
notes: n.data.notes ?? null,
|
||||||
parent_id: n.data.parent_id ?? null,
|
parent_id: n.data.parent_id ?? null,
|
||||||
container_mode: n.data.container_mode ?? false,
|
container_mode: n.data.container_mode ?? false,
|
||||||
|
custom_colors: n.data.custom_colors ?? null,
|
||||||
pos_x: n.position.x,
|
pos_x: n.position.x,
|
||||||
pos_y: n.position.y,
|
pos_y: n.position.y,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { type LucideIcon } from 'lucide-react'
|
import { type LucideIcon } from 'lucide-react'
|
||||||
import type { NodeData, NodeStatus } from '@/types'
|
import type { NodeData, NodeStatus } from '@/types'
|
||||||
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
|
|
||||||
const STATUS_COLORS: Record<NodeStatus, string> = {
|
const STATUS_COLORS: Record<NodeStatus, string> = {
|
||||||
online: '#39d353',
|
online: '#39d353',
|
||||||
@@ -11,10 +12,10 @@ const STATUS_COLORS: Record<NodeStatus, string> = {
|
|||||||
|
|
||||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||||
icon: LucideIcon
|
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 statusColor = STATUS_COLORS[data.status]
|
||||||
const isOnline = data.status === 'online'
|
const isOnline = data.status === 'online'
|
||||||
|
|
||||||
@@ -22,12 +23,12 @@ export function BaseNode({ data, selected, icon: Icon, glowColor = '#00d4ff' }:
|
|||||||
<div
|
<div
|
||||||
className="relative flex flex-row items-center gap-2.5 px-2.5 py-2 rounded-lg border transition-all duration-200"
|
className="relative flex flex-row items-center gap-2.5 px-2.5 py-2 rounded-lg border transition-all duration-200"
|
||||||
style={{
|
style={{
|
||||||
background: '#21262d',
|
background: colors.background,
|
||||||
borderColor: selected ? glowColor : '#30363d',
|
borderColor: selected ? colors.border : '#30363d',
|
||||||
boxShadow: isOnline
|
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
|
: selected
|
||||||
? `0 0 8px ${glowColor}44`
|
? `0 0 8px ${colors.border}44`
|
||||||
: 'none',
|
: 'none',
|
||||||
opacity: data.status === 'offline' ? 0.55 : 1,
|
opacity: data.status === 'offline' ? 0.55 : 1,
|
||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
@@ -38,7 +39,7 @@ export function BaseNode({ data, selected, icon: Icon, glowColor = '#00d4ff' }:
|
|||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
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} />
|
<Icon size={15} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { Layers } from 'lucide-react'
|
import { Layers } from 'lucide-react'
|
||||||
import type { NodeData, NodeStatus } from '@/types'
|
import type { NodeData, NodeStatus } from '@/types'
|
||||||
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
|
|
||||||
const STATUS_COLORS: Record<NodeStatus, string> = {
|
const STATUS_COLORS: Record<NodeStatus, string> = {
|
||||||
@@ -10,18 +11,18 @@ const STATUS_COLORS: Record<NodeStatus, string> = {
|
|||||||
unknown: '#8b949e',
|
unknown: '#8b949e',
|
||||||
}
|
}
|
||||||
|
|
||||||
const GLOW = '#ff6e00'
|
|
||||||
|
|
||||||
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||||
const { data, selected } = props
|
const { data, selected } = props
|
||||||
|
const colors = resolveNodeColors(data)
|
||||||
|
|
||||||
// Render as a regular node when container mode is disabled
|
// Render as a regular node when container mode is disabled
|
||||||
if (data.container_mode === false) {
|
if (data.container_mode === false) {
|
||||||
return <BaseNode {...props} icon={Layers} glowColor={GLOW} />
|
return <BaseNode {...props} icon={Layers} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColor = STATUS_COLORS[data.status]
|
const statusColor = STATUS_COLORS[data.status]
|
||||||
const isOnline = data.status === 'online'
|
const isOnline = data.status === 'online'
|
||||||
|
const glow = colors.border
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -29,36 +30,36 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
minWidth={220}
|
minWidth={220}
|
||||||
minHeight={160}
|
minHeight={160}
|
||||||
isVisible={selected}
|
isVisible={selected}
|
||||||
lineStyle={{ borderColor: GLOW, opacity: 0.6 }}
|
lineStyle={{ borderColor: glow, opacity: 0.6 }}
|
||||||
handleStyle={{ borderColor: GLOW, backgroundColor: '#21262d' }}
|
handleStyle={{ borderColor: glow, backgroundColor: '#21262d' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Group border */}
|
{/* Group border */}
|
||||||
<div
|
<div
|
||||||
className="w-full h-full rounded-xl border-2 flex flex-col overflow-hidden"
|
className="w-full h-full rounded-xl border-2 flex flex-col overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
borderColor: selected ? GLOW : isOnline ? `${GLOW}66` : '#30363d',
|
borderColor: selected ? glow : isOnline ? `${glow}66` : '#30363d',
|
||||||
background: isOnline ? `${GLOW}08` : '#0d111766',
|
background: isOnline ? `${colors.background}cc` : `${colors.background}aa`,
|
||||||
boxShadow: isOnline
|
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
|
: selected
|
||||||
? `0 0 12px ${GLOW}33`
|
? `0 0 12px ${glow}33`
|
||||||
: 'none',
|
: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 px-2.5 py-1.5 shrink-0"
|
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
|
<div
|
||||||
className="flex items-center justify-center w-5 h-5 rounded-md shrink-0"
|
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} />
|
<Layers size={12} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0 flex-1">
|
<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}
|
{data.label}
|
||||||
</span>
|
</span>
|
||||||
{data.ip && (
|
{data.ip && (
|
||||||
|
|||||||
@@ -8,15 +8,14 @@ import type { NodeData } from '@/types'
|
|||||||
|
|
||||||
type N = NodeProps<Node<NodeData>>
|
type N = NodeProps<Node<NodeData>>
|
||||||
|
|
||||||
export const IspNode = (props: N) => <BaseNode {...props} icon={Globe} glowColor="#00d4ff" />
|
export const IspNode = (props: N) => <BaseNode {...props} icon={Globe} />
|
||||||
export const RouterNode = (props: N) => <BaseNode {...props} icon={Router} glowColor="#00d4ff" />
|
export const RouterNode = (props: N) => <BaseNode {...props} icon={Router} />
|
||||||
export const SwitchNode = (props: N) => <BaseNode {...props} icon={Network} glowColor="#39d353" />
|
export const SwitchNode = (props: N) => <BaseNode {...props} icon={Network} />
|
||||||
export const ServerNode = (props: N) => <BaseNode {...props} icon={Server} glowColor="#a855f7" />
|
export const ServerNode = (props: N) => <BaseNode {...props} icon={Server} />
|
||||||
export const ProxmoxNode = (props: N) => <BaseNode {...props} icon={Layers} glowColor="#ff6e00" />
|
export const ProxmoxNode = (props: N) => <BaseNode {...props} icon={Layers} />
|
||||||
export const VmNode = (props: N) => <BaseNode {...props} icon={Box} glowColor="#a855f7" />
|
export const VmNode = (props: N) => <BaseNode {...props} icon={Box} />
|
||||||
export const LxcNode = (props: N) => <BaseNode {...props} icon={Container} glowColor="#00d4ff" />
|
export const LxcNode = (props: N) => <BaseNode {...props} icon={Container} />
|
||||||
export const NasNode = (props: N) => <BaseNode {...props} icon={HardDrive} glowColor="#39d353" />
|
export const NasNode = (props: N) => <BaseNode {...props} icon={HardDrive} />
|
||||||
export const IotNode = (props: N) => <BaseNode {...props} icon={Cpu} glowColor="#e3b341" />
|
export const IotNode = (props: N) => <BaseNode {...props} icon={Cpu} />
|
||||||
export const ApNode = (props: N) => <BaseNode {...props} icon={Wifi} glowColor="#00d4ff" />
|
export const ApNode = (props: N) => <BaseNode {...props} icon={Wifi} />
|
||||||
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} glowColor="#8b949e" />
|
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { RotateCcw } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
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][]
|
const NODE_TYPES = Object.entries(NODE_TYPE_LABELS) as [NodeType, string][]
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ const DEFAULT_DATA: Partial<NodeData> = {
|
|||||||
check_method: 'ping',
|
check_method: 'ping',
|
||||||
services: [],
|
services: [],
|
||||||
container_mode: true,
|
container_mode: true,
|
||||||
|
custom_colors: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodeModalProps {
|
interface NodeModalProps {
|
||||||
@@ -177,6 +180,50 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
</div>
|
</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 */}
|
{/* Notes */}
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
<Label className="text-xs text-muted-foreground">Notes</Label>
|
<Label className="text-xs text-muted-foreground">Notes</Label>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
notes?: string
|
notes?: string
|
||||||
parent_id?: string
|
parent_id?: string
|
||||||
container_mode?: boolean
|
container_mode?: boolean
|
||||||
|
custom_colors?: { border?: string; background?: string; icon?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EdgeData extends Record<string, unknown> {
|
export interface EdgeData extends Record<string, unknown> {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { resolveNodeColors, NODE_DEFAULT_COLORS } from '../nodeColors'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
function makeData(overrides: Partial<NodeData> = {}): Pick<NodeData, 'type' | 'custom_colors'> {
|
||||||
|
return { type: 'server', custom_colors: undefined, ...overrides }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveNodeColors', () => {
|
||||||
|
it('returns defaults when no custom_colors set', () => {
|
||||||
|
const result = resolveNodeColors(makeData({ type: 'server' }))
|
||||||
|
expect(result).toEqual(NODE_DEFAULT_COLORS.server)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns correct defaults for each node type', () => {
|
||||||
|
const types = Object.keys(NODE_DEFAULT_COLORS) as (keyof typeof NODE_DEFAULT_COLORS)[]
|
||||||
|
for (const type of types) {
|
||||||
|
const result = resolveNodeColors(makeData({ type }))
|
||||||
|
expect(result).toEqual(NODE_DEFAULT_COLORS[type])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overrides all three colors when custom_colors is fully set', () => {
|
||||||
|
const custom = { border: '#ff0000', background: '#00ff00', icon: '#0000ff' }
|
||||||
|
const result = resolveNodeColors(makeData({ type: 'server', custom_colors: custom }))
|
||||||
|
expect(result).toEqual(custom)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overrides only border when only border is set', () => {
|
||||||
|
const result = resolveNodeColors(makeData({ type: 'switch', custom_colors: { border: '#ff0000' } }))
|
||||||
|
expect(result.border).toBe('#ff0000')
|
||||||
|
expect(result.background).toBe(NODE_DEFAULT_COLORS.switch.background)
|
||||||
|
expect(result.icon).toBe(NODE_DEFAULT_COLORS.switch.icon)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overrides only background when only background is set', () => {
|
||||||
|
const result = resolveNodeColors(makeData({ type: 'router', custom_colors: { background: '#123456' } }))
|
||||||
|
expect(result.border).toBe(NODE_DEFAULT_COLORS.router.border)
|
||||||
|
expect(result.background).toBe('#123456')
|
||||||
|
expect(result.icon).toBe(NODE_DEFAULT_COLORS.router.icon)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overrides only icon when only icon is set', () => {
|
||||||
|
const result = resolveNodeColors(makeData({ type: 'proxmox', custom_colors: { icon: '#abcdef' } }))
|
||||||
|
expect(result.border).toBe(NODE_DEFAULT_COLORS.proxmox.border)
|
||||||
|
expect(result.background).toBe(NODE_DEFAULT_COLORS.proxmox.background)
|
||||||
|
expect(result.icon).toBe('#abcdef')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to generic defaults for unknown type', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const result = resolveNodeColors({ type: 'unknown' as any, custom_colors: undefined })
|
||||||
|
expect(result).toEqual(NODE_DEFAULT_COLORS.generic)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { NodeData, NodeType } from '@/types'
|
||||||
|
|
||||||
|
export interface NodeColors {
|
||||||
|
border: string
|
||||||
|
background: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NODE_DEFAULT_COLORS: Record<NodeType, NodeColors> = {
|
||||||
|
isp: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' },
|
||||||
|
router: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' },
|
||||||
|
switch: { border: '#39d353', background: '#21262d', icon: '#39d353' },
|
||||||
|
server: { border: '#a855f7', background: '#21262d', icon: '#a855f7' },
|
||||||
|
proxmox: { border: '#ff6e00', background: '#21262d', icon: '#ff6e00' },
|
||||||
|
vm: { border: '#a855f7', background: '#21262d', icon: '#a855f7' },
|
||||||
|
lxc: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' },
|
||||||
|
nas: { border: '#39d353', background: '#21262d', icon: '#39d353' },
|
||||||
|
iot: { border: '#e3b341', background: '#21262d', icon: '#e3b341' },
|
||||||
|
ap: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' },
|
||||||
|
generic: { border: '#8b949e', background: '#21262d', icon: '#8b949e' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNodeColors(data: Pick<NodeData, 'type' | 'custom_colors'>): NodeColors {
|
||||||
|
const defaults = NODE_DEFAULT_COLORS[data.type] ?? NODE_DEFAULT_COLORS.generic
|
||||||
|
const custom = data.custom_colors
|
||||||
|
return {
|
||||||
|
border: custom?.border ?? defaults.border,
|
||||||
|
background: custom?.background ?? defaults.background,
|
||||||
|
icon: custom?.icon ?? defaults.icon,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user