411 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|