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

411 lines
22 KiB
TypeScript

import { useState, useEffect } from 'react';
import { apiClient } from '../../../shared/api/client';
import { JSMpegPlayer } from '../components/JSMpegPlayer';
import { Plus, Settings, Trash2, X, Video } 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;
}
// 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="bg-white rounded-xl shadow-lg overflow-hidden border border-slate-200">
{children}
</div>
);
}
export function MonitoringPage() {
const { user } = useAuth();
const [cameras, setCameras] = useState<Camera[]>([]);
const [showForm, setShowForm] = useState(false);
const [editingCamera, setEditingCamera] = useState<Camera | null>(null);
const [formData, setFormData] = useState<Partial<Camera>>({
name: '',
ip_address: '',
port: 554,
username: '',
password: '',
stream_path: '/stream1',
transport_mode: 'tcp',
rtsp_encoding: false,
quality: 'low'
});
const [loading, setLoading] = useState(false);
const [streamVersions, setStreamVersions] = useState<{ [key: number]: number }>({});
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
useEffect(() => {
fetchCameras();
}, []);
const fetchCameras = async () => {
try {
const res = await apiClient.get('/cameras');
setCameras(res.data);
} catch (err) {
console.error('Failed to fetch cameras', err);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
if (editingCamera) {
await apiClient.put(`/cameras/${editingCamera.id}`, formData);
// Force stream refresh for this camera
setStreamVersions(prev => ({ ...prev, [editingCamera.id]: Date.now() }));
} else {
await apiClient.post('/cameras', formData);
}
setShowForm(false);
setEditingCamera(null);
setFormData({
name: '',
ip_address: '',
port: 554,
username: '',
password: '',
stream_path: '/stream1',
transport_mode: 'tcp',
rtsp_encoding: false,
quality: 'low'
});
fetchCameras();
} catch (err) {
console.error('Failed to save camera', err);
const errMsg = (err as any).response?.data?.error || (err as any).message || '카메라 저장 실패';
alert(`오류 발생: ${errMsg}`);
} finally {
setLoading(false);
}
};
const handleEdit = (camera: Camera) => {
setEditingCamera(camera);
setFormData(camera);
setShowForm(true);
};
const handleDelete = async (id: number) => {
if (!window.confirm('정말 삭제하시겠습니까?')) return;
try {
await apiClient.delete(`/cameras/${id}`);
fetchCameras();
} catch (err) {
console.error('Failed to delete camera', err);
}
};
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();
checkServerVersion();
}, []);
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Video className="text-blue-600" />
CCTV <span className="text-xs text-slate-400 font-normal">({user?.role})</span>
</h1>
{(user?.role === 'admin' || user?.role === 'supervisor') && (
<button
onClick={() => {
setEditingCamera(null);
setFormData({
name: '',
ip_address: '',
port: 554,
username: '',
password: '',
stream_path: '/stream1'
});
setShowForm(true);
}}
className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-blue-700 transition"
>
<Plus size={18} />
</button>
)}
</div>
{/* Camera Grid with DnD */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={cameras.map(c => c.id)}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
{cameras.map(camera => (
<SortableCamera key={camera.id} camera={camera} disabled={user?.role !== 'admin' && user?.role !== 'supervisor'}>
<div className="relative aspect-video bg-black group">
<JSMpegPlayer
key={`${camera.id}-${streamVersions[camera.id] || 0}`}
url={getStreamUrl(camera.id)}
className="w-full h-full"
/>
{/* Overlay Controls */}
{(user?.role === 'admin' || user?.role === 'supervisor') && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2" onPointerDown={(e) => e.stopPropagation()}>
<button
onClick={() => handleToggleStatus(camera)}
className={`${(camera.is_active !== 0 && camera.is_active !== false) ? 'bg-orange-500/80 hover:bg-orange-600' : 'bg-green-600/80 hover:bg-green-700'} text-white p-2 rounded-full`}
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="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" 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="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
)}
</button>
<button
onClick={() => handlePing(camera.id)}
className="bg-green-600/80 text-white p-2 rounded-full hover:bg-green-700"
title="연결 테스트 (Ping)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" 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={() => handleEdit(camera)}
className="bg-black/50 text-white p-2 rounded-full hover:bg-black/70"
title="설정"
>
<Settings size={16} />
</button>
<button
onClick={() => handleDelete(camera.id)}
className="bg-red-500/80 text-white p-2 rounded-full hover:bg-red-600"
title="삭제"
>
<Trash2 size={16} />
</button>
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
<h3 className="text-white font-medium flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${(camera.is_active !== 0 && camera.is_active !== false) ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}></span>
{camera.name} {(camera.is_active === 0 || camera.is_active === false) && <span className="text-xs text-slate-400">()</span>}
</h3>
{/* IP/Port Hidden as requested */}
</div>
</div>
</SortableCamera>
))}
</div>
</SortableContext>
</DndContext>
{cameras.length === 0 && (
<div className="text-center py-20 text-slate-500">
<Video size={48} className="mx-auto mb-4 opacity-50" />
<p> . .</p>
</div>
)}
{/* Add/Edit Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg p-6 animate-in fade-in zoom-in duration-200">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold">
{editingCamera ? '카메라 설정 수정' : '새 카메라 추가'}
</h2>
<button onClick={() => setShowForm(false)} className="text-slate-400 hover:text-slate-600">
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"> </label>
<input type="text" name="name" value={formData.name} onChange={handleInputChange} required className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="예: 정문 CCTV" />
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-slate-700 mb-1">IP </label><input type="text" name="ip_address" value={formData.ip_address} onChange={handleInputChange} required className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="192.168.1.100" /></div>
<div><label className="block text-sm font-medium text-slate-700 mb-1"></label><input type="number" name="port" value={formData.port} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="554" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-slate-700 mb-1">RTSP </label><input type="text" name="username" value={formData.username} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="admin" /></div>
<div><label className="block text-sm font-medium text-slate-700 mb-1">RTSP </label><input type="password" name="password" value={formData.password} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" /></div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"> </label>
<input type="text" name="stream_path" value={formData.stream_path} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="/stream1" />
</div>
{/* Advanced Settings */}
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
<h3 className="text-sm font-bold text-slate-700 mb-3 flex items-center gap-2">
<Settings size={14} />
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1"> (Transport)</label>
<select name="transport_mode" value={formData.transport_mode || 'tcp'} onChange={(e) => setFormData(prev => ({ ...prev, transport_mode: e.target.value as any }))} className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none">
<option value="tcp">TCP ( - )</option>
<option value="udp">UDP ( - )</option>
<option value="auto">Auto</option>
</select>
</div>
<div className="flex items-center">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="rtsp_encoding" checked={!!formData.rtsp_encoding} onChange={(e) => setFormData(prev => ({ ...prev, rtsp_encoding: e.target.checked }))} className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500" />
<span className="text-sm text-slate-600"> </span>
</label>
</div>
<div className="col-span-2">
<label className="block text-xs font-medium text-slate-600 mb-1"> (Quality)</label>
<select name="quality" value={formData.quality || 'low'} onChange={(e) => setFormData(prev => ({ ...prev, quality: e.target.value as any }))} className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none">
<option value="low">Low (640px) - ()</option>
<option value="medium">Medium (1280px) - HD </option>
<option value="original">Original - ( )</option>
</select>
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button type="button" onClick={() => setShowForm(false)} className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition"></button>
<button type="submit" disabled={loading} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50">{loading ? '저장 중...' : '저장하기'}</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}