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
+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'