generated from CubeCraft-Creations/Tracehound
Merge pull request 'CUB-176: Central hub frontend — camera grid, start/stop controls, history viewer' (#15) from agent/rex/CUB-176-central-hub-frontend into dev
Reviewed-on: #15 Reviewed-by: Joshua <joshua@cnjmail.com>
This commit was merged in pull request #15.
This commit is contained in:
+185
-53
@@ -1,88 +1,220 @@
|
||||
import { Camera, Radio } from 'lucide-react'
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { Camera, Play, Square, Wifi, WifiOff, AlertTriangle } from 'lucide-react'
|
||||
import { useSSE } from './hooks/useSSE'
|
||||
import { useCameraStore } from './store/useCameraStore'
|
||||
import { CameraCard } from './components'
|
||||
import { api } from './services/api'
|
||||
import CameraCard from './components/CameraCard'
|
||||
import HistoryViewer from './components/HistoryViewer'
|
||||
|
||||
function App() {
|
||||
// Connect to SSE endpoint — auto-updates the camera store
|
||||
useSSE()
|
||||
const [commandBusy, setCommandBusy] = useState(false)
|
||||
const [commandError, setCommandError] = useState<string | null>(null)
|
||||
const [historyCameraId, setHistoryCameraId] = useState<string | null>(null)
|
||||
const [historyCameraName, setHistoryCameraName] = useState<string>()
|
||||
|
||||
// Subscribe to the camera store for reactivity.
|
||||
// getCameras / getOnlineCount / getRecordingCount pull from live state.
|
||||
const { getCameras, getOnlineCount, getRecordingCount } = useCameraStore()
|
||||
const cameras = getCameras()
|
||||
const onlineCount = getOnlineCount()
|
||||
const recordingCount = getRecordingCount()
|
||||
// SSE connection + live store
|
||||
const { connectionState } = useSSE()
|
||||
|
||||
// Subscribe to full camera state — dashboard needs every change
|
||||
const camerasMap = useCameraStore((s) => s.cameras)
|
||||
const cameras = useMemo(() => Array.from(camerasMap.values()), [camerasMap])
|
||||
const onlineCount = useMemo(() => cameras.filter((c) => c.online).length, [cameras])
|
||||
const recordingCount = useMemo(() => cameras.filter((c) => c.recording).length, [cameras])
|
||||
|
||||
const cameraIds = cameras.map((c) => c.camera_id)
|
||||
|
||||
// ── Command helpers ──
|
||||
|
||||
const handleStart = useCallback(async (cameraId: string) => {
|
||||
setCommandBusy(true)
|
||||
setCommandError(null)
|
||||
try {
|
||||
await api.startRecording(cameraId)
|
||||
} catch (err) {
|
||||
setCommandError(err instanceof Error ? err.message : 'Command failed')
|
||||
} finally {
|
||||
setCommandBusy(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleStop = useCallback(async (cameraId: string) => {
|
||||
setCommandBusy(true)
|
||||
setCommandError(null)
|
||||
try {
|
||||
await api.stopRecording(cameraId)
|
||||
} catch (err) {
|
||||
setCommandError(err instanceof Error ? err.message : 'Command failed')
|
||||
} finally {
|
||||
setCommandBusy(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleStartAll = useCallback(async () => {
|
||||
setCommandBusy(true)
|
||||
setCommandError(null)
|
||||
try {
|
||||
await Promise.all(cameraIds.map((id) => api.startRecording(id)))
|
||||
} catch {
|
||||
// Individual failures are non-fatal — some may succeed
|
||||
} finally {
|
||||
setCommandBusy(false)
|
||||
}
|
||||
}, [cameraIds])
|
||||
|
||||
const handleStopAll = useCallback(async () => {
|
||||
setCommandBusy(true)
|
||||
setCommandError(null)
|
||||
try {
|
||||
await Promise.all(cameraIds.map((id) => api.stopRecording(id)))
|
||||
} catch {
|
||||
// Individual failures are non-fatal
|
||||
} finally {
|
||||
setCommandBusy(false)
|
||||
}
|
||||
}, [cameraIds])
|
||||
|
||||
const handleViewHistory = useCallback((cameraId: string) => {
|
||||
const cam = useCameraStore.getState().cameras.get(cameraId)
|
||||
setHistoryCameraId(cameraId)
|
||||
setHistoryCameraName(cam?.friendly_name ?? cameraId)
|
||||
}, [])
|
||||
|
||||
const handleCloseHistory = useCallback(() => {
|
||||
setHistoryCameraId(null)
|
||||
}, [])
|
||||
|
||||
// ── Connection badge ──
|
||||
|
||||
const connectionBadge = {
|
||||
connected: { icon: Wifi, label: 'Live', class: 'bg-rig-success/15 text-rig-success' },
|
||||
connecting: { icon: Wifi, label: 'Connecting...', class: 'bg-rig-warning/15 text-rig-warning' },
|
||||
disconnected: { icon: WifiOff, label: 'Disconnected', class: 'bg-rig-danger/15 text-rig-danger' },
|
||||
error: { icon: AlertTriangle, label: 'Stream Error', class: 'bg-rig-danger/15 text-rig-danger' },
|
||||
}[connectionState] ?? {
|
||||
icon: WifiOff,
|
||||
label: 'Disconnected',
|
||||
class: 'bg-rig-danger/15 text-rig-danger',
|
||||
}
|
||||
|
||||
const BadgeIcon = connectionBadge.icon
|
||||
|
||||
// ── Render ──
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-rig-dark-900">
|
||||
<div className="min-h-screen bg-rig-dark-900 flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="border-b border-rig-dark-700 bg-rig-dark-800/50 backdrop-blur-sm">
|
||||
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Camera className="h-7 w-7 text-rig-accent" />
|
||||
<h1 className="text-xl font-bold tracking-tight text-rig-dark-50">
|
||||
<header className="shrink-0 border-b border-rig-dark-700 bg-rig-dark-800/50 backdrop-blur-sm">
|
||||
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Camera className="h-6 w-6 shrink-0 text-rig-accent" />
|
||||
<h1 className="text-lg font-bold tracking-tight text-rig-dark-50 truncate">
|
||||
RemoteRig
|
||||
</h1>
|
||||
<span className="ml-2 rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent">
|
||||
<span className="hidden sm:inline rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent">
|
||||
Dashboard
|
||||
</span>
|
||||
|
||||
{/* Stats badges */}
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
{/* Online count */}
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-rig-dark-700/60 px-3 py-1 text-xs font-medium text-rig-dark-200"
|
||||
title="Cameras online"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-rig-success" />
|
||||
{onlineCount} online
|
||||
</span>
|
||||
|
||||
{/* Recording count */}
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-rig-dark-700/60 px-3 py-1 text-xs font-medium text-rig-dark-200"
|
||||
title="Cameras recording"
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rig-danger opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-rig-danger" />
|
||||
</span>
|
||||
{recordingCount} recording
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connection status */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* SSE badge */}
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${connectionBadge.class}`}
|
||||
>
|
||||
<BadgeIcon className="h-3 w-3" />
|
||||
{connectionBadge.label}
|
||||
</span>
|
||||
|
||||
{/* Global controls */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleStartAll}
|
||||
disabled={commandBusy || cameras.length === 0}
|
||||
className="flex items-center gap-1 rounded-md bg-rig-success/20 px-3 py-1.5 text-xs font-medium text-rig-success hover:bg-rig-success/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Start recording on all cameras"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 fill-current" />
|
||||
<span className="hidden sm:inline">Start All</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStopAll}
|
||||
disabled={commandBusy || cameras.length === 0}
|
||||
className="flex items-center gap-1 rounded-md bg-rig-danger/20 px-3 py-1.5 text-xs font-medium text-rig-danger hover:bg-rig-danger/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Stop recording on all cameras"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
<span className="hidden sm:inline">Stop All</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats strip */}
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-rig-dark-400">
|
||||
<span>
|
||||
<strong className="text-rig-dark-100">{cameras.length}</strong> camera{cameras.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-rig-success">{onlineCount}</strong> online
|
||||
</span>
|
||||
<span>
|
||||
<strong className={recordingCount > 0 ? 'text-rig-danger' : 'text-rig-dark-300'}>
|
||||
{recordingCount}
|
||||
</strong>{' '}
|
||||
recording
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Command error toast */}
|
||||
{commandError && (
|
||||
<div className="shrink-0 border-b border-rig-danger/30 bg-rig-danger/10 px-4 py-2">
|
||||
<p className="mx-auto max-w-7xl text-xs text-rig-danger">
|
||||
<AlertTriangle className="inline h-3 w-3 mr-1" />
|
||||
{commandError}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<main className="flex-1 mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
{cameras.length === 0 ? (
|
||||
/* Empty state */
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-rig-dark-600 bg-rig-dark-800/30 py-24 text-center">
|
||||
<span className="relative mb-4 inline-flex">
|
||||
<Radio className="h-12 w-12 animate-pulse text-rig-accent" />
|
||||
</span>
|
||||
<Camera className="mb-4 h-12 w-12 text-rig-dark-500" />
|
||||
<h2 className="text-lg font-semibold text-rig-dark-200">
|
||||
Waiting for cameras…
|
||||
No Cameras Connected
|
||||
</h2>
|
||||
<p className="mt-2 max-w-sm text-sm text-rig-dark-400">
|
||||
Connect cameras to your RemoteRig server and they will appear here
|
||||
automatically.
|
||||
Waiting for camera nodes to connect. Ensure ESP32 bridges are powered on and connected to the network.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Camera grid */
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{cameras.map((camera) => (
|
||||
<CameraCard key={camera.camera_id} camera={camera} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{cameras.map((cam) => (
|
||||
<CameraCard
|
||||
key={cam.camera_id}
|
||||
camera={cam}
|
||||
onStart={handleStart}
|
||||
onStop={handleStop}
|
||||
onViewHistory={handleViewHistory}
|
||||
disabled={commandBusy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* History modal */}
|
||||
<HistoryViewer
|
||||
cameraId={historyCameraId}
|
||||
cameraName={historyCameraName}
|
||||
onClose={handleCloseHistory}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-rig-dark-700 bg-rig-dark-800/30">
|
||||
<footer className="shrink-0 border-t border-rig-dark-700 bg-rig-dark-800/30">
|
||||
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
|
||||
<p className="text-center text-xs text-rig-dark-500">
|
||||
RemoteRig v0.1.0 — Multi-Camera Remote Monitoring System
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import CameraCard from './CameraCard'
|
||||
import type { CameraStatus } from '../types'
|
||||
|
||||
@@ -19,52 +19,52 @@ function makeCamera(overrides: Partial<CameraStatus> = {}): CameraStatus {
|
||||
}
|
||||
}
|
||||
|
||||
const noop = vi.fn()
|
||||
const renderCard = (overrides?: Partial<CameraStatus>) =>
|
||||
render(<CameraCard camera={makeCamera(overrides ?? {})} onStart={noop} onStop={noop} onViewHistory={noop} />)
|
||||
const renderCardContainer = (camera: CameraStatus) =>
|
||||
render(<CameraCard camera={camera} onStart={noop} onStop={noop} onViewHistory={noop} />)
|
||||
|
||||
describe('CameraCard', () => {
|
||||
// ── Basic rendering ────────────────────────────────────────────────────
|
||||
|
||||
it('renders camera name', () => {
|
||||
render(<CameraCard camera={makeCamera()} />)
|
||||
renderCard()
|
||||
expect(screen.getByText('Front Camera')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows resolution and FPS', () => {
|
||||
render(<CameraCard camera={makeCamera()} />)
|
||||
renderCard()
|
||||
expect(screen.getByText(/1080p/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/30\s*FPS/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows battery percentage', () => {
|
||||
render(<CameraCard camera={makeCamera({ battery_pct: 85 })} />)
|
||||
renderCard({ battery_pct: 85 })
|
||||
expect(screen.getByText('85%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows N/A when battery is null', () => {
|
||||
render(<CameraCard camera={makeCamera({ battery_pct: null })} />)
|
||||
renderCard({ battery_pct: null })
|
||||
expect(screen.getByText('N/A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Battery bar colors ─────────────────────────────────────────────────
|
||||
|
||||
it('uses green bar for high battery (>=50%)', () => {
|
||||
const { container } = render(
|
||||
<CameraCard camera={makeCamera({ battery_pct: 85 })} />,
|
||||
)
|
||||
const { container } = renderCard({ battery_pct: 85 })
|
||||
const bar = container.querySelector('[role="progressbar"] div')
|
||||
expect(bar?.className).toContain('bg-rig-success')
|
||||
})
|
||||
|
||||
it('uses yellow bar for medium battery (15-49%)', () => {
|
||||
const { container } = render(
|
||||
<CameraCard camera={makeCamera({ battery_pct: 30 })} />,
|
||||
)
|
||||
const { container } = renderCard({ battery_pct: 30 })
|
||||
const bar = container.querySelector('[role="progressbar"] div')
|
||||
expect(bar?.className).toContain('bg-rig-warning')
|
||||
})
|
||||
|
||||
it('uses red bar for low battery (<15%)', () => {
|
||||
const { container } = render(
|
||||
<CameraCard camera={makeCamera({ battery_pct: 8 })} />,
|
||||
)
|
||||
const { container } = renderCard({ battery_pct: 8 })
|
||||
const bar = container.querySelector('[role="progressbar"] div')
|
||||
expect(bar?.className).toContain('bg-rig-danger')
|
||||
})
|
||||
@@ -72,24 +72,24 @@ describe('CameraCard', () => {
|
||||
// ── Recording state ────────────────────────────────────────────────────
|
||||
|
||||
it('shows REC badge when recording', () => {
|
||||
render(<CameraCard camera={makeCamera({ recording: true })} />)
|
||||
renderCard({ recording: true })
|
||||
expect(screen.getByText('REC')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows IDLE badge when not recording', () => {
|
||||
render(<CameraCard camera={makeCamera({ recording: false })} />)
|
||||
renderCard({ recording: false })
|
||||
expect(screen.getByText('IDLE')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Online / Offline badges ────────────────────────────────────────────
|
||||
|
||||
it('shows Online badge when camera is online', () => {
|
||||
render(<CameraCard camera={makeCamera({ online: true })} />)
|
||||
renderCard({ online: true })
|
||||
expect(screen.getByText('Online')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Offline badge when camera is offline', () => {
|
||||
render(<CameraCard camera={makeCamera({ online: false })} />)
|
||||
renderCard({ online: false })
|
||||
const offlineElements = screen.getAllByText('Offline')
|
||||
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
@@ -97,13 +97,13 @@ describe('CameraCard', () => {
|
||||
// ── Video remaining ────────────────────────────────────────────────────
|
||||
|
||||
it('shows video remaining time when available', () => {
|
||||
render(<CameraCard camera={makeCamera({ video_remaining_sec: 125 })} />)
|
||||
renderCard({ video_remaining_sec: 125 })
|
||||
// formatTimeLeft(125) → "2m 5s left"
|
||||
expect(screen.getByText(/2m 5s left/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show video remaining when null', () => {
|
||||
render(<CameraCard camera={makeCamera({ video_remaining_sec: null })} />)
|
||||
renderCard({ video_remaining_sec: null })
|
||||
// The Radio icon and time text should not be present
|
||||
expect(screen.queryByText(/m\s+\d+s left/)).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -111,53 +111,67 @@ describe('CameraCard', () => {
|
||||
// ── Footer ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('shows Live + timestamp in footer when online', () => {
|
||||
render(<CameraCard camera={makeCamera({ online: true })} />)
|
||||
// Footer shows "Live" when online
|
||||
renderCard({ online: true })
|
||||
expect(screen.getByText('Live')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Offline + timestamp in footer when offline', () => {
|
||||
render(<CameraCard camera={makeCamera({ online: false })} />)
|
||||
// Footer says "Offline" (the text appears both in the badge and footer)
|
||||
// When offline, the footer specifically shows "Offline" text
|
||||
it('shows Offline in footer when offline', () => {
|
||||
renderCard({ online: false })
|
||||
const offlineElements = screen.getAllByText('Offline')
|
||||
// At least one should exist (badge + footer)
|
||||
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows "unknown" when last_seen is malformed', () => {
|
||||
render(
|
||||
<CameraCard camera={makeCamera({ last_seen: 'not-a-date' })} />,
|
||||
)
|
||||
renderCard({ last_seen: 'not-a-date' })
|
||||
expect(screen.getByText('unknown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "unknown" when last_seen is in the future', () => {
|
||||
const future = new Date(Date.now() + 86400000).toISOString() // +1 day
|
||||
render(<CameraCard camera={makeCamera({ last_seen: future })} />)
|
||||
const cam = makeCamera({ last_seen: future })
|
||||
renderCardContainer(cam)
|
||||
expect(screen.getByText('unknown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Edge cases ──────────────────────────────────────────────────────────
|
||||
|
||||
it('clamps negative battery_pct to 0%', () => {
|
||||
render(<CameraCard camera={makeCamera({ battery_pct: -5 })} />)
|
||||
renderCard({ battery_pct: -5 })
|
||||
expect(screen.getByText('0%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows exact boundary: 15% battery → yellow bar', () => {
|
||||
const { container } = render(
|
||||
<CameraCard camera={makeCamera({ battery_pct: 15 })} />,
|
||||
)
|
||||
const { container } = renderCard({ battery_pct: 15 })
|
||||
const bar = container.querySelector('[role="progressbar"] div')
|
||||
expect(bar?.className).toContain('bg-rig-warning')
|
||||
})
|
||||
|
||||
it('shows exact boundary: 50% battery → green bar', () => {
|
||||
const { container } = render(
|
||||
<CameraCard camera={makeCamera({ battery_pct: 50 })} />,
|
||||
)
|
||||
const { container } = renderCard({ battery_pct: 50 })
|
||||
const bar = container.querySelector('[role="progressbar"] div')
|
||||
expect(bar?.className).toContain('bg-rig-success')
|
||||
})
|
||||
|
||||
// ── New prop-driven tests ──────────────────────────────────────────────
|
||||
|
||||
it('calls onStart when Record button is clicked', () => {
|
||||
const onStart = vi.fn()
|
||||
render(<CameraCard camera={makeCamera({ recording: false })} onStart={onStart} onStop={noop} onViewHistory={noop} />)
|
||||
screen.getByText('Record').click()
|
||||
expect(onStart).toHaveBeenCalledWith('cam-1')
|
||||
})
|
||||
|
||||
it('calls onStop when Stop button is clicked', () => {
|
||||
const onStop = vi.fn()
|
||||
render(<CameraCard camera={makeCamera({ recording: true })} onStart={noop} onStop={onStop} onViewHistory={noop} />)
|
||||
screen.getByText('Stop').click()
|
||||
expect(onStop).toHaveBeenCalledWith('cam-1')
|
||||
})
|
||||
|
||||
it('calls onViewHistory when History button is clicked', () => {
|
||||
const onViewHistory = vi.fn()
|
||||
render(<CameraCard camera={makeCamera({})} onStart={noop} onStop={noop} onViewHistory={onViewHistory} />)
|
||||
screen.getByText('History').click()
|
||||
expect(onViewHistory).toHaveBeenCalledWith('cam-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Video, Wifi, WifiOff, Signal, Battery, Radio } from 'lucide-react'
|
||||
import { Video, Wifi, WifiOff, Signal, Battery, Radio, Play, Square } from 'lucide-react'
|
||||
import type { CameraStatus } from '../types'
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -23,11 +23,11 @@ function formatRelativeTime(iso: string): string {
|
||||
return `${diffDay}d ago`
|
||||
}
|
||||
|
||||
function batteryColor(pct: number | null): { bar: string; text: string } {
|
||||
if (pct === null) return { bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' }
|
||||
if (pct >= 50) return { bar: 'bg-rig-success', text: 'text-rig-success' }
|
||||
if (pct >= 15) return { bar: 'bg-rig-warning', text: 'text-rig-warning' }
|
||||
return { bar: 'bg-rig-danger', text: 'text-rig-danger' }
|
||||
function batteryColor(pct: number | null): { status: 'good' | 'low' | 'critical'; bar: string; text: string } {
|
||||
if (pct === null) return { status: 'critical', bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' }
|
||||
if (pct >= 50) return { status: 'good', bar: 'bg-rig-success', text: 'text-rig-success' }
|
||||
if (pct >= 15) return { status: 'low', bar: 'bg-rig-warning', text: 'text-rig-warning' }
|
||||
return { status: 'critical', bar: 'bg-rig-danger', text: 'text-rig-danger' }
|
||||
}
|
||||
|
||||
function formatTimeLeft(sec: number): string {
|
||||
@@ -37,14 +37,33 @@ function formatTimeLeft(sec: number): string {
|
||||
return `${m}m ${s}s left`
|
||||
}
|
||||
|
||||
function cameraStatus(online: boolean, batteryPct: number | null): 'good' | 'warning' | 'critical' {
|
||||
if (!online) return 'critical'
|
||||
if (batteryPct === null) return 'good'
|
||||
if (batteryPct >= 50) return 'good'
|
||||
if (batteryPct >= 15) return 'warning'
|
||||
return 'critical'
|
||||
}
|
||||
|
||||
const STATUS_BORDER: Record<string, string> = {
|
||||
good: 'border-l-rig-success',
|
||||
warning: 'border-l-rig-warning',
|
||||
critical: 'border-l-rig-danger',
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface CameraCardProps {
|
||||
camera: CameraStatus
|
||||
onStart: (cameraId: string) => void
|
||||
onStop: (cameraId: string) => void
|
||||
onViewHistory: (cameraId: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function CameraCard({ camera }: CameraCardProps) {
|
||||
export default function CameraCard({ camera, onStart, onStop, onViewHistory, disabled }: CameraCardProps) {
|
||||
const {
|
||||
camera_id,
|
||||
friendly_name,
|
||||
online,
|
||||
resolution,
|
||||
@@ -57,21 +76,23 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
||||
} = camera
|
||||
|
||||
const batt = batteryColor(battery_pct)
|
||||
const status = cameraStatus(online, battery_pct)
|
||||
const borderColor = STATUS_BORDER[status]
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`rounded-xl border bg-rig-dark-800/60 transition-colors ${
|
||||
className={`rounded-xl border border-rig-dark-600 bg-rig-dark-800/60 transition-colors border-l-4 ${borderColor} ${
|
||||
online
|
||||
? 'border-rig-dark-600 hover:border-rig-accent/40'
|
||||
: 'border-rig-dark-700 opacity-75'
|
||||
? 'hover:border-rig-accent/40'
|
||||
: 'opacity-75'
|
||||
}`}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Video className="h-4 w-4 text-rig-accent" aria-hidden="true" />
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Video className="h-4 w-4 shrink-0 text-rig-accent" aria-hidden="true" />
|
||||
<h3
|
||||
className="text-sm font-semibold text-rig-dark-100 truncate max-w-[180px]"
|
||||
className="text-sm font-semibold text-rig-dark-100 truncate"
|
||||
title={friendly_name}
|
||||
>
|
||||
{friendly_name}
|
||||
@@ -82,7 +103,7 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
||||
<span
|
||||
role="status"
|
||||
aria-label={online ? 'Camera online' : 'Camera offline'}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
className={`ml-2 shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
online
|
||||
? 'bg-rig-success/15 text-rig-success'
|
||||
: 'bg-rig-danger/15 text-rig-danger'
|
||||
@@ -99,6 +120,9 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="space-y-2.5 px-4 pb-3">
|
||||
{/* Camera ID */}
|
||||
<p className="text-[11px] font-mono text-rig-dark-500">{camera_id}</p>
|
||||
|
||||
{/* Resolution + FPS */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-rig-dark-300">
|
||||
<Signal className="h-3.5 w-3.5" />
|
||||
@@ -159,7 +183,40 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex items-center justify-between rounded-b-xl border-t border-rig-dark-700/50 bg-rig-dark-900/30 px-4 py-2">
|
||||
<div className="rounded-b-xl border-t border-rig-dark-700/50 bg-rig-dark-900/30">
|
||||
{/* Controls row */}
|
||||
<div className="flex items-center gap-1 px-3 py-2">
|
||||
{recording ? (
|
||||
<button
|
||||
onClick={() => onStop(camera_id)}
|
||||
disabled={disabled}
|
||||
className="flex items-center gap-1 rounded-md bg-rig-danger/20 px-2.5 py-1 text-xs font-medium text-rig-danger hover:bg-rig-danger/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label={`Stop recording ${friendly_name}`}
|
||||
>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onStart(camera_id)}
|
||||
disabled={disabled || !online}
|
||||
className="flex items-center gap-1 rounded-md bg-rig-success/20 px-2.5 py-1 text-xs font-medium text-rig-success hover:bg-rig-success/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label={`Start recording ${friendly_name}`}
|
||||
>
|
||||
<Play className="h-3 w-3 fill-current" />
|
||||
Record
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onViewHistory(camera_id)}
|
||||
className="ml-auto rounded-md bg-rig-dark-700/50 px-2 py-1 text-[11px] text-rig-dark-300 hover:bg-rig-dark-600 hover:text-rig-dark-100 transition-colors"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status strip */}
|
||||
<div className="flex items-center justify-between px-4 pb-2">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{online ? (
|
||||
<>
|
||||
@@ -180,6 +237,7 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { X, Clock, Battery, Radio, Video } from 'lucide-react'
|
||||
import { api } from '../services/api'
|
||||
import type { StatusLog } from '../types'
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function batteryColor(pct: number | null): string {
|
||||
if (pct === null) return 'text-rig-dark-400'
|
||||
if (pct >= 50) return 'text-rig-success'
|
||||
if (pct >= 15) return 'text-rig-warning'
|
||||
return 'text-rig-danger'
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface HistoryViewerProps {
|
||||
cameraId: string | null
|
||||
cameraName?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function HistoryViewer({ cameraId, cameraName, onClose }: HistoryViewerProps) {
|
||||
const [logs, setLogs] = useState<StatusLog[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!cameraId) {
|
||||
setLogs([])
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
api
|
||||
.getCameraDetail(cameraId)
|
||||
.then((data) => {
|
||||
setLogs(data.history)
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load history')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [cameraId])
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
if (cameraId === null) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`History for ${cameraName ?? cameraId}`}
|
||||
>
|
||||
<div className="mx-4 w-full max-w-2xl max-h-[85vh] flex flex-col rounded-xl border border-rig-dark-600 bg-rig-dark-800 shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between rounded-t-xl border-b border-rig-dark-700 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-rig-accent" />
|
||||
<h2 className="text-sm font-semibold text-rig-dark-100">
|
||||
History — {cameraName ?? cameraId}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-rig-dark-400 hover:bg-rig-dark-700 hover:text-rig-dark-100 transition-colors"
|
||||
aria-label="Close history"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-rig-dark-500 border-t-rig-accent" />
|
||||
<span className="ml-3 text-sm text-rig-dark-400">Loading history...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-rig-danger/30 bg-rig-danger/10 px-4 py-3 text-sm text-rig-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && logs.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-rig-dark-400">
|
||||
No history entries found for this camera.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && logs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{logs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-rig-dark-700/50 bg-rig-dark-900/40 px-3 py-2.5 text-xs"
|
||||
>
|
||||
{/* Timestamp */}
|
||||
<span className="font-mono text-rig-dark-400 min-w-[130px]">
|
||||
{formatTimestamp(log.recorded_at)}
|
||||
</span>
|
||||
|
||||
{/* Online/Recording badges */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
log.online
|
||||
? 'bg-rig-success/15 text-rig-success'
|
||||
: 'bg-rig-danger/15 text-rig-danger'
|
||||
}`}
|
||||
>
|
||||
{log.online ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
|
||||
{log.recording_state ? (
|
||||
<span className="rounded bg-rig-danger/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-rig-danger">
|
||||
REC
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded bg-rig-dark-600/50 px-1.5 py-0.5 text-[10px] text-rig-dark-500">
|
||||
IDLE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Battery */}
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Battery className="h-3 w-3 text-rig-dark-500" />
|
||||
<span className={`font-mono ${batteryColor(log.battery_pct)}`}>
|
||||
{log.battery_pct !== null ? `${log.battery_pct}%` : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Storage remaining */}
|
||||
{log.video_remaining_sec !== null && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Radio className="h-3 w-3 text-rig-dark-500" />
|
||||
<span className="font-mono text-rig-dark-400">
|
||||
{Math.floor(log.video_remaining_sec / 60)}m left
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mode */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Video className="h-3 w-3 text-rig-dark-500" />
|
||||
<span className="text-rig-dark-400">{log.mode}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="rounded-b-xl border-t border-rig-dark-700 px-5 py-3">
|
||||
<p className="text-[11px] text-rig-dark-500">
|
||||
{logs.length} entries (last 24 hours)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { default as CameraCard } from './CameraCard'
|
||||
export { default as HistoryViewer } from './HistoryViewer'
|
||||
|
||||
+19
-6
@@ -1,4 +1,4 @@
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '/api'
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
|
||||
async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
@@ -12,9 +12,22 @@ async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getCameras: () => request<[]>('/cameras'),
|
||||
getCameraStatus: (id: string) => request<[]>(`/cameras/${id}/status`),
|
||||
getSystemHealth: () => request<[]>('/system/health'),
|
||||
toggleRecording: (cameraId: string) =>
|
||||
request<[]>(`/cameras/${cameraId}/recording`, { method: 'POST' }),
|
||||
/** GET /api/v1/cameras — list all cameras with latest status */
|
||||
getCameras: () => request<import('../types').CameraStatus[]>('/cameras'),
|
||||
|
||||
/** GET /api/v1/cameras/{id} — full detail + 24h history */
|
||||
getCameraDetail: (id: string) =>
|
||||
request<import('../types').CameraDetail>(`/cameras/${id}`),
|
||||
|
||||
/** POST /api/v1/cameras/{id}/start — start recording */
|
||||
startRecording: (cameraId: string) =>
|
||||
request<import('../types').StartStopResponse>(`/cameras/${cameraId}/start`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
/** POST /api/v1/cameras/{id}/stop — stop recording */
|
||||
stopRecording: (cameraId: string) =>
|
||||
request<import('../types').StartStopResponse>(`/cameras/${cameraId}/stop`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -21,6 +21,42 @@ export interface SSEEvent {
|
||||
payload?: unknown
|
||||
}
|
||||
|
||||
/** A single status log entry from GET /api/v1/cameras/{id} */
|
||||
export interface StatusLog {
|
||||
id: number
|
||||
camera_id: string
|
||||
recorded_at: string
|
||||
battery_pct: number | null
|
||||
video_remaining_sec: number | null
|
||||
recording_state: number // 0 or 1 (SQLite bool)
|
||||
mode: string
|
||||
resolution: string
|
||||
fps: number
|
||||
online: number // 0 or 1
|
||||
raw_battery_pct: number | null
|
||||
}
|
||||
|
||||
/** Camera detail response from GET /api/v1/cameras/{id} */
|
||||
export interface CameraDetail {
|
||||
camera: CameraInfo
|
||||
last_status: StatusLog
|
||||
history: StatusLog[]
|
||||
}
|
||||
|
||||
export interface CameraInfo {
|
||||
CameraID: string
|
||||
FriendlyName: string
|
||||
MacAddress: string | null
|
||||
CreatedAt: string
|
||||
UpdatedAt: string
|
||||
}
|
||||
|
||||
/** Generic API responses */
|
||||
export interface StartStopResponse {
|
||||
status: string
|
||||
camera_id: string
|
||||
}
|
||||
|
||||
export interface Camera {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
Reference in New Issue
Block a user