CUB-196: CameraCard component with live SSE status display #3

Merged
overseer merged 8 commits from agent/hermes/CUB-196-cameracard into dev 2026-05-21 10:26:56 -04:00
15 changed files with 2326 additions and 13 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
}
})
+65
View File
@@ -0,0 +1,65 @@
name: CI/CD
on:
push:
branches: [dev, main]
pull_request:
branches: [dev, main]
jobs:
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npx tsc --noEmit
test:
needs: lint-and-typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy static files
run: |
echo "Deploying to production..."
echo "Deploy target: /var/www/remote-rig/"
echo "Placeholder — configure deploy target before merging to main"
+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.
+1151 -1
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"lucide-react": "^0.469.0",
@@ -17,6 +19,8 @@
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
@@ -25,10 +29,12 @@
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.13.0",
"jsdom": "^29.1.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.7.2",
"typescript-eslint": "^8.18.0",
"vite": "^6.1.0"
"vite": "^6.1.0",
"vitest": "^4.1.7"
}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
View File
+54 -4
View File
@@ -1,6 +1,19 @@
import { Camera } from 'lucide-react'
import { Camera, Radio } from 'lucide-react'
import { useSSE } from './hooks/useSSE'
import { useCameraStore } from './store/useCameraStore'
import { CameraCard } from './components'
function App() {
// Connect to SSE endpoint — auto-updates the camera store
useSSE()
// 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()
return (
<div className="min-h-screen bg-rig-dark-900">
{/* Header */}
@@ -14,21 +27,58 @@ function App() {
<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
</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 ? (
/* 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">
<Camera className="mb-4 h-12 w-12 text-rig-dark-500" />
<span className="relative mb-4 inline-flex">
<Radio className="h-12 w-12 animate-pulse text-rig-accent" />
</span>
<h2 className="text-lg font-semibold text-rig-dark-200">
Dashboard Coming Soon
Waiting for cameras&hellip;
</h2>
<p className="mt-2 max-w-sm text-sm text-rig-dark-400">
Camera monitoring and remote control interface under construction.
Connect cameras to your RemoteRig server and they will appear here
automatically.
</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>
)}
</main>
{/* Footer */}
+163
View File
@@ -0,0 +1,163 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
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', () => {
// ── Basic rendering ────────────────────────────────────────────────────
it('renders camera name', () => {
render(<CameraCard camera={makeCamera()} />)
expect(screen.getByText('Front Camera')).toBeInTheDocument()
})
it('shows resolution and FPS', () => {
render(<CameraCard camera={makeCamera()} />)
expect(screen.getByText(/1080p/)).toBeInTheDocument()
expect(screen.getByText(/30\s*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()
})
// ── Battery bar colors ─────────────────────────────────────────────────
it('uses green bar for high battery (>=50%)', () => {
const { container } = render(
<CameraCard camera={makeCamera({ 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 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 bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-danger')
})
// ── Recording state ────────────────────────────────────────────────────
it('shows REC badge 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()
})
// ── Online / Offline badges ────────────────────────────────────────────
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 })} />)
const offlineElements = screen.getAllByText('Offline')
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
})
// ── Video remaining ────────────────────────────────────────────────────
it('shows video remaining time when available', () => {
render(<CameraCard camera={makeCamera({ 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 })} />)
// The Radio icon and time text should not be present
expect(screen.queryByText(/m\s+\d+s left/)).not.toBeInTheDocument()
})
// ── Footer ─────────────────────────────────────────────────────────────
it('shows Live + timestamp in footer when online', () => {
render(<CameraCard camera={makeCamera({ online: true })} />)
// Footer shows "Live" when online
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
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' })} />,
)
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 })} />)
expect(screen.getByText('unknown')).toBeInTheDocument()
})
// ── Edge cases ──────────────────────────────────────────────────────────
it('clamps negative battery_pct to 0%', () => {
render(<CameraCard camera={makeCamera({ 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 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 bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-success')
})
})
+185
View File
@@ -0,0 +1,185 @@
import { Video, Wifi, WifiOff, Signal, Battery, Radio } from 'lucide-react'
import type { CameraStatus } from '../types'
// ── Helpers ────────────────────────────────────────────────────────────────
function formatRelativeTime(iso: string): string {
const then = new Date(iso).getTime()
if (isNaN(then)) return 'unknown'
const diffSec = Math.floor((Date.now() - then) / 1000)
if (diffSec < 0) return 'unknown'
if (diffSec < 10) return 'just now'
if (diffSec < 60) return `${diffSec}s ago`
const diffMin = Math.floor(diffSec / 60)
if (diffMin < 60) return `${diffMin}m ago`
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return `${diffHr}h ago`
const diffDay = Math.floor(diffHr / 24)
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 formatTimeLeft(sec: number): string {
if (sec <= 0 || !isFinite(sec)) return '--'
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}m ${s}s left`
}
// ── Component ──────────────────────────────────────────────────────────────
interface CameraCardProps {
camera: CameraStatus
}
export default function CameraCard({ camera }: CameraCardProps) {
const {
friendly_name,
online,
resolution,
fps,
recording,
mode,
battery_pct,
last_seen,
video_remaining_sec,
} = camera
const batt = batteryColor(battery_pct)
return (
<article
className={`rounded-xl border bg-rig-dark-800/60 transition-colors ${
online
? 'border-rig-dark-600 hover:border-rig-accent/40'
: 'border-rig-dark-700 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" />
<h3
className="text-sm font-semibold text-rig-dark-100 truncate max-w-[180px]"
title={friendly_name}
>
{friendly_name}
</h3>
</div>
{/* Online / Offline badge */}
<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 ${
online
? 'bg-rig-success/15 text-rig-success'
: 'bg-rig-danger/15 text-rig-danger'
}`}
>
{online ? (
<Wifi className="h-3 w-3" aria-hidden="true" />
) : (
<WifiOff className="h-3 w-3" aria-hidden="true" />
)}
{online ? 'Online' : 'Offline'}
</span>
</div>
{/* ── Body ── */}
<div className="space-y-2.5 px-4 pb-3">
{/* Resolution + FPS */}
<div className="flex items-center gap-1.5 text-xs text-rig-dark-300">
<Signal className="h-3.5 w-3.5" />
<span>
{resolution} &middot; {fps} FPS
</span>
</div>
{/* Recording state */}
<div className="flex items-center gap-2">
{recording ? (
<>
<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="relative inline-flex h-2.5 w-2.5 rounded-full bg-rig-danger" />
</span>
<span className="rounded bg-rig-danger/15 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-rig-danger">
REC
</span>
</>
) : (
<span className="rounded bg-rig-dark-600/50 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-rig-dark-400">
IDLE
</span>
)}
<span className="text-[11px] text-rig-dark-400">{mode}</span>
</div>
{/* Battery */}
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1 text-rig-dark-400">
<Battery className="h-3 w-3" />
Battery
</span>
<span className={`font-mono text-xs ${batt.text}`}>
{battery_pct !== null ? `${Math.max(0, battery_pct)}%` : 'N/A'}
</span>
</div>
<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
className={`h-full rounded-full transition-all ${batt.bar}`}
style={{ width: battery_pct !== null ? `${Math.min(100, Math.max(0, battery_pct))}%` : '0%' }}
/>
</div>
</div>
</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>
</>
) : (
<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>
</article>
)
}
+1
View File
@@ -0,0 +1 @@
export { default as CameraCard } from './CameraCard'
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom'
+6
View File
@@ -1,3 +1,4 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
@@ -13,4 +14,9 @@ export default defineConfig({
},
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test-setup.ts'],
},
})