394 lines
20 KiB
TypeScript
394 lines
20 KiB
TypeScript
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 (
|
|
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className={`relative bg-black overflow-hidden border ${disabled ? 'border-slate-800' : 'border-slate-700 hover:border-indigo-500'} transition-colors group h-full w-full`}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function MonitoringPage() {
|
|
const { user } = useAuth();
|
|
const [cameras, setCameras] = useState<Camera[]>([]);
|
|
const [activeZone, setActiveZone] = useState<string>('');
|
|
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('<html')) {
|
|
alert('❌ Server Sync Error!\nThe production server is still running old code (received HTML instead of JSON).\nPlease re-upload the "server" folder and restart PM2.');
|
|
return;
|
|
}
|
|
|
|
if (res.data.success) {
|
|
alert(`✅ Ping Success!\nServer can reach Camera (${res.data.ip}).`);
|
|
} else {
|
|
alert(`❌ Ping Failed!\nServer cannot reach Camera (${res.data.ip || 'Unknown IP'}).\nCheck NAS network/firewall.`);
|
|
}
|
|
} catch (err: any) {
|
|
if (err.response?.status === 404) {
|
|
alert('❌ API Not Found (404)!\nThe server does not have the "ping" feature. Please update and restart the backend.');
|
|
} else {
|
|
alert('Ping API Error. Please check server logs.');
|
|
}
|
|
}
|
|
};
|
|
|
|
const checkServerVersion = async () => {
|
|
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: <div className="w-4 h-4 border border-white/40" /> },
|
|
{ id: '1*2', label: '1*2', icon: <div className="w-4 h-4 border border-white/40 flex"><div className="w-1/2 border-r border-white/40" /></div> },
|
|
{ id: '2*2', label: '2*2', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-2 grid-rows-2"><div className="border-r border-b border-white/40" /><div className="border-b border-white/40" /><div className="border-r border-white/40" /></div> },
|
|
{ id: '3*3', label: '3*3', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-3 grid-rows-3"><div className="border-r border-b border-white/40" /><div className="border-r border-b border-white/40" /><div className="border-b border-white/40" /><div className="border-r border-b border-white/40" /><div className="border-r border-b border-white/40" /><div className="border-b border-white/40" /></div> },
|
|
{ id: '4*4', label: '4*4', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-4 grid-rows-4" /> },
|
|
{ id: '5*5', label: '5*5', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-5 grid-rows-5" /> },
|
|
{ id: '6*6', label: '6*6', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-6 grid-rows-6" /> }
|
|
];
|
|
|
|
return createPortal(
|
|
<div className="flex items-center w-full h-full pointer-events-none">
|
|
{/* Left Area: Zone Tabs */}
|
|
<div className="flex gap-1 h-full items-end pb-1 overflow-x-auto scrollbar-hide pointer-events-auto border-l border-slate-200 ml-4 pl-4">
|
|
{zoneTabs.map(name => (
|
|
<button
|
|
key={name}
|
|
onClick={() => setActiveZone(name)}
|
|
className={`px-4 py-2 text-[13px] font-bold transition-all border-b-2 whitespace-nowrap ${activeZone === name
|
|
? 'text-indigo-600 border-indigo-600 bg-indigo-50/20'
|
|
: 'text-slate-400 border-transparent hover:text-slate-600'
|
|
}`}
|
|
>
|
|
{name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Spacer to push everything else to the right */}
|
|
<div className="flex-1" />
|
|
|
|
{/* Right Area: Layout Selector */}
|
|
<div className="px-3 pointer-events-auto flex items-center">
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setShowLayoutMenu(!showLayoutMenu)}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border ${showLayoutMenu ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-slate-50 text-slate-600 border-slate-200 hover:border-indigo-400'} ${!activeZone ? 'opacity-50 grayscale cursor-not-allowed' : ''}`}
|
|
disabled={!activeZone}
|
|
>
|
|
<LayoutGrid size={14} />
|
|
레이아웃: {!activeZone ? '--' : viewLayout}
|
|
<ChevronDown size={14} className={`transition-transform duration-200 ${showLayoutMenu ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
{showLayoutMenu && activeZone && (
|
|
<div className="absolute right-0 top-full mt-2 w-48 bg-[#1a1a1a] rounded-xl shadow-2xl border border-white/10 p-2 z-[100] animate-in fade-in zoom-in duration-200">
|
|
<div className="px-2 py-1.5 text-[9px] font-bold text-white/30 uppercase tracking-[0.2em] border-b border-white/5 mb-1">
|
|
Monitor Arrangement
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-1">
|
|
{layoutOptions.map(opt => (
|
|
<button
|
|
key={opt.id}
|
|
onClick={() => handleLayoutChange(opt.id)}
|
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-[11px] font-medium transition-all ${viewLayout === opt.id ? 'bg-indigo-600 text-white' : 'text-white/60 hover:bg-white/5 hover:text-white'}`}
|
|
>
|
|
<div className="w-4 flex justify-center opacity-60">{opt.icon}</div>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
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 (
|
|
<div className="w-full h-full bg-[#0a0a0a] flex flex-col min-h-0">
|
|
<HeaderDropdown />
|
|
|
|
<div className="flex-1 p-2 overflow-hidden flex flex-col min-h-0">
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={filteredCameras.map(c => c.id)}
|
|
strategy={rectSortingStrategy}
|
|
disabled={true}
|
|
>
|
|
<div className={`grid h-full w-full gap-1 ${layoutInfo.cols} ${totalSlots > layoutInfo.total ? 'overflow-y-auto' : ''}`}
|
|
style={{ gridAutoRows: (viewLayout === '1' || viewLayout === '1*2') ? '100%' : `${100 / Math.sqrt(layoutInfo.total)}%` }}>
|
|
{slots.map((camera, index) => (
|
|
<div key={camera ? camera.id : `empty-${index}`} className="h-full w-full">
|
|
{camera ? (
|
|
<SortableCamera camera={camera} disabled={true}>
|
|
<div className="relative w-full h-full flex flex-col group text-white/90">
|
|
{/* VMS Slot Header */}
|
|
<div className="absolute top-0 left-0 right-0 z-10 bg-black/40 backdrop-blur-sm border-b border-white/5 px-3 py-1.5 flex justify-between items-center opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-2 h-2 rounded-full ${(camera.is_active !== 0 && camera.is_active !== false) ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}></div>
|
|
<span className="text-[11px] font-bold truncate max-w-[150px]">{camera.name}</span>
|
|
</div>
|
|
|
|
{(user?.role === 'admin' || user?.role === 'supervisor') && (
|
|
<div className="flex gap-1" onPointerDown={(e) => e.stopPropagation()}>
|
|
<button
|
|
onClick={() => handlePing(camera.id)}
|
|
className="p-1 text-white/50 hover:text-green-400 transition-colors"
|
|
title="Ping"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3L15.5 7.5z" /></svg>
|
|
</button>
|
|
<button
|
|
onClick={() => handleToggleStatus(camera)}
|
|
className={`p-1 transition-colors ${(camera.is_active !== 0 && camera.is_active !== false) ? 'text-white/50 hover:text-orange-400' : 'text-white/50 hover:text-green-400'}`}
|
|
title={(camera.is_active !== 0 && camera.is_active !== false) ? "Pause" : "Play"}
|
|
>
|
|
{(camera.is_active !== 0 && camera.is_active !== false) ? (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>
|
|
) : (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Streaming Video */}
|
|
<div className="flex-1 bg-black flex items-center justify-center">
|
|
<JSMpegPlayer
|
|
key={`${camera.id}-${streamVersions[camera.id] || 0}`}
|
|
url={getStreamUrl(camera.id)}
|
|
className="w-full h-full object-contain"
|
|
/>
|
|
</div>
|
|
|
|
{/* Small persistent ID or Name overlay */}
|
|
<div className="absolute bottom-2 left-2 px-2 py-0.5 bg-black/60 rounded text-[10px] text-white/30 font-mono pointer-events-none border border-white/5">
|
|
CH {index + 1} | {camera.ip_address}
|
|
</div>
|
|
</div>
|
|
</SortableCamera>
|
|
) : (
|
|
<div className="h-full w-full bg-[#121212] border border-slate-900/50 flex flex-col items-center justify-center text-slate-700 select-none">
|
|
<Video size={32} className="opacity-10 mb-2" />
|
|
<span className="text-[11px] font-bold opacity-20 uppercase tracking-widest">No Buffer</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|