diff --git a/frontend/src/components/modals/SettingsModal.tsx b/frontend/src/components/modals/SettingsModal.tsx index e3cba24..5804b34 100644 --- a/frontend/src/components/modals/SettingsModal.tsx +++ b/frontend/src/components/modals/SettingsModal.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { settingsApi } from '@/api/client' +import { useCanvasStore } from '@/stores/canvasStore' import { toast } from 'sonner' import { type AlignmentSettings, @@ -10,6 +11,8 @@ import { subscribeAlignmentSettings, } from '@/utils/alignmentSettings' +const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' + interface SettingsModalProps { open: boolean onClose: () => void @@ -19,9 +22,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { const [interval, setIntervalValue] = useState(60) const [saving, setSaving] = useState(false) const [alignment, setAlignment] = useState(readAlignmentSettings) + const hideIp = useCanvasStore((s) => s.hideIp) + const setHideIp = useCanvasStore((s) => s.setHideIp) useEffect(() => { - if (!open) return + if (!open || STANDALONE) return settingsApi.get() .then((res) => setIntervalValue(res.data.interval_seconds)) .catch(() => {/* use default */}) @@ -36,6 +41,12 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { } const handleSave = async () => { + // Canvas prefs (alignment, hide-IP) persist on change; only the backend + // status-check interval needs an API round-trip. + if (STANDALONE) { + onClose() + return + } setSaving(true) try { await settingsApi.save({ interval_seconds: interval }) @@ -57,6 +68,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
{/* Status checker */} + {!STANDALONE && (
@@ -74,6 +86,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { How often node health is polled (ping, HTTP, SSH…)

+ )} {/* Canvas */}
@@ -90,6 +103,17 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { /> + +
diff --git a/frontend/src/components/modals/__tests__/SettingsModal.test.tsx b/frontend/src/components/modals/__tests__/SettingsModal.test.tsx index 40763a8..7da48d8 100644 --- a/frontend/src/components/modals/__tests__/SettingsModal.test.tsx +++ b/frontend/src/components/modals/__tests__/SettingsModal.test.tsx @@ -12,6 +12,7 @@ vi.mock('@/api/client', () => ({ import { settingsApi } from '@/api/client' import { toast } from 'sonner' +import { useCanvasStore } from '@/stores/canvasStore' describe('SettingsModal', () => { beforeEach(() => { @@ -64,6 +65,17 @@ describe('SettingsModal', () => { expect(onClose).not.toHaveBeenCalled() }) + it('reflects and persists the hide-IP preference', async () => { + useCanvasStore.setState({ hideIp: false }) + localStorage.removeItem('homelable.hideIp') + render() + const checkbox = screen.getByLabelText('Toggle IP address masking') as HTMLInputElement + expect(checkbox.checked).toBe(false) + fireEvent.click(checkbox) + expect(useCanvasStore.getState().hideIp).toBe(true) + expect(localStorage.getItem('homelable.hideIp')).toBe('true') + }) + it('calls onClose on Cancel', async () => { const onClose = vi.fn() render() diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 74f8ed8..1155f7f 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react' +import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react' import { Logo } from '@/components/ui/Logo' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' @@ -90,7 +90,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee } } - const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore() + const { nodes, hasUnsavedChanges } = useCanvasStore() const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text') const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length @@ -253,13 +253,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee {!STANDALONE && } {!STANDALONE && } - - {!STANDALONE && ( - - )} + {!STANDALONE && ( > = {}) { vi.mocked(useCanvasStore).mockReturnValue({ nodes: [], hasUnsavedChanges: false, - hideIp: false, - toggleHideIp: mockToggleHideIp, addNode: vi.fn(), scanEventTs: 0, ...overrides, @@ -191,16 +188,10 @@ describe('Sidebar', () => { expect(defaultProps.onSave).toHaveBeenCalledOnce() }) - it('calls toggleHideIp when Hide IPs is clicked', () => { + it('calls onOpenSettings when Settings is clicked', () => { render() - fireEvent.click(screen.getByText('Hide IPs')) - expect(mockToggleHideIp).toHaveBeenCalledOnce() - }) - - it('shows Show IPs label when hideIp is true', () => { - mockStore({ hideIp: true }) - render() - expect(screen.getByText('Show IPs')).toBeInTheDocument() + fireEvent.click(screen.getByText('Settings')) + expect(defaultProps.onOpenSettings).toHaveBeenCalledOnce() }) // ── Unsaved changes badge ────────────────────────────────────────────────── diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index bd4c5d8..370b00a 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -800,6 +800,26 @@ describe('canvasStore', () => { expect(useCanvasStore.getState().nodes).toHaveLength(1) }) + // --- Hide IP preference (persisted to localStorage) --- + + it('toggleHideIp flips the flag and persists it', () => { + localStorage.removeItem('homelable.hideIp') + useCanvasStore.setState({ hideIp: false }) + useCanvasStore.getState().toggleHideIp() + expect(useCanvasStore.getState().hideIp).toBe(true) + expect(localStorage.getItem('homelable.hideIp')).toBe('true') + useCanvasStore.getState().toggleHideIp() + expect(useCanvasStore.getState().hideIp).toBe(false) + expect(localStorage.getItem('homelable.hideIp')).toBe('false') + }) + + it('setHideIp sets the flag and persists it', () => { + localStorage.removeItem('homelable.hideIp') + useCanvasStore.getState().setHideIp(true) + expect(useCanvasStore.getState().hideIp).toBe(true) + expect(localStorage.getItem('homelable.hideIp')).toBe('true') + }) + // --- Node resizing (width / height) --- it('addNode preserves explicit width and height', () => { diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index a58c08a..9b0f8eb 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -13,6 +13,7 @@ import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeSty import { generateUUID } from '@/utils/uuid' import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' import { applyOpacity } from '@/utils/colorUtils' +import { readHideIp, writeHideIp } from '@/utils/ipDisplay' type HistoryEntry = { nodes: Node[]; edges: Edge[] } type Clipboard = { nodes: Node[]; edges: Edge[] } @@ -69,6 +70,7 @@ interface CanvasState { notifyScanDeviceFound: () => void hideIp: boolean toggleHideIp: () => void + setHideIp: (value: boolean) => void applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void applyAllCustomStyles: (def: CustomStyleDef) => void @@ -82,7 +84,7 @@ export const useCanvasStore = create((set) => ({ selectedNodeIds: [], editingGroupRectId: null, editingTextId: null, - hideIp: false, + hideIp: readHideIp(), scanEventTs: 0, fitViewPending: false, @@ -579,7 +581,16 @@ export const useCanvasStore = create((set) => ({ notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }), - toggleHideIp: () => set((s) => ({ hideIp: !s.hideIp })), + toggleHideIp: () => set((s) => { + const hideIp = !s.hideIp + writeHideIp(hideIp) + return { hideIp } + }), + + setHideIp: (value) => { + writeHideIp(value) + set({ hideIp: value }) + }, loadCanvas: (nodes, edges) => { // React Flow requires parents before children in the array diff --git a/frontend/src/utils/__tests__/ipDisplay.test.ts b/frontend/src/utils/__tests__/ipDisplay.test.ts new file mode 100644 index 0000000..5c17527 --- /dev/null +++ b/frontend/src/utils/__tests__/ipDisplay.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { readHideIp, writeHideIp } from '@/utils/ipDisplay' + +describe('ipDisplay persistence', () => { + beforeEach(() => localStorage.clear()) + + it('defaults to false when nothing is stored', () => { + expect(readHideIp()).toBe(false) + }) + + it('round-trips true', () => { + writeHideIp(true) + expect(localStorage.getItem('homelable.hideIp')).toBe('true') + expect(readHideIp()).toBe(true) + }) + + it('round-trips false', () => { + writeHideIp(true) + writeHideIp(false) + expect(readHideIp()).toBe(false) + }) +}) diff --git a/frontend/src/utils/ipDisplay.ts b/frontend/src/utils/ipDisplay.ts new file mode 100644 index 0000000..02ab40d --- /dev/null +++ b/frontend/src/utils/ipDisplay.ts @@ -0,0 +1,21 @@ +// Persisted client-side preference for masking IP addresses on the canvas. +// Kept in localStorage (per-user UI preference, not canvas data) so it +// survives a page reload. + +const KEY = 'homelable.hideIp' + +export function readHideIp(): boolean { + try { + return localStorage.getItem(KEY) === 'true' + } catch { + return false + } +} + +export function writeHideIp(value: boolean): void { + try { + localStorage.setItem(KEY, String(value)) + } catch { + /* quota / SSR */ + } +}