wing-ops/frontend/src/tabs/aerial/components/RealtimeDrone.tsx
jeonghyo.k a86188f473 feat(map): 전체 탭 지도 배경 토글 통합 및 기본지도 변경
- 지도 스타일 상수를 mapStyles.ts로 추출
- useBaseMapStyle 훅 생성 (mapToggles 기반 스타일 반환)
- 9개 탭 컴포넌트의 하드코딩 스타일을 공유 훅으로 교체
- 각 Map에 S57EncOverlay 추가
- 초기 mapToggles를 모두 false로 변경 (기본지도 표시)
2026-03-31 17:56:40 +09:00

496 lines
28 KiB
TypeScript
Raw Blame 히스토리

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, useRef } from 'react'
import { Map, Marker, Popup } from '@vis.gl/react-maplibre'
import 'maplibre-gl/dist/maplibre-gl.css'
import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi'
import type { DroneStreamItem } from '../services/aerialApi'
import { CCTVPlayer } from './CCTVPlayer'
import type { CCTVPlayerHandle } from './CCTVPlayer'
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'
import { useMapStore } from '@common/store/mapStore'
/** 함정 위치 + 드론 비행 위치 */
const DRONE_POSITIONS: Record<string, { ship: { lat: number; lon: number }; drone: { lat: number; lon: number } }> = {
'busan-1501': { ship: { lat: 35.0796, lon: 129.0756 }, drone: { lat: 35.1100, lon: 129.1100 } },
'incheon-3008': { ship: { lat: 37.4541, lon: 126.5986 }, drone: { lat: 37.4800, lon: 126.5600 } },
'mokpo-3015': { ship: { lat: 34.7780, lon: 126.3780 }, drone: { lat: 34.8050, lon: 126.4100 } },
}
export function RealtimeDrone() {
const [streams, setStreams] = useState<DroneStreamItem[]>([])
const [loading, setLoading] = useState(true)
const [selectedStream, setSelectedStream] = useState<DroneStreamItem | null>(null)
const [gridMode, setGridMode] = useState(1)
const [activeCells, setActiveCells] = useState<DroneStreamItem[]>([])
const [mapPopup, setMapPopup] = useState<DroneStreamItem | null>(null)
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
const currentMapStyle = useBaseMapStyle()
const mapToggles = useMapStore((s) => s.mapToggles)
const showMap = activeCells.length === 0
const loadStreams = useCallback(async () => {
try {
const items = await fetchDroneStreams()
setStreams(items)
// Update selected stream and active cells with latest status
setSelectedStream(prev => prev ? items.find(s => s.id === prev.id) ?? prev : prev)
setActiveCells(prev => prev.map(cell => items.find(s => s.id === cell.id) ?? cell))
} catch {
// Fallback: show configured streams as idle
setStreams([
{ id: 'busan-1501', name: '1501함 드론', shipName: '부산서 1501함', droneModel: 'DJI M300 RTK', ip: '10.26.7.213', rtspUrl: 'rtsp://10.26.7.213:554/stream0', region: '부산', status: 'idle', hlsUrl: null, error: null },
{ id: 'incheon-3008', name: '3008함 드론', shipName: '인천서 3008함', droneModel: 'DJI M30T', ip: '10.26.5.21', rtspUrl: 'rtsp://10.26.5.21:554/stream0', region: '인천', status: 'idle', hlsUrl: null, error: null },
{ id: 'mokpo-3015', name: '3015함 드론', shipName: '목포서 3015함', droneModel: 'DJI Mavic 3E', ip: '10.26.7.85', rtspUrl: 'rtsp://10.26.7.85:554/stream0', region: '목포', status: 'idle', hlsUrl: null, error: null },
])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadStreams()
}, [loadStreams])
// Poll status every 3 seconds when any stream is starting
useEffect(() => {
const hasStarting = streams.some(s => s.status === 'starting')
if (!hasStarting) return
const timer = setInterval(loadStreams, 3000)
return () => clearInterval(timer)
}, [streams, loadStreams])
const handleStartStream = async (id: string) => {
try {
await startDroneStreamApi(id)
// Immediately update to 'starting' state
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'starting' as const, error: null } : s))
// Poll for status update
setTimeout(loadStreams, 2000)
} catch {
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'error' as const, error: '스트림 시작 요청 실패' } : s))
}
}
const handleStopStream = async (id: string) => {
try {
await stopDroneStreamApi(id)
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'idle' as const, hlsUrl: null, error: null } : s))
setActiveCells(prev => prev.filter(c => c.id !== id))
} catch {
// ignore
}
}
const handleSelectStream = (stream: DroneStreamItem) => {
setSelectedStream(stream)
if (stream.status === 'streaming' && stream.hlsUrl) {
if (gridMode === 1) {
setActiveCells([stream])
} else {
setActiveCells(prev => {
if (prev.length < gridMode && !prev.find(c => c.id === stream.id)) return [...prev, stream]
return prev
})
}
}
}
const statusInfo = (status: string) => {
switch (status) {
case 'streaming': return { label: '송출중', color: 'var(--color-success)', bg: 'rgba(34,197,94,.12)' }
case 'starting': return { label: '연결중', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.12)' }
case 'error': return { label: '오류', color: 'var(--color-danger)', bg: 'rgba(239,68,68,.12)' }
default: return { label: '대기', color: 'var(--fg-disabled)', bg: 'rgba(255,255,255,.06)' }
}
}
const gridCols = gridMode === 1 ? 1 : 2
const totalCells = gridMode
return (
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
{/* 좌측: 드론 스트림 목록 */}
<div className="flex flex-col overflow-hidden bg-bg-surface border-r border-stroke w-[260px] min-w-[260px]">
{/* 헤더 */}
<div className="p-3 pb-2.5 border-b border-stroke shrink-0 bg-bg-elevated">
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-bold text-fg font-korean flex items-center gap-1.5">
<span className="w-[7px] h-[7px] rounded-full inline-block" style={{ background: streams.some(s => s.status === 'streaming') ? 'var(--color-success)' : 'var(--fg-disabled)' }} />
</div>
<button
onClick={loadStreams}
className="px-2 py-0.5 text-[9px] font-korean bg-bg-card border border-stroke rounded text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
></button>
</div>
<div className="text-[9px] text-fg-disabled font-korean">ViewLink RTSP · </div>
</div>
{/* 드론 스트림 카드 */}
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
{loading ? (
<div className="px-3.5 py-4 text-[11px] text-fg-disabled font-korean"> ...</div>
) : streams.map(stream => {
const si = statusInfo(stream.status)
const isSelected = selectedStream?.id === stream.id
return (
<div
key={stream.id}
onClick={() => handleSelectStream(stream)}
className="px-3.5 py-3 border-b cursor-pointer transition-colors"
style={{
borderColor: 'rgba(255,255,255,.04)',
background: isSelected ? 'rgba(6,182,212,.08)' : 'transparent',
}}
>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<div className="text-sm">🚁</div>
<div>
<div className="text-[11px] font-semibold text-fg font-korean">{stream.shipName} <span className="text-[9px] text-color-accent font-semibold">({stream.droneModel})</span></div>
<div className="text-[9px] text-fg-disabled font-mono">{stream.ip}</div>
</div>
</div>
<span
className="text-[8px] font-bold px-1.5 py-0.5 rounded-full"
style={{ background: si.bg, color: si.color }}
>{si.label}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-fg-disabled font-korean px-1.5 py-0.5 rounded bg-bg-card">{stream.region}</span>
<span className="text-[8px] text-fg-disabled font-mono px-1.5 py-0.5 rounded bg-bg-card">RTSP :554</span>
</div>
{stream.error && (
<div className="mt-1.5 text-[8px] text-color-danger font-korean px-1.5 py-1 rounded bg-[rgba(239,68,68,.06)]">
{stream.error}
</div>
)}
{/* 시작/중지 버튼 */}
<div className="mt-2 flex gap-1.5">
{stream.status === 'idle' || stream.status === 'error' ? (
<button
onClick={(e) => { e.stopPropagation(); handleStartStream(stream.id) }}
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
style={{ background: 'rgba(34,197,94,.1)', borderColor: 'rgba(34,197,94,.3)', color: 'var(--color-success)' }}
> </button>
) : (
<button
onClick={(e) => { e.stopPropagation(); handleStopStream(stream.id) }}
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
style={{ background: 'rgba(239,68,68,.1)', borderColor: 'rgba(239,68,68,.3)', color: 'var(--color-danger)' }}
> </button>
)}
</div>
</div>
)
})}
</div>
{/* 하단 안내 */}
<div className="px-3 py-2 border-t border-stroke bg-bg-elevated shrink-0">
<div className="text-[8px] text-fg-disabled font-korean leading-relaxed">
RTSP .
ViewLink .
</div>
</div>
</div>
{/* 중앙: 영상 뷰어 */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0 bg-bg-base">
{/* 툴바 */}
<div className="flex items-center justify-between px-4 py-2 border-b border-stroke bg-bg-elevated shrink-0 gap-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="text-xs font-bold text-fg font-korean whitespace-nowrap overflow-hidden text-ellipsis">
{selectedStream ? `🚁 ${selectedStream.shipName}` : '🚁 드론 스트림을 선택하세요'}
</div>
{selectedStream?.status === 'streaming' && (
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(34,197,94,.14)', border: '1px solid rgba(34,197,94,.35)', color: 'var(--color-success)' }}>
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-success)' }} />LIVE
</div>
)}
{selectedStream?.status === 'starting' && (
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(6,182,212,.14)', border: '1px solid rgba(6,182,212,.35)', color: 'var(--color-accent)' }}>
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-accent)' }} />
</div>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{/* 분할 모드 */}
<div className="flex border border-stroke rounded-[5px] overflow-hidden">
{[
{ mode: 1, icon: '▣', label: '1화면' },
{ mode: 4, icon: '⊞', label: '4분할' },
].map(g => (
<button
key={g.mode}
onClick={() => { setGridMode(g.mode); setActiveCells(prev => prev.slice(0, g.mode)) }}
title={g.label}
className="px-2 py-1 text-[11px] cursor-pointer border-none transition-colors"
style={gridMode === g.mode
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)' }
: { background: 'var(--bg-card)', color: 'var(--fg-sub)' }
}
>{g.icon}</button>
))}
</div>
<button
onClick={() => playerRefs.current.forEach(r => r?.capture())}
className="px-2.5 py-1 bg-bg-card border border-stroke rounded-[5px] text-fg-sub text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-surface-hover transition-colors"
>📷 </button>
</div>
</div>
{/* 드론 위치 지도 또는 영상 그리드 */}
{showMap ? (
<div className="flex-1 overflow-hidden relative">
<Map
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
mapStyle={currentMapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
{streams.map(stream => {
const pos = DRONE_POSITIONS[stream.id]
if (!pos) return null
const statusColor = stream.status === 'streaming' ? '#22c55e' : stream.status === 'starting' ? '#06b6d4' : stream.status === 'error' ? '#ef4444' : '#94a3b8'
return (
<Marker
key={stream.id}
longitude={(pos.ship.lon + pos.drone.lon) / 2}
latitude={(pos.ship.lat + pos.drone.lat) / 2}
anchor="center"
onClick={e => { e.originalEvent.stopPropagation(); setMapPopup(stream) }}
>
<div className="cursor-pointer group" title={stream.shipName}>
<svg width="130" height="85" viewBox="0 0 130 85" fill="none" className="drop-shadow-lg transition-transform group-hover:scale-105" style={{ overflow: 'visible' }}>
{/* 연결선 (점선) */}
<line x1="28" y1="52" x2="88" y2="30" stroke={statusColor} strokeWidth="1.2" strokeDasharray="4 3" opacity="0.5" />
{/* ── 함정: MarineTraffic 스타일 삼각형 (선수 방향 위) ── */}
<polygon points="28,38 18,58 38,58" fill={statusColor} opacity="0.85" />
<polygon points="28,38 18,58 38,58" fill="none" stroke="#fff" strokeWidth="0.8" opacity="0.5" />
{/* 함정명 라벨 */}
<rect x="3" y="61" width="50" height="13" rx="3" fill="rgba(0,0,0,.75)" />
<text x="28" y="70.5" textAnchor="middle" fill="#fff" fontSize="7" fontFamily="sans-serif" fontWeight="bold">{stream.shipName.replace(/서 /, ' ')}</text>
{/* ── 드론: 쿼드콥터 아이콘 ── */}
{/* 외곽 원 */}
<circle cx="88" cy="30" r="18" fill="rgba(10,14,24,.7)" stroke={statusColor} strokeWidth="1.5" />
{/* X자 팔 */}
<line x1="76" y1="18" x2="100" y2="42" stroke={statusColor} strokeWidth="1.2" opacity="0.5" />
<line x1="100" y1="18" x2="76" y2="42" stroke={statusColor} strokeWidth="1.2" opacity="0.5" />
{/* 프로펠러 4개 (회전 애니메이션) */}
<ellipse cx="76" cy="18" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
<animateTransform attributeName="transform" type="rotate" from="0 76 18" to="360 76 18" dur="1.5s" repeatCount="indefinite" />
</ellipse>
<ellipse cx="100" cy="18" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
<animateTransform attributeName="transform" type="rotate" from="0 100 18" to="-360 100 18" dur="1.5s" repeatCount="indefinite" />
</ellipse>
<ellipse cx="76" cy="42" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
<animateTransform attributeName="transform" type="rotate" from="0 76 42" to="-360 76 42" dur="1.5s" repeatCount="indefinite" />
</ellipse>
<ellipse cx="100" cy="42" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
<animateTransform attributeName="transform" type="rotate" from="0 100 42" to="360 100 42" dur="1.5s" repeatCount="indefinite" />
</ellipse>
{/* 본체 */}
<circle cx="88" cy="30" r="6" fill={statusColor} opacity="0.8" />
{/* 카메라 렌즈 */}
<circle cx="88" cy="30" r="3" fill="#fff" opacity="0.9" />
<circle cx="88" cy="30" r="1.5" fill={statusColor} />
{/* 송출중 REC LED */}
{stream.status === 'streaming' && (
<circle cx="100" cy="16" r="3" fill="#ef4444">
<animate attributeName="opacity" values="1;0.2;1" dur="1s" repeatCount="indefinite" />
</circle>
)}
{/* 드론 모델명 */}
<rect x="65" y="51" width="46" height="12" rx="3" fill="rgba(0,0,0,.75)" />
<text x="88" y="60" textAnchor="middle" fill={statusColor} fontSize="7" fontFamily="sans-serif" fontWeight="bold">{stream.droneModel.split(' ').slice(-1)[0]}</text>
</svg>
</div>
</Marker>
)
})}
{/* 드론 클릭 팝업 */}
{mapPopup && DRONE_POSITIONS[mapPopup.id] && (
<Popup
longitude={DRONE_POSITIONS[mapPopup.id].drone.lon}
latitude={DRONE_POSITIONS[mapPopup.id].drone.lat}
anchor="bottom"
onClose={() => setMapPopup(null)}
closeOnClick={false}
offset={36}
className="cctv-dark-popup"
>
<div className="p-2.5" style={{ minWidth: 170, background: 'var(--bg-card)', borderRadius: 6 }}>
<div className="flex items-center gap-1.5 mb-1">
<span className="text-sm">🚁</span>
<div className="text-[11px] font-bold text-fg">{mapPopup.shipName}</div>
</div>
<div className="text-[9px] text-fg-disabled mb-0.5">{mapPopup.droneModel}</div>
<div className="text-[8px] text-fg-disabled font-mono mb-2">{mapPopup.ip} · {mapPopup.region}</div>
<div className="flex items-center gap-1.5 mb-2">
<span className="text-[8px] font-bold px-1.5 py-px rounded-full"
style={{ background: statusInfo(mapPopup.status).bg, color: statusInfo(mapPopup.status).color }}
> {statusInfo(mapPopup.status).label}</span>
</div>
{mapPopup.status === 'idle' || mapPopup.status === 'error' ? (
<button
onClick={() => { handleStartStream(mapPopup.id); handleSelectStream(mapPopup); setMapPopup(null) }}
className="w-full px-2 py-1.5 rounded text-[10px] font-bold cursor-pointer border transition-colors"
style={{ background: 'rgba(34,197,94,.15)', borderColor: 'rgba(34,197,94,.3)', color: '#4ade80' }}
> </button>
) : mapPopup.status === 'streaming' ? (
<button
onClick={() => { handleSelectStream(mapPopup); setMapPopup(null) }}
className="w-full px-2 py-1.5 rounded text-[10px] font-bold cursor-pointer border transition-colors"
style={{ background: 'rgba(6,182,212,.15)', borderColor: 'rgba(6,182,212,.3)', color: '#67e8f9' }}
> </button>
) : (
<div className="text-[9px] text-color-accent font-korean text-center animate-pulse"> ...</div>
)}
</div>
</Popup>
)}
</Map>
{/* 지도 위 안내 배지 */}
<div className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full text-[10px] font-bold font-korean z-10"
style={{ background: 'rgba(0,0,0,.7)', color: 'rgba(255,255,255,.7)', backdropFilter: 'blur(4px)' }}>
🚁 ({streams.length})
</div>
</div>
) : (
<div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
style={{
gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
gridTemplateRows: `repeat(${gridCols}, 1fr)`,
}}>
{Array.from({ length: totalCells }).map((_, i) => {
const stream = activeCells[i]
return (
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-bg-base" style={{ border: '1px solid var(--stroke-light)' }}>
{stream && stream.status === 'streaming' && stream.hlsUrl ? (
<CCTVPlayer
ref={el => { playerRefs.current[i] = el }}
cameraNm={stream.shipName}
streamUrl={stream.hlsUrl}
sttsCd="LIVE"
coordDc={`${stream.ip} · RTSP`}
sourceNm="ViewLink"
cellIndex={i}
/>
) : stream && stream.status === 'starting' ? (
<div className="flex flex-col items-center justify-center gap-2">
<div className="text-lg opacity-40 animate-pulse">🚁</div>
<div className="text-[10px] text-color-accent font-korean animate-pulse">RTSP ...</div>
<div className="text-[8px] text-fg-disabled font-mono">{stream.ip}:554</div>
</div>
) : stream && stream.status === 'error' ? (
<div className="flex flex-col items-center justify-center gap-2">
<div className="text-lg opacity-30"></div>
<div className="text-[10px] text-color-danger font-korean"> </div>
<div className="text-[8px] text-fg-disabled font-korean max-w-[200px] text-center">{stream.error}</div>
<button
onClick={() => handleStartStream(stream.id)}
className="mt-1 px-2.5 py-1 rounded text-[9px] font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
></button>
</div>
) : (
<div className="text-[10px] text-fg-disabled font-korean opacity-40">
{streams.length > 0 ? '스트림을 시작하고 선택하세요' : '드론 스트림을 선택하세요'}
</div>
)}
</div>
)
})}
</div>
)}
{/* 하단 정보 바 */}
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-stroke bg-bg-elevated shrink-0">
<div className="text-[10px] text-fg-disabled font-korean">: <b className="text-fg">{selectedStream?.shipName ?? ''}</b></div>
<div className="text-[10px] text-fg-disabled font-korean">IP: <span className="text-color-accent font-mono text-[9px]">{selectedStream?.ip ?? ''}</span></div>
<div className="text-[10px] text-fg-disabled font-korean">: <span className="text-fg-sub">{selectedStream?.region ?? ''}</span></div>
<div className="ml-auto text-[9px] text-fg-disabled font-korean">RTSP HLS · ViewLink </div>
</div>
</div>
{/* 우측: 정보 패널 */}
<div className="flex flex-col overflow-hidden bg-bg-surface border-l border-stroke w-[220px] min-w-[220px]">
{/* 헤더 */}
<div className="px-3 py-2 border-b border-stroke text-[11px] font-bold text-fg font-korean bg-bg-elevated shrink-0">
📋
</div>
<div className="flex-1 overflow-y-auto px-3 py-2.5" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
{selectedStream ? (
<div className="flex flex-col gap-1.5">
{[
['함정명', selectedStream.shipName],
['드론명', selectedStream.name],
['기체모델', selectedStream.droneModel],
['IP 주소', selectedStream.ip],
['RTSP 포트', '554'],
['지역', selectedStream.region],
['프로토콜', 'RTSP → HLS'],
['상태', statusInfo(selectedStream.status).label],
].map(([k, v], i) => (
<div key={i} className="flex justify-between px-2 py-1 bg-bg-base rounded text-[9px]">
<span className="text-fg-disabled font-korean">{k}</span>
<span className="font-mono text-fg">{v}</span>
</div>
))}
{selectedStream.hlsUrl && (
<div className="px-2 py-1 bg-bg-base rounded text-[8px]">
<div className="text-fg-disabled font-korean mb-0.5">HLS URL</div>
<div className="font-mono text-color-accent break-all">{selectedStream.hlsUrl}</div>
</div>
)}
</div>
) : (
<div className="text-[10px] text-fg-disabled font-korean"> </div>
)}
{/* 연동 시스템 */}
<div className="mt-3 pt-2.5 border-t border-stroke">
<div className="text-[10px] font-bold text-fg-sub font-korean mb-2">🔗 </div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between px-2 py-1.5 bg-bg-card rounded-[5px]" style={{ border: '1px solid rgba(6,182,212,.2)' }}>
<span className="text-[9px] text-fg-sub font-korean">ViewLink 3.5</span>
<span className="text-[9px] font-bold" style={{ color: 'var(--color-accent)' }}> RTSP</span>
</div>
<div className="flex items-center justify-between px-2 py-1.5 bg-bg-card rounded-[5px]" style={{ border: '1px solid rgba(59,130,246,.2)' }}>
<span className="text-[9px] text-fg-sub font-korean">FFmpeg </span>
<span className="text-[9px] font-bold" style={{ color: 'var(--blue, #3b82f6)' }}>RTSPHLS</span>
</div>
</div>
</div>
{/* 전체 상태 요약 */}
<div className="mt-3 pt-2.5 border-t border-stroke">
<div className="text-[10px] font-bold text-fg-sub font-korean mb-2">📊 </div>
<div className="grid grid-cols-2 gap-1.5">
{[
{ label: '전체', value: streams.length, color: 'text-fg' },
{ label: '송출중', value: streams.filter(s => s.status === 'streaming').length, color: 'text-color-success' },
{ label: '연결중', value: streams.filter(s => s.status === 'starting').length, color: 'text-color-accent' },
{ label: '오류', value: streams.filter(s => s.status === 'error').length, color: 'text-color-danger' },
].map((item, i) => (
<div key={i} className="px-2 py-1.5 bg-bg-base rounded text-center">
<div className="text-[8px] text-fg-disabled font-korean">{item.label}</div>
<div className={`text-sm font-bold font-mono ${item.color}`}>{item.value}</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}