Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions ui/src/features/cameras/components/CameraPreviewPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,19 @@ describe('CameraPreviewPanel', () => {
expect(screen.getByText('viewers 2')).toBeTruthy()
})

it('can hide push-to-talk for compact live camera cards', () => {
// Given: A ready preview rendered in a compact Live card context
mockReadyPreviewSession()

// When: The preview panel opts out of talk controls
render(<CameraPreviewPanel cameraName="front" showTalkControl={false} />)

// Then: Preview controls remain while push-to-talk is not mounted
expect(screen.getByRole('button', { name: 'Attach preview' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Hold to talk' })).toBeNull()
expect(usePushToTalkMock).not.toHaveBeenCalled()
})

it('initializes hls.js playback when a playlist URL becomes ready', async () => {
// Given: A ready preview session with a tokenized playlist URL
mockReadyPreviewSession()
Expand Down
20 changes: 15 additions & 5 deletions ui/src/features/cameras/components/CameraPreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ type WebKitVideoElement = HTMLVideoElement & {

interface CameraPreviewPanelProps {
cameraName: string
title?: string
subtitle?: string
showTalkControl?: boolean
className?: string
}

function previewTone(
Expand Down Expand Up @@ -103,7 +107,13 @@ async function exitActiveFullscreen(): Promise<void> {
}
}

export function CameraPreviewPanel({ cameraName }: CameraPreviewPanelProps) {
export function CameraPreviewPanel({
cameraName,
title = 'Live preview',
subtitle = 'On-demand HLS stream from the active runtime.',
showTalkControl = true,
className,
}: CameraPreviewPanelProps) {
const {
error,
isPending,
Expand Down Expand Up @@ -390,11 +400,11 @@ export function CameraPreviewPanel({ cameraName }: CameraPreviewPanelProps) {
}, [error, playerError, playlistReady, playlistUrl, status?.enabled, warning])

return (
<section className="camera-preview">
<section className={className ? `camera-preview ${className}` : 'camera-preview'}>
<header className="camera-preview__header">
<div>
<p className="camera-preview__title">Live preview</p>
<p className="camera-preview__subtitle">On-demand HLS stream from the active runtime.</p>
<p className="camera-preview__title">{title}</p>
<p className="camera-preview__subtitle">{subtitle}</p>
</div>
<div className="camera-item__badges">
<StatusBadge tone={previewTone(effectiveState)}>{previewLabel(effectiveState)}</StatusBadge>
Expand Down Expand Up @@ -438,7 +448,7 @@ export function CameraPreviewPanel({ cameraName }: CameraPreviewPanelProps) {
<p className="camera-preview__message">{statusMessage}</p>
) : null}

<PushToTalkControl cameraName={cameraName} />
{showTalkControl ? <PushToTalkControl cameraName={cameraName} /> : null}

<div className="inline-form__actions">
<Button
Expand Down
106 changes: 106 additions & 0 deletions ui/src/features/live/LiveCameraCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { createSearchParams, Link } from 'react-router-dom'

import type { CameraResponse } from '../../api/generated/types'
import { CameraCard } from '../../components/ui/CameraCard'
import { MediaPanel } from '../../components/ui/MediaPanel'
import { StatusBadge, type StatusBadgeTone } from '../../components/ui/StatusBadge'
import { TechnicalDetailsDisclosure } from '../../components/ui/TechnicalDetailsDisclosure'
import { CameraPreviewPanel } from '../cameras/components/CameraPreviewPanel'

interface LiveCameraCardProps {
camera: CameraResponse
}

function cameraStatusTone(camera: CameraResponse): StatusBadgeTone {
if (!camera.enabled) {
return 'unknown'
}
return camera.healthy ? 'healthy' : 'unhealthy'
}

function cameraStatusLabel(camera: CameraResponse): string {
if (!camera.enabled) {
return 'Disabled'
}
return camera.healthy ? 'Online' : 'Offline'
}

function formatLastSeen(value: number | null): string {
if (!value) {
return 'Status unavailable'
}
return new Date(value * 1000).toLocaleString()
}

function eventsSearch(cameraName: string): string {
return createSearchParams({ camera: cameraName }).toString()
}

function canShowPreview(camera: CameraResponse): boolean {
return camera.enabled && camera.source_backend === 'rtsp'
}

function renderPreview(camera: CameraResponse) {
if (canShowPreview(camera)) {
return (
<CameraPreviewPanel
cameraName={camera.name}
title="Preview"
subtitle="Start preview when you want to watch this camera."
showTalkControl={false}
/>
)
}

return (
<MediaPanel
title="Preview"
placeholder={
camera.enabled
? 'Live preview is not available for this camera source.'
: 'Enable this camera to use live preview.'
}
/>
)
}

export function LiveCameraCard({ camera }: LiveCameraCardProps) {
return (
<CameraCard
title={camera.name}
status={
<StatusBadge tone={cameraStatusTone(camera)}>
{cameraStatusLabel(camera)}
</StatusBadge>
}
media={renderPreview(camera)}
meta={[
{ label: 'Last seen', value: formatLastSeen(camera.last_heartbeat) },
]}
technicalDetails={
<TechnicalDetailsDisclosure>
<dl className="camera-card__meta">
<div className="camera-card__meta-row">
<dt>Source</dt>
<dd>{camera.source_backend}</dd>
</div>
<div className="camera-card__meta-row">
<dt>Enabled</dt>
<dd>{camera.enabled ? 'true' : 'false'}</dd>
</div>
</dl>
</TechnicalDetailsDisclosure>
}
actions={
<>
<Link className="button button--primary" to={`/events?${eventsSearch(camera.name)}`}>
View Events
</Link>
<Link className="button button--ghost" to="/cameras">
Camera controls
</Link>
</>
}
/>
)
}
118 changes: 118 additions & 0 deletions ui/src/features/live/LivePage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// @vitest-environment happy-dom

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'

import type { CameraResponse } from '../../api/generated/types'
import { LivePage } from './LivePage'

const useCamerasQueryMock = vi.fn()
const useSetupRedirectMock = vi.fn()

vi.mock('../../api/hooks/useCamerasQuery', () => ({
useCamerasQuery: () => useCamerasQueryMock(),
}))

vi.mock('../setup/useSetupRedirect', () => ({
useSetupRedirect: () => useSetupRedirectMock(),
}))

vi.mock('../cameras/components/CameraPreviewPanel', () => ({
CameraPreviewPanel: ({
cameraName,
showTalkControl,
title,
}: {
cameraName: string
showTalkControl?: boolean
title?: string
}) => (
<div data-testid={`preview-${cameraName}`} data-talk={String(showTalkControl)}>
{title} for {cameraName}
</div>
),
}))

function makeCamera(overrides: Partial<CameraResponse> = {}): CameraResponse {
return {
name: 'front',
enabled: true,
healthy: true,
source_backend: 'rtsp',
last_heartbeat: 1_739_590_400,
source_config: {},
...overrides,
}
}

function renderLivePage(cameras: CameraResponse[]) {
useSetupRedirectMock.mockReturnValue({ isChecking: false, shouldRedirect: false })
useCamerasQueryMock.mockReturnValue({
data: cameras,
isPending: false,
isFetching: false,
error: null,
refetch: vi.fn().mockResolvedValue(undefined),
})

render(
<MemoryRouter initialEntries={['/live']}>
<LivePage />
</MemoryRouter>,
)
}

describe('LivePage camera cards', () => {
beforeEach(() => {
useCamerasQueryMock.mockReset()
useSetupRedirectMock.mockReset()
})

afterEach(() => {
cleanup()
})

it('renders a camera-first grid with status, preview, and event actions', () => {
// Given: Existing camera API data includes an RTSP camera
renderLivePage([makeCamera({ name: 'front_door' })])

// When: The Live page renders camera cards
const card = screen.getByRole('heading', { name: 'front_door' }).closest('article')

// Then: The card exposes status, preview, and event navigation using existing data
expect(card ? within(card).getByText('Online') : null).toBeTruthy()
expect(card ? within(card).getByTestId('preview-front_door') : null).toBeTruthy()
expect(screen.getByTestId('preview-front_door').getAttribute('data-talk')).toBe('false')
expect(card ? within(card).getByText('Last seen') : null).toBeTruthy()
expect(
card ? within(card).getByRole('link', { name: 'View Events' }).getAttribute('href') : null,
).toBe('/events?camera=front_door')
expect(
card
? within(card).getByRole('link', { name: 'Camera controls' }).getAttribute('href')
: null,
).toBe('/cameras')
})

it('uses lightweight placeholders for cameras without live preview support', () => {
// Given: Existing camera data includes a non-RTSP source and a disabled camera
renderLivePage([
makeCamera({ name: 'dropbox_uploads', source_backend: 'local_folder' }),
makeCamera({ name: 'garage', enabled: false, healthy: false }),
])

// When: The Live page renders the cards
const disabledCard = screen.getByRole('heading', { name: 'garage' }).closest('article')

// Then: The list does not auto-start preview streams and still shows clear status
expect(screen.getByText('Live preview is not available for this camera source.')).toBeTruthy()
expect(disabledCard ? within(disabledCard).getByText('Disabled') : null).toBeTruthy()
expect(
disabledCard
? within(disabledCard).getByText('Enable this camera to use live preview.')
: null,
).toBeTruthy()
expect(screen.queryByTestId('preview-dropbox_uploads')).toBeNull()
})
})
47 changes: 3 additions & 44 deletions ui/src/features/live/LivePage.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,15 @@
import { createSearchParams, Link } from 'react-router-dom'
import { Link } from 'react-router-dom'

import { clearApiKey, isUnauthorizedAPIError, saveApiKey } from '../../api/client'
import { useCamerasQuery } from '../../api/hooks/useCamerasQuery'
import { ApiKeyGate } from '../../components/ui/ApiKeyGate'
import { Button } from '../../components/ui/Button'
import { CameraCard } from '../../components/ui/CameraCard'
import { Card } from '../../components/ui/Card'
import { EmptyState } from '../../components/ui/EmptyState'
import { ResponsivePageShell } from '../../components/ui/ResponsivePageShell'
import { StatusBadge } from '../../components/ui/StatusBadge'
import { describeCameraError } from '../cameras/presentation'
import { useSetupRedirect } from '../setup/useSetupRedirect'

function cameraStatusTone(enabled: boolean, healthy: boolean): 'healthy' | 'unknown' | 'unhealthy' {
if (!enabled) {
return 'unknown'
}
return healthy ? 'healthy' : 'unhealthy'
}

function cameraStatusLabel(enabled: boolean, healthy: boolean): string {
if (!enabled) {
return 'Disabled'
}
return healthy ? 'Online' : 'Offline'
}

function eventsSearch(cameraName: string): string {
return createSearchParams({ camera: cameraName }).toString()
}
import { LiveCameraCard } from './LiveCameraCard'

export function LivePage() {
const { shouldRedirect, isChecking } = useSetupRedirect()
Expand Down Expand Up @@ -103,29 +84,7 @@ export function LivePage() {
{cameras.length > 0 ? (
<div className="live-camera-list">
{cameras.map((camera) => (
<CameraCard
key={camera.name}
title={camera.name}
subtitle={camera.source_backend}
status={
<StatusBadge tone={cameraStatusTone(camera.enabled, camera.healthy)}>
{cameraStatusLabel(camera.enabled, camera.healthy)}
</StatusBadge>
}
actions={
<>
<Link className="button button--primary" to="/cameras">
Camera controls
</Link>
<Link
className="button button--ghost"
to={`/events?${eventsSearch(camera.name)}`}
>
View Events
</Link>
</>
}
/>
<LiveCameraCard key={camera.name} camera={camera} />
))}
</div>
) : null}
Expand Down
5 changes: 5 additions & 0 deletions ui/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,11 @@ a:hover {
gap: var(--space-3);
}

.live-camera-list {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr));
align-items: start;
}

.settings-grid {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
Expand Down
Loading