diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 27da919..ef5bad8 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -20,9 +20,11 @@ class Base(DeclarativeBase): async def init_db() -> None: async with engine.begin() as conn: 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): 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(): diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 5dff858..f402eee 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -34,6 +34,7 @@ class Node(Base): pos_y: Mapped[float] = mapped_column(Float, default=0) parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id")) 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)) response_time_ms: Mapped[int | None] = mapped_column(Integer) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py index 9429f62..86291e8 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -21,6 +21,7 @@ class NodeSave(BaseModel): notes: str | None = None parent_id: str | None = None container_mode: bool = False + custom_colors: dict | None = None pos_x: float = 0 pos_y: float = 0 diff --git a/backend/app/schemas/nodes.py b/backend/app/schemas/nodes.py index 65592f4..48fd3e7 100644 --- a/backend/app/schemas/nodes.py +++ b/backend/app/schemas/nodes.py @@ -20,6 +20,7 @@ class NodeBase(BaseModel): pos_y: float = 0 parent_id: str | None = None container_mode: bool = False + custom_colors: dict | None = None class NodeCreate(NodeBase): @@ -41,6 +42,7 @@ class NodeUpdate(BaseModel): pos_x: float | None = None pos_y: float | None = None container_mode: bool | None = None + custom_colors: dict | None = None class NodeResponse(NodeBase): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e42b669..508c454 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -52,6 +52,7 @@ export default function App() { notes: n.data.notes ?? null, parent_id: n.data.parent_id ?? null, container_mode: n.data.container_mode ?? false, + custom_colors: n.data.custom_colors ?? null, pos_x: n.position.x, pos_y: n.position.y, })) diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 40b38fa..b79d3f5 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -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 = { online: '#39d353', @@ -11,10 +12,10 @@ const STATUS_COLORS: Record = { interface BaseNodeProps extends NodeProps> { 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' }:
diff --git a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx index 51cf764..be883ae 100644 --- a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx +++ b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx @@ -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 = { @@ -10,18 +11,18 @@ const STATUS_COLORS: Record = { unknown: '#8b949e', } -const GLOW = '#ff6e00' - export function ProxmoxGroupNode(props: NodeProps>) { const { data, selected } = props + const colors = resolveNodeColors(data) // Render as a regular node when container mode is disabled if (data.container_mode === false) { - return + return } 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>) { 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 */}
{/* Header bar */}
- + {data.label} {data.ip && ( diff --git a/frontend/src/components/canvas/nodes/index.tsx b/frontend/src/components/canvas/nodes/index.tsx index c02f3e0..4582581 100644 --- a/frontend/src/components/canvas/nodes/index.tsx +++ b/frontend/src/components/canvas/nodes/index.tsx @@ -8,15 +8,14 @@ import type { NodeData } from '@/types' type N = NodeProps> -export const IspNode = (props: N) => -export const RouterNode = (props: N) => -export const SwitchNode = (props: N) => -export const ServerNode = (props: N) => -export const ProxmoxNode = (props: N) => -export const VmNode = (props: N) => -export const LxcNode = (props: N) => -export const NasNode = (props: N) => -export const IotNode = (props: N) => -export const ApNode = (props: N) => -export const GenericNode = (props: N) => - +export const IspNode = (props: N) => +export const RouterNode = (props: N) => +export const SwitchNode = (props: N) => +export const ServerNode = (props: N) => +export const ProxmoxNode = (props: N) => +export const VmNode = (props: N) => +export const LxcNode = (props: N) => +export const NasNode = (props: N) => +export const IotNode = (props: N) => +export const ApNode = (props: N) => +export const GenericNode = (props: N) => diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index 7713591..92203bf 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -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 = { 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'
)} + {/* Appearance */} +
+
+ + {form.custom_colors && ( + + )} +
+
+ {(['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 ( +
+
+ {!form.custom_colors && ( +

Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.

+ )} +
+ {/* Notes */}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a3c9433..5e88030 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -41,6 +41,7 @@ export interface NodeData extends Record { notes?: string parent_id?: string container_mode?: boolean + custom_colors?: { border?: string; background?: string; icon?: string } } export interface EdgeData extends Record { diff --git a/frontend/src/utils/__tests__/nodeColors.test.ts b/frontend/src/utils/__tests__/nodeColors.test.ts new file mode 100644 index 0000000..46ccc39 --- /dev/null +++ b/frontend/src/utils/__tests__/nodeColors.test.ts @@ -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 = {}): Pick { + 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) + }) +}) diff --git a/frontend/src/utils/nodeColors.ts b/frontend/src/utils/nodeColors.ts new file mode 100644 index 0000000..7a1a886 --- /dev/null +++ b/frontend/src/utils/nodeColors.ts @@ -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 = { + 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): 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, + } +}