generated from CubeCraftLabs/Tracehound
CUB-176: central hub frontend — camera grid, start/stop controls, history viewer
- 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:
@@ -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,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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user