Compare commits

...

13 Commits

Author SHA1 Message Date
Pouzor 8541922386 bump: version 2.0.3 2026-05-13 11:37:06 +02:00
Rémy 3ed9cb0d4f Merge pull request #146 from Pouzor/fix/zigbee-no-check-method
fix(zigbee): hide check method in modal, force none/online for zigbee nodes
2026-05-13 11:35:10 +02:00
Pouzor fff11a4b6a fix(zigbee): hide check method in modal, force none/online for zigbee nodes
Zigbee nodes have no IP-based check — hide Check Method and Check Target
fields in NodeModal for all three zigbee types. Default and force
check_method='none' so the scheduler marks them always online.
Backend approve and zigbee import routes also set status='online' and
check_method='none' for zigbee node types.
2026-05-13 11:14:35 +02:00
Pouzor 0680566081 bump: 2.0.2 2026-05-11 19:55:21 +02:00
Remy 2f5a90a00e Merge pull request #142 from Pouzor/fix/visual
Node modal polish + Smart Home and Brand icon pickers
2026-05-11 19:53:48 +02:00
Pouzor 928f63df0f fix(icons): narrow ICON_MAP lookup type for strict build
tsc -b (used in npm run build) flagged TS2774 because LucideIcon is a
function and therefore always truthy. Cast the lookup result to
LucideIcon | undefined so the falsy branch becomes meaningful.
2026-05-11 19:44:20 +02:00
Pouzor e84a4e0eb3 merge feat/brand-icons into fix/visual 2026-05-11 19:20:10 +02:00
Pouzor 3b0dbd7a8b feat(icons): add brand icon picker from dashboard-icons
Add a second tab in the node Icon picker to choose from the ~2250 brand
icons hosted by homarr-labs/dashboard-icons (Plex, Sonarr, Home
Assistant, etc.) served via jsDelivr CDN. The Generic tab keeps the
existing lucide picker unchanged.

Storage uses a 'brand:<slug>' prefix on custom_icon, so existing nodes
referencing lucide keys keep working with zero migration. A new
resolveCustomIcon helper returns a discriminated union (lucide | brand)
and a NodeIcon component centralizes rendering for both kinds.

