From 4ab7d41329788dd722d1b6a87938c1baba84b77a Mon Sep 17 00:00:00 2001 From: rex-bot Date: Thu, 21 May 2026 11:59:40 +0000 Subject: [PATCH] CUB-196: add accessibility and edge case fixes for CameraCard --- .gitea/workflows/build-dev.yaml | 70 +++ .gitea/workflows/deploy-dev.yaml | 101 ++++ docs/plans/2026-05-21-cub-196-cameracard.md | 515 ++++++++++++++++++++ remoterig.db | Bin 0 -> 49152 bytes remoterig.db-shm | Bin 0 -> 32768 bytes remoterig.db-wal | 0 src/components/CameraCard.tsx | 42 +- 7 files changed, 717 insertions(+), 11 deletions(-) create mode 100644 .gitea/workflows/build-dev.yaml create mode 100644 .gitea/workflows/deploy-dev.yaml create mode 100644 docs/plans/2026-05-21-cub-196-cameracard.md create mode 100644 remoterig.db create mode 100644 remoterig.db-shm create mode 100644 remoterig.db-wal diff --git a/.gitea/workflows/build-dev.yaml b/.gitea/workflows/build-dev.yaml new file mode 100644 index 0000000..074a1f2 --- /dev/null +++ b/.gitea/workflows/build-dev.yaml @@ -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 + } + }) \ No newline at end of file diff --git a/.gitea/workflows/deploy-dev.yaml b/.gitea/workflows/deploy-dev.yaml new file mode 100644 index 0000000..7807064 --- /dev/null +++ b/.gitea/workflows/deploy-dev.yaml @@ -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 \ No newline at end of file diff --git a/docs/plans/2026-05-21-cub-196-cameracard.md b/docs/plans/2026-05-21-cub-196-cameracard.md new file mode 100644 index 0000000..7bc7513 --- /dev/null +++ b/docs/plans/2026-05-21-cub-196-cameracard.md @@ -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//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 ( +
+ {/* Header: camera name + online indicator */} +
+
+
+ + {online ? ( + + ) : ( + + )} + {online ? 'Online' : 'Offline'} + +
+ + {/* Body: resolution + fps, recording indicator */} +
+
+ + {camera.resolution} + · + {camera.fps} FPS +
+ +
+ + + {camera.recording ? 'REC' : 'IDLE'} + + {camera.mode} +
+
+ + {/* Battery bar */} +
+
+
+ + Battery +
+ + {camera.battery_pct !== null ? `${camera.battery_pct}%` : 'N/A'} + +
+
+
+
+
+ + {/* Footer: last seen + video remaining */} +
+
+ + + {camera.online ? 'Live' : `Last seen ${formatRelativeTime(camera.last_seen)}`} + +
+ {camera.video_remaining_sec !== null && ( + + {Math.floor(camera.video_remaining_sec / 60)}m {camera.video_remaining_sec % 60}s left + + )} +
+
+ ) +} +``` + +**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 ( +
+ {/* Header */} +
+
+
+
+ +

+ RemoteRig +

+ + Dashboard + +
+ + {/* Stats */} + {cameras.length > 0 && ( +
+ + + {onlineCount} online + + + + {recordingCount} recording + +
+ )} +
+
+
+ + {/* Main Content */} +
+ {cameras.length === 0 ? ( +
+ +

+ Waiting for cameras... +

+

+ Connect a camera or start the hub backend to see live status here. +

+
+ ) : ( +
+ {cameras.map((cam) => ( + + ))} +
+ )} +
+ + {/* Footer */} +
+
+

+ RemoteRig v0.1.0 — Multi-Camera Remote Monitoring System +

+
+
+
+ ) +} + +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 +/// +// ... 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 { + 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() + expect(screen.getByText('Front Camera')).toBeInTheDocument() + }) + + it('shows resolution and FPS', () => { + render() + expect(screen.getByText('4K')).toBeInTheDocument() + expect(screen.getByText('60 FPS')).toBeInTheDocument() + }) + + it('shows battery percentage', () => { + render() + expect(screen.getByText('85%')).toBeInTheDocument() + }) + + it('shows N/A when battery is null', () => { + render() + expect(screen.getByText('N/A')).toBeInTheDocument() + }) + + it('uses green for high battery (>=50%)', () => { + const { container } = render( + , + ) + // 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( + , + ) + 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( + , + ) + 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() + expect(screen.getByText('REC')).toBeInTheDocument() + }) + + it('shows IDLE badge when not recording', () => { + render() + expect(screen.getByText('IDLE')).toBeInTheDocument() + }) + + it('shows Online badge when camera is online', () => { + render() + expect(screen.getByText('Online')).toBeInTheDocument() + }) + + it('shows Offline badge when camera is offline', () => { + render() + expect(screen.getByText('Offline')).toBeInTheDocument() + }) + + it('shows video remaining time when available', () => { + render( + , + ) + expect(screen.getByText('2m 5s left')).toBeInTheDocument() + }) + + it('does not show video remaining when null', () => { + const { container } = render( + , + ) + expect(container.textContent).not.toContain('left') + }) + + it('shows Live in footer when online', () => { + render() + expect(screen.getByText('Live')).toBeInTheDocument() + }) + + it('shows relative time in footer when offline', () => { + render( + , + ) + 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. diff --git a/remoterig.db b/remoterig.db new file mode 100644 index 0000000000000000000000000000000000000000..22661af34055284ac5dbb5fd037be4a9bc491cad GIT binary patch literal 49152 zcmeI5TWs6r6@V#G5-I9JC%!0-YkVY1{% zbS}fz&Tg^m%eG<|u)YmThoZvX-WJgFU-y46j``XEUie25T{xwyPbMg5Gab`)T>v$RxgZt z$5DNt-rB(Z?e22a&=216@&BPbY?1Q6$iKt?$bZO7d4BE@-2ZkjutoR2?H0x9XaEhM z0W^RH(7+R7U_HVyf$nbGMqH7W@^V>Llw9Frxq9mn=TnpEl#rfG%%p_sRAGP<)XSIT z>)MTWNvF=Hg;Vp>Cno353CC0C1}gzqr2Lu;0%lL9h1tcK8GY{BYF1L@Y({;WlT_D{ zo<5Nhsu4mmb#!uZCM^tTF-mSl9ym}aUOf;Da>2026%e~^RIZSfuaz(7)u&4tX-z3s z9%rgKW}?-5-u1n1CU7ETTR&L|$z`u)O7e2ClvTGhBVUmVN;$JEt;i)QqpgXY#~BW$ zXOpS3LbIhS8-8+DASnaJ8gkh|p{z)y>XxS##NbAdWdfm)?ImZW2;`#VV|QIq$hk&w za#>xx_Ir9ZotjF`!vYDDi|LcovnuICYBsGGWHUo`kxD@~t1+KCnwn3|im3%b4=UF- zC#WwBwrOiQt0BeJRlVSwA^Mz>ER~A|Jz;Il`qb=NNm6q9bYl~^;JSxl0%D(S{m5o9 z)m1BNm)GRN zvTU?w^&wMNHDcyKLG8amH8l9rfRhRI_1V4|(3!Pu139_sS+$r3#n-JqR1;Jeec)!$ zd(tSiL3x0ETrdBUq$qOfdS-Q5sU}kGWIfy6|`NsEHBpw)OHQcoqi;$ z?hBi}T|Lr7bm&SkYaFsRS6bN_^tmOuT+FXkn^KRcHSC4ea%~~wp0PfC@nRvLD-if? z6N(zEl&)q-73x8Gh6|o{sLj`9Tie_bjD0h+A}t$tom(0o#0ctPs=7O?i)5s1R^7gG zaHGw}1iHFxFTPM|K_kStvDdYpG0|u{{ZYm^^i;ZL%in|xrJP*I=C5b8j;}w=7zLVZT=(v&cl=# zmq7z)01co4G=K)s02)98XaEhM0W^RH9<70$bkJ7+R*KV4_ZpM5H$ogfy2p5^zKcQE zW@_(2*!^^$TVK5L27>XAHqxDUDSbF&=h{+)ZnJ;DB$eUmM*L(Jcp8_XA(F4qUH@404NjPq^hSDl9) z_Z+V}&O6%Zcj*oKY5T|apQ1jVN!RzXpaQDn!PL`IRRuw@X) z=*+J72+*0SA*&!wQ+3mN+CEQ!dZt2_K_DZZ`xXJ>rv|Np;vl2B-Crg^b5rV9eOfDj z9Ap&he47BprVd&K4TFqMGruN4r>BCJK_H_Eww(Y?hyz}#SM6+8?Q9kdzS_R4=a_#G zkU8;yB?!nQ>iw92M8&79KwvkgnK1&A7WZ3%fK0}AQv_s8?5{zpd%HFut=EI~jfA^!ISBqZ*$0)gG@W1k@)KC#yl1Z2`3_$&eG76mI1*tOH0-yk53c=`4?6$yw&6;2~2u32&X@vpuh&%5PjCf*~1qN)@ zu;+P#5l#fGFhCv)>@Nw%LZZV012*b$_FaN;Jkf520rF^L{!TF3#I~)?u8myXzEJ}$ zw=0m^^M!!eY6$`|IpTbUfE*EbT7ke;J}CF!(A0lDmR?II}qk`7B0*tj!}e-f0LByEiXa_M3|Bq&`; zyCn*2-MJ3(>Hb{OW{m=J8T61_G^oA)xAFHV^#?C#01co4G=K)s02)98XaEhM0W^RH z(7=;nfVa_oHt?T4V%qrs7;}T-Kj2^Izs4`}2mSZ`cl@vUpZ71gMRz;<7xpIm9ripM zW^K$LnH$_)?zh|zxfM?2I^Fl&x81L}FZ+YO`@Z*mulv@0XM7+2G9T+Km(7-K**tvqN)GRpY2aA#hOQhQv%VZ(ePAJ zqrR@c9{kOV!$jAmZrlr5D?oz4b8`dw3$Z& zW~*r=EY42zNWk1-8VSqOVjc;YylEsXkl#EKFny+xutZ+-NWk
-
{/* Online / Offline badge */} {online ? ( - + @@ -103,7 +110,11 @@ export default function CameraCard({ camera }: CameraCardProps) {
{recording ? ( <> - + @@ -130,7 +141,14 @@ export default function CameraCard({ camera }: CameraCardProps) { {battery_pct !== null ? `${battery_pct}%` : 'N/A'}
-
+
Live ) : ( - {formatRelativeTime(last_seen)} + Offline )} + · + {formatRelativeTime(last_seen)}
{video_remaining_sec !== null && ( @@ -159,6 +179,6 @@ export default function CameraCard({ camera }: CameraCardProps) {
)}
-
+ ) }