import { useState, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { apiClient } from '../../../shared/api/client'; import { JSMpegPlayer } from '../components/JSMpegPlayer'; import { Video, LayoutGrid, ChevronDown } from 'lucide-react'; import { useAuth } from '../../../shared/auth/AuthContext'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, rectSortingStrategy } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; interface Camera { id: number; name: string; ip_address: string; port: number; username?: string; password?: string; stream_path?: string; transport_mode?: 'tcp' | 'udp' | 'auto'; rtsp_encoding?: boolean; quality?: 'low' | 'medium' | 'original'; display_order?: number; is_active?: boolean | number; zone?: string; } // Wrap Camera Card with Sortable function SortableCamera({ camera, children, disabled }: { camera: Camera, children: React.ReactNode, disabled: boolean }) { const { attributes, listeners, setNodeRef, transform, transition, } = useSortable({ id: camera.id, disabled }); const style = { transform: CSS.Transform.toString(transform), transition, }; return (
{children}
); } export function MonitoringPage() { const { user } = useAuth(); const [cameras, setCameras] = useState([]); const [activeZone, setActiveZone] = useState(''); const [availableZones, setAvailableZones] = useState<{ name: string, layout: string }[]>([]); const [showLayoutMenu, setShowLayoutMenu] = useState(false); const fetchedRef = useRef(false); const [streamVersions] = useState<{ [key: number]: number }>({}); const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); useEffect(() => { if (fetchedRef.current) return; fetchedRef.current = true; fetchCameras(); }, []); const fetchCameras = async () => { try { const res = await apiClient.get('/cameras'); setCameras(res.data); } catch (err) { console.error('Failed to fetch cameras', err); } }; const fetchZones = async () => { try { const res = await apiClient.get('/cameras/zones'); const zones = res.data.map((z: any) => ({ name: z.name, layout: z.layout || '2*2' })); setAvailableZones(zones); // Default to the first available zone to show video immediately if (!activeZone && zones.length > 0) { setActiveZone(zones[0].name); } } catch (err) { console.error('Failed to fetch zones', err); } }; // Determine layout based on active zone's setting const currentZoneConfig = availableZones.find(z => z.name === activeZone); const viewLayout = currentZoneConfig ? currentZoneConfig.layout : '2*2'; const handleLayoutChange = async (newLayout: string) => { if (!activeZone) return; // Optimistic UI update setAvailableZones(prev => prev.map(z => z.name === activeZone ? { ...z, layout: newLayout } : z )); setShowLayoutMenu(false); try { await apiClient.patch(`/cameras/zones/${activeZone}/layout`, { layout: newLayout }); } catch (err) { console.error('Failed to update layout', err); // Revert or show alert if needed } }; const filteredCameras = !activeZone ? [] : cameras.filter(c => (c.zone || '기본 구역') === activeZone); const handleToggleStatus = async (camera: Camera) => { // Optimistic UI Update: Calculate new status const currentStatus = camera.is_active !== 0 && camera.is_active !== false; const newStatus = !currentStatus; // Immediately update UI state setCameras(prev => prev.map(c => c.id === camera.id ? { ...c, is_active: newStatus ? 1 : 0 } : c )); try { // Send request to server await apiClient.patch(`/cameras/${camera.id}/status`, { is_active: newStatus }); // Only silent fetch to sync eventually, no alert needed fetchCameras(); } catch (err) { console.error('Failed to toggle status', err); // Revert UI on failure setCameras(prev => prev.map(c => c.id === camera.id ? { ...c, is_active: currentStatus ? 1 : 0 } : c )); alert('상태 변경 실패 (되돌리기)'); } }; const handleDragEnd = async (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { setCameras((items) => { const oldIndex = items.findIndex((item) => item.id === active.id); const newIndex = items.findIndex((item) => item.id === over.id); const newItems = arrayMove(items, oldIndex, newIndex); // Save new order apiClient.put('/cameras/reorder', { cameras: newItems.map(c => c.id) }) .catch(e => console.error('Failed to save order', e)); return newItems; }); } }; const getStreamUrl = (cameraId: number) => { // Use current host (proxied or direct) const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const version = streamVersions[cameraId] || 0; const url = `${protocol}//${window.location.host}/api/stream?cameraId=${cameraId}&v=${version}`; console.log(`[CCTV] Connecting to: ${url}`); return url; }; const handlePing = async (id: number) => { try { const res = await apiClient.get(`/cameras/${id}/ping`); // Critical check: If the server returns HTML (catch-all), it's old code if (typeof res.data === 'string' && res.data.includes(' { try { const res = await apiClient.get('/health'); console.log('[System] Server Version:', res.data); } catch (e) { console.warn('[System] Could not fetch server health - might be old version.'); } }; useEffect(() => { fetchCameras(); fetchZones(); checkServerVersion(); }, []); const HeaderDropdown = () => { const portalRoot = document.getElementById('header-portal-root'); if (!portalRoot) return null; // Tabs: [Zones...] const zoneTabs = availableZones.map(z => z.name); const layoutOptions = [ { id: '1', label: '1개', icon:
}, { id: '1*2', label: '1*2', icon:
}, { id: '2*2', label: '2*2', icon:
}, { id: '3*3', label: '3*3', icon:
}, { id: '4*4', label: '4*4', icon:
}, { id: '5*5', label: '5*5', icon:
}, { id: '6*6', label: '6*6', icon:
} ]; return createPortal(
{/* Left Area: Zone Tabs */}
{zoneTabs.map(name => ( ))}
{/* Spacer to push everything else to the right */}
{/* Right Area: Layout Selector */}
{showLayoutMenu && activeZone && (
Monitor Arrangement
{layoutOptions.map(opt => ( ))}
)}
, portalRoot ); }; const getLayoutInfo = () => { if (viewLayout === '1') return { cols: 'grid-cols-1', total: 1 }; if (viewLayout === '1*2') return { cols: 'grid-cols-2', total: 2 }; // Multi-grid like 2*2, 3*3, 4*4 const parts = viewLayout.split('*'); if (parts.length === 2) { const n = parseInt(parts[0]); return { cols: `grid-cols-${n}`, total: n * n }; } return { cols: 'grid-cols-2', total: 4 }; }; const layoutInfo = getLayoutInfo(); const totalSlots = Math.max(layoutInfo.total, filteredCameras.length); // Fill slots: actual cameras + empty placeholders if needed to complete the grid rows const slots = Array.from({ length: totalSlots }).map((_, i) => filteredCameras[i] || null); return (
c.id)} strategy={rectSortingStrategy} disabled={true} >
layoutInfo.total ? 'overflow-y-auto' : ''}`} style={{ gridAutoRows: (viewLayout === '1' || viewLayout === '1*2') ? '100%' : `${100 / Math.sqrt(layoutInfo.total)}%` }}> {slots.map((camera, index) => (
{camera ? (
{/* VMS Slot Header */}
{camera.name}
{(user?.role === 'admin' || user?.role === 'supervisor') && (
e.stopPropagation()}>
)}
{/* Streaming Video */}
{/* Small persistent ID or Name overlay */}
CH {index + 1} | {camera.ip_address}
) : (
)}
))}
); }