smart_ims/src/modules/cctv/pages/MonitoringPage.tsx

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>
);
}