Includes a manifest fetch script (scripts/fetch-dashboard-icons.mjs)
and a checked-in dashboardIcons.json snapshot.
2026-05-11 19:18:01 +02:00
Pouzor 3a9b3b2650 feat(icons): add Smart Home / Sensors icon category
Add 27 new icons covering common IoT/Zigbee endpoints: smart plug,
relay, energy meter, solar, door/window sensor, smart lock, smoke
detector, siren, motion radar, presence, vibration, water leak,
humidity, air quality, HVAC vent, fan, AC, smart light, blinds,
doorbell, speaker, remote, garage, valve, weather station, plus
voice assistant and webhook in the existing Automation category.
2026-05-11 17:08:11 +02:00
Pouzor ff02f3b5db fix(node-modal): cap height at 90vh with scroll
Modal grew taller than viewport when icon picker expanded, hiding
header and footer buttons. Constrain DialogContent to 90vh and add
overflow-y-auto so all controls stay reachable.
2026-05-11 16:54:33 +02:00
Pouzor a66e6aa906 bump: version 2.0.1 2026-05-11 16:11:51 +02:00
Remy 8e59d9a0d1 Merge pull request #141 from Pouzor/fix/zigbee-node-types-in-modal
fix(node-modal): expose Zigbee device types in selector
2026-05-11 16:09:07 +02:00
Pouzor ee4136b506 fix(node-modal): expose Zigbee device types in selector
Add Zigbee section (coordinator, router, end device) to NodeModal
type group list, matching types used by zigbee2mqtt import.
2026-05-11 15:55:34 +02:00
17 changed files with 412 additions and 45 deletions
+1 -1
View File
@@ -1 +1 @@
2.0.0
2.0.3
+5 -5
View File
@@ -225,18 +225,18 @@ async def approve_device(
if device.status != "pending":
raise HTTPException(status_code=409, detail="Device already processed")
device.status = "approved"
_zigbee_types = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
_is_zigbee = node_data.type in _zigbee_types
node = Node(
label=node_data.label,
type=node_data.type,
ip=node_data.ip,
hostname=node_data.hostname,
status=node_data.status,
status="online" if _is_zigbee else node_data.status,
services=node_data.services or [],
ieee_address=device.ieee_address,
# Honour caller-supplied check_method, else default to ping when an IP exists
# so the scheduler doesn't silently skip the new node.
check_method=node_data.check_method or ("ping" if node_data.ip else None),
check_target=node_data.check_target,
check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
check_target=None if _is_zigbee else node_data.check_target,
)
db.add(node)
await db.flush()
+2 -1
View File
@@ -157,7 +157,8 @@ async def _persist_pending_import(
node = Node(
label=label,
type=n.get("type") or "zigbee_coordinator",
status="unknown",
status="online",
check_method="none",
ieee_address=ieee,
services=[],
)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "2.0.0",
"version": "2.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "1.13.0",
"version": "2.0.3",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "2.0.0",
"version": "2.0.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -0,0 +1,27 @@
#!/usr/bin/env node
// Regenerate frontend/src/data/dashboardIcons.json from the upstream
// homarr-labs/dashboard-icons repo. Run manually to refresh the manifest.
//
// node scripts/fetch-dashboard-icons.mjs
import { writeFileSync, mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const TREE_URL = 'https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/tree.json'
const OUT = resolve(dirname(fileURLToPath(import.meta.url)), '../src/data/dashboardIcons.json')
const res = await fetch(TREE_URL)
if (!res.ok) {
console.error(`fetch failed: ${res.status} ${res.statusText}`)
process.exit(1)
}
const tree = await res.json()
const slugs = (tree.svg ?? [])
.filter((f) => f.endsWith('.svg'))
.map((f) => f.slice(0, -4))
.sort()
mkdirSync(dirname(OUT), { recursive: true })
writeFileSync(OUT, JSON.stringify(slugs))
console.log(`wrote ${slugs.length} slugs → ${OUT}`)
@@ -44,6 +44,7 @@ vi.mock('@/utils/nodeColors', () => ({
vi.mock('@/utils/nodeIcons', () => ({
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
isBrandIconKey: (k: string | undefined) => !!k && k.startsWith('brand:'),
}))
vi.mock('@/utils/maskIp', () => ({
@@ -3,7 +3,8 @@ import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, typ
import { Cpu, MemoryStick, HardDrive, ExternalLink, type LucideIcon } from 'lucide-react'
import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { resolveNodeIcon } from '@/utils/nodeIcons'
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
import { NodeIcon } from '@/components/ui/NodeIcon'
import { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
@@ -98,7 +99,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
background: theme.colors.nodeIconBackground,
}}
>
{createElement(resolvedIcon, { size: 15 })}
{isBrandIconKey(data.custom_icon)
? <NodeIcon typeIcon={typeIcon} customIconKey={data.custom_icon} size={15} />
: createElement(resolvedIcon, { size: 15 })}
</div>
{/* Label + IP */}
@@ -3,7 +3,8 @@ import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflo
import { Layers } from 'lucide-react'
import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { resolveNodeIcon } from '@/utils/nodeIcons'
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
import { NodeIcon } from '@/components/ui/NodeIcon'
import { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useCanvasStore } from '@/stores/canvasStore'
import { maskIp, splitIps } from '@/utils/maskIp'
@@ -87,7 +88,9 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
background: theme.colors.nodeIconBackground,
}}
>
{createElement(resolvedIcon, { size: 12 })}
{isBrandIconKey(data.custom_icon)
? <NodeIcon typeIcon={Layers} customIconKey={data.custom_icon} size={12} />
: createElement(resolvedIcon, { size: 12 })}
</div>
<div className="flex flex-col min-w-0 flex-1">
<span
@@ -0,0 +1,85 @@
import { useMemo, useState } from 'react'
import { Input } from '@/components/ui/input'
import { brandIconUrl, BRAND_ICON_PREFIX } from '@/utils/nodeIcons'
import dashboardIcons from '@/data/dashboardIcons.json'
const SLUGS: string[] = dashboardIcons as string[]
const PAGE = 120
interface BrandIconPickerProps {
value?: string
onSelect: (key: string) => void
}
export function BrandIconPicker({ value, onSelect }: BrandIconPickerProps) {
const [query, setQuery] = useState('')
const [limit, setLimit] = useState(PAGE)
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return SLUGS
return SLUGS.filter((s) => s.includes(q))
}, [query])
const visible = filtered.slice(0, limit)
const selectedSlug = value?.startsWith(BRAND_ICON_PREFIX) ? value.slice(BRAND_ICON_PREFIX.length) : null
return (
<div className="flex flex-col gap-2">
<Input
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setLimit(PAGE) }}
placeholder={`Search ${SLUGS.length} brand icons...`}
className="bg-[#0d1117] border-[#30363d] text-xs h-7"
aria-label="Brand icon search"
/>
<div className="text-[10px] text-muted-foreground/60">
{filtered.length} match{filtered.length === 1 ? '' : 'es'} · icons served via jsDelivr CDN
</div>
<div className="max-h-52 overflow-y-auto pr-1">
<div className="grid grid-cols-7 gap-1">
{visible.map((slug) => {
const selected = slug === selectedSlug
return (
<button
key={slug}
type="button"
onClick={() => onSelect(`${BRAND_ICON_PREFIX}${slug}`)}
title={slug}
aria-label={slug}
aria-pressed={selected}
className={`flex items-center justify-center aspect-square rounded-md border transition-colors cursor-pointer ${
selected
? 'border-[#00d4ff] bg-[#00d4ff]/10'
: 'border-[#30363d] hover:border-[#484f58] bg-[#0d1117]'
}`}
>
<img
src={brandIconUrl(slug)}
alt={slug}
loading="lazy"
width={20}
height={20}
style={{ width: 20, height: 20, objectFit: 'contain' }}
/>
</button>
)
})}
</div>
{filtered.length > limit && (
<button
type="button"
onClick={() => setLimit((l) => l + PAGE)}
className="mt-2 w-full text-[11px] text-muted-foreground hover:text-foreground py-1"
>
Load more ({filtered.length - limit} remaining)
</button>
)}
{filtered.length === 0 && (
<div className="text-center text-[11px] text-muted-foreground py-4">No icons match.</div>
)}
</div>
</div>
)
}
+78 -28
View File
@@ -8,18 +8,21 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, 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, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
import { BrandIconPicker } from './BrandIconPicker'
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
]
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice']
const CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
none: 'None',
@@ -57,9 +60,12 @@ interface NodeModalProps {
// 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', parentContainerNodes = [] }: NodeModalProps) {
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
const merged = { ...DEFAULT_DATA, ...initial }
if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
const [form, setForm] = useState<Partial<NodeData>>(merged)
const [iconSearch, setIconSearch] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [iconTab, setIconTab] = useState<'generic' | 'brand'>(isBrandIconKey(initial?.custom_icon) ? 'brand' : 'generic')
const [labelError, setLabelError] = useState(false)
const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
const showServicesEnabled = form.custom_colors?.show_services === true
@@ -94,7 +100,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
</DialogHeader>
@@ -104,7 +110,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
{/* Type + Icon on the same row */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Type</Label>
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
<Select value={form.type} onValueChange={(v) => {
const t = v as NodeType
setForm((f) => ({ ...f, type: t, ...(ZIGBEE_TYPES.includes(t) ? { check_method: 'none' as CheckMethod } : {}) }))
}}>
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Node type selector">
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
</SelectTrigger>
@@ -151,6 +160,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
>
<span className="flex items-center gap-2 min-w-0">
{(() => {
if (isBrandIconKey(form.custom_icon)) {
const slug = brandIconSlug(form.custom_icon!)
return <><img src={brandIconUrl(slug)} alt={slug} width={13} height={13} className="shrink-0" style={{ width: 13, height: 13, objectFit: 'contain' }} /><span className="text-foreground truncate">{slug}</span></>
}
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
if (entry) {
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
@@ -166,6 +179,37 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
{/* Inline icon picker - full width, shown below the type+icon row */}
{iconPickerOpen && (
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
<div className="flex gap-1 mb-1" role="tablist" aria-label="Icon source">
<button
type="button"
role="tab"
aria-selected={iconTab === 'generic'}
onClick={() => setIconTab('generic')}
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
iconTab === 'generic' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
}`}
>
Generic
</button>
<button
type="button"
role="tab"
aria-selected={iconTab === 'brand'}
onClick={() => setIconTab('brand')}
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
iconTab === 'brand' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
}`}
>
Brand
</button>
</div>
{iconTab === 'brand' ? (
<BrandIconPicker
value={form.custom_icon}
onSelect={(key) => { set('custom_icon', key); setIconPickerOpen(false) }}
/>
) : (
<>
<Input
value={iconSearch}
onChange={(e) => setIconSearch(e.target.value)}
@@ -211,6 +255,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
)
})}
</div>
</>
)}
</div>
)}
@@ -249,31 +295,35 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<span className="text-[10px] text-muted-foreground/50">comma-separated</span>
</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 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Check method selector">
<SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue>
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{CHECK_METHODS.map((m) => (
<SelectItem key={m} value={m} className="text-sm">{CHECK_METHOD_LABELS[m]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Check method — hidden for zigbee nodes (always none/online) */}
{!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
<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 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Check method selector">
<SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue>
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{CHECK_METHODS.map((m) => (
<SelectItem key={m} value={m} className="text-sm">{CHECK_METHOD_LABELS[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 ${modalStyles['modal-radius']}`}
/>
</div>
{/* Check target — hidden for zigbee nodes */}
{!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
<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 ${modalStyles['modal-radius']}`}
/>
</div>
)}
{/* Parent container */}
{form.type !== 'groupRect' && form.type !== 'group' && filteredParentNodes.length > 0 && (
@@ -433,4 +433,24 @@ describe('NodeModal', () => {
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.value).toBe('48')
})
// ── Zigbee nodes ──────────────────────────────────────────────────────
const zigbeeTypes = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] as const
it.each(zigbeeTypes)('hides Check Method for %s type', (type) => {
renderModal({ initial: { ...BASE, type } })
expect(screen.queryByText('Check Method')).toBeNull()
})
it.each(zigbeeTypes)('hides Check Target for %s type', (type) => {
renderModal({ initial: { ...BASE, type } })
expect(screen.queryByText('Check Target')).toBeNull()
})
it.each(zigbeeTypes)('submits check_method=none for %s type', (type) => {
const { onSubmit } = renderModal({ initial: { ...BASE, type, label: 'Zigbee Node' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_method).toBe('none')
})
})
+35
View File
@@ -0,0 +1,35 @@
import { createElement } from 'react'
import type { LucideIcon } from 'lucide-react'
import { resolveCustomIcon, brandIconUrl, isBrandIconKey } from '@/utils/nodeIcons'
interface NodeIconProps {
/** Default icon for the node type (lucide). Used when no customIconKey or unknown key. */
typeIcon: LucideIcon
/** Optional override key. Legacy lucide keys or `brand:<slug>` for dashboard-icons. */
customIconKey?: string
size?: number
className?: string
/** Optional inline color (lucide only — ignored for brand icons). */
color?: string
}
export function NodeIcon({ typeIcon, customIconKey, size = 16, className, color }: NodeIconProps) {
const resolved = resolveCustomIcon(customIconKey)
if (resolved?.kind === 'brand') {
return (
<img
src={resolved.url}
alt={resolved.slug}
width={size}
height={size}
loading="lazy"
className={className}
style={{ width: size, height: size, objectFit: 'contain' }}
/>
)
}
const Icon = resolved?.kind === 'lucide' ? resolved.icon : typeIcon
return createElement(Icon, { size, className, color })
}
export { brandIconUrl, isBrandIconKey }
File diff suppressed because one or more lines are too long
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import {
BRAND_ICON_PREFIX,
isBrandIconKey,
brandIconSlug,
brandIconUrl,
resolveCustomIcon,
ICON_MAP,
} from '../nodeIcons'
describe('brand icon helpers', () => {
it('isBrandIconKey returns true only for prefixed keys', () => {
expect(isBrandIconKey('brand:plex')).toBe(true)
expect(isBrandIconKey('plex')).toBe(false)
expect(isBrandIconKey('plug')).toBe(false)
expect(isBrandIconKey(undefined)).toBe(false)
expect(isBrandIconKey(null)).toBe(false)
expect(isBrandIconKey('')).toBe(false)
})
it('brandIconSlug strips the prefix', () => {
expect(brandIconSlug(`${BRAND_ICON_PREFIX}home-assistant`)).toBe('home-assistant')
})
it('brandIconUrl points at jsDelivr CDN', () => {
expect(brandIconUrl('plex')).toBe(
'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg',
)
})
})
describe('resolveCustomIcon', () => {
it('returns null when no key', () => {
expect(resolveCustomIcon(undefined)).toBeNull()
expect(resolveCustomIcon('')).toBeNull()
})
it('resolves legacy lucide keys', () => {
const r = resolveCustomIcon('plug')
expect(r?.kind).toBe('lucide')
if (r?.kind === 'lucide') expect(r.icon).toBe(ICON_MAP['plug'])
})
it('resolves brand-prefixed keys to a CDN url', () => {
const r = resolveCustomIcon('brand:plex')
expect(r?.kind).toBe('brand')
if (r?.kind === 'brand') {
expect(r.slug).toBe('plex')
expect(r.url).toContain('cdn.jsdelivr.net')
expect(r.url).toContain('/plex.svg')
}
})
it('returns null for unknown legacy key', () => {
expect(resolveCustomIcon('definitely-not-a-known-icon-key')).toBeNull()
})
})
@@ -33,6 +33,22 @@ describe('ICON_REGISTRY', () => {
expect(keys).toContain('database') // DB services
expect(keys).toContain('cctv') // IP Camera / CCTV
})
it('contains Smart Home / Sensors icons', () => {
const keys = ICON_REGISTRY.map((e) => e.key)
expect(keys).toContain('plug')
expect(keys).toContain('smoke')
expect(keys).toContain('door')
expect(keys).toContain('motion')
expect(keys).toContain('leak')
expect(keys).toContain('lock-smart')
expect(keys).toContain('battery-charging')
})
it('exposes the Smart Home / Sensors category', () => {
const categories = ICON_REGISTRY.map((e) => e.category)
expect(categories).toContain('Smart Home / Sensors')
})
})
describe('ICON_CATEGORIES', () => {
+71 -3
View File
@@ -11,7 +11,12 @@ import {
// Security & Auth
Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame,
// Automation & IoT
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio,
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio, BotMessageSquare, Webhook,
// Smart Home / Sensors
Plug, Power, BatteryCharging, Sun, DoorOpen, KeyRound, AlarmSmoke, Siren,
Radar, PersonStanding, Vibrate, Droplet, Droplets, Wind, AirVent, Fan,
Snowflake, LampCeiling, Blinds, BellRing, Speaker, Joystick, Warehouse,
CircleDot, CloudSun,
// Transfers & sync
Download, Upload, RefreshCw,
// Containers & Dev
@@ -97,6 +102,35 @@ export const ICON_REGISTRY: IconEntry[] = [
{ 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 },
{ key: 'voice', label: 'Voice Assistant', category: 'Automation', icon: BotMessageSquare },
{ key: 'webhook', label: 'Webhook / IFTTT', category: 'Automation', icon: Webhook },
// --- Smart Home / Sensors ---
{ key: 'plug', label: 'Smart Plug / Outlet', category: 'Smart Home / Sensors', icon: Plug },
{ key: 'power', label: 'Switch / Relay', category: 'Smart Home / Sensors', icon: Power },
{ key: 'battery-charging', label: 'Energy Meter / EV', category: 'Smart Home / Sensors', icon: BatteryCharging },
{ key: 'solar', label: 'Solar Panel', category: 'Smart Home / Sensors', icon: Sun },
{ key: 'door', label: 'Door / Window Sensor', category: 'Smart Home / Sensors', icon: DoorOpen },
{ key: 'lock-smart', label: 'Smart Lock', category: 'Smart Home / Sensors', icon: KeyRound },
{ key: 'smoke', label: 'Smoke Detector', category: 'Smart Home / Sensors', icon: AlarmSmoke },
{ key: 'siren', label: 'Siren / Alarm', category: 'Smart Home / Sensors', icon: Siren },
{ key: 'motion', label: 'Motion / Radar', category: 'Smart Home / Sensors', icon: Radar },
{ key: 'presence', label: 'Presence Sensor', category: 'Smart Home / Sensors', icon: PersonStanding },
{ key: 'vibration', label: 'Vibration Sensor', category: 'Smart Home / Sensors', icon: Vibrate },
{ key: 'leak', label: 'Water Leak', category: 'Smart Home / Sensors', icon: Droplet },
{ key: 'humidity', label: 'Humidity', category: 'Smart Home / Sensors', icon: Droplets },
{ key: 'air-quality', label: 'Air Quality / VOC', category: 'Smart Home / Sensors', icon: Wind },
{ key: 'air-vent', label: 'HVAC Vent', category: 'Smart Home / Sensors', icon: AirVent },
{ key: 'fan', label: 'Fan', category: 'Smart Home / Sensors', icon: Fan },
{ key: 'snowflake', label: 'AC / Cooling', category: 'Smart Home / Sensors', icon: Snowflake },
{ key: 'lamp', label: 'Smart Light', category: 'Smart Home / Sensors', icon: LampCeiling },
{ key: 'blinds', label: 'Blinds / Cover', category: 'Smart Home / Sensors', icon: Blinds },
{ key: 'doorbell', label: 'Doorbell', category: 'Smart Home / Sensors', icon: BellRing },
{ key: 'speaker', label: 'Smart Speaker', category: 'Smart Home / Sensors', icon: Speaker },
{ key: 'remote', label: 'Remote / Button', category: 'Smart Home / Sensors', icon: Joystick },
{ key: 'garage', label: 'Garage Door', category: 'Smart Home / Sensors', icon: Warehouse },
{ key: 'valve', label: 'Smart Valve', category: 'Smart Home / Sensors', icon: CircleDot },
{ key: 'weather', label: 'Weather Station', category: 'Smart Home / Sensors', icon: CloudSun },
// --- Containers & Dev ---
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
@@ -145,11 +179,45 @@ export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
text: Type,
}
/** Resolve the display icon for a node — custom_icon takes priority over type default. */
/** Resolve the display icon for a node — custom_icon takes priority over type default.
* Legacy: returns a LucideIcon component. Brand icons must use `resolveCustomIcon`. */
export function resolveNodeIcon(
typeIcon: LucideIcon,
customIconKey?: string,
): LucideIcon {
if (customIconKey && ICON_MAP[customIconKey]) return ICON_MAP[customIconKey]
if (customIconKey && !customIconKey.startsWith('brand:') && ICON_MAP[customIconKey]) {
return ICON_MAP[customIconKey]
}
return typeIcon
}
export const BRAND_ICON_PREFIX = 'brand:'
export function isBrandIconKey(key: string | undefined | null): boolean {
return !!key && key.startsWith(BRAND_ICON_PREFIX)
}
export function brandIconSlug(key: string): string {
return key.slice(BRAND_ICON_PREFIX.length)
}
export function brandIconUrl(slug: string): string {
return `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/${slug}.svg`
}
export type ResolvedIcon =
| { kind: 'lucide'; icon: LucideIcon }
| { kind: 'brand'; slug: string; url: string }
/** Resolve a node's icon to either a lucide component or a brand CDN URL.
* Used by renderers that support brand icons. Backwards-compatible with legacy
* string keys (no prefix → lucide lookup). Returns null when key unknown. */
export function resolveCustomIcon(customIconKey?: string): ResolvedIcon | null {
if (!customIconKey) return null
if (isBrandIconKey(customIconKey)) {
const slug = brandIconSlug(customIconKey)
return { kind: 'brand', slug, url: brandIconUrl(slug) }
}
const icon = ICON_MAP[customIconKey] as LucideIcon | undefined
return icon ? { kind: 'lucide', icon } : null
}