From b0a67744f5aab6c00f2d6ff9cf7d8a87f243699d Mon Sep 17 00:00:00 2001 From: findthelorax Date: Fri, 17 Apr 2026 22:34:17 -0400 Subject: [PATCH 01/26] bug: fixed to allow draging from the titlebar --- .../canvas/__tests__/GroupNode.test.tsx | 15 ++++++++++++++- .../src/components/canvas/nodes/GroupNode.tsx | 7 ++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/canvas/__tests__/GroupNode.test.tsx b/frontend/src/components/canvas/__tests__/GroupNode.test.tsx index 217cc68..f99ffae 100644 --- a/frontend/src/components/canvas/__tests__/GroupNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/GroupNode.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { GroupNode } from '../nodes/GroupNode' import * as canvasStore from '@/stores/canvasStore' import type { Node } from '@xyflow/react' @@ -102,6 +102,19 @@ describe('GroupNode', () => { expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('true') }) + it('allows dragging from the header while keeping rename controls nodrag', () => { + renderGroupNode({ selected: true }) + + expect(screen.getByText('My Group').closest('div')).not.toHaveClass('nodrag') + + const renameButton = screen.getByTitle('Rename group') + expect(renameButton).toHaveClass('nodrag') + + fireEvent.click(renameButton) + + expect(screen.getByDisplayValue('My Group')).toHaveClass('nodrag') + }) + it('shows online/offline status summary from children', () => { const storeNodes = [ { id: 'c1', parentId: 'g1', data: { status: 'online' } }, diff --git a/frontend/src/components/canvas/nodes/GroupNode.tsx b/frontend/src/components/canvas/nodes/GroupNode.tsx index 983a730..e8396b6 100644 --- a/frontend/src/components/canvas/nodes/GroupNode.tsx +++ b/frontend/src/components/canvas/nodes/GroupNode.tsx @@ -66,13 +66,13 @@ export function GroupNode({ id, data, selected }: NodeProps>) { borderBottom: isVisible ? `1px solid ${borderColor}40` : 'none', pointerEvents: 'auto', }} - className="nodrag" > {editing ? ( setLabelDraft(e.target.value)} onKeyDown={(e) => { @@ -97,11 +97,12 @@ export function GroupNode({ id, data, selected }: NodeProps>) { {editing ? ( <> - - + + ) : ( + + + )} {loading && } {!loading && devices.length === 0 && (

No pending devices

@@ -288,10 +379,16 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: key={d.id} ref={isHighlighted ? highlightRef : null} onClick={() => setSelected(d)} - className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`} + className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : checkedIds.has(d.id) ? 'bg-[#21262d] border-[#00d4ff]/40' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`} >
- + toggleCheck(d.id, e)} + onChange={() => {}} + className="w-3 h-3 accent-[#00d4ff] cursor-pointer shrink-0" + /> {title}
{showIpBelow && ( diff --git a/frontend/src/components/panels/__tests__/Sidebar.test.tsx b/frontend/src/components/panels/__tests__/Sidebar.test.tsx index ddfbbbb..a63d0ed 100644 --- a/frontend/src/components/panels/__tests__/Sidebar.test.tsx +++ b/frontend/src/components/panels/__tests__/Sidebar.test.tsx @@ -9,6 +9,9 @@ import type { NodeData } from '@/types' vi.mock('@/stores/canvasStore') +const mockBulkApprove = vi.fn() +const mockBulkHide = vi.fn() + vi.mock('@/api/client', () => ({ scanApi: { trigger: vi.fn().mockResolvedValue({}), @@ -16,6 +19,12 @@ vi.mock('@/api/client', () => ({ hidden: vi.fn().mockResolvedValue({ data: [] }), runs: vi.fn().mockResolvedValue({ data: [] }), stop: vi.fn().mockResolvedValue({}), + clearPending: vi.fn().mockResolvedValue({}), + approve: vi.fn().mockResolvedValue({ data: { approved: true, node_id: 'new-node-1' } }), + hide: vi.fn().mockResolvedValue({ data: { hidden: true } }), + ignore: vi.fn().mockResolvedValue({ data: { ignored: true } }), + bulkApprove: (...args: unknown[]) => mockBulkApprove(...args), + bulkHide: (...args: unknown[]) => mockBulkHide(...args), }, settingsApi: { get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }), @@ -259,3 +268,100 @@ describe('Sidebar', () => { expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument() }) }) + +// ── PendingDevicesPanel — bulk select ───────────────────────────────────────── + +const DEVICE_A = { + id: 'dev-a', + ip: '192.168.1.10', + hostname: 'host-a', + mac: null, + os: null, + services: [], + suggested_type: 'generic', + status: 'pending', + discovery_source: 'arp', +} + +const DEVICE_B = { + id: 'dev-b', + ip: '192.168.1.11', + hostname: 'host-b', + mac: null, + os: null, + services: [], + suggested_type: 'generic', + status: 'pending', + discovery_source: 'arp', +} + +describe('PendingDevicesPanel — bulk select', () => { + beforeEach(() => { + mockStore() + vi.clearAllMocks() + mockBulkApprove.mockResolvedValue({ + data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], skipped: 0 }, + }) + mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } }) + }) + + async function renderWithDevices() { + const { scanApi } = await import('@/api/client') + vi.mocked(scanApi.pending).mockResolvedValue({ data: [DEVICE_A, DEVICE_B] } as never) + render() + await waitFor(() => expect(screen.getByText('host-a')).toBeInTheDocument()) + } + + it('renders checkboxes for each device', async () => { + await renderWithDevices() + const checkboxes = screen.getAllByRole('checkbox') + // select-all + 2 device checkboxes + expect(checkboxes.length).toBe(3) + }) + + it('shows bulk action bar when a device is checked', async () => { + await renderWithDevices() + const [, firstDeviceCheckbox] = screen.getAllByRole('checkbox') + fireEvent.click(firstDeviceCheckbox) + await waitFor(() => expect(screen.getByText(/Approve \(1\)/)).toBeInTheDocument()) + expect(screen.getByText(/Hide \(1\)/)).toBeInTheDocument() + }) + + it('hides bulk action bar when no device is checked', async () => { + await renderWithDevices() + expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument() + }) + + it('select-all checks all devices', async () => { + await renderWithDevices() + const [selectAll] = screen.getAllByRole('checkbox') + fireEvent.click(selectAll) + await waitFor(() => expect(screen.getByText(/Approve \(2\)/)).toBeInTheDocument()) + }) + + it('select-all unchecks all when all are selected', async () => { + await renderWithDevices() + const [selectAll] = screen.getAllByRole('checkbox') + fireEvent.click(selectAll) // select all + fireEvent.click(selectAll) // deselect all + await waitFor(() => expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument()) + }) + + it('calls bulkApprove with checked ids and removes devices from list', async () => { + await renderWithDevices() + const [selectAll] = screen.getAllByRole('checkbox') + fireEvent.click(selectAll) + fireEvent.click(screen.getByText(/Approve \(2\)/)) + await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b'])) + await waitFor(() => expect(screen.queryByText('host-a')).not.toBeInTheDocument()) + }) + + it('calls bulkHide with checked ids and removes devices from list', async () => { + await renderWithDevices() + const [selectAll] = screen.getAllByRole('checkbox') + fireEvent.click(selectAll) + fireEvent.click(screen.getByText(/Hide \(2\)/)) + await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a', 'dev-b'])) + await waitFor(() => expect(screen.queryByText('host-b')).not.toBeInTheDocument()) + }) +}) From b5eb8d1b74dd151101979ac74f4f4718027fa42a Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Apr 2026 22:30:50 +0200 Subject: [PATCH 11/26] fix: remove duplicate primaryIp export in maskIp.ts after rebase --- frontend/src/utils/maskIp.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frontend/src/utils/maskIp.ts b/frontend/src/utils/maskIp.ts index dec2969..8dfa9f8 100644 --- a/frontend/src/utils/maskIp.ts +++ b/frontend/src/utils/maskIp.ts @@ -43,8 +43,3 @@ export function splitIps(ip: string): string[] { export function primaryIp(ip: string): string { return splitIps(ip)[0] ?? '' } - -export function primaryIp(ip: string): string { - if (!ip?.trim()) return '' - return ip.split(',')[0].trim() -} From fbfacec6dc3ce11f776ea9906d1da8bd6e140dbd Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Apr 2026 22:58:10 +0200 Subject: [PATCH 12/26] fix: prevent node from expanding beyond resized width on reload --- frontend/src/components/canvas/nodes/BaseNode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 4dae035..3c0f22c 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -43,7 +43,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: return (
Date: Sun, 19 Apr 2026 23:43:15 +0200 Subject: [PATCH 13/26] fix: prevent node width expansion when content overflows after resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxmox nodes with container_mode=false fell through both width conditions in deserializeApiNode and got no explicit width on reload, causing RF to auto-size to content width and ignoring the user's manual resize. - canvasSerializer: unified width restore logic — saved width applies to all node types; proxmox container_mode defaults (300x200) only kick in when no saved width exists - BaseNode: add overflow-hidden + min-w-0 to properties row so truncate actually clips long values instead of expanding the node --- frontend/src/components/canvas/nodes/BaseNode.tsx | 8 ++++---- frontend/src/utils/canvasSerializer.ts | 11 ++++------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 3c0f22c..b114055 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -77,7 +77,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: {/* Main row */} -
+
{/* Icon */}
0 && ( <>
-
+
{visibleProperties.map((prop) => { const Icon = resolvePropertyIcon(prop.icon) return ( -
+
{Icon && } {prop.key} - · {prop.value} + · {prop.value}
) })} diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts index 68b20d7..a0b0a43 100644 --- a/frontend/src/utils/canvasSerializer.ts +++ b/frontend/src/utils/canvasSerializer.ts @@ -102,8 +102,8 @@ export function serializeNode(n: Node): Record { disk_gb: n.data.disk_gb ?? null, show_hardware: n.data.show_hardware ?? false, properties: n.data.properties ?? [], - width: n.width ?? null, - height: n.height ?? null, + width: n.measured?.width ?? n.width ?? null, + height: n.measured?.height ?? n.height ?? null, bottom_handles: n.data.bottom_handles ?? 1, pos_x: n.position.x, pos_y: n.position.y, @@ -156,11 +156,8 @@ export function deserializeApiNode( position: { x: n.pos_x, y: n.pos_y }, data: n as unknown as NodeData, ...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}), - ...(n.type === 'proxmox' && n.container_mode !== false - ? { width: n.width ?? 300, height: n.height ?? 200 } - : {}), - ...(n.width && n.type !== 'proxmox' ? { width: n.width } : {}), - ...(n.height && n.type !== 'proxmox' ? { height: n.height } : {}), + ...(n.width ? { width: n.width } : n.type === 'proxmox' && n.container_mode !== false ? { width: 300 } : {}), + ...(n.height ? { height: n.height } : n.type === 'proxmox' && n.container_mode !== false ? { height: 200 } : {}), } } From a5bf9c9db61402298558aab668efa397c13a337b Mon Sep 17 00:00:00 2001 From: findthelorax Date: Sun, 19 Apr 2026 21:00:20 -0400 Subject: [PATCH 14/26] feature: added new icons for properties --- frontend/src/utils/__tests__/propertyIcons.test.ts | 7 ++++++- frontend/src/utils/propertyIcons.ts | 10 ++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/frontend/src/utils/__tests__/propertyIcons.test.ts b/frontend/src/utils/__tests__/propertyIcons.test.ts index e2e2ad9..ccdcb98 100644 --- a/frontend/src/utils/__tests__/propertyIcons.test.ts +++ b/frontend/src/utils/__tests__/propertyIcons.test.ts @@ -1,12 +1,17 @@ import { describe, it, expect } from 'vitest' -import { Cpu, HardDrive, MemoryStick } from 'lucide-react' +import { CircuitBoard, Cpu, EthernetPort, Gpu, HardDrive, HdmiPort, MemoryStick, Usb } from 'lucide-react' import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '../propertyIcons' describe('PROPERTY_ICONS', () => { it('contains the hardware migration icons', () => { + expect(PROPERTY_ICONS['CircuitBoard']).toBe(CircuitBoard) expect(PROPERTY_ICONS['Cpu']).toBe(Cpu) + expect(PROPERTY_ICONS['EthernetPort']).toBe(EthernetPort) + expect(PROPERTY_ICONS['Gpu']).toBe(Gpu) expect(PROPERTY_ICONS['HardDrive']).toBe(HardDrive) + expect(PROPERTY_ICONS['HdmiPort']).toBe(HdmiPort) expect(PROPERTY_ICONS['MemoryStick']).toBe(MemoryStick) + expect(PROPERTY_ICONS['Usb']).toBe(Usb) }) it('has at least 10 icons', () => { diff --git a/frontend/src/utils/propertyIcons.ts b/frontend/src/utils/propertyIcons.ts index af24a00..b9826fa 100644 --- a/frontend/src/utils/propertyIcons.ts +++ b/frontend/src/utils/propertyIcons.ts @@ -1,11 +1,15 @@ import { Battery, Box, + CircuitBoard, Clock, Cpu, Database, + EthernetPort, Globe, + Gpu, HardDrive, + HdmiPort, Hash, Key, Layers, @@ -17,6 +21,7 @@ import { Shield, Tag, Thermometer, + Usb, Wifi, Zap, } from 'lucide-react' @@ -25,11 +30,15 @@ import type { LucideIcon } from 'lucide-react' export const PROPERTY_ICONS: Record = { Battery, Box, + CircuitBoard, Clock, Cpu, Database, + EthernetPort, Globe, + Gpu, HardDrive, + HdmiPort, Hash, Key, Layers, @@ -41,6 +50,7 @@ export const PROPERTY_ICONS: Record = { Shield, Tag, Thermometer, + Usb, Wifi, Zap, } From 9dddd008584ee13dbe4790c6204b17a99aafd7f0 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Mon, 20 Apr 2026 00:06:29 -0400 Subject: [PATCH 15/26] fix: resets form data after submission --- frontend/src/components/modals/NodeModal.tsx | 12 +++++++++--- .../modals/__tests__/NodeModal.test.tsx | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index 5ed1070..0895f77 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -1,4 +1,4 @@ -import { createElement, useState } from 'react' +import { createElement, useEffect, useState } from 'react' import { RotateCcw, ChevronDown } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' @@ -42,14 +42,20 @@ interface NodeModalProps { const CHILD_TYPES: NodeType[] = ['vm', 'lxc'] -// 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', proxmoxNodes = [] }: NodeModalProps) { const [form, setForm] = useState>({ ...DEFAULT_DATA, ...initial }) const [iconSearch, setIconSearch] = useState('') const [iconPickerOpen, setIconPickerOpen] = useState(false) const [labelError, setLabelError] = useState(false) + useEffect(() => { + if (!open) return + setForm({ ...DEFAULT_DATA, ...initial }) + setIconSearch('') + setIconPickerOpen(false) + setLabelError(false) + }, [open, initial]) + const set = (key: keyof NodeData, value: unknown) => setForm((f) => ({ ...f, [key]: value })) diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index ffc0c2b..ffe9b2c 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -130,6 +130,21 @@ describe('NodeModal', () => { expect(data.notes).toBe('rack A') }) + it('resets form values when reopened in Add mode', () => { + const onClose = vi.fn() + const onSubmit = vi.fn() + + const { rerender } = render() + fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Temp Node' } }) + fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'temp.local' } }) + + rerender() + rerender() + + expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('') + expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('') + }) + it('submits check_target', () => { const { onSubmit } = renderModal({ initial: BASE }) fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } }) From f6de7d17702276cefb6e22172ba8ece01ebfc302 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Mon, 20 Apr 2026 00:13:07 -0400 Subject: [PATCH 16/26] fix: removed setState within an effect, responsibility moved to parent key --- frontend/src/App.tsx | 1 + frontend/src/components/modals/NodeModal.tsx | 10 +--------- .../src/components/modals/__tests__/NodeModal.test.tsx | 6 +++--- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fdd1b1c..2a2717b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -422,6 +422,7 @@ export default function App() {
setAddNodeOpen(false)} onSubmit={handleAddNode} diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index 0895f77..5caa1cd 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -1,4 +1,4 @@ -import { createElement, useEffect, useState } from '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' @@ -48,14 +48,6 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' const [iconPickerOpen, setIconPickerOpen] = useState(false) const [labelError, setLabelError] = useState(false) - useEffect(() => { - if (!open) return - setForm({ ...DEFAULT_DATA, ...initial }) - setIconSearch('') - setIconPickerOpen(false) - setLabelError(false) - }, [open, initial]) - const set = (key: keyof NodeData, value: unknown) => setForm((f) => ({ ...f, [key]: value })) diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index ffe9b2c..7a8eee7 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -134,12 +134,12 @@ describe('NodeModal', () => { const onClose = vi.fn() const onSubmit = vi.fn() - const { rerender } = render() + const { rerender } = render() fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Temp Node' } }) fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'temp.local' } }) - rerender() - rerender() + rerender() + rerender() expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('') expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('') From 7608d0725586d5e401459953cb6f4569d2c8f962 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 20 Apr 2026 00:31:30 +0200 Subject: [PATCH 17/26] fix: flush before reading node IDs in bulk/single approve; 404/409 guards; catch scan errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - db.flush() ensures node.id is populated before reading — fixes bulk approve where node_ids were null, causing frontend to skip addNode for every device - approve_device raises 404 on missing device, 409 on already-processed device - _background_scan rollbacks dirty session then marks run as "failed" - Explicit Node() field mapping instead of **model_dump() to prevent injection - update_scan_config rolls back in-memory change if save_overrides() fails - clear_pending uses bulk DELETE instead of N individual row deletes --- backend/app/api/routes/scan.py | 55 +++++++++++++++++++++++----------- backend/tests/test_scan.py | 4 +-- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index 1e4c86c..5c77bf1 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -41,7 +41,15 @@ router = APIRouter() async def _background_scan(run_id: str, ranges: list[str]) -> None: async with AsyncSessionLocal() as db: - await run_scan(ranges, db, run_id) + try: + await run_scan(ranges, db, run_id) + except Exception: + logger.exception("Scan run %s failed unexpectedly", run_id) + await db.rollback() + run = await db.get(ScanRun, run_id) + if run and run.status == "running": + run.status = "failed" + await db.commit() @router.post("/trigger", response_model=ScanRunResponse) @@ -89,12 +97,10 @@ async def clear_pending( db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user), ) -> dict[str, int]: - result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending")) - devices = result.scalars().all() - for device in devices: - await db.delete(device) + from sqlalchemy import delete as sa_delete + result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending")) await db.commit() - return {"deleted": len(devices)} + return {"deleted": result.rowcount} @router.get("/hidden", response_model=list[PendingDeviceResponse]) @@ -116,7 +122,7 @@ async def bulk_approve_devices( ) ) devices = result.scalars().all() - node_ids: list[str] = [] + created_nodes: list[Node] = [] for device in devices: device.status = "approved" node = Node( @@ -128,9 +134,11 @@ async def bulk_approve_devices( services=device.services or [], ) db.add(node) - node_ids.append(node.id) - await db.commit() + created_nodes.append(node) + await db.flush() # populates node.id from Python-side default before reading + node_ids = [n.id for n in created_nodes] approved_device_ids = [d.id for d in devices] + await db.commit() return { "approved": len(node_ids), "node_ids": node_ids, @@ -166,13 +174,24 @@ async def approve_device( _: str = Depends(get_current_user), ) -> dict[str, Any]: device = await db.get(PendingDevice, device_id) - if device: - device.status = "approved" - node = Node(**node_data.model_dump()) - db.add(node) - await db.commit() - return {"approved": True, "node_id": node.id} - return {"approved": False} + if not device: + raise HTTPException(status_code=404, detail="Device not found") + if device.status != "pending": + raise HTTPException(status_code=409, detail="Device already processed") + device.status = "approved" + node = Node( + label=node_data.label, + type=node_data.type, + ip=node_data.ip, + hostname=node_data.hostname, + status=node_data.status, + services=node_data.services or [], + ) + db.add(node) + await db.flush() + node_id = node.id + await db.commit() + return {"approved": True, "node_id": node_id} @router.post("/pending/{device_id}/hide") @@ -212,10 +231,12 @@ async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig: @router.post("/config", response_model=ScanConfig) async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig: + previous = settings.scanner_ranges + settings.scanner_ranges = payload.ranges try: - settings.scanner_ranges = payload.ranges settings.save_overrides() return payload except Exception as exc: + settings.scanner_ranges = previous logger.error("Failed to save scan config: %s", exc) raise HTTPException(status_code=500, detail="Failed to save scan config") from exc diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py index 5e76d70..86ce554 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -120,8 +120,7 @@ async def test_approve_nonexistent_device(client: AsyncClient, headers): json=node_payload, headers=headers, ) - assert res.status_code == 200 - assert res.json()["approved"] is False + assert res.status_code == 404 # --- Hide device --- @@ -478,6 +477,7 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p data = res.json() assert data["approved"] == 2 assert len(data["node_ids"]) == 2 + assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs" assert len(data["device_ids"]) == 2 assert data["skipped"] == 0 # Pending list should now be empty From 7e08a85f733e30a303187a3a8fe9fcc6c9733283 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 20 Apr 2026 11:40:16 +0200 Subject: [PATCH 18/26] feat: add quality selector to PNG export (standard / high / ultra) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking Export PNG now opens a modal with three quality presets: - Standard (1× pixel ratio) — small file - High (2×, default) — recommended for sharing - Ultra (4×) — print quality Adds ExportModal component, updates exportToPng() to accept a quality param, and wires the modal into App.tsx replacing the direct export call. --- frontend/src/App.tsx | 18 +++-- .../src/components/modals/ExportModal.tsx | 71 ++++++++++++++++++ .../modals/__tests__/ExportModal.test.tsx | 74 +++++++++++++++++++ frontend/src/utils/__tests__/export.test.ts | 71 ++++++++++++++++++ frontend/src/utils/export.ts | 17 +++-- 5 files changed, 237 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/modals/ExportModal.tsx create mode 100644 frontend/src/components/modals/__tests__/ExportModal.test.tsx create mode 100644 frontend/src/utils/__tests__/export.test.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fdd1b1c..a3073ca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,7 @@ import { applyDagreLayout } from '@/utils/layout' import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' import { generateUUID } from '@/utils/uuid' import { generateMarkdownTable } from '@/utils/exportMarkdown' -import { exportToPng } from '@/utils/export' +import { ExportModal } from '@/components/modals/ExportModal' import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml' import { parseYamlToCanvas } from '@/utils/importYaml' import { TooltipProvider } from '@/components/ui/tooltip' @@ -53,6 +53,7 @@ export default function App() { const [pendingConnection, setPendingConnection] = useState(null) const [editEdgeId, setEditEdgeId] = useState(null) const [scanConfigOpen, setScanConfigOpen] = useState(false) + const [exportModalOpen, setExportModalOpen] = useState(false) // Declare handleSave before the Ctrl+S effect so it is in scope const handleSave = useCallback(async () => { @@ -305,15 +306,10 @@ export default function App() { } }, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved]) - const handleExport = useCallback(async () => { + const handleExport = useCallback(() => { const el = canvasRef.current?.querySelector('.react-flow') if (!el) { toast.error('Canvas not ready'); return } - try { - await exportToPng(el) - toast.success('Exported as PNG') - } catch { - toast.error('Export failed') - } + setExportModalOpen(true) }, []) const handleEdgeConnect = useCallback((connection: Connection) => { @@ -531,6 +527,12 @@ export default function App() { /> setShortcutsOpen(false)} /> + setExportModalOpen(false)} + getElement={() => canvasRef.current?.querySelector('.react-flow') ?? null} + /> + diff --git a/frontend/src/components/modals/ExportModal.tsx b/frontend/src/components/modals/ExportModal.tsx new file mode 100644 index 0000000..4c6ad06 --- /dev/null +++ b/frontend/src/components/modals/ExportModal.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { Download, Loader2 } from 'lucide-react' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { exportToPng, EXPORT_QUALITY_OPTIONS, type ExportQuality } from '@/utils/export' + +interface ExportModalProps { + open: boolean + onClose: () => void + getElement: () => HTMLElement | null +} + +export function ExportModal({ open, onClose, getElement }: ExportModalProps) { + const [quality, setQuality] = useState('high') + const [exporting, setExporting] = useState(false) + + const handleExport = async () => { + const el = getElement() + if (!el) return + setExporting(true) + try { + await exportToPng(el, quality) + onClose() + } finally { + setExporting(false) + } + } + + return ( + !v && onClose()}> + + + Export as PNG + + +
+ {EXPORT_QUALITY_OPTIONS.map((opt) => ( + + ))} +
+ + + + + +
+
+ ) +} diff --git a/frontend/src/components/modals/__tests__/ExportModal.test.tsx b/frontend/src/components/modals/__tests__/ExportModal.test.tsx new file mode 100644 index 0000000..5baba23 --- /dev/null +++ b/frontend/src/components/modals/__tests__/ExportModal.test.tsx @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { ExportModal } from '../ExportModal' + +const mockExportToPng = vi.fn() +vi.mock('@/utils/export', () => ({ + exportToPng: (...args: unknown[]) => mockExportToPng(...args), + EXPORT_QUALITY_OPTIONS: [ + { value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' }, + { value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' }, + { value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' }, + ], +})) + +const el = document.createElement('div') +const getElement = () => el +const onClose = vi.fn() + +describe('ExportModal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExportToPng.mockResolvedValue(undefined) + }) + + it('renders all three quality options', () => { + render() + expect(screen.getByText('Standard')).toBeInTheDocument() + expect(screen.getByText('High')).toBeInTheDocument() + expect(screen.getByText('Ultra')).toBeInTheDocument() + }) + + it('selects High by default', () => { + render() + const highBtn = screen.getByText('High').closest('button')! + expect(highBtn.className).toContain('border-[#00d4ff]') + }) + + it('changes selection when another option is clicked', () => { + render() + fireEvent.click(screen.getByText('Ultra').closest('button')!) + expect(screen.getByText('Ultra').closest('button')!.className).toContain('border-[#00d4ff]') + expect(screen.getByText('High').closest('button')!.className).not.toContain('border-[#00d4ff]') + }) + + it('calls exportToPng with selected quality on Download click', async () => { + render() + fireEvent.click(screen.getByText('Standard').closest('button')!) + fireEvent.click(screen.getByRole('button', { name: /download/i })) + await waitFor(() => expect(mockExportToPng).toHaveBeenCalledWith(el, 'standard')) + }) + + it('closes after successful export', async () => { + render() + fireEvent.click(screen.getByRole('button', { name: /download/i })) + await waitFor(() => expect(onClose).toHaveBeenCalled()) + }) + + it('calls onClose when Cancel is clicked', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /cancel/i })) + expect(onClose).toHaveBeenCalled() + }) + + it('does not call exportToPng when getElement returns null', async () => { + render( null} />) + fireEvent.click(screen.getByRole('button', { name: /download/i })) + await waitFor(() => expect(mockExportToPng).not.toHaveBeenCalled()) + }) + + it('does not render when closed', () => { + render() + expect(screen.queryByText('Export as PNG')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/utils/__tests__/export.test.ts b/frontend/src/utils/__tests__/export.test.ts new file mode 100644 index 0000000..9d42221 --- /dev/null +++ b/frontend/src/utils/__tests__/export.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { exportToPng, EXPORT_QUALITY_OPTIONS } from '../export' + +const mockToPng = vi.fn() +vi.mock('html-to-image', () => ({ toPng: (...args: unknown[]) => mockToPng(...args) })) + +describe('exportToPng', () => { + let el: HTMLElement + let clickSpy: ReturnType + let appendSpy: ReturnType + let createSpy: ReturnType + + beforeEach(() => { + el = document.createElement('div') + clickSpy = vi.fn() + createSpy = vi.spyOn(document, 'createElement').mockReturnValue( + Object.assign(document.createElement('a'), { click: clickSpy }) as HTMLAnchorElement + ) + appendSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((n) => n) + mockToPng.mockResolvedValue('data:image/png;base64,abc') + }) + + afterEach(() => { + createSpy.mockRestore() + appendSpy.mockRestore() + }) + + it('calls toPng with pixelRatio 1 for standard quality', async () => { + await exportToPng(el, 'standard') + expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 1 })) + }) + + it('calls toPng with pixelRatio 2 for high quality', async () => { + await exportToPng(el, 'high') + expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 2 })) + }) + + it('calls toPng with pixelRatio 4 for ultra quality', async () => { + await exportToPng(el, 'ultra') + expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 4 })) + }) + + it('defaults to high quality when no quality arg given', async () => { + await exportToPng(el) + expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 2 })) + }) + + it('triggers a download with the correct filename', async () => { + await exportToPng(el, 'high') + expect(clickSpy).toHaveBeenCalled() + }) + + it('passes dark background color', async () => { + await exportToPng(el, 'standard') + expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ backgroundColor: '#0d1117' })) + }) +}) + +describe('EXPORT_QUALITY_OPTIONS', () => { + it('has exactly three options', () => { + expect(EXPORT_QUALITY_OPTIONS).toHaveLength(3) + }) + + it('options are standard, high, ultra in order', () => { + expect(EXPORT_QUALITY_OPTIONS.map((o) => o.value)).toEqual(['standard', 'high', 'ultra']) + }) + + it('pixel ratios are 1, 2, 4', () => { + expect(EXPORT_QUALITY_OPTIONS.map((o) => o.pixelRatio)).toEqual([1, 2, 4]) + }) +}) diff --git a/frontend/src/utils/export.ts b/frontend/src/utils/export.ts index 9705e69..83e53ab 100644 --- a/frontend/src/utils/export.ts +++ b/frontend/src/utils/export.ts @@ -1,14 +1,19 @@ import { toPng } from 'html-to-image' -/** - * Export the React Flow canvas as a PNG and trigger a browser download. - * Pass the `.react-flow` wrapper element. - */ -export async function exportToPng(element: HTMLElement): Promise { +export type ExportQuality = 'standard' | 'high' | 'ultra' + +export const EXPORT_QUALITY_OPTIONS: { value: ExportQuality; label: string; pixelRatio: number; hint: string }[] = [ + { value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' }, + { value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' }, + { value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' }, +] + +export async function exportToPng(element: HTMLElement, quality: ExportQuality = 'high'): Promise { + const option = EXPORT_QUALITY_OPTIONS.find((o) => o.value === quality) ?? EXPORT_QUALITY_OPTIONS[1] const dataUrl = await toPng(element, { backgroundColor: '#0d1117', + pixelRatio: option.pixelRatio, style: { - // Exclude controls from the export '--xy-controls-display': 'none', } as Partial, }) From 074b49358bbfca896df2c74590793e908233b13c Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 20 Apr 2026 14:05:06 +0200 Subject: [PATCH 19/26] feat: add opacity slider to zone color pickers (fixes #72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native only supports 6-digit hex, stripping alpha and forcing background/border/text colors to be fully opaque on edit. Each color field now shows an opacity slider (0–100%) below the swatch. Values are stored as 8-digit hex (#rrggbbaa). Existing zones with 6-digit colors are handled transparently (alpha defaults to 100%). - colorUtils.ts: hexToRgba / rgbaToHex8 helpers - GroupRectModal: opacity sliders for all three color fields - 26 new tests across colorUtils and GroupRectModal --- .../src/components/modals/GroupRectModal.tsx | 43 +++++--- .../modals/__tests__/GroupRectModal.test.tsx | 56 +++++++++++ .../src/utils/__tests__/colorUtils.test.ts | 97 +++++++++++++++++++ frontend/src/utils/colorUtils.ts | 29 ++++++ 4 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 frontend/src/utils/__tests__/colorUtils.test.ts create mode 100644 frontend/src/utils/colorUtils.ts diff --git a/frontend/src/components/modals/GroupRectModal.tsx b/frontend/src/components/modals/GroupRectModal.tsx index b9a4027..e698ce2 100644 --- a/frontend/src/components/modals/GroupRectModal.tsx +++ b/frontend/src/components/modals/GroupRectModal.tsx @@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import type { TextPosition } from '@/types' +import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils' export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none' @@ -204,23 +205,35 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
- {colorFields.map(({ key, label }) => ( -
-
diff --git a/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx b/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx index 7c7233c..4190bca 100644 --- a/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx +++ b/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx @@ -251,4 +251,60 @@ describe('GroupRectModal', () => { const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData expect(submitted.border_style).toBe('solid') }) + + it('shows opacity sliders for all three color fields', () => { + render() + const sliders = screen.getAllByRole('slider') + expect(sliders).toHaveLength(3) + }) + + it('default background_color is 8-digit hex with low alpha', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.background_color).toBe('#00d4ff0d') + expect(submitted.background_color.length).toBe(9) + }) + + it('moving background opacity slider updates background_color alpha', () => { + const onSubmit = vi.fn() + render() + // background slider is the third one (Text, Border, Background) + const sliders = screen.getAllByRole('slider') + fireEvent.change(sliders[2], { target: { value: '50' } }) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + // alpha 50% → 0x80 = 128 + expect(submitted.background_color).toBe('#00d4ff80') + }) + + it('moving border opacity slider to 0 makes border fully transparent', () => { + const onSubmit = vi.fn() + render() + const sliders = screen.getAllByRole('slider') + fireEvent.change(sliders[1], { target: { value: '0' } }) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.border_color).toBe('#00d4ff00') + }) + + it('pre-fills opacity from 8-digit initial background_color', () => { + render( + + ) + const sliders = screen.getAllByRole('slider') + expect((sliders[2] as HTMLInputElement).value).toBe('50') + }) + + it('shows opacity percentage in label', () => { + render() + // Background default is 5% opacity + expect(screen.getByText(/Background 5%/)).toBeInTheDocument() + }) }) diff --git a/frontend/src/utils/__tests__/colorUtils.test.ts b/frontend/src/utils/__tests__/colorUtils.test.ts new file mode 100644 index 0000000..bb2b98f --- /dev/null +++ b/frontend/src/utils/__tests__/colorUtils.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest' +import { hexToRgba, rgbaToHex8 } from '../colorUtils' + +describe('hexToRgba', () => { + it('splits 8-digit hex into hex6 and alpha', () => { + const { hex6, alpha } = hexToRgba('#00d4ff0d') + expect(hex6).toBe('#00d4ff') + expect(alpha).toBe(5) + }) + + it('handles fully opaque 8-digit hex (ff)', () => { + const { hex6, alpha } = hexToRgba('#00d4ffff') + expect(hex6).toBe('#00d4ff') + expect(alpha).toBe(100) + }) + + it('handles fully transparent 8-digit hex (00)', () => { + const { hex6, alpha } = hexToRgba('#00d4ff00') + expect(hex6).toBe('#00d4ff') + expect(alpha).toBe(0) + }) + + it('defaults alpha to 100 for 6-digit hex', () => { + const { hex6, alpha } = hexToRgba('#00d4ff') + expect(hex6).toBe('#00d4ff') + expect(alpha).toBe(100) + }) + + it('handles 6-digit hex without leading #', () => { + const { hex6, alpha } = hexToRgba('ff6e00') + expect(hex6).toBe('#ff6e00') + expect(alpha).toBe(100) + }) + + it('handles 8-digit hex without leading #', () => { + const { hex6, alpha } = hexToRgba('ff6e0080') + expect(hex6).toBe('#ff6e00') + expect(alpha).toBe(50) + }) + + it('returns fallback for invalid input', () => { + const { hex6, alpha } = hexToRgba('invalid') + expect(hex6).toBe('#000000') + expect(alpha).toBe(100) + }) + + it('is case-insensitive', () => { + const { hex6 } = hexToRgba('#00D4FF0D') + expect(hex6).toBe('#00D4FF') + }) +}) + +describe('rgbaToHex8', () => { + it('combines hex6 and alpha into 8-digit hex', () => { + expect(rgbaToHex8('#00d4ff', 5)).toBe('#00d4ff0d') + }) + + it('produces ff for alpha 100', () => { + expect(rgbaToHex8('#00d4ff', 100)).toBe('#00d4ffff') + }) + + it('produces 00 for alpha 0', () => { + expect(rgbaToHex8('#00d4ff', 0)).toBe('#00d4ff00') + }) + + it('produces 80 for alpha 50', () => { + expect(rgbaToHex8('#ff6e00', 50)).toBe('#ff6e0080') + }) + + it('clamps alpha below 0 to 0', () => { + expect(rgbaToHex8('#ffffff', -10)).toBe('#ffffff00') + }) + + it('clamps alpha above 100 to 100', () => { + expect(rgbaToHex8('#ffffff', 150)).toBe('#ffffffff') + }) + + it('pads single-digit alpha hex with leading zero', () => { + const result = rgbaToHex8('#000000', 1) + const alphaPart = result.slice(7) + expect(alphaPart.length).toBe(2) + }) +}) + +describe('round-trip', () => { + it('hexToRgba → rgbaToHex8 round-trips correctly', () => { + const original = '#00d4ff0d' + const { hex6, alpha } = hexToRgba(original) + expect(rgbaToHex8(hex6, alpha)).toBe(original) + }) + + it('round-trips fully opaque color', () => { + const original = '#a855f7ff' + const { hex6, alpha } = hexToRgba(original) + expect(rgbaToHex8(hex6, alpha)).toBe(original) + }) +}) diff --git a/frontend/src/utils/colorUtils.ts b/frontend/src/utils/colorUtils.ts new file mode 100644 index 0000000..0e62436 --- /dev/null +++ b/frontend/src/utils/colorUtils.ts @@ -0,0 +1,29 @@ +/** + * Split a 6- or 8-digit hex color into its RGB hex and alpha (0–100). + * 6-digit input returns alpha 100. + * Invalid input returns { hex6: '#000000', alpha: 100 }. + */ +export function hexToRgba(hex: string): { hex6: string; alpha: number } { + const clean = hex.replace('#', '') + if (clean.length === 8) { + const alphaByte = parseInt(clean.slice(6, 8), 16) + return { + hex6: `#${clean.slice(0, 6)}`, + alpha: Math.round((alphaByte / 255) * 100), + } + } + if (clean.length === 6) { + return { hex6: `#${clean}`, alpha: 100 } + } + return { hex6: '#000000', alpha: 100 } +} + +/** + * Combine a 6-digit hex color and an alpha (0–100) into an 8-digit hex. + */ +export function rgbaToHex8(hex6: string, alpha: number): string { + const clamped = Math.max(0, Math.min(100, alpha)) + const alphaByte = Math.round((clamped / 100) * 255) + const alphaHex = alphaByte.toString(16).padStart(2, '0') + return `${hex6}${alphaHex}` +} From c7be851c34fe39f2882e1a691bdd93579e7435cd Mon Sep 17 00:00:00 2001 From: findthelorax Date: Sun, 19 Apr 2026 23:00:32 -0400 Subject: [PATCH 20/26] feature: add support for services to use a path --- backend/tests/test_canvas.py | 9 +++ .../src/components/panels/DetailPanel.tsx | 53 +++++++++---- frontend/src/components/panels/Sidebar.tsx | 2 +- .../panels/__tests__/DetailPanel.test.ts | 21 ++++- .../panels/__tests__/DetailPanel.test.tsx | 36 ++++++++- frontend/src/types/index.ts | 3 +- frontend/src/utils/exportMarkdown.ts | 6 +- frontend/src/utils/serviceUrl.ts | 77 +++++++++++++++++-- 8 files changed, 179 insertions(+), 28 deletions(-) diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 7b25f25..3d7dadf 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -453,6 +453,15 @@ async def test_save_canvas_persists_services_and_notes(client: AsyncClient, head assert node["notes"] == "My NAS device" +async def test_save_canvas_persists_service_paths(client: AsyncClient, headers: dict): + services = [{"service_name": "Grafana", "protocol": "tcp", "port": 3000, "path": "/login"}] + n1 = node_payload(ip="192.168.1.50:8080", services=services) + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["nodes"][0]["services"] == services + + async def test_save_canvas_persists_check_fields(client: AsyncClient, headers: dict): n1 = node_payload(check_method="ping", check_target="192.168.1.1") await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index 2deff7e..ce2499e 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -13,8 +13,8 @@ interface DetailPanelProps { onEdit: (id: string) => void } -type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string } -const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' } +type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string } +const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' } type PropForm = { key: string; value: string; icon: string | null; visible: boolean } const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true } @@ -94,10 +94,18 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { } const handleAddService = () => { - const port = parseInt(newSvc.port, 10) - if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return + const trimmedPort = newSvc.port.trim() + const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10) + if (!newSvc.service_name.trim()) return + if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return snapshotHistory() - const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() } + const path = newSvc.path.trim() + const svc: ServiceInfo = { + ...(port != null ? { port } : {}), + protocol: newSvc.protocol, + service_name: newSvc.service_name.trim(), + ...(path ? { path } : {}), + } updateNode(node.id, { services: [...services, svc] }) setNewSvc(EMPTY_FORM) setAddingForNode(null) @@ -113,18 +121,29 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { const handleStartEdit = (index: number) => { const svc = services[index] if (!svc) return - setEditSvc({ port: String(svc.port), protocol: svc.protocol, service_name: svc.service_name }) + setEditSvc({ port: svc.port != null ? String(svc.port) : '', protocol: svc.protocol, service_name: svc.service_name, path: svc.path ?? '' }) setEditingFor({ nodeId: node.id, index }) setAddingForNode(null) } const handleSaveEdit = () => { if (editingIndex === null) return - const port = parseInt(editSvc.port, 10) - if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return + const trimmedPort = editSvc.port.trim() + const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10) + if (!editSvc.service_name.trim()) return + if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return snapshotHistory() + const path = editSvc.path.trim() const updated = services.map((svc, i) => - i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc + i === editingIndex + ? { + ...svc, + protocol: editSvc.protocol, + service_name: editSvc.service_name.trim(), + ...(port != null ? { port } : { port: undefined }), + ...(path ? { path } : { path: undefined }), + } + : svc ) updateNode(node.id, { services: updated }) setEditingFor(null) @@ -280,7 +299,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { editingIndex === i ? ( setEditingFor(null)} confirmLabel="Save" autoFocus /> ) : ( - handleStartEdit(i)} onRemove={() => handleRemoveService(i)} /> + handleStartEdit(i)} onRemove={() => handleRemoveService(i)} /> ) )}
@@ -480,8 +499,8 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono? } function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: { - form: { port: string; protocol: 'tcp' | 'udp'; service_name: string } - onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void + form: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string } + onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }) => void onConfirm: () => void onCancel: () => void confirmLabel: string @@ -491,12 +510,13 @@ function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFo
onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
- onChange({ ...form, port: e.target.value })} placeholder="Port" min={1} max={65535} className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} /> + onChange({ ...form, port: e.target.value })} placeholder="Port" min={1} max={65535} className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-28 shrink-0" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
+ onChange({ ...form, path: e.target.value })} placeholder="Path (/dashboard)" className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
@@ -619,14 +639,17 @@ const CATEGORY_COLORS: Record = { function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) { const url = getServiceUrl(svc, host) const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e' + const portLabel = svc.port != null ? String(svc.port) : 'host' + const pathLabel = svc.path?.trim() ? svc.path.trim() : null const inner = (
- {svc.service_name} + {svc.service_name} + {pathLabel && {pathLabel}}
- {svc.port}/{svc.protocol} + {portLabel}/{svc.protocol} {url && } diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index a8f7688..bf382d4 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -359,7 +359,7 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:

No pending devices

)} {devices.map((d) => { - const namedService = d.services.find((s) => s.category != null && !COMMON_PORTS.has(s.port)) + const namedService = d.services.find((s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port)) const titleService = namedService ?? d.services.find((s) => s.port === 80) ?? d.services.find((s) => s.port === 443) diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.ts b/frontend/src/components/panels/__tests__/DetailPanel.test.ts index 786a341..92b3ee6 100644 --- a/frontend/src/components/panels/__tests__/DetailPanel.test.ts +++ b/frontend/src/components/panels/__tests__/DetailPanel.test.ts @@ -2,10 +2,11 @@ import { describe, it, expect } from 'vitest' import { getServiceUrl } from '@/utils/serviceUrl' import type { ServiceInfo } from '@/types' -const svc = (port: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test'): ServiceInfo => ({ - port, +const svc = (port?: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test', path?: string): ServiceInfo => ({ + ...(port != null ? { port } : {}), protocol, service_name, + ...(path ? { path } : {}), }) describe('getServiceUrl', () => { @@ -63,4 +64,20 @@ describe('getServiceUrl', () => { it('uses host string directly (works with both IP and hostname)', () => { expect(getServiceUrl(svc(80), 'myserver.lan')).toBe('http://myserver.lan:80') }) + + it('uses the node port when the host already includes one', () => { + expect(getServiceUrl(svc(undefined, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:8080') + }) + + it('lets the service port override the node port', () => { + expect(getServiceUrl(svc(3000, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:3000') + }) + + it('appends a normalized path to the final URL', () => { + expect(getServiceUrl(svc(3000, 'tcp', 'app', 'admin/login'), '192.168.1.10')).toBe('http://192.168.1.10:3000/admin/login') + }) + + it('supports path-only services inheriting the node port', () => { + expect(getServiceUrl(svc(undefined, 'tcp', 'app', '/metrics'), '192.168.1.10:9090')).toBe('http://192.168.1.10:9090/metrics') + }) }) diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx index 07066bd..8bef56f 100644 --- a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx +++ b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx @@ -293,9 +293,35 @@ describe('DetailPanel', () => { fireEvent.click(addHeaders[addHeaders.length - 1]) fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } }) fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } }) + fireEvent.change(screen.getByPlaceholderText('Path'), { target: { value: '/admin' } }) fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' }) expect(updateNode).toHaveBeenCalledOnce() - expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' }) + expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp', path: '/admin' }) + }) + + it('allows adding a service without a port', () => { + const updateNode = vi.fn() + vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ + nodes: [makeNode({ ip: '192.168.1.10:8080' })], + selectedNodeId: 'n1', + selectedNodeIds: [], + setSelectedNode: vi.fn(), + deleteNode: vi.fn(), + updateNode, + snapshotHistory: vi.fn(), + createGroup: vi.fn(), + ungroup: vi.fn(), + } as unknown as ReturnType) + render() + const addHeaders = screen.getAllByText('Add') + fireEvent.click(addHeaders[addHeaders.length - 1]) + fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'health' } }) + fireEvent.change(screen.getByPlaceholderText('Path (/dashboard)'), { target: { value: 'healthz' } }) + fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement) + + expect(updateNode).toHaveBeenCalledOnce() + expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'health', protocol: 'tcp', path: 'healthz' }) + expect(updateNode.mock.calls[0][1].services[0].port).toBeUndefined() }) it('calls updateNode without the removed service when X is clicked', () => { @@ -332,15 +358,17 @@ describe('DetailPanel', () => { const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' } it('shows edit form pre-filled when pencil is clicked', () => { - setupStore({ services: [svc] }) + setupStore({ services: [{ ...svc, path: '/admin' }] }) render() // Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only) const editBtn = screen.getByTitle('Edit service') fireEvent.click(editBtn) const nameInput = screen.getByPlaceholderText('Service name') as HTMLInputElement expect(nameInput.value).toBe('nginx') - const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement + const portInput = screen.getByPlaceholderText('Port (/dashboard)') as HTMLInputElement expect(portInput.value).toBe('80') + const pathInput = screen.getByPlaceholderText('Path (/dashboard)') as HTMLInputElement + expect(pathInput.value).toBe('/admin') }) it('calls updateNode with updated values on Save', () => { @@ -359,11 +387,13 @@ describe('DetailPanel', () => { const nameInput = screen.getByPlaceholderText('Service name') fireEvent.change(nameInput, { target: { value: 'apache' } }) + fireEvent.change(screen.getByPlaceholderText('Path (/dashboard)'), { target: { value: '/ui' } }) fireEvent.click(screen.getByRole('button', { name: 'Save' })) expect(updateNode).toHaveBeenCalledOnce() expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache') expect(updateNode.mock.calls[0][1].services[0].port).toBe(80) + expect(updateNode.mock.calls[0][1].services[0].path).toBe('/ui') }) it('cancels edit without updating', () => { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2481349..a9bdaee 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -36,9 +36,10 @@ export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown' export type CheckMethod = 'ping' | 'http' | 'https' | 'tcp' | 'ssh' | 'prometheus' | 'health' | 'none' export interface ServiceInfo { - port: number + port?: number protocol: 'tcp' | 'udp' service_name: string + path?: string icon?: string category?: string } diff --git a/frontend/src/utils/exportMarkdown.ts b/frontend/src/utils/exportMarkdown.ts index 19808c5..4dfef69 100644 --- a/frontend/src/utils/exportMarkdown.ts +++ b/frontend/src/utils/exportMarkdown.ts @@ -15,7 +15,11 @@ export function generateMarkdownTable(nodes: Node[]): string { .map((n) => { const d = n.data const services = d.services?.length - ? d.services.map((s) => `${s.service_name}:${s.port}`).join(', ') + ? d.services.map((s) => { + const port = s.port != null ? `:${s.port}` : '' + const path = s.path?.trim() ? s.path.trim() : '' + return `${s.service_name}${port}${path}` + }).join(', ') : EMPTY return [ cell(d.label), diff --git a/frontend/src/utils/serviceUrl.ts b/frontend/src/utils/serviceUrl.ts index b90d061..b2719e6 100644 --- a/frontend/src/utils/serviceUrl.ts +++ b/frontend/src/utils/serviceUrl.ts @@ -20,15 +20,82 @@ const NON_HTTP_PORTS = new Set([ 27017, 27018, // MongoDB ]) +function splitFirstHost(host: string): string { + return host.split(',')[0]?.trim() ?? '' +} + +function parsePort(port: string): number | undefined { + if (!/^\d+$/.test(port)) return undefined + const parsed = Number.parseInt(port, 10) + return parsed >= 1 && parsed <= 65535 ? parsed : undefined +} + +function parseHostParts(host: string): { protocol?: 'http' | 'https'; hostname: string; port?: number } | null { + const firstHost = splitFirstHost(host) + if (!firstHost) return null + + if (firstHost.startsWith('http://') || firstHost.startsWith('https://')) { + const url = new URL(firstHost) + return { + protocol: url.protocol === 'https:' ? 'https' : 'http', + hostname: url.hostname, + port: parsePort(url.port), + } + } + + if (firstHost.startsWith('[')) { + const bracketIndex = firstHost.indexOf(']') + if (bracketIndex === -1) return { hostname: firstHost } + const hostname = firstHost.slice(1, bracketIndex) + const remainder = firstHost.slice(bracketIndex + 1) + return { + hostname, + port: remainder.startsWith(':') ? parsePort(remainder.slice(1)) : undefined, + } + } + + const colonCount = (firstHost.match(/:/g) ?? []).length + if (colonCount === 1) { + const [hostname, rawPort] = firstHost.split(':') + const parsedPort = parsePort(rawPort) + if (hostname && parsedPort != null) { + return { hostname, port: parsedPort } + } + } + + return { hostname: firstHost } +} + +function normalizePath(path?: string): string { + const trimmed = path?.trim() + if (!trimmed) return '' + if (trimmed === '/') return '/' + return trimmed.startsWith('/') ? trimmed : `/${trimmed}` +} + +function formatHostname(hostname: string): string { + return hostname.includes(':') && !hostname.startsWith('[') ? `[${hostname}]` : hostname +} + export function getServiceUrl(svc: ServiceInfo, host?: string): string | null { if (!host) return null - if (svc.port === 22) return null // SSH — no browser if (svc.protocol === 'udp') return null // UDP — not HTTP - if (NON_HTTP_PORTS.has(svc.port)) return null + + const parts = parseHostParts(host) + if (!parts?.hostname) return null + + const effectivePort = svc.port ?? parts.port + if (effectivePort === 22) return null // SSH — no browser + if (effectivePort != null && NON_HTTP_PORTS.has(effectivePort)) return null const name = svc.service_name.toLowerCase() - const isHttps = + const protocol = parts.protocol ?? ( name.includes('https') || name.includes('ssl') || name.includes('tls') || - svc.port === 443 || svc.port === 8443 - return `${isHttps ? 'https' : 'http'}://${host}:${svc.port}` + effectivePort === 443 || effectivePort === 8443 + ? 'https' + : 'http' + ) + const base = `${protocol}://${formatHostname(parts.hostname)}` + const port = effectivePort != null ? `:${effectivePort}` : '' + return `${base}${port}${normalizePath(svc.path)}` } From 2c94616afa54ca0b3065c62b3d597a6a968cdce6 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Sun, 19 Apr 2026 23:07:26 -0400 Subject: [PATCH 21/26] cleanup path examples --- frontend/src/components/panels/DetailPanel.tsx | 2 +- .../components/panels/__tests__/DetailPanel.test.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index ce2499e..86ad5be 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -516,7 +516,7 @@ function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFo
- onChange({ ...form, path: e.target.value })} placeholder="Path (/dashboard)" className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} /> + onChange({ ...form, path: e.target.value })} placeholder="Path (/admin)" className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx index 8bef56f..2761655 100644 --- a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx +++ b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx @@ -316,7 +316,7 @@ describe('DetailPanel', () => { const addHeaders = screen.getAllByText('Add') fireEvent.click(addHeaders[addHeaders.length - 1]) fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'health' } }) - fireEvent.change(screen.getByPlaceholderText('Path (/dashboard)'), { target: { value: 'healthz' } }) + fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: 'healthz' } }) fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement) expect(updateNode).toHaveBeenCalledOnce() @@ -365,9 +365,9 @@ describe('DetailPanel', () => { fireEvent.click(editBtn) const nameInput = screen.getByPlaceholderText('Service name') as HTMLInputElement expect(nameInput.value).toBe('nginx') - const portInput = screen.getByPlaceholderText('Port (/dashboard)') as HTMLInputElement + const portInput = screen.getByPlaceholderText('Port (/admin)') as HTMLInputElement expect(portInput.value).toBe('80') - const pathInput = screen.getByPlaceholderText('Path (/dashboard)') as HTMLInputElement + const pathInput = screen.getByPlaceholderText('Path (/admin)') as HTMLInputElement expect(pathInput.value).toBe('/admin') }) @@ -387,13 +387,13 @@ describe('DetailPanel', () => { const nameInput = screen.getByPlaceholderText('Service name') fireEvent.change(nameInput, { target: { value: 'apache' } }) - fireEvent.change(screen.getByPlaceholderText('Path (/dashboard)'), { target: { value: '/ui' } }) + fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } }) fireEvent.click(screen.getByRole('button', { name: 'Save' })) expect(updateNode).toHaveBeenCalledOnce() expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache') expect(updateNode.mock.calls[0][1].services[0].port).toBe(80) - expect(updateNode.mock.calls[0][1].services[0].path).toBe('/ui') + expect(updateNode.mock.calls[0][1].services[0].path).toBe('admin') }) it('cancels edit without updating', () => { From 9cf6a48b04cc767c086285a32bff8bf1464bd223 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Sun, 19 Apr 2026 23:15:26 -0400 Subject: [PATCH 22/26] fix: port input to text numeric and removed up/down arrows --- .../src/components/panels/DetailPanel.tsx | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index 86ad5be..47b0f1d 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -506,11 +506,33 @@ function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFo confirmLabel: string autoFocus?: boolean }) { + const setPort = (value: string) => { + const digitsOnly = value.replace(/\D/g, '').slice(0, 5) + onChange({ ...form, port: digitsOnly }) + } + + const clampPort = (value: string) => { + if (!value) return '' + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed)) return '' + return String(Math.max(1, Math.min(65535, parsed))) + } + return (
onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
- onChange({ ...form, port: e.target.value })} placeholder="Port" min={1} max={65535} className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-28 shrink-0" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} /> + setPort(e.target.value)} + onBlur={() => onChange({ ...form, port: clampPort(form.port) })} + placeholder="Port" + className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-28 shrink-0" + onKeyDown={(e) => e.key === 'Enter' && onConfirm()} + />