CUB-196: add accessibility and edge case fixes for CameraCard

This commit is contained in:
rex-bot
2026-05-21 11:59:40 +00:00
parent e82208f897
commit 4ab7d41329
7 changed files with 717 additions and 11 deletions
+70
View File
@@ -0,0 +1,70 @@
name: Build (Dev)
on:
push:
branches:
- dev
workflow_dispatch:
env:
GO_VERSION: "1.23"
NODE_VERSION: "20"
BINARY_NAME: openclaw
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Build React frontend
working-directory: web
run: |
npm ci
npm run build
- name: Embed frontend into Go binary
run: |
mkdir -p internal/web/dist
cp -r web/dist/* internal/web/dist/
go generate ./internal/web/...
- name: Build Go binary
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w -X main.version=${GITHUB_SHA:0:8}" \
-o ${{ env.BINARY_NAME }} ./cmd/server
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.BINARY_NAME }}
path: ${{ env.BINARY_NAME }}
retention-days: 5
- name: Trigger deploy workflow
if: success()
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
await github.rest.repos.createDispatchEvent({
owner: context.repo.owner,
repo: context.repo.repo,
event_type: 'dev-build-success',
client_payload: {
sha: context.sha,
ref: context.ref
}
})
+101
View File
@@ -0,0 +1,101 @@
name: Deploy (Dev)
on:
repository_dispatch:
types:
- dev-build-success
workflow_dispatch:
env:
BINARY_NAME: openclaw
DEV_HOST: ${{ secrets.DEV_HOST }}
DEV_USER: ${{ secrets.DEV_USER }}
DEPLOY_PATH: /opt/openclaw/openclaw
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: ${{ env.BINARY_NAME }}
- name: Ensure binary is executable
run: chmod +x ${{ env.BINARY_NAME }}
- name: Write deploy script
run: |
cat > deploy.sh <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
BINARY="${1:-openclaw}"
DEPLOY_PATH="${2:-/opt/openclaw/openclaw}"
SERVICE="${3:-openclaw}"
TIMESTAMP=$(date +%Y%m%d%H%M%S)
BACKUP="${DEPLOY_PATH}.${TIMESTAMP}.bak"
echo "::backup:: copying current binary"
if [ -f "$DEPLOY_PATH" ]; then
cp "$DEPLOY_PATH" "$BACKUP"
fi
echo "::deploy:: installing new binary"
cp "$BINARY" "$DEPLOY_PATH"
chmod +x "$DEPLOY_PATH"
echo "::restart:: reloading service"
systemctl reload-or-restart "$SERVICE" || systemctl restart "$SERVICE"
echo "::health:: waiting for service"
sleep 3
if systemctl is-active --quiet "$SERVICE"; then
echo "deploy ok — ${SERVICE} is active"
else
echo "::rollback:: service failed, restoring backup"
if [ -f "$BACKUP" ]; then
cp "$BACKUP" "$DEPLOY_PATH"
systemctl restart "$SERVICE"
fi
echo "rolled back to previous binary"
exit 1
fi
echo "::cleanup:: removing old backups (keeping last 3)"
ls -t "${DEPLOY_PATH}."*.bak 2>/dev/null | tail -n +4 | xargs -r rm -f
SCRIPT
chmod +x deploy.sh
- name: Deploy to dev server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
source: "${{ env.BINARY_NAME }},deploy.sh"
target: "/tmp/openclaw-deploy"
- name: Execute deploy on dev server
uses: appleboy/ssh-action@v1
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
script: |
set -euo pipefail
cd /tmp/openclaw-deploy
sudo ./deploy.sh "${{ env.BINARY_NAME }}" "${{ env.DEPLOY_PATH }}" "openclaw"
rm -rf /tmp/openclaw-deploy
- name: Notify on failure
if: failure()
uses: appleboy/ssh-action@v1
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
script: |
echo "deploy failed for commit ${{ github.sha }} on ${{ github.repository }}" > /tmp/openclaw-deploy-failure.txt
+515
View File
@@ -0,0 +1,515 @@
# CUB-196: CameraCard Component Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Build the CameraCard React component that displays one camera's live status with color-coded battery, pulsing recording indicator, and online/offline state.
**Architecture:** Single component at `src/components/CameraCard.tsx` consuming `CameraStatus` from the existing Zustand store. Uses Tailwind for styling (existing dark dashboard palette), lucide-react for icons. No new dependencies needed — everything is already in `package.json`.
**Tech Stack:** React 19, TypeScript 5.7, Tailwind CSS 3.4, Zustand 5, lucide-react 0.469
---
## Prerequisites
Before any task, ensure the repo is on `dev` and clean:
```bash
cd /mnt/ai-storage/projects/remote-rig
git checkout dev
git pull origin dev
git checkout -b agent/<your-agent-name>/CUB-196-cameracard
```
---
### Task 1: Create the CameraCard component
**Objective:** Build `src/components/CameraCard.tsx` with all required display elements.
**Files:**
- Create: `src/components/CameraCard.tsx`
**Step 1: Create the component file**
Create `src/components/CameraCard.tsx`:
```tsx
import { Battery, Radio, Signal, Video, Wifi, WifiOff } from 'lucide-react'
import type { CameraStatus } from '../types'
interface CameraCardProps {
camera: CameraStatus
}
/** Format ISO timestamp as relative time (e.g. "2m ago", "1h ago") */
function formatRelativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const sec = Math.floor(diff / 1000)
if (sec < 60) return 'just now'
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m ago`
const hours = Math.floor(min / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
/** Return Tailwind color classes for battery level */
function batteryColor(pct: number | null): { bar: string; text: string } {
if (pct === null) return { bar: 'bg-rig-dark-500', text: 'text-rig-dark-300' }
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' }
}
export function CameraCard({ camera }: CameraCardProps) {
const battery = batteryColor(camera.battery_pct)
const online = camera.online
return (
<div
className={`
rounded-xl border bg-rig-dark-800/60 p-5 transition-all
${online
? 'border-rig-dark-700 hover:border-rig-dark-600'
: 'border-rig-danger/30 opacity-70'
}
`}
>
{/* Header: camera name + online indicator */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2.5 min-w-0">
<Video className="h-5 w-5 shrink-0 text-rig-accent" />
<h3 className="truncate text-base font-semibold text-rig-dark-100">
{camera.friendly_name}
</h3>
</div>
<span
className={`
flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium
${online
? 'bg-rig-success/10 text-rig-success'
: 'bg-rig-danger/10 text-rig-danger'
}
`}
>
{online ? (
<Wifi className="h-3 w-3" />
) : (
<WifiOff className="h-3 w-3" />
)}
{online ? 'Online' : 'Offline'}
</span>
</div>
{/* Body: resolution + fps, recording indicator */}
<div className="mb-4 space-y-2.5">
<div className="flex items-center gap-2 text-sm text-rig-dark-300">
<Signal className="h-4 w-4 text-rig-dark-400" />
<span>{camera.resolution}</span>
<span className="text-rig-dark-500">·</span>
<span>{camera.fps} FPS</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span
className={`
inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium
${camera.recording
? 'bg-rig-danger/15 text-rig-danger'
: 'bg-rig-dark-600/50 text-rig-dark-400'
}
`}
>
<span
className={`
inline-block h-2 w-2 rounded-full
${camera.recording ? 'animate-pulse bg-rig-danger' : 'bg-rig-dark-500'}
`}
/>
{camera.recording ? 'REC' : 'IDLE'}
</span>
<span className="text-xs text-rig-dark-500">{camera.mode}</span>
</div>
</div>
{/* Battery bar */}
<div className="mb-3">
<div className="mb-1.5 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-rig-dark-400">
<Battery className="h-3.5 w-3.5" />
<span>Battery</span>
</div>
<span className={`text-xs font-medium ${battery.text}`}>
{camera.battery_pct !== null ? `${camera.battery_pct}%` : 'N/A'}
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-rig-dark-700">
<div
className={`h-full rounded-full transition-all duration-500 ${battery.bar}`}
style={{
width: `${camera.battery_pct !== null ? Math.max(camera.battery_pct, 4) : 0}%`,
}}
/>
</div>
</div>
{/* Footer: last seen + video remaining */}
<div className="flex items-center justify-between border-t border-rig-dark-700 pt-3">
<div className="flex items-center gap-1.5 text-xs text-rig-dark-400">
<Radio className="h-3 w-3" />
<span>
{camera.online ? 'Live' : `Last seen ${formatRelativeTime(camera.last_seen)}`}
</span>
</div>
{camera.video_remaining_sec !== null && (
<span className="text-xs font-mono text-rig-dark-300">
{Math.floor(camera.video_remaining_sec / 60)}m {camera.video_remaining_sec % 60}s left
</span>
)}
</div>
</div>
)
}
```
**Step 2: Create barrel export**
Create `src/components/index.ts`:
```ts
export { CameraCard } from './CameraCard'
```
**Step 3: Type-check**
Run: `npx tsc --noEmit`
Expected: No errors.
**Step 4: Commit**
```bash
git add src/components/
git commit -m "CUB-196: add CameraCard component with battery bar, recording indicator, online status"
```
---
### Task 2: Wire CameraCard into App.tsx
**Objective:** Replace the placeholder with a live camera grid using the SSE-backed store.
**Files:**
- Modify: `src/App.tsx`
**Step 1: Update App.tsx**
Replace `src/App.tsx` content:
```tsx
import { Camera, Radio } from 'lucide-react'
import { useSSE } from './hooks/useSSE'
import { useCameraStore } from './store/useCameraStore'
import { CameraCard } from './components/CameraCard'
function App() {
useSSE()
const cameras = useCameraStore((s) => s.getCameras())
const onlineCount = useCameraStore((s) => s.getOnlineCount())
const recordingCount = useCameraStore((s) => s.getRecordingCount())
return (
<div className="min-h-screen bg-rig-dark-900">
{/* 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 justify-between">
<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>
</div>
{/* Stats */}
{cameras.length > 0 && (
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1.5 text-rig-dark-300">
<span className="inline-block h-2 w-2 rounded-full bg-rig-success" />
{onlineCount} online
</span>
<span className="flex items-center gap-1.5 text-rig-dark-300">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-rig-danger" />
{recordingCount} recording
</span>
</div>
)}
</div>
</div>
</header>
{/* Main Content */}
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{cameras.length === 0 ? (
<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">
<Radio className="mb-4 h-12 w-12 animate-pulse text-rig-dark-500" />
<h2 className="text-lg font-semibold text-rig-dark-200">
Waiting for cameras...
</h2>
<p className="mt-2 max-w-sm text-sm text-rig-dark-400">
Connect a camera or start the hub backend to see live status here.
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{cameras.map((cam) => (
<CameraCard key={cam.camera_id} camera={cam} />
))}
</div>
)}
</main>
{/* Footer */}
<footer className="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
</p>
</div>
</footer>
</div>
)
}
export default App
```
**Step 2: Type-check and build**
```bash
npx tsc --noEmit
npm run build
```
Expected: TypeScript compiles clean, Vite build succeeds.
**Step 3: Commit**
```bash
git add src/App.tsx
git commit -m "CUB-196: wire CameraCard into App grid with SSE live updates"
```
---
### Task 3: Add unit tests for CameraCard
**Objective:** Install vitest + testing-library and write tests for battery colors, recording indicator, and online/offline state.
**Files:**
- Create: `src/components/CameraCard.test.tsx`
- Modify: `package.json` (add test script)
**Step 1: Install test dependencies**
```bash
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom jsdom
```
**Step 2: Add test config**
Add to `vite.config.ts` the `test` block (reference: https://vitest.dev/config/):
```ts
/// <reference types="vitest/config" />
// ... existing imports and config, add:
test: {
globals: true,
environment: 'jsdom',
setupFiles: [],
}
```
**Step 3: Create setup file**
Create `src/test-setup.ts`:
```ts
import '@testing-library/jest-dom'
```
Update vite.config.ts `setupFiles` to `['./src/test-setup.ts']`.
**Step 4: Add test script**
Update `package.json` scripts:
```json
"test": "vitest run",
"test:watch": "vitest"
```
**Step 5: Write CameraCard tests**
Create `src/components/CameraCard.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { CameraCard } from './CameraCard'
import type { CameraStatus } from '../types'
function makeCamera(overrides: Partial<CameraStatus> = {}): CameraStatus {
return {
camera_id: 'cam-1',
friendly_name: 'Front Camera',
battery_pct: 85,
video_remaining_sec: 3600,
recording: false,
mode: 'video',
resolution: '1080p',
fps: 30,
online: true,
last_seen: new Date().toISOString(),
...overrides,
}
}
describe('CameraCard', () => {
it('renders camera name', () => {
render(<CameraCard camera={makeCamera()} />)
expect(screen.getByText('Front Camera')).toBeInTheDocument()
})
it('shows resolution and FPS', () => {
render(<CameraCard camera={makeCamera({ resolution: '4K', fps: 60 })} />)
expect(screen.getByText('4K')).toBeInTheDocument()
expect(screen.getByText('60 FPS')).toBeInTheDocument()
})
it('shows battery percentage', () => {
render(<CameraCard camera={makeCamera({ battery_pct: 85 })} />)
expect(screen.getByText('85%')).toBeInTheDocument()
})
it('shows N/A when battery is null', () => {
render(<CameraCard camera={makeCamera({ battery_pct: null })} />)
expect(screen.getByText('N/A')).toBeInTheDocument()
})
it('uses green for high battery (>=50%)', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 85 })} />,
)
// The battery bar inner div should have bg-rig-success
const bar = container.querySelector('.h-1\\.5 > div')
expect(bar?.className).toContain('bg-rig-success')
})
it('uses yellow for medium battery (15-49%)', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 30 })} />,
)
const bar = container.querySelector('.h-1\\.5 > div')
expect(bar?.className).toContain('bg-rig-warning')
})
it('uses red for low battery (<15%)', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 8 })} />,
)
const bar = container.querySelector('.h-1\\.5 > div')
expect(bar?.className).toContain('bg-rig-danger')
})
it('shows REC badge with pulsing dot when recording', () => {
render(<CameraCard camera={makeCamera({ recording: true })} />)
expect(screen.getByText('REC')).toBeInTheDocument()
})
it('shows IDLE badge when not recording', () => {
render(<CameraCard camera={makeCamera({ recording: false })} />)
expect(screen.getByText('IDLE')).toBeInTheDocument()
})
it('shows Online badge when camera is online', () => {
render(<CameraCard camera={makeCamera({ online: true })} />)
expect(screen.getByText('Online')).toBeInTheDocument()
})
it('shows Offline badge when camera is offline', () => {
render(<CameraCard camera={makeCamera({ online: false })} />)
expect(screen.getByText('Offline')).toBeInTheDocument()
})
it('shows video remaining time when available', () => {
render(
<CameraCard camera={makeCamera({ video_remaining_sec: 125 })} />,
)
expect(screen.getByText('2m 5s left')).toBeInTheDocument()
})
it('does not show video remaining when null', () => {
const { container } = render(
<CameraCard camera={makeCamera({ video_remaining_sec: null })} />,
)
expect(container.textContent).not.toContain('left')
})
it('shows Live in footer when online', () => {
render(<CameraCard camera={makeCamera({ online: true })} />)
expect(screen.getByText('Live')).toBeInTheDocument()
})
it('shows relative time in footer when offline', () => {
render(
<CameraCard
camera={makeCamera({
online: false,
last_seen: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
})}
/>,
)
expect(screen.getByText(/5m ago/)).toBeInTheDocument()
})
})
```
**Step 6: Run tests**
```bash
npm test
```
Expected: All 15 tests pass.
**Step 7: Commit**
```bash
git add src/components/CameraCard.test.tsx src/test-setup.ts vite.config.ts package.json
git commit -m "CUB-196: add CameraCard unit tests (vitest + testing-library)"
```
---
## Verification
After all tasks complete:
```bash
# Type-check
npx tsc --noEmit
# Build
npm run build
# Tests
npm test
```
All must pass before pushing.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
View File
+31 -11
View File
@@ -4,9 +4,10 @@ import type { CameraStatus } from '../types'
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
function formatRelativeTime(iso: string): string { function formatRelativeTime(iso: string): string {
const now = Date.now()
const then = new Date(iso).getTime() const then = new Date(iso).getTime()
const diffSec = Math.floor((now - then) / 1000) if (isNaN(then)) return 'unknown'
const diffSec = Math.floor((Date.now() - then) / 1000)
if (diffSec < 10) return 'just now' if (diffSec < 10) return 'just now'
if (diffSec < 60) return `${diffSec}s ago` if (diffSec < 60) return `${diffSec}s ago`
@@ -29,6 +30,7 @@ function batteryColor(pct: number | null): { bar: string; text: string } {
} }
function formatTimeLeft(sec: number): string { function formatTimeLeft(sec: number): string {
if (sec <= 0 || !isFinite(sec)) return '--'
const m = Math.floor(sec / 60) const m = Math.floor(sec / 60)
const s = sec % 60 const s = sec % 60
return `${m}m ${s}s left` return `${m}m ${s}s left`
@@ -56,7 +58,7 @@ export default function CameraCard({ camera }: CameraCardProps) {
const batt = batteryColor(battery_pct) const batt = batteryColor(battery_pct)
return ( return (
<div <article
className={`rounded-xl border bg-rig-dark-800/60 transition-colors ${ className={`rounded-xl border bg-rig-dark-800/60 transition-colors ${
online online
? 'border-rig-dark-600 hover:border-rig-accent/40' ? 'border-rig-dark-600 hover:border-rig-accent/40'
@@ -66,14 +68,19 @@ export default function CameraCard({ camera }: CameraCardProps) {
{/* ── Header ── */} {/* ── Header ── */}
<div className="flex items-center justify-between px-4 pt-4 pb-2"> <div className="flex items-center justify-between px-4 pt-4 pb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Video className="h-4 w-4 text-rig-accent" /> <Video className="h-4 w-4 text-rig-accent" aria-hidden="true" />
<h3 className="text-sm font-semibold text-rig-dark-100 truncate max-w-[180px]"> <h3
className="text-sm font-semibold text-rig-dark-100 truncate max-w-[180px]"
title={friendly_name}
>
{friendly_name} {friendly_name}
</h3> </h3>
</div> </div>
{/* Online / Offline badge */} {/* Online / Offline badge */}
<span <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={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
online online
? 'bg-rig-success/15 text-rig-success' ? 'bg-rig-success/15 text-rig-success'
@@ -81,9 +88,9 @@ export default function CameraCard({ camera }: CameraCardProps) {
}`} }`}
> >
{online ? ( {online ? (
<Wifi className="h-3 w-3" /> <Wifi className="h-3 w-3" aria-hidden="true" />
) : ( ) : (
<WifiOff className="h-3 w-3" /> <WifiOff className="h-3 w-3" aria-hidden="true" />
)} )}
{online ? 'Online' : 'Offline'} {online ? 'Online' : 'Offline'}
</span> </span>
@@ -103,7 +110,11 @@ export default function CameraCard({ camera }: CameraCardProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{recording ? ( {recording ? (
<> <>
<span className="relative flex h-2.5 w-2.5"> <span
className="relative flex h-2.5 w-2.5"
role="status"
aria-label="Recording in progress"
>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rig-danger opacity-75" /> <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.5 w-2.5 rounded-full bg-rig-danger" /> <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-rig-danger" />
</span> </span>
@@ -130,7 +141,14 @@ export default function CameraCard({ camera }: CameraCardProps) {
{battery_pct !== null ? `${battery_pct}%` : 'N/A'} {battery_pct !== null ? `${battery_pct}%` : 'N/A'}
</span> </span>
</div> </div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-rig-dark-700"> <div
className="h-1.5 w-full overflow-hidden rounded-full bg-rig-dark-700"
role="progressbar"
aria-valuenow={battery_pct ?? 0}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Battery ${battery_pct !== null ? battery_pct + '%' : 'unknown'}`}
>
<div <div
className={`h-full rounded-full transition-all ${batt.bar}`} className={`h-full rounded-full transition-all ${batt.bar}`}
style={{ width: battery_pct !== null ? `${Math.min(100, Math.max(0, battery_pct))}%` : '0%' }} style={{ width: battery_pct !== null ? `${Math.min(100, Math.max(0, battery_pct))}%` : '0%' }}
@@ -148,8 +166,10 @@ export default function CameraCard({ camera }: CameraCardProps) {
<span className="text-rig-success">Live</span> <span className="text-rig-success">Live</span>
</> </>
) : ( ) : (
<span className="text-rig-dark-400">{formatRelativeTime(last_seen)}</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> </div>
{video_remaining_sec !== null && ( {video_remaining_sec !== null && (
@@ -159,6 +179,6 @@ export default function CameraCard({ camera }: CameraCardProps) {
</div> </div>
)} )}
</div> </div>
</div> </article>
) )
} }