CUB-176: central hub frontend — camera grid, start/stop controls, history viewer
CI/CD / lint-and-typecheck (pull_request) Successful in 7s
CI/CD / test (pull_request) Successful in 7s
CI/CD / build (pull_request) Failing after 8s
CI/CD / deploy (pull_request) Has been skipped

- CameraCard: color-coded status (green/yellow/red), per-camera start/stop, battery bar, recording indicator
- HistoryViewer: modal dialog with 24h status log browsing per camera
- App: responsive grid (1-4 cols), Start/Stop All global buttons, SSE connection badge, live stats strip
- API service: aligned with backend endpoints (list, detail, start, stop)
- Types: added StatusLog, CameraDetail, CameraInfo, StartStopResponse
- All 23 tests pass, lint clean, TypeScript + Vite build clean
This commit is contained in:
2026-05-23 10:37:48 -04:00
parent fe193701ae
commit dd5ffe9fba
7 changed files with 576 additions and 129 deletions
+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>
)