From d98bfba506ff9a7f67f66484b41a60182f651553 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 7 Mar 2026 23:01:30 +0100 Subject: [PATCH] feat: add custom icon picker to node create/edit modal - Add 65+ icons across 7 categories (Infrastructure, Media, Monitoring, Storage, Security, Automation, Dev & Containers, Communications) covering popular self-hosted apps: Home Assistant, Jellyfin, Plex, Grafana, Portainer, Pi-hole, Vaultwarden, Gitea, Nextcloud, Node-RED, Frigate, etc. - New nodeIcons.ts utility with ICON_REGISTRY, ICON_MAP and resolveNodeIcon() - Inline icon picker in NodeModal: collapsible panel with search + grid grouped by category; click to select, click again or Reset to revert to type default - BaseNode uses resolveNodeIcon() so custom icon renders live on canvas - Add custom_icon field to NodeData type, NodeBase/NodeUpdate schemas, Node ORM model, and database.py idempotent ALTER TABLE migration --- backend/app/db/database.py | 2 + backend/app/db/models.py | 1 + backend/app/schemas/nodes.py | 2 + .../src/components/canvas/nodes/BaseNode.tsx | 7 +- frontend/src/components/modals/NodeModal.tsx | 90 ++++++++++++- frontend/src/types/index.ts | 1 + frontend/src/utils/nodeIcons.ts | 123 ++++++++++++++++++ 7 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 frontend/src/utils/nodeIcons.ts diff --git a/backend/app/db/database.py b/backend/app/db/database.py index f4532ff..bf7813b 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -30,6 +30,8 @@ async def init_db() -> None: await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN custom_color TEXT") with suppress(Exception): await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN path_style TEXT") + with suppress(Exception): + await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_icon TEXT") async def get_db() -> AsyncGenerator[AsyncSession, None]: diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 0726db2..f762666 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -36,6 +36,7 @@ class Node(Base): parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id")) container_mode: Mapped[bool] = mapped_column(Boolean, default=False) custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + custom_icon: Mapped[str | None] = mapped_column(String, 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/nodes.py b/backend/app/schemas/nodes.py index 55b8084..bd3cad3 100644 --- a/backend/app/schemas/nodes.py +++ b/backend/app/schemas/nodes.py @@ -21,6 +21,7 @@ class NodeBase(BaseModel): parent_id: str | None = None container_mode: bool = False custom_colors: dict[str, Any] | None = None + custom_icon: str | None = None class NodeCreate(NodeBase): @@ -43,6 +44,7 @@ class NodeUpdate(BaseModel): pos_y: float | None = None container_mode: bool | None = None custom_colors: dict[str, Any] | None = None + custom_icon: str | None = None class NodeResponse(NodeBase): diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index e848aff..5098a69 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -1,7 +1,9 @@ +import { createElement } from 'react' 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' +import { resolveNodeIcon } from '@/utils/nodeIcons' const STATUS_COLORS: Record = { online: '#39d353', @@ -14,7 +16,8 @@ interface BaseNodeProps extends NodeProps> { icon: LucideIcon } -export function BaseNode({ data, selected, icon: Icon }: BaseNodeProps) { +export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) { + const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon) const colors = resolveNodeColors(data) const statusColor = STATUS_COLORS[data.status] const isOnline = data.status === 'online' @@ -42,7 +45,7 @@ export function BaseNode({ data, selected, icon: Icon }: BaseNodeProps) { className="flex items-center justify-center w-7 h-7 rounded-md shrink-0" style={{ color: isOnline ? colors.icon : '#8b949e', background: '#161b22' }} > - + {createElement(resolvedIcon, { size: 15 })} {/* Details */} diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index 92203bf..5c0ff40 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react' -import { RotateCcw } from 'lucide-react' +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' @@ -7,6 +7,7 @@ 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][] @@ -22,6 +23,7 @@ const DEFAULT_DATA: Partial = { services: [], container_mode: true, custom_colors: undefined, + custom_icon: undefined, } interface NodeModalProps { @@ -39,6 +41,8 @@ const CHILD_TYPES: NodeType[] = ['vm', 'lxc'] // 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>({ ...DEFAULT_DATA, ...initial }) + const [iconSearch, setIconSearch] = useState('') + const [iconPickerOpen, setIconPickerOpen] = useState(false) const set = (key: keyof NodeData, value: unknown) => setForm((f) => ({ ...f, [key]: value })) @@ -76,6 +80,88 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' + {/* Icon */} +
+
+ + {form.custom_icon && ( + + )} +
+ {/* Trigger button */} + + {/* Inline picker panel */} + {iconPickerOpen && ( +
+ setIconSearch(e.target.value)} + placeholder="Search icons…" + className="bg-[#21262d] border-[#30363d] text-xs h-7" + autoFocus + /> +
+ {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 ( +
+

{cat}

+
+ {entries.map((entry) => { + const isSelected = form.custom_icon === entry.key + return ( + + ) + })} +
+
+ ) + })} +
+
+ )} +
+ {/* Label */}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e7169f6..334db4b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -42,6 +42,7 @@ export interface NodeData extends Record { parent_id?: string container_mode?: boolean custom_colors?: { border?: string; background?: string; icon?: string } + custom_icon?: string } export type EdgePathStyle = 'bezier' | 'smooth' diff --git a/frontend/src/utils/nodeIcons.ts b/frontend/src/utils/nodeIcons.ts new file mode 100644 index 0000000..6b934f5 --- /dev/null +++ b/frontend/src/utils/nodeIcons.ts @@ -0,0 +1,123 @@ +import { + // Infrastructure (node types) + Globe, Router, Network, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, + // Media + Play, Film, Tv, Tv2, Music, Camera, Video, Headphones, Clapperboard, + // Monitoring & Observability + Activity, BarChart2, LineChart, Eye, Bell, Gauge, Monitor, + // Storage & Databases + Database, Archive, Cloud, FolderOpen, + // Security & Auth + Shield, ShieldCheck, Lock, Key, Users, UserCheck, + // Automation & IoT + Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio, + // Transfers & sync + Download, Upload, RefreshCw, + // Containers & Dev + Anchor, GitBranch, Terminal, Code2, Settings, + // Communications + Mail, MessageSquare, Phone, + // Misc devices + Printer, Smartphone, Search, Filter, BookOpen, +} from 'lucide-react' +import type { LucideIcon } from 'lucide-react' + +export interface IconEntry { + key: string + label: string + category: string + icon: LucideIcon +} + +export const ICON_REGISTRY: IconEntry[] = [ + // --- Infrastructure --- + { key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe }, + { key: 'router', label: 'Router', category: 'Infrastructure', icon: Router }, + { key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network }, + { key: 'server', label: 'Server', category: 'Infrastructure', icon: Server }, + { key: 'layers', label: 'Proxmox / Hypervisor', category: 'Infrastructure', icon: Layers }, + { key: 'box', label: 'VM', category: 'Infrastructure', icon: Box }, + { key: 'container', label: 'Container / LXC', category: 'Infrastructure', icon: Container }, + { key: 'harddrive', label: 'NAS / Storage', category: 'Infrastructure', icon: HardDrive }, + { key: 'cpu', label: 'IoT / Embedded', category: 'Infrastructure', icon: Cpu }, + { key: 'wifi', label: 'Access Point', category: 'Infrastructure', icon: Wifi }, + { key: 'circle', label: 'Generic', category: 'Infrastructure', icon: Circle }, + { key: 'monitor', label: 'Workstation', category: 'Infrastructure', icon: Monitor }, + { key: 'smartphone', label: 'Phone / Mobile', category: 'Infrastructure', icon: Smartphone }, + { key: 'printer', label: 'Printer', category: 'Infrastructure', icon: Printer }, + + // --- Media --- + { key: 'play', label: 'Jellyfin / Emby', category: 'Media', icon: Play }, + { key: 'tv2', label: 'Plex', category: 'Media', icon: Tv2 }, + { key: 'film', label: 'Sonarr / Movies', category: 'Media', icon: Film }, + { key: 'clapperboard', label: 'Radarr', category: 'Media', icon: Clapperboard }, + { key: 'music', label: 'Lidarr / Music', category: 'Media', icon: Music }, + { key: 'book', label: 'Readarr / Books', category: 'Media', icon: BookOpen }, + { key: 'tv', label: 'IPTV / Live TV', category: 'Media', icon: Tv }, + { key: 'headphones', label: 'Audiobookshelf', category: 'Media', icon: Headphones }, + { key: 'video', label: 'Video / Streaming', category: 'Media', icon: Video }, + { key: 'camera', label: 'Camera / Frigate', category: 'Media', icon: Camera }, + + // --- Monitoring --- + { key: 'activity', label: 'Prometheus / Uptime', category: 'Monitoring', icon: Activity }, + { key: 'barchart', label: 'Grafana / Kibana', category: 'Monitoring', icon: BarChart2 }, + { key: 'linechart', label: 'InfluxDB / Metrics', category: 'Monitoring', icon: LineChart }, + { key: 'eye', label: 'Overseerr / Watchlist', category: 'Monitoring', icon: Eye }, + { key: 'bell', label: 'Alerts / Notifiarr', category: 'Monitoring', icon: Bell }, + { key: 'gauge', label: 'Dashboard / Status', category: 'Monitoring', icon: Gauge }, + + // --- Storage & Databases --- + { key: 'database', label: 'Database (SQL/NoSQL)', category: 'Storage', icon: Database }, + { key: 'archive', label: 'Backup / Archive', category: 'Storage', icon: Archive }, + { key: 'cloud', label: 'Nextcloud / S3', category: 'Storage', icon: Cloud }, + { key: 'folder', label: 'Files / Filebrowser', category: 'Storage', icon: FolderOpen }, + { key: 'download', label: 'Downloader (Torrent/NZB)', category: 'Storage', icon: Download }, + { key: 'upload', label: 'Upload / Sync', category: 'Storage', icon: Upload }, + { key: 'refresh', label: 'Sync / Resilio', category: 'Storage', icon: RefreshCw }, + + // --- Security & Auth --- + { key: 'shield', label: 'Pi-hole / DNS Block', category: 'Security', icon: Shield }, + { key: 'shieldcheck', label: 'AdGuard Home', category: 'Security', icon: ShieldCheck }, + { key: 'lock', label: 'Authelia / Authentik', category: 'Security', icon: Lock }, + { key: 'key', label: 'Vaultwarden / Vault', category: 'Security', icon: Key }, + { key: 'users', label: 'LDAP / SSO', category: 'Security', icon: Users }, + { key: 'usercheck', label: 'Keycloak', category: 'Security', icon: UserCheck }, + { key: 'filter', label: 'Prowlarr / Jackett', category: 'Security', icon: Filter }, + { key: 'search', label: 'Search / Indexer', category: 'Security', icon: Search }, + + // --- Automation & Smart Home --- + { key: 'home', label: 'Home Assistant', category: 'Automation', icon: Home }, + { key: 'zap', label: 'ESPHome / Node-RED', category: 'Automation', icon: Zap }, + { key: 'workflow', label: 'n8n / Node-RED', category: 'Automation', icon: Workflow }, + { key: 'bot', label: 'Bot / Automation', category: 'Automation', icon: Bot }, + { key: 'thermometer', label: 'Sensor / Temperature', category: 'Automation', icon: Thermometer }, + { key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb }, + { key: 'radio', label: 'MQTT / RTL-SDR', category: 'Automation', icon: Radio }, + + // --- Containers & Dev --- + { key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor }, + { key: 'gitbranch', label: 'Gitea / Gitlab', category: 'Dev & Containers', icon: GitBranch }, + { key: 'terminal', label: 'SSH / Shell', category: 'Dev & Containers', icon: Terminal }, + { key: 'code', label: 'VS Code Server', category: 'Dev & Containers', icon: Code2 }, + { key: 'settings', label: 'Config / Admin', category: 'Dev & Containers', icon: Settings }, + + // --- Communications --- + { key: 'mail', label: 'Mail Server', category: 'Communications', icon: Mail }, + { key: 'chat', label: 'Chat / Synapse', category: 'Communications', icon: MessageSquare }, + { key: 'phone', label: 'VoIP / FreePBX', category: 'Communications', icon: Phone }, +] + +export const ICON_CATEGORIES = [...new Set(ICON_REGISTRY.map((e) => e.category))] + +export const ICON_MAP: Record = Object.fromEntries( + ICON_REGISTRY.map((e) => [e.key, e.icon]), +) + +/** Resolve the display icon for a node — custom_icon takes priority over type default. */ +export function resolveNodeIcon( + typeIcon: LucideIcon, + customIconKey?: string, +): LucideIcon { + if (customIconKey && ICON_MAP[customIconKey]) return ICON_MAP[customIconKey] + return typeIcon +}