From d0a49d0a0dbfb381f5e928f93788d39c2cb830d3 Mon Sep 17 00:00:00 2001 From: Lucas Van Vonderen Date: Thu, 23 Apr 2026 15:32:50 -0400 Subject: [PATCH 01/11] fix(mcp): mount session manager via Starlette Mount to avoid double response.start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StreamableHTTPSessionManager is an ASGI app — it sends its own http.response.start and http.response.body messages via the scope/receive/send triple. Wrapping it inside a @app.api_route FastAPI handler causes FastAPI to try to finalize the response after the handler returns, emitting a second http.response.start. uvicorn rejects this with: RuntimeError: Unexpected ASGI message 'http.response.start' sent, after response already completed. Every POST /mcp raises, making the server unreachable from any MCP client (tested with Claude Code 2.x against mcp==1.27.0). Fix: mount the session manager as a Starlette Mount so it owns the response cycle directly. Auth middleware still applies because add_middleware attaches at the app level, wrapping all mounted sub-apps. --- mcp/app/main.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/mcp/app/main.py b/mcp/app/main.py index ca40953..7beafa2 100644 --- a/mcp/app/main.py +++ b/mcp/app/main.py @@ -1,7 +1,8 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI, Request +from fastapi import FastAPI from mcp.server import Server from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from starlette.routing import Mount from .auth import ApiKeyMiddleware from .backend_client import backend @@ -28,15 +29,20 @@ async def lifespan(app: FastAPI): await backend.stop() -app = FastAPI(title="Homelable MCP", lifespan=lifespan) +# Mount the session manager as an ASGI sub-app instead of wrapping it in a +# FastAPI @app.api_route handler. Wrapping it in a route handler causes +# FastAPI to send http.response.start after the session manager has already +# started the response, raising `RuntimeError: Unexpected ASGI message +# 'http.response.start' sent, after response already completed` on every +# POST /mcp — which makes the server unreachable from any MCP client. +app = FastAPI( + title="Homelable MCP", + lifespan=lifespan, + routes=[Mount("/mcp", app=session_manager.handle_request)], +) app.add_middleware(ApiKeyMiddleware) -@app.api_route("/mcp", methods=["GET", "POST", "DELETE"]) -async def mcp_endpoint(request: Request): - await session_manager.handle_request(request.scope, request.receive, request._send) - - @app.get("/health") async def health(): return {"status": "ok"} From 3ccdde0beafdf3a57c372171bc7aa7ca2c2f10e1 Mon Sep 17 00:00:00 2001 From: Brett Ferrante <83841899+findthelorax@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:35:53 -0400 Subject: [PATCH 02/11] Update Docker image references to use repository owner --- .github/workflows/docker-publish.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 600ae67..fe0a4e8 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -15,13 +15,13 @@ jobs: strategy: matrix: include: - - image: ghcr.io/pouzor/homelable-backend + - image: ghcr.io/${{ github.repository_owner }}/homelable-backend dockerfile: Dockerfile.backend build_args: "" - - image: ghcr.io/pouzor/homelable-frontend + - image: ghcr.io/${{ github.repository_owner }}/homelable-frontend dockerfile: Dockerfile.frontend build_args: "" - - image: ghcr.io/pouzor/homelable-frontend-standalone + - image: ghcr.io/${{ github.repository_owner }}/homelable-frontend-standalone dockerfile: Dockerfile.frontend build_args: "VITE_STANDALONE=true" From cd0e08fb91fbbe498f1a85e9b78a1ef04adba3a2 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Mon, 20 Apr 2026 23:48:24 -0400 Subject: [PATCH 03/11] fixed canvas style options to fit better and be max 50vw --- frontend/src/components/modals/ThemeModal.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/modals/ThemeModal.tsx b/frontend/src/components/modals/ThemeModal.tsx index 23f1d6c..62901e7 100644 --- a/frontend/src/components/modals/ThemeModal.tsx +++ b/frontend/src/components/modals/ThemeModal.tsx @@ -70,13 +70,13 @@ function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) { {/* Label */}
{preset.label}
{preset.description} @@ -121,19 +121,20 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) { return ( { if (!o) handleCancel() }}> - + Choose Canvas Style -
+
{THEME_ORDER.map((id) => ( - handleSelect(id)} - /> +
+ handleSelect(id)} + /> +
))}
From adb208875258d539aa8e6aabef6a1fc7ce843635 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Mon, 20 Apr 2026 23:49:17 -0400 Subject: [PATCH 04/11] revert: restore workflow to upstream version --- .github/workflows/docker-publish.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index fe0a4e8..600ae67 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -15,13 +15,13 @@ jobs: strategy: matrix: include: - - image: ghcr.io/${{ github.repository_owner }}/homelable-backend + - image: ghcr.io/pouzor/homelable-backend dockerfile: Dockerfile.backend build_args: "" - - image: ghcr.io/${{ github.repository_owner }}/homelable-frontend + - image: ghcr.io/pouzor/homelable-frontend dockerfile: Dockerfile.frontend build_args: "" - - image: ghcr.io/${{ github.repository_owner }}/homelable-frontend-standalone + - image: ghcr.io/pouzor/homelable-frontend-standalone dockerfile: Dockerfile.frontend build_args: "VITE_STANDALONE=true" From da287d459ce85f1b2a28a2be75380348b2eeb122 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Mon, 20 Apr 2026 23:57:28 -0400 Subject: [PATCH 05/11] allow for keyboard navigation and adjusted card height to match regardless of text inside --- frontend/src/components/modals/ThemeModal.tsx | 41 +++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/modals/ThemeModal.tsx b/frontend/src/components/modals/ThemeModal.tsx index 62901e7..5b50979 100644 --- a/frontend/src/components/modals/ThemeModal.tsx +++ b/frontend/src/components/modals/ThemeModal.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useRef, useState, type KeyboardEvent } from 'react' import { toast } from 'sonner' import { Check } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' @@ -14,17 +14,21 @@ interface ThemeCardProps { themeId: ThemeId selected: boolean onClick: () => void + onKeyDown?: (event: KeyboardEvent) => void + buttonRef?: (element: HTMLButtonElement | null) => void } -function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) { +function ThemeCard({ themeId, selected, onClick, onKeyDown, buttonRef }: ThemeCardProps) { const preset = THEMES[themeId] const c = preset.colors return ( +
+ ) +} + +// ── Edge type editor ───────────────────────────────────────────────────────── + +interface EdgeEditorProps { + edgeType: EdgeType + style: EdgeTypeStyle + onChange: (s: EdgeTypeStyle) => void + onApplyToExisting: () => void +} + +function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditorProps) { + const set = useCallback((k: K, v: EdgeTypeStyle[K]) => { + onChange({ ...style, [k]: v }) + }, [style, onChange]) + + return ( +
+
{EDGE_TYPE_LABELS[edgeType]}
+
+ set('color', v)} + onOpacityChange={(v) => set('opacity', v)} + /> +
+ +
+
+
Path style
+
+ {(['bezier', 'smooth'] as EdgePathStyle[]).map((ps) => ( + + ))} +
+
+ +
+
Animation
+ +
+
+ + +
+ ) +} + +// ── Main modal ─────────────────────────────────────────────────────────────── + +type Tab = 'nodes' | 'edges' +type Selection = { kind: 'node'; type: NodeType } | { kind: 'edge'; type: EdgeType } | null + +interface CustomStyleModalProps { + open: boolean + onClose: () => void +} + +export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) { + const { customStyle, setCustomStyle } = useThemeStore() + const { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore() + + const [tab, setTab] = useState('nodes') + const [selection, setSelection] = useState(null) + const [draft, setDraft] = useState(() => ({ + nodes: { ...customStyle.nodes }, + edges: { ...customStyle.edges }, + })) + + const handleOpen = (isOpen: boolean) => { + if (isOpen) { + // Reset draft to current saved customStyle on open + setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } }) + setSelection(null) + } else { + onClose() + } + } + + const getNodeStyle = (t: NodeType): NodeTypeStyle => + draft.nodes[t] ?? defaultNodeStyle(t) + + const getEdgeStyle = (t: EdgeType): EdgeTypeStyle => + draft.edges[t] ?? defaultEdgeStyle(t) + + const handleNodeChange = (t: NodeType, s: NodeTypeStyle) => + setDraft((d) => ({ ...d, nodes: { ...d.nodes, [t]: s } })) + + const handleEdgeChange = (t: EdgeType, s: EdgeTypeStyle) => + setDraft((d) => ({ ...d, edges: { ...d.edges, [t]: s } })) + + const handleApplyNodeType = (t: NodeType) => { + const style = getNodeStyle(t) + applyTypeNodeStyle(t, style) + toast.success(`Applied style to all ${NODE_TYPE_LABELS[t]} nodes`) + } + + const handleApplyEdgeType = (t: EdgeType) => { + const style = getEdgeStyle(t) + applyTypeEdgeStyle(t, style) + toast.success(`Applied style to all ${EDGE_TYPE_LABELS[t]} edges`) + } + + const handleSave = () => { + setCustomStyle(draft) + markUnsaved() + toast.success('Custom style saved — save your canvas to persist') + onClose() + } + + const handleApplyAll = () => { + setCustomStyle(draft) + applyAllCustomStyles(draft) + markUnsaved() + toast.success('Custom style applied to all nodes and edges') + onClose() + } + + const selectedNodeStyle = selection?.kind === 'node' ? getNodeStyle(selection.type) : null + const selectedEdgeStyle = selection?.kind === 'edge' ? getEdgeStyle(selection.type) : null + + return ( + + + + Custom Style Editor + + +
+ {/* Left panel — type list */} +
+ {/* Tabs */} +
+ {(['nodes', 'edges'] as Tab[]).map((t) => ( + + ))} +
+ + {/* Type list */} +
+ {tab === 'nodes' && EDITABLE_NODE_TYPES.map((t) => { + const Icon = NODE_ICONS[t] ?? Circle + const style = draft.nodes[t] + const isSelected = selection?.kind === 'node' && selection.type === t + const swatchColor = style + ? applyOpacity(style.borderColor, style.borderOpacity) + : THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e' + + return ( + + ) + })} + + {tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => { + const style = draft.edges[t] + const isSelected = selection?.kind === 'edge' && selection.type === t + const swatchColor = style + ? applyOpacity(style.color, style.opacity) + : THEMES.default.colors.edgeColors[t] + + return ( + + ) + })} +
+
+ + {/* Right panel — editor */} +
+ {!selection && ( +
+ Select a {tab === 'nodes' ? 'node type' : 'edge type'} from the list to edit its style +
+ )} + + {selection?.kind === 'node' && selectedNodeStyle && ( + handleNodeChange(selection.type, s)} + onApplyToExisting={() => handleApplyNodeType(selection.type)} + /> + )} + + {selection?.kind === 'edge' && selectedEdgeStyle && ( + handleEdgeChange(selection.type, s)} + onApplyToExisting={() => handleApplyEdgeType(selection.type)} + /> + )} +
+
+ + {/* Footer */} +
+ +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/modals/ThemeModal.tsx b/frontend/src/components/modals/ThemeModal.tsx index 27aa58c..3100f27 100644 --- a/frontend/src/components/modals/ThemeModal.tsx +++ b/frontend/src/components/modals/ThemeModal.tsx @@ -1,11 +1,12 @@ import { useRef, useState, type KeyboardEvent } from 'react' import { toast } from 'sonner' -import { Check } from 'lucide-react' +import { Check, Pencil } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes' import { useThemeStore } from '@/stores/themeStore' import { useCanvasStore } from '@/stores/canvasStore' +import { CustomStyleModal } from './CustomStyleModal' // Node-type accent colors to display as preview swatches const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const @@ -16,76 +17,106 @@ interface ThemeCardProps { onClick: () => void onKeyDown?: (event: KeyboardEvent) => void buttonRef?: (element: HTMLButtonElement | null) => void + onEdit?: () => void } -function ThemeCard({ themeId, selected, onClick, onKeyDown, buttonRef }: ThemeCardProps) { +function ThemeCard({ themeId, selected, onClick, onKeyDown, buttonRef, onEdit }: ThemeCardProps) { + const { customStyle } = useThemeStore() const preset = THEMES[themeId] const c = preset.colors + const isCustom = themeId === 'custom' + + // For custom theme, use defined node colors for preview swatches + const swatchColors = isCustom + ? PREVIEW_TYPES.map((t) => customStyle.nodes[t]?.borderColor ?? c.nodeAccents[t].border) + : PREVIEW_TYPES.map((t) => c.nodeAccents[t].border) + + const ethernetColor = isCustom + ? (customStyle.edges['ethernet']?.color ?? c.edgeColors.ethernet) + : c.edgeColors.ethernet + const wifiColor = isCustom + ? (customStyle.edges['wifi']?.color ?? c.edgeColors.wifi) + : c.edgeColors.wifi return ( - +
+ {preset.label} +
+
+ {preset.description} +
+ + + {/* Edit button — only for custom theme */} + {isCustom && onEdit && ( + + )} +
) } @@ -98,6 +129,7 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) { const { activeTheme, setTheme } = useThemeStore() const { markUnsaved } = useCanvasStore() const cardRefs = useRef>([]) + const [customStyleOpen, setCustomStyleOpen] = useState(false) // Capture the theme that was active when the modal opened const [originalTheme] = useState(activeTheme) @@ -105,7 +137,6 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) { const handleSelect = (id: ThemeId) => { setSelected(id) - // Live-preview the selected theme on the canvas setTheme(id) } @@ -137,65 +168,65 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) { setTheme(selected) markUnsaved() onClose() - toast.info('Style applied — save your canvas to make it permanent', { - duration: 5000, - }) + toast.info('Style applied — save your canvas to make it permanent', { duration: 5000 }) } const handleCancel = () => { - // Revert to the original theme setTheme(originalTheme) onClose() } return ( - { if (!o) handleCancel() }}> - - - Choose Canvas Style - + <> + { if (!o) handleCancel() }}> + + + Choose Canvas Style + -
- {THEME_ORDER.map((id, index) => ( -
- handleSelect(id)} - onKeyDown={handleCardKeyDown(index)} - buttonRef={(element) => { - cardRefs.current[index] = element - }} - /> -
- ))} -
+
+ {THEME_ORDER.map((id, index) => ( +
+ handleSelect(id)} + onKeyDown={handleCardKeyDown(index)} + buttonRef={(element) => { cardRefs.current[index] = element }} + onEdit={id === 'custom' ? () => setCustomStyleOpen(true) : undefined} + /> +
+ ))} +
-
- - -
-
-
+
+ + +
+
+
+ + setCustomStyleOpen(false)} /> + ) } diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index 5e8c9ff..893ccc9 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -722,3 +722,98 @@ describe('canvasStore', () => { expect(updated?.sourceHandle).toBe('bottom') }) }) + +describe('canvasStore — custom style apply', () => { + beforeEach(() => { + useCanvasStore.setState({ + nodes: [], + edges: [], + hasUnsavedChanges: false, + selectedNodeId: null, + selectedNodeIds: [], + editingGroupRectId: null, + past: [], + future: [], + clipboard: [], + }) + }) + + const serverStyle = { + borderColor: '#ff0000', + borderOpacity: 1, + bgColor: '#111111', + bgOpacity: 1, + iconColor: '#ff0000', + iconOpacity: 1, + width: 220, + height: 90, + } + + it('applyTypeNodeStyle updates matching nodes custom_colors', () => { + useCanvasStore.setState({ + nodes: [makeNode('n1', { type: 'server' }), makeNode('n2', { type: 'proxmox' })], + edges: [], + }) + useCanvasStore.getState().applyTypeNodeStyle('server', serverStyle) + + const n1 = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')! + const n2 = useCanvasStore.getState().nodes.find((n) => n.id === 'n2')! + expect(n1.data.custom_colors?.border).toBe('#ff0000') + expect(n1.width).toBe(220) + expect(n1.height).toBe(90) + expect(n2.data.custom_colors?.border).toBeUndefined() + }) + + it('applyTypeNodeStyle with opacity < 1 produces rgba', () => { + useCanvasStore.setState({ nodes: [makeNode('n1', { type: 'server' })], edges: [] }) + useCanvasStore.getState().applyTypeNodeStyle('server', { ...serverStyle, borderOpacity: 0.5 }) + + const n1 = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')! + expect(n1.data.custom_colors?.border).toMatch(/^rgba\(/) + }) + + it('applyTypeNodeStyle marks canvas unsaved', () => { + useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [] }) + useCanvasStore.getState().applyTypeNodeStyle('server', serverStyle) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + }) + + it('applyTypeEdgeStyle updates matching edges', () => { + const e1: Edge = { id: 'e1', source: 'n1', target: 'n2', type: 'ethernet', data: { type: 'ethernet' } } + const e2: Edge = { id: 'e2', source: 'n1', target: 'n2', type: 'wifi', data: { type: 'wifi' } } + useCanvasStore.setState({ nodes: [], edges: [e1, e2] }) + + useCanvasStore.getState().applyTypeEdgeStyle('ethernet', { color: '#00ff00', opacity: 1, pathStyle: 'smooth', animated: 'flow' }) + + const updated1 = useCanvasStore.getState().edges.find((e) => e.id === 'e1')! + const updated2 = useCanvasStore.getState().edges.find((e) => e.id === 'e2')! + expect(updated1.data?.custom_color).toBe('#00ff00') + expect(updated1.data?.path_style).toBe('smooth') + expect(updated1.data?.animated).toBe('flow') + expect(updated2.data?.custom_color).toBeUndefined() + }) + + it('applyAllCustomStyles applies all defined types', () => { + const proxmoxNode = makeNode('np', { type: 'proxmox' }) + const serverNode = makeNode('ns', { type: 'server' }) + const e1: Edge = { id: 'e1', source: 'np', target: 'ns', type: 'ethernet', data: { type: 'ethernet' } } + useCanvasStore.setState({ nodes: [proxmoxNode, serverNode], edges: [e1] }) + + useCanvasStore.getState().applyAllCustomStyles({ + nodes: { + proxmox: { borderColor: '#ff6e00', borderOpacity: 1, bgColor: '#111', bgOpacity: 1, iconColor: '#ff6e00', iconOpacity: 1, width: 0, height: 0 }, + }, + edges: { + ethernet: { color: '#aabbcc', opacity: 1, pathStyle: 'bezier', animated: 'none' }, + }, + }) + + const np = useCanvasStore.getState().nodes.find((n) => n.id === 'np')! + const ns = useCanvasStore.getState().nodes.find((n) => n.id === 'ns')! + const e = useCanvasStore.getState().edges.find((e) => e.id === 'e1')! + expect(np.data.custom_colors?.border).toBe('#ff6e00') + expect(ns.data.custom_colors?.border).toBeUndefined() + expect(e.data?.custom_color).toBe('#aabbcc') + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + }) +}) diff --git a/frontend/src/stores/__tests__/themeStore.test.ts b/frontend/src/stores/__tests__/themeStore.test.ts index a8639de..ee07407 100644 --- a/frontend/src/stores/__tests__/themeStore.test.ts +++ b/frontend/src/stores/__tests__/themeStore.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, beforeEach } from 'vitest' import { useThemeStore } from '@/stores/themeStore' +import type { CustomStyleDef } from '@/types' describe('themeStore', () => { beforeEach(() => { - useThemeStore.setState({ activeTheme: 'default' }) + useThemeStore.setState({ activeTheme: 'default', customStyle: { nodes: {}, edges: {} } }) }) it('starts with default theme', () => { @@ -15,8 +16,8 @@ describe('themeStore', () => { expect(useThemeStore.getState().activeTheme).toBe('matrix') }) - it('setTheme can switch between all presets', () => { - const themes = ['default', 'dark', 'light', 'neon', 'matrix'] as const + it('setTheme can switch between all presets including custom', () => { + const themes = ['default', 'dark', 'light', 'neon', 'matrix', 'custom'] as const for (const id of themes) { useThemeStore.getState().setTheme(id) expect(useThemeStore.getState().activeTheme).toBe(id) @@ -28,4 +29,26 @@ describe('themeStore', () => { useThemeStore.getState().setTheme('default') expect(useThemeStore.getState().activeTheme).toBe('default') }) + + it('starts with empty customStyle', () => { + const { customStyle } = useThemeStore.getState() + expect(customStyle.nodes).toEqual({}) + expect(customStyle.edges).toEqual({}) + }) + + it('setCustomStyle replaces the entire definition', () => { + const def: CustomStyleDef = { + nodes: { server: { borderColor: '#ff0000', borderOpacity: 1, bgColor: '#000000', bgOpacity: 1, iconColor: '#ff0000', iconOpacity: 1, width: 200, height: 80 } }, + edges: { ethernet: { color: '#00ff00', opacity: 0.8, pathStyle: 'bezier', animated: 'none' } }, + } + useThemeStore.getState().setCustomStyle(def) + expect(useThemeStore.getState().customStyle.nodes.server?.borderColor).toBe('#ff0000') + expect(useThemeStore.getState().customStyle.edges.ethernet?.color).toBe('#00ff00') + }) + + it('setCustomStyle with empty def clears styles', () => { + useThemeStore.getState().setCustomStyle({ nodes: { server: { borderColor: '#aaa', borderOpacity: 1, bgColor: '#000', bgOpacity: 1, iconColor: '#aaa', iconOpacity: 1, width: 0, height: 0 } }, edges: {} }) + useThemeStore.getState().setCustomStyle({ nodes: {}, edges: {} }) + expect(useThemeStore.getState().customStyle.nodes).toEqual({}) + }) }) diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index 5e1c043..f1af04a 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -9,9 +9,10 @@ import { applyEdgeChanges, addEdge, } from '@xyflow/react' -import type { NodeData, EdgeData } from '@/types' +import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef } from '@/types' import { generateUUID } from '@/utils/uuid' import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' +import { applyOpacity } from '@/utils/colorUtils' type HistoryEntry = { nodes: Node[]; edges: Edge[] } @@ -58,6 +59,9 @@ interface CanvasState { notifyScanDeviceFound: () => void hideIp: boolean toggleHideIp: () => void + applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void + applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void + applyAllCustomStyles: (def: CustomStyleDef) => void } export const useCanvasStore = create((set) => ({ @@ -468,4 +472,82 @@ export const useCanvasStore = create((set) => ({ }, clearFitViewPending: () => set({ fitViewPending: false }), + + applyTypeNodeStyle: (nodeType, style) => + set((state) => ({ + nodes: state.nodes.map((n) => { + if (n.data.type !== nodeType) return n + return { + ...n, + width: style.width > 0 ? style.width : n.width, + height: style.height > 0 ? style.height : n.height, + data: { + ...n.data, + custom_colors: { + ...n.data.custom_colors, + border: applyOpacity(style.borderColor, style.borderOpacity), + background: applyOpacity(style.bgColor, style.bgOpacity), + icon: applyOpacity(style.iconColor, style.iconOpacity), + }, + }, + } + }), + hasUnsavedChanges: true, + })), + + applyTypeEdgeStyle: (edgeType, style) => + set((state) => ({ + edges: state.edges.map((e) => { + if ((e.data?.type ?? 'ethernet') !== edgeType) return e + return { + ...e, + data: { + ...e.data, + type: edgeType, + custom_color: applyOpacity(style.color, style.opacity), + path_style: style.pathStyle, + animated: style.animated, + } as EdgeData, + } + }), + hasUnsavedChanges: true, + })), + + applyAllCustomStyles: (def) => + set((state) => { + const nodes = state.nodes.map((n) => { + const style = def.nodes[n.data.type] + if (!style) return n + return { + ...n, + width: style.width > 0 ? style.width : n.width, + height: style.height > 0 ? style.height : n.height, + data: { + ...n.data, + custom_colors: { + ...n.data.custom_colors, + border: applyOpacity(style.borderColor, style.borderOpacity), + background: applyOpacity(style.bgColor, style.bgOpacity), + icon: applyOpacity(style.iconColor, style.iconOpacity), + }, + }, + } + }) + const edges = state.edges.map((e) => { + const edgeType = (e.data?.type ?? 'ethernet') as EdgeType + const style = def.edges[edgeType] + if (!style) return e + return { + ...e, + data: { + ...e.data, + type: edgeType, + custom_color: applyOpacity(style.color, style.opacity), + path_style: style.pathStyle, + animated: style.animated, + } as EdgeData, + } + }) + return { nodes, edges, hasUnsavedChanges: true } + }), })) diff --git a/frontend/src/stores/themeStore.ts b/frontend/src/stores/themeStore.ts index edf4727..3e4e4dd 100644 --- a/frontend/src/stores/themeStore.ts +++ b/frontend/src/stores/themeStore.ts @@ -1,12 +1,17 @@ import { create } from 'zustand' import type { ThemeId } from '@/utils/themes' +import type { CustomStyleDef } from '@/types' interface ThemeState { activeTheme: ThemeId setTheme: (id: ThemeId) => void + customStyle: CustomStyleDef + setCustomStyle: (def: CustomStyleDef) => void } export const useThemeStore = create((set) => ({ activeTheme: 'default', setTheme: (id) => set({ activeTheme: id }), + customStyle: { nodes: {}, edges: {} }, + setCustomStyle: (def) => set({ customStyle: def }), })) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b4e0f59..b37ca34 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -150,3 +150,26 @@ export const EDGE_TYPE_LABELS: Record = { virtual: 'Virtual', cluster: 'Cluster', } + +export interface NodeTypeStyle { + borderColor: string + borderOpacity: number + bgColor: string + bgOpacity: number + iconColor: string + iconOpacity: number + width: number + height: number +} + +export interface EdgeTypeStyle { + color: string + opacity: number + pathStyle: EdgePathStyle + animated: 'none' | 'snake' | 'flow' | 'basic' +} + +export interface CustomStyleDef { + nodes: Partial> + edges: Partial> +} diff --git a/frontend/src/utils/colorUtils.ts b/frontend/src/utils/colorUtils.ts index 0e62436..dd6c8ea 100644 --- a/frontend/src/utils/colorUtils.ts +++ b/frontend/src/utils/colorUtils.ts @@ -27,3 +27,17 @@ export function rgbaToHex8(hex6: string, alpha: number): string { const alphaHex = alphaByte.toString(16).padStart(2, '0') return `${hex6}${alphaHex}` } + +/** + * Combine a hex color and opacity (0–1) into a CSS rgba() string. + * Returns the plain hex when opacity is 1. + */ +export function applyOpacity(hex: string, opacity: number): string { + if (opacity >= 1) return hex + let h = hex.replace('#', '') + if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2] + const r = parseInt(h.slice(0, 2), 16) + const g = parseInt(h.slice(2, 4), 16) + const b = parseInt(h.slice(4, 6), 16) + return `rgba(${r}, ${g}, ${b}, ${Math.round(opacity * 100) / 100})` +} diff --git a/frontend/src/utils/themes.ts b/frontend/src/utils/themes.ts index 7f1be3c..583aa29 100644 --- a/frontend/src/utils/themes.ts +++ b/frontend/src/utils/themes.ts @@ -1,6 +1,6 @@ import type { NodeType, EdgeType, NodeStatus } from '@/types' -export type ThemeId = 'default' | 'dark' | 'light' | 'neon' | 'matrix' +export type ThemeId = 'default' | 'dark' | 'light' | 'neon' | 'matrix' | 'custom' export interface ThemeColors { // Per node-type accent (border + icon) @@ -315,7 +315,63 @@ export const THEMES: Record = { reactFlowColorMode: 'dark', }, }, + + custom: { + id: 'custom', + label: 'Custom', + description: 'Your own colors per node and edge type', + colors: { + nodeAccents: { + isp: { border: '#00d4ff', icon: '#00d4ff' }, + router: { border: '#00d4ff', icon: '#00d4ff' }, + switch: { border: '#39d353', icon: '#39d353' }, + server: { border: '#a855f7', icon: '#a855f7' }, + proxmox: { border: '#ff6e00', icon: '#ff6e00' }, + vm: { border: '#a855f7', icon: '#a855f7' }, + lxc: { border: '#00d4ff', icon: '#00d4ff' }, + nas: { border: '#39d353', icon: '#39d353' }, + iot: { border: '#e3b341', icon: '#e3b341' }, + ap: { border: '#00d4ff', icon: '#00d4ff' }, + camera: { border: '#8b949e', icon: '#8b949e' }, + printer: { border: '#8b949e', icon: '#8b949e' }, + computer: { border: '#a855f7', icon: '#a855f7' }, + cpl: { border: '#e3b341', icon: '#e3b341' }, + docker_host: { border: '#2496ED', icon: '#2496ED' }, + docker_container: { border: '#0ea5e9', icon: '#0ea5e9' }, + generic: { border: '#8b949e', icon: '#8b949e' }, + groupRect: { border: '#00d4ff', icon: '#00d4ff' }, + group: { border: '#00d4ff', icon: '#00d4ff' }, + }, + nodeCardBackground: '#21262d', + nodeIconBackground: '#161b22', + nodeLabelColor: '#e6edf3', + nodeSubtextColor: '#8b949e', + statusColors: { + online: '#39d353', + offline: '#f85149', + pending: '#e3b341', + unknown: '#8b949e', + }, + edgeColors: { + ethernet: '#30363d', + wifi: '#00d4ff', + iot: '#e3b341', + vlan: '#00d4ff', + virtual: '#8b949e', + cluster: '#ff6e00', + }, + edgeSelectedColor: '#00d4ff', + edgeLabelBackground:'#161b22', + edgeLabelColor: '#8b949e', + edgeLabelBorder: '#30363d', + canvasBackground: '#0d1117', + canvasDotColor: '#30363d', + handleBackground: '#30363d', + handleBorder: '#8b949e', + reactFlowColorMode: 'dark', + }, + }, } // Ordered list for display in the modal -export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix'] +export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix', 'custom'] From 31b61904ac147803ec27755f8ce4879a2d2784e5 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 24 Apr 2026 02:17:03 +0200 Subject: [PATCH 08/11] fix: add setCustomStyle to useEffect dependency array --- frontend/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fe8a255..477f2e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -119,7 +119,7 @@ export default function App() { } }) .catch(() => loadCanvas(demoNodes, demoEdges)) - }, [isAuthenticated, loadCanvas, setTheme]) + }, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // Keep refs for store actions so keydown handler is always up-to-date without re-registering const undoRef = useRef(undo) From 84235d81bf17a0e5845ffc9a9a6feb7422a19c56 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 24 Apr 2026 10:35:19 +0200 Subject: [PATCH 09/11] fix: show edge type label in select trigger after selection --- frontend/src/components/modals/EdgeModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/modals/EdgeModal.tsx b/frontend/src/components/modals/EdgeModal.tsx index 664b12d..e254dca 100644 --- a/frontend/src/components/modals/EdgeModal.tsx +++ b/frontend/src/components/modals/EdgeModal.tsx @@ -69,7 +69,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,