generated from CubeCraft-Creations/Tracehound
CUB-196: CameraCard component with live SSE status display #3
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 — 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.
|
||||
Generated
+1151
-1
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+60
-10
@@ -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">
|
||||
<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" />
|
||||
<h2 className="text-lg font-semibold text-rig-dark-200">
|
||||
Dashboard Coming Soon
|
||||
</h2>
|
||||
<p className="mt-2 max-w-sm text-sm text-rig-dark-400">
|
||||
Camera monitoring and remote control interface — under construction.
|
||||
</p>
|
||||
</div>
|
||||
{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">
|
||||
<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">
|
||||
Waiting for cameras…
|
||||
</h2>
|
||||
<p className="mt-2 max-w-sm text-sm text-rig-dark-400">
|
||||
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 */}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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} · {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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CameraCard } from './CameraCard'
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
@@ -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'],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user