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
CI/CD / lint-and-typecheck (push) Successful in 8s
CI/CD / test (push) Successful in 8s
CI/CD / build (push) Failing after 10s
CI/CD / deploy (push) Has been skipped
Build (Dev) / build (push) Failing after 5m1s

Reviewed-on: #15
Reviewed-by: Joshua <joshua@cnjmail.com>
This commit was merged in pull request #15.
This commit is contained in:
2026-05-28 07:22:37 -04:00
7 changed files with 576 additions and 129 deletions
+188 -56
View File
@@ -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">
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">
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
<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="hidden sm:inline rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent">
Dashboard
</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&hellip;
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 &mdash; Multi-Camera Remote Monitoring System
+52 -38
View File
@@ -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')
})
})
+87 -29
View File
@@ -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,26 +183,60 @@ 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="flex items-center gap-1.5 text-xs">
{online ? (
<>
<span className="h-1.5 w-1.5 rounded-full bg-rig-success" />
<span className="text-rig-success">Live</span>
</>
<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>
) : (
<span className="text-rig-dark-400">Offline</span>
<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>
)}
<span className="text-rig-dark-500">·</span>
<span className="text-rig-dark-400">{formatRelativeTime(last_seen)}</span>
<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>
{video_remaining_sec !== null && (
<div className="flex items-center gap-1 text-xs text-rig-dark-400">
<Radio className="h-3 w-3" />
<span className="font-mono">{formatTimeLeft(video_remaining_sec)}</span>
{/* Status strip */}
<div className="flex items-center justify-between px-4 pb-2">
<div className="flex items-center gap-1.5 text-xs">
{online ? (
<>
<span className="h-1.5 w-1.5 rounded-full bg-rig-success" />
<span className="text-rig-success">Live</span>
</>
) : (
<span className="text-rig-dark-400">Offline</span>
)}
<span className="text-rig-dark-500">·</span>
<span className="text-rig-dark-400">{formatRelativeTime(last_seen)}</span>
</div>
)}
{video_remaining_sec !== null && (
<div className="flex items-center gap-1 text-xs text-rig-dark-400">
<Radio className="h-3 w-3" />
<span className="font-mono">{formatTimeLeft(video_remaining_sec)}</span>
</div>
)}
</div>
</div>
</article>
)
+193
View File
@@ -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 &mdash; {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
View File
@@ -1 +1,2 @@
export { default as CameraCard } from './CameraCard'
export { default as HistoryViewer } from './HistoryViewer'
+19 -6
View File
@@ -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',
}),
}
+36
View File
@@ -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