diff --git a/docker/nginx.conf b/docker/nginx.conf index a6b197e..bb3e02e 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -4,6 +4,16 @@ server { root /usr/share/nginx/html; index index.html; + # Proxy WebSocket (must be before /api/ to take priority) + location /api/v1/status/ws/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + # Proxy API to backend location /api/ { proxy_pass http://backend:8000; @@ -11,7 +21,7 @@ server { proxy_set_header X-Real-IP $remote_addr; } - # Proxy WebSocket + # Proxy legacy /ws/ path location /ws/ { proxy_pass http://backend:8000; proxy_http_version 1.1; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 26ba702..de9dfd4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useCallback, useRef, useState } from 'react' import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react' import { type Node } from '@xyflow/react' import { applyDagreLayout } from '@/utils/layout' +import { generateUUID } from '@/utils/uuid' import { generateMarkdownTable } from '@/utils/exportMarkdown' import { exportToPng } from '@/utils/export' import { TooltipProvider } from '@/components/ui/tooltip' @@ -238,7 +239,7 @@ export default function App() { const handleAddNode = useCallback((data: Partial) => { snapshotHistory() - const id = crypto.randomUUID() + const id = generateUUID() const isProxmox = data.type === 'proxmox' const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null // Children position is relative to parent; place near top-left with padding @@ -260,7 +261,7 @@ export default function App() { const handleAddGroupRect = useCallback((data: GroupRectFormData) => { snapshotHistory() - const id = crypto.randomUUID() + const id = generateUUID() const newNode: Node = { id, type: 'groupRect', diff --git a/frontend/src/hooks/useStatusPolling.ts b/frontend/src/hooks/useStatusPolling.ts index 46abff4..5d5cbf7 100644 --- a/frontend/src/hooks/useStatusPolling.ts +++ b/frontend/src/hooks/useStatusPolling.ts @@ -23,8 +23,8 @@ export function useStatusPolling() { if (STANDALONE || !isAuthenticated || !token) return const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' - const host = window.location.hostname - const url = `${protocol}://${host}:8000/api/v1/status/ws/status?token=${encodeURIComponent(token)}` + const host = window.location.host // includes port when non-standard + const url = `${protocol}://${host}/api/v1/status/ws/status?token=${encodeURIComponent(token)}` const ws = new WebSocket(url) wsRef.current = ws diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index 09135f2..32064be 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -10,6 +10,7 @@ import { addEdge, } from '@xyflow/react' import type { NodeData, EdgeData } from '@/types' +import { generateUUID } from '@/utils/uuid' type HistoryEntry = { nodes: Node[]; edges: Edge[] } @@ -108,7 +109,7 @@ export const useCanvasStore = create((set) => ({ if (state.clipboard.length === 0) return state const newNodes = state.clipboard.map((n) => ({ ...n, - id: crypto.randomUUID(), + id: generateUUID(), position: { x: n.position.x + 50, y: n.position.y + 50 }, selected: false, parentId: undefined, diff --git a/frontend/src/utils/__tests__/uuid.test.ts b/frontend/src/utils/__tests__/uuid.test.ts new file mode 100644 index 0000000..a342095 --- /dev/null +++ b/frontend/src/utils/__tests__/uuid.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { generateUUID } from '../uuid' + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +describe('generateUUID', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns a valid v4 UUID using crypto.randomUUID when available', () => { + const id = generateUUID() + expect(id).toMatch(UUID_REGEX) + }) + + it('returns a valid v4 UUID using crypto.getRandomValues fallback', () => { + vi.spyOn(crypto, 'randomUUID' as never).mockImplementation(undefined as never) + const id = generateUUID() + expect(id).toMatch(UUID_REGEX) + }) + + it('generates unique IDs', () => { + const ids = new Set(Array.from({ length: 100 }, () => generateUUID())) + expect(ids.size).toBe(100) + }) +}) diff --git a/frontend/src/utils/uuid.ts b/frontend/src/utils/uuid.ts new file mode 100644 index 0000000..5b8ae64 --- /dev/null +++ b/frontend/src/utils/uuid.ts @@ -0,0 +1,25 @@ +/** + * Generates a UUID v4. + * Falls back to a manual implementation when crypto.randomUUID is unavailable + * (HTTP non-secure contexts, older browsers). + */ +export function generateUUID(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + // Fallback: RFC 4122 v4 UUID using crypto.getRandomValues if available + if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + return [...bytes] + .map((b, i) => ([4, 6, 8, 10].includes(i) ? '-' : '') + b.toString(16).padStart(2, '0')) + .join('') + } + // Last resort: Math.random based (not cryptographically secure) + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0 + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16) + }) +}