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 (
{children}
);
}
export function MonitoringPage() {
const { user } = useAuth();
const [cameras, setCameras] = useState([]);
const [showForm, setShowForm] = useState(false);
const [editingCamera, setEditingCamera] = useState(null);
const [formData, setFormData] = useState>({
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) => {
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(' {
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 (
CCTV ({user?.role})
{(user?.role === 'admin' || user?.role === 'supervisor') && (
)}
{/* Camera Grid with DnD */}
c.id)}
strategy={rectSortingStrategy}
>
{cameras.map(camera => (
{/* Overlay Controls */}
{(user?.role === 'admin' || user?.role === 'supervisor') && (
e.stopPropagation()}>
)}
{camera.name} {(camera.is_active === 0 || camera.is_active === false) && (중지됨)}
{/* IP/Port Hidden as requested */}
))}
{cameras.length === 0 && (
등록된 카메라가 없습니다. 카메라를 추가해주세요.
)}
{/* Add/Edit Modal */}
{showForm && (
{editingCamera ? '카메라 설정 수정' : '새 카메라 추가'}
)}
);
}