@camstack/addon-admin-ui 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/index.html +22 -0
  2. package/package.json +69 -0
  3. package/public/brand/logo-dark.svg +16 -0
  4. package/public/brand/logo-horizontal-dark.svg +21 -0
  5. package/public/brand/logo-horizontal-light.svg +21 -0
  6. package/public/brand/logo-light.svg +16 -0
  7. package/public/brand/logo-wide-dark.svg +24 -0
  8. package/public/brand/logo-wide-light.svg +24 -0
  9. package/public/favicon.svg +8 -0
  10. package/public/vendor/react-jsx-runtime.mjs +24 -0
  11. package/public/vendor/react.mjs +16 -0
  12. package/src/App.tsx +71 -0
  13. package/src/components/addons/AddonCard.tsx +339 -0
  14. package/src/components/addons/AddonUploadZone.tsx +307 -0
  15. package/src/components/addons/CapabilityBadge.tsx +55 -0
  16. package/src/components/addons/CapabilityMap.tsx +133 -0
  17. package/src/components/addons/UpdatesList.tsx +119 -0
  18. package/src/components/agents/AgentCard.tsx +281 -0
  19. package/src/components/agents/AgentLogs.tsx +231 -0
  20. package/src/components/agents/ProcessList.tsx +127 -0
  21. package/src/components/agents/ProcessTree.tsx +369 -0
  22. package/src/components/agents/TaskList.tsx +68 -0
  23. package/src/components/cameras/CameraCard.tsx +60 -0
  24. package/src/components/cameras/LiveEventsPanel.tsx +91 -0
  25. package/src/components/cameras/ProviderSection.tsx +50 -0
  26. package/src/components/cameras/StreamArea.tsx +107 -0
  27. package/src/components/cameras/tabs/AddonsTab.tsx +113 -0
  28. package/src/components/cameras/tabs/CameraEventsTab.tsx +129 -0
  29. package/src/components/cameras/tabs/PipelineTab.tsx +118 -0
  30. package/src/components/cameras/tabs/StreamsTab.tsx +114 -0
  31. package/src/components/dashboard/BlockPicker.tsx +54 -0
  32. package/src/components/dashboard/BlockWrapper.tsx +97 -0
  33. package/src/components/dashboard/DashboardGrid.tsx +160 -0
  34. package/src/components/dashboard/block-registry.ts +15 -0
  35. package/src/components/dashboard/blocks/PipelineStagesBlock.tsx +39 -0
  36. package/src/components/dashboard/blocks/StorageBlock.tsx +66 -0
  37. package/src/components/dashboard/blocks/SystemStatusBlock.tsx +67 -0
  38. package/src/components/dashboard/blocks/index.ts +32 -0
  39. package/src/components/device/DeviceHeader.tsx +116 -0
  40. package/src/components/device/FloatingPanel.tsx +132 -0
  41. package/src/components/device/FloatingPanelManager.tsx +167 -0
  42. package/src/components/device/PanelContent.tsx +196 -0
  43. package/src/components/device/QuickConfigWizard.tsx +507 -0
  44. package/src/components/device/tabs/DetectionConfigTab.tsx +96 -0
  45. package/src/components/device/tabs/EventsTab.tsx +19 -0
  46. package/src/components/device/tabs/LogsTab.tsx +22 -0
  47. package/src/components/device/tabs/OverviewTab.tsx +104 -0
  48. package/src/components/device/tabs/ProviderSettingsTab.tsx +34 -0
  49. package/src/components/device/tabs/RecordingTab.tsx +47 -0
  50. package/src/components/device/tabs/ReplTab.tsx +153 -0
  51. package/src/components/device/tabs/TrackTrailTab.tsx +49 -0
  52. package/src/components/device/tabs/ZonesTab.tsx +98 -0
  53. package/src/components/device/zone-editor/ZoneCanvas.tsx +354 -0
  54. package/src/components/device/zone-editor/ZoneForm.tsx +128 -0
  55. package/src/components/device/zone-editor/ZoneList.tsx +150 -0
  56. package/src/components/form-builder/FormBuilder.tsx +135 -0
  57. package/src/components/form-builder/FormField.tsx +732 -0
  58. package/src/components/form-builder/ModelSelector.tsx +239 -0
  59. package/src/components/integrations/AddDeviceDialog.tsx +205 -0
  60. package/src/components/integrations/CompactDeviceCard.tsx +35 -0
  61. package/src/components/integrations/DeviceCard.tsx +29 -0
  62. package/src/components/integrations/DeviceDiscoveryStep.tsx +105 -0
  63. package/src/components/integrations/DeviceGrid.tsx +79 -0
  64. package/src/components/integrations/DeviceGroupHeader.tsx +17 -0
  65. package/src/components/integrations/DiscoveredDeviceCard.tsx +26 -0
  66. package/src/components/integrations/IntegrationCard.tsx +40 -0
  67. package/src/components/integrations/IntegrationWizard.tsx +171 -0
  68. package/src/components/integrations/ProviderConfigForm.tsx +89 -0
  69. package/src/components/integrations/ProviderPicker.tsx +91 -0
  70. package/src/components/integrations/SnapshotPopover.tsx +68 -0
  71. package/src/components/metrics/AgentLoad.tsx +113 -0
  72. package/src/components/metrics/IntegrationUsage.tsx +90 -0
  73. package/src/components/metrics/PipelineStatus.tsx +105 -0
  74. package/src/components/metrics/ProcessResources.tsx +139 -0
  75. package/src/components/pipeline/PhaseSettings.tsx +131 -0
  76. package/src/components/shared/CapabilityBadges.tsx +30 -0
  77. package/src/components/shared/ProviderIcon.tsx +42 -0
  78. package/src/components/shared/StatusBadge.tsx +23 -0
  79. package/src/components/shared/WebRtcPlayer.tsx +211 -0
  80. package/src/components/timeline/EventMarker.tsx +32 -0
  81. package/src/components/timeline/TimelineBar.tsx +131 -0
  82. package/src/components/ui/ConfirmDialog.tsx +115 -0
  83. package/src/components/ui/ToastContainer.tsx +92 -0
  84. package/src/contexts/auth-context.tsx +91 -0
  85. package/src/hooks/useBackendClient.ts +6 -0
  86. package/src/hooks/useTheme.ts +1 -0
  87. package/src/i18n/en.json +164 -0
  88. package/src/i18n/index.ts +29 -0
  89. package/src/i18n/it.json +164 -0
  90. package/src/index.css +63 -0
  91. package/src/layouts/AddonPageLoader.tsx +120 -0
  92. package/src/layouts/AppLayout.tsx +238 -0
  93. package/src/layouts/ProtectedRoute.tsx +25 -0
  94. package/src/lib/addon-page-context.ts +29 -0
  95. package/src/lib/backend.ts +16 -0
  96. package/src/main.tsx +21 -0
  97. package/src/pages/AccessDenied.tsx +22 -0
  98. package/src/pages/Cameras.tsx +127 -0
  99. package/src/pages/Dashboard.tsx +6 -0
  100. package/src/pages/DeviceDetail.tsx +175 -0
  101. package/src/pages/IntegrationDetail.tsx +224 -0
  102. package/src/pages/Integrations.tsx +330 -0
  103. package/src/pages/Login.tsx +106 -0
  104. package/src/pages/Metrics.tsx +18 -0
  105. package/src/pages/PipelineConfig.tsx +282 -0
  106. package/src/pages/Showroom.tsx +351 -0
  107. package/src/pages/Timeline.tsx +269 -0
  108. package/src/pages/system/Addons.tsx +525 -0
  109. package/src/pages/system/Agents.tsx +362 -0
  110. package/src/pages/system/Logs.tsx +131 -0
  111. package/src/pages/system/Models.tsx +102 -0
  112. package/src/pages/system/Processes.tsx +129 -0
  113. package/src/pages/system/Repl.tsx +148 -0
  114. package/src/pages/system/Settings.tsx +168 -0
  115. package/src/pages/system/Users.tsx +174 -0
  116. package/src/server/addon.ts +54 -0
  117. package/src/types/config-ui.ts +210 -0
  118. package/src/types/dashboard.ts +39 -0
  119. package/tsconfig.json +29 -0
  120. package/tsconfig.server.json +16 -0
  121. package/tsup.config.ts +20 -0
  122. package/vite.config.ts +68 -0
@@ -0,0 +1,238 @@
1
+ import { Outlet, useLocation, useNavigate } from 'react-router-dom'
2
+ import {
3
+ LayoutDashboard, Plug, LogOut, Activity,
4
+ Moon, Sun, Monitor, Puzzle, Bot, Cpu, ScrollText,
5
+ Users, Wrench, Gauge, Terminal, Box, Languages,
6
+ Camera, Clock, GitBranch, Server,
7
+ type LucideIcon,
8
+ } from 'lucide-react'
9
+ import { useQuery } from '@tanstack/react-query'
10
+ import { useThemeMode } from '@camstack/ui'
11
+ import { useAuth } from '../contexts/auth-context'
12
+ import { useTranslation } from 'react-i18next'
13
+ import { getBackendClient } from '../lib/backend'
14
+ import type { AddonPageInfo } from './AddonPageLoader'
15
+ import { flattenPages } from './AddonPageLoader'
16
+
17
+ const THEME_ICONS = { dark: Moon, light: Sun, system: Monitor } as const
18
+
19
+ /** Map icon name strings from addon page declarations to Lucide components */
20
+ const ICON_MAP: Record<string, LucideIcon> = {
21
+ gauge: Gauge,
22
+ puzzle: Puzzle,
23
+ bot: Bot,
24
+ cpu: Cpu,
25
+ terminal: Terminal,
26
+ box: Box,
27
+ wrench: Wrench,
28
+ activity: Activity,
29
+ plug: Plug,
30
+ }
31
+
32
+ export function AppLayout() {
33
+ const location = useLocation()
34
+ const navigate = useNavigate()
35
+ const { user, logout } = useAuth()
36
+ const theme = useThemeMode()
37
+ const { t, i18n } = useTranslation()
38
+ const client = getBackendClient()
39
+
40
+ const { data: addonPages } = useQuery({
41
+ queryKey: ['addon-pages'],
42
+ queryFn: async (): Promise<readonly AddonPageInfo[]> => {
43
+ const raw = await client.trpc.addonPages.listPages.query()
44
+ return flattenPages(raw as readonly unknown[])
45
+ },
46
+ staleTime: 60_000,
47
+ })
48
+
49
+ const handleLogout = () => {
50
+ logout()
51
+ navigate('/login')
52
+ }
53
+
54
+ const isActive = (path: string) =>
55
+ location.pathname === path || location.pathname.startsWith(path + '/')
56
+
57
+ const toggleLanguage = () => {
58
+ const nextLng = i18n.language === 'en' ? 'it' : 'en'
59
+ i18n.changeLanguage(nextLng)
60
+ }
61
+
62
+ const CAMERA_ITEMS = [
63
+ { label: t('nav.cameras', 'Cameras'), icon: Camera, path: '/cameras' },
64
+ { label: t('nav.integrations'), icon: Plug, path: '/integrations' },
65
+ { label: t('nav.timeline', 'Timeline'), icon: Clock, path: '/timeline' },
66
+ { label: t('nav.pipeline', 'Pipeline'), icon: GitBranch, path: '/pipeline' },
67
+ ] as const
68
+
69
+ const SYSTEM_ITEMS = [
70
+ { label: t('system.addons'), icon: Puzzle, path: '/system/addons' },
71
+ { label: t('system.agents', 'Agents & Processes'), icon: Server, path: '/system/agents' },
72
+ { label: t('system.logs'), icon: ScrollText, path: '/system/logs' },
73
+ { label: t('system.users'), icon: Users, path: '/system/users' },
74
+ { label: t('system.settings'), icon: Wrench, path: '/system/settings' },
75
+ ] as const
76
+
77
+ const themeMode = theme?.mode ?? 'system'
78
+ const THEME_LABELS: Record<string, string> = {
79
+ dark: t('theme.dark'),
80
+ light: t('theme.light'),
81
+ system: t('theme.system'),
82
+ }
83
+
84
+ return (
85
+ <div className="flex h-screen bg-background text-foreground">
86
+ {/* Sidebar */}
87
+ <aside className="flex w-48 flex-col border-r border-border bg-surface/80 backdrop-blur-sm shrink-0">
88
+ {/* App logo */}
89
+ <div className="flex h-14 items-center px-3 border-b border-border">
90
+ <img
91
+ src={theme?.resolvedMode === 'light' ? '/brand/logo-horizontal-light.svg' : '/brand/logo-horizontal-dark.svg'}
92
+ alt="CamStack Admin"
93
+ className="h-9"
94
+ />
95
+ </div>
96
+
97
+ {/* Navigation */}
98
+ <nav className="flex-1 overflow-y-auto py-3 px-2 space-y-0.5">
99
+ {/* Dashboard */}
100
+ {(() => {
101
+ const active = isActive('/dashboard')
102
+ return (
103
+ <button
104
+ onClick={() => navigate('/dashboard')}
105
+ className={`flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-[13px] font-medium transition-all ${
106
+ active
107
+ ? 'bg-primary/12 text-primary shadow-sm shadow-primary/5'
108
+ : 'text-foreground-subtle hover:bg-surface-hover hover:text-foreground'
109
+ }`}
110
+ >
111
+ <LayoutDashboard className={`h-4 w-4 shrink-0 ${active ? 'text-primary' : ''}`} />
112
+ {t('nav.dashboard')}
113
+ </button>
114
+ )
115
+ })()}
116
+
117
+ {/* Cameras section */}
118
+ <div className="pt-4 pb-1.5 px-2.5">
119
+ <span className="text-[10px] font-semibold uppercase tracking-widest text-foreground-subtle/60">
120
+ {t('nav.camerasSection', 'Cameras')}
121
+ </span>
122
+ </div>
123
+ {CAMERA_ITEMS.map((item) => {
124
+ const Icon = item.icon
125
+ const active = isActive(item.path)
126
+ return (
127
+ <button
128
+ key={item.path}
129
+ onClick={() => navigate(item.path)}
130
+ className={`flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[12px] transition-all ${
131
+ active
132
+ ? 'bg-primary/12 text-primary'
133
+ : 'text-foreground-subtle hover:bg-surface-hover hover:text-foreground'
134
+ }`}
135
+ >
136
+ <Icon className={`h-3.5 w-3.5 shrink-0 ${active ? 'text-primary' : ''}`} />
137
+ {item.label}
138
+ </button>
139
+ )
140
+ })}
141
+
142
+ {/* System section */}
143
+ <div className="pt-4 pb-1.5 px-2.5">
144
+ <span className="text-[10px] font-semibold uppercase tracking-widest text-foreground-subtle/60">
145
+ {t('system.title')}
146
+ </span>
147
+ </div>
148
+ {SYSTEM_ITEMS.map((item) => {
149
+ const Icon = item.icon
150
+ const active = isActive(item.path)
151
+ return (
152
+ <button
153
+ key={item.path}
154
+ onClick={() => navigate(item.path)}
155
+ className={`flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[12px] transition-all ${
156
+ active
157
+ ? 'bg-primary/12 text-primary'
158
+ : 'text-foreground-subtle hover:bg-surface-hover hover:text-foreground'
159
+ }`}
160
+ >
161
+ <Icon className={`h-3.5 w-3.5 shrink-0 ${active ? 'text-primary' : ''}`} />
162
+ {item.label}
163
+ </button>
164
+ )
165
+ })}
166
+
167
+ {/* Addon pages are rendered after system items via dynamic query */}
168
+
169
+ {/* Dynamic addon pages section */}
170
+ {addonPages && addonPages.length > 0 && (
171
+ <div className="pt-4 pb-1.5 px-2.5">
172
+ <span className="text-[10px] font-semibold uppercase tracking-widest text-foreground-subtle/60">
173
+ {t('nav.addonPages', 'Addon Pages')}
174
+ </span>
175
+ </div>
176
+ )}
177
+ {addonPages?.map((page) => {
178
+ const active = isActive(page.path)
179
+ const Icon = ICON_MAP[page.icon] ?? Puzzle
180
+ return (
181
+ <button
182
+ key={page.id}
183
+ onClick={() => navigate(page.path)}
184
+ className={`flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[12px] transition-all ${
185
+ active
186
+ ? 'bg-primary/12 text-primary'
187
+ : 'text-foreground-subtle hover:bg-surface-hover hover:text-foreground'
188
+ }`}
189
+ >
190
+ <Icon className={`h-3.5 w-3.5 shrink-0 ${active ? 'text-primary' : ''}`} />
191
+ {page.label}
192
+ </button>
193
+ )
194
+ })}
195
+ </nav>
196
+
197
+ {/* Bottom controls */}
198
+ <div className="border-t border-border p-2 space-y-0.5">
199
+ {/* Language toggle */}
200
+ <button
201
+ onClick={toggleLanguage}
202
+ title={t('language.select')}
203
+ className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[12px] text-foreground-subtle hover:bg-surface-hover hover:text-foreground transition-colors"
204
+ >
205
+ <Languages className="h-3.5 w-3.5 shrink-0" />
206
+ <span>{i18n.language === 'en' ? t('language.en') : t('language.it')}</span>
207
+ </button>
208
+
209
+ {/* Theme toggle */}
210
+ <button
211
+ onClick={() => theme?.toggleMode()}
212
+ className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[12px] text-foreground-subtle hover:bg-surface-hover hover:text-foreground transition-colors"
213
+ >
214
+ {(() => {
215
+ const ThemeIcon = THEME_ICONS[themeMode]
216
+ return <ThemeIcon className="h-3.5 w-3.5" />
217
+ })()}
218
+ <span>{THEME_LABELS[themeMode]}</span>
219
+ </button>
220
+
221
+ {/* Logout */}
222
+ <button
223
+ onClick={handleLogout}
224
+ className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[12px] text-foreground-subtle hover:bg-surface-hover hover:text-danger transition-colors"
225
+ >
226
+ <LogOut className="h-3.5 w-3.5" />
227
+ <span className="truncate">{user?.username ?? 'User'}</span>
228
+ </button>
229
+ </div>
230
+ </aside>
231
+
232
+ {/* Main content */}
233
+ <main className="flex-1 overflow-auto bg-background">
234
+ <Outlet />
235
+ </main>
236
+ </div>
237
+ )
238
+ }
@@ -0,0 +1,25 @@
1
+ import { Navigate } from 'react-router-dom'
2
+ import { useAuth } from '../contexts/auth-context'
3
+
4
+ interface ProtectedRouteProps {
5
+ children: React.ReactNode
6
+ requiredRole?: string
7
+ }
8
+
9
+ export function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
10
+ const { user, loading } = useAuth()
11
+
12
+ if (loading) {
13
+ return <div className="flex h-screen items-center justify-center bg-background text-foreground-subtle">Loading...</div>
14
+ }
15
+
16
+ if (!user) {
17
+ return <Navigate to="/login" replace />
18
+ }
19
+
20
+ if (requiredRole === 'admin' && user.role !== 'admin' && user.role !== 'super_admin') {
21
+ return <Navigate to="/access-denied" replace />
22
+ }
23
+
24
+ return <>{children}</>
25
+ }
@@ -0,0 +1,29 @@
1
+ import React from 'react'
2
+ import * as ReactDOM from 'react-dom/client'
3
+ import * as ReactQuery from '@tanstack/react-query'
4
+
5
+ export interface AddonPageContext {
6
+ readonly trpc: unknown
7
+ readonly theme: { readonly isDark: boolean }
8
+ readonly locale: string
9
+ readonly navigate: (path: string) => void
10
+ readonly toast: (message: string, type?: 'success' | 'error' | 'info') => void
11
+ }
12
+
13
+ declare global {
14
+ interface Window {
15
+ __camstack: {
16
+ readonly React: typeof React
17
+ readonly ReactDOM: typeof ReactDOM
18
+ readonly ReactQuery: typeof ReactQuery
19
+ }
20
+ }
21
+ }
22
+
23
+ export function initializeAddonPageContext(): void {
24
+ window.__camstack = {
25
+ React,
26
+ ReactDOM,
27
+ ReactQuery,
28
+ }
29
+ }
@@ -0,0 +1,16 @@
1
+ import { BackendClient } from '@camstack/sdk'
2
+
3
+ let instance: BackendClient | null = null
4
+
5
+ export function getBackendClient(): BackendClient {
6
+ if (!instance) {
7
+ const serverUrl = window.location.origin
8
+ const token = localStorage.getItem('camstack_admin_token') ?? undefined
9
+ instance = new BackendClient({ serverUrl, token })
10
+ }
11
+ return instance
12
+ }
13
+
14
+ export function resetBackendClient(): void {
15
+ instance = null
16
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,21 @@
1
+ import * as React from 'react'
2
+
3
+ // Expose React on window so addon page vendor modules can access it.
4
+ // The import map in index.html maps 'react' → /vendor/react.mjs which
5
+ // re-exports from window.__camstackReact.
6
+ ;(window as any).__camstackReact = React
7
+
8
+ import { initializeAddonPageContext } from './lib/addon-page-context'
9
+ initializeAddonPageContext()
10
+
11
+ import { StrictMode } from 'react'
12
+ import { createRoot } from 'react-dom/client'
13
+ import { App } from './App'
14
+ import './index.css'
15
+ import './i18n'
16
+
17
+ createRoot(document.getElementById('root')!).render(
18
+ <StrictMode>
19
+ <App />
20
+ </StrictMode>,
21
+ )
@@ -0,0 +1,22 @@
1
+ import { useNavigate } from 'react-router-dom'
2
+ import { ShieldX } from 'lucide-react'
3
+
4
+ export function AccessDeniedPage() {
5
+ const navigate = useNavigate()
6
+
7
+ return (
8
+ <div className="flex min-h-screen flex-col items-center justify-center bg-background text-foreground p-4">
9
+ <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-danger/10 mb-4">
10
+ <ShieldX className="h-8 w-8 text-danger" />
11
+ </div>
12
+ <h1 className="text-xl font-bold">Access Denied</h1>
13
+ <p className="mt-2 text-sm text-foreground-subtle">You don't have permission to access this page.</p>
14
+ <button
15
+ onClick={() => navigate('/dashboard')}
16
+ className="mt-6 rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground shadow-md shadow-primary/20 hover:shadow-lg transition-all"
17
+ >
18
+ Go to Dashboard
19
+ </button>
20
+ </div>
21
+ )
22
+ }
@@ -0,0 +1,127 @@
1
+ import { useMemo } from 'react'
2
+ import { useQuery } from '@tanstack/react-query'
3
+ import { Camera, Search } from 'lucide-react'
4
+ import { useState } from 'react'
5
+ import { useTranslation } from 'react-i18next'
6
+ import { useBackendClient } from '../hooks/useBackendClient'
7
+ import { ProviderSection } from '../components/cameras/ProviderSection'
8
+
9
+ export function CamerasPage() {
10
+ const { t } = useTranslation()
11
+ const client = useBackendClient()
12
+ const [search, setSearch] = useState('')
13
+
14
+ const { data: devices, isLoading: devicesLoading } = useQuery({
15
+ queryKey: ['devices'],
16
+ queryFn: () => client.listDevices(),
17
+ refetchInterval: 5_000,
18
+ })
19
+
20
+ const { data: providers, isLoading: providersLoading } = useQuery({
21
+ queryKey: ['providers'],
22
+ queryFn: () => client.listProviders(),
23
+ })
24
+
25
+ const deviceList = (devices ?? []) as unknown as Array<Record<string, unknown>>
26
+ const providerList = (providers ?? []) as unknown as Array<Record<string, unknown>>
27
+
28
+ // Filter camera-type devices only and apply search
29
+ const cameraDevices = useMemo(() => {
30
+ const searchLower = search.toLowerCase()
31
+ return deviceList.filter((d) => {
32
+ const type = String(d.type ?? '').toLowerCase()
33
+ const capabilities = (d.capabilities ?? []) as string[]
34
+ const isCamera =
35
+ type === 'camera' ||
36
+ type.includes('camera') ||
37
+ capabilities.some((c) => c.toLowerCase().includes('video') || c.toLowerCase().includes('stream'))
38
+
39
+ // If no type info, include all devices (early stage where type may not be set)
40
+ const include = isCamera || !type || type === '—'
41
+
42
+ if (!include) return false
43
+ if (!searchLower) return true
44
+
45
+ const name = String(d.name ?? d.id ?? '').toLowerCase()
46
+ return name.includes(searchLower)
47
+ })
48
+ }, [deviceList, search])
49
+
50
+ // Group by provider
51
+ const groupedByProvider = useMemo(() => {
52
+ const groups = new Map<string, Array<Record<string, unknown>>>()
53
+ for (const device of cameraDevices) {
54
+ const providerId = String(device.providerId ?? '')
55
+ if (!groups.has(providerId)) {
56
+ groups.set(providerId, [])
57
+ }
58
+ groups.get(providerId)!.push(device)
59
+ }
60
+ return groups
61
+ }, [cameraDevices])
62
+
63
+ const isLoading = devicesLoading || providersLoading
64
+
65
+ return (
66
+ <div className="p-6 space-y-6">
67
+ {/* Header */}
68
+ <div className="flex items-center justify-between gap-4">
69
+ <h1 className="text-lg font-semibold text-foreground">{t('nav.cameras', 'Cameras')}</h1>
70
+ <div className="flex items-center gap-3">
71
+ {/* Search */}
72
+ <div className="relative">
73
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-foreground-subtle" />
74
+ <input
75
+ type="text"
76
+ value={search}
77
+ onChange={(e) => setSearch(e.target.value)}
78
+ placeholder="Search cameras..."
79
+ className="rounded-lg border border-border bg-surface pl-8 pr-3 py-1.5 text-xs text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-primary/50 w-48"
80
+ />
81
+ </div>
82
+ <span className="text-[11px] text-foreground-subtle">
83
+ {cameraDevices.length} camera{cameraDevices.length !== 1 ? 's' : ''}
84
+ </span>
85
+ </div>
86
+ </div>
87
+
88
+ {/* Loading state */}
89
+ {isLoading && (
90
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
91
+ {[1, 2, 3, 4, 5, 6].map((i) => (
92
+ <div key={i} className="aspect-[16/12] rounded-lg border border-border bg-surface animate-pulse" />
93
+ ))}
94
+ </div>
95
+ )}
96
+
97
+ {/* Empty state */}
98
+ {!isLoading && cameraDevices.length === 0 && (
99
+ <div className="flex flex-col items-center py-20 text-foreground-subtle">
100
+ <Camera className="h-12 w-12 mb-4 opacity-20" />
101
+ <p className="text-sm font-medium text-foreground">No cameras found</p>
102
+ <p className="text-xs mt-1">
103
+ {search ? 'Try a different search term' : 'Add an integration to discover cameras'}
104
+ </p>
105
+ </div>
106
+ )}
107
+
108
+ {/* Provider sections */}
109
+ {!isLoading && Array.from(groupedByProvider.entries()).map(([providerId, providerDevices]) => {
110
+ const provider = providerList.find((p) => String(p.id) === providerId)
111
+ return (
112
+ <ProviderSection
113
+ key={providerId}
114
+ providerType={String(provider?.type ?? 'rtsp')}
115
+ providerName={String(provider?.name ?? providerId)}
116
+ providerStatus={String(provider?.status ?? 'stopped')}
117
+ devices={providerDevices.map((d) => ({
118
+ id: String(d.id),
119
+ name: String(d.name ?? d.id),
120
+ status: String(d.status ?? 'offline'),
121
+ }))}
122
+ />
123
+ )
124
+ })}
125
+ </div>
126
+ )
127
+ }
@@ -0,0 +1,6 @@
1
+ import '../components/dashboard/blocks' // side-effect: registers all blocks
2
+ import { DashboardGrid } from '../components/dashboard/DashboardGrid'
3
+
4
+ export function DashboardPage() {
5
+ return <DashboardGrid />
6
+ }
@@ -0,0 +1,175 @@
1
+ import { useState } from 'react'
2
+ import { useParams, Link } from 'react-router-dom'
3
+ import { useQuery } from '@tanstack/react-query'
4
+ import { ChevronRight } from 'lucide-react'
5
+ import { useBackendClient } from '../hooks/useBackendClient'
6
+ import { StatusBadge } from '../components/shared/StatusBadge'
7
+ import { ProviderIcon, getProviderLabel } from '../components/shared/ProviderIcon'
8
+ import { StreamArea } from '../components/cameras/StreamArea'
9
+ import { LiveEventsPanel } from '../components/cameras/LiveEventsPanel'
10
+ import { CameraEventsTab } from '../components/cameras/tabs/CameraEventsTab'
11
+ import { PipelineTab } from '../components/cameras/tabs/PipelineTab'
12
+ import { RecordingTab } from '../components/device/tabs/RecordingTab'
13
+ import { ZonesTab } from '../components/device/tabs/ZonesTab'
14
+ import { AddonsTab } from '../components/cameras/tabs/AddonsTab'
15
+ import { StreamsTab } from '../components/cameras/tabs/StreamsTab'
16
+ import { LogsTab } from '../components/device/tabs/LogsTab'
17
+ import { ReplTab } from '../components/device/tabs/ReplTab'
18
+
19
+ const TABS = [
20
+ { id: 'events', label: 'Events' },
21
+ { id: 'pipeline', label: 'Pipeline' },
22
+ { id: 'recording', label: 'Recording' },
23
+ { id: 'zones', label: 'Zones' },
24
+ { id: 'addons', label: 'Addons' },
25
+ { id: 'streams', label: 'Streams' },
26
+ { id: 'logs', label: 'Logs' },
27
+ { id: 'repl', label: 'REPL' },
28
+ ] as const
29
+
30
+ type TabId = (typeof TABS)[number]['id']
31
+
32
+ export function DeviceDetailPage() {
33
+ const { deviceId } = useParams<{ deviceId: string }>()
34
+ const client = useBackendClient()
35
+ const [activeTab, setActiveTab] = useState<TabId>('events')
36
+
37
+ const { data: deviceData, isLoading } = useQuery({
38
+ queryKey: ['device', deviceId],
39
+ queryFn: () => client.getDevice(deviceId!),
40
+ enabled: !!deviceId,
41
+ refetchInterval: 5_000,
42
+ })
43
+
44
+ const device = (deviceData ?? {}) as Record<string, unknown>
45
+ const name = String(device.name ?? deviceId ?? 'Device')
46
+ const status = String(device.status ?? 'offline')
47
+ const provider = String(device.providerId ?? device.provider ?? 'rtsp')
48
+ const phase = String(device.phase ?? '—')
49
+ const priority = device.priority != null ? String(device.priority) : null
50
+
51
+ function renderTabContent() {
52
+ if (!deviceId) return null
53
+
54
+ switch (activeTab) {
55
+ case 'events':
56
+ return <CameraEventsTab deviceId={deviceId} />
57
+ case 'pipeline':
58
+ return <PipelineTab deviceId={deviceId} />
59
+ case 'recording':
60
+ return <RecordingTab />
61
+ case 'zones':
62
+ return <ZonesTab />
63
+ case 'addons':
64
+ return <AddonsTab deviceId={deviceId} />
65
+ case 'streams':
66
+ return <StreamsTab deviceId={deviceId} />
67
+ case 'logs':
68
+ return <LogsTab />
69
+ case 'repl':
70
+ return <ReplTab deviceId={deviceId} />
71
+ default:
72
+ return null
73
+ }
74
+ }
75
+
76
+ if (isLoading) {
77
+ return (
78
+ <div className="flex flex-col h-full">
79
+ <div className="border-b border-border bg-surface px-6 py-4">
80
+ <div className="h-4 w-48 rounded bg-surface-hover animate-pulse mb-3" />
81
+ <div className="h-8 w-64 rounded bg-surface-hover animate-pulse" />
82
+ </div>
83
+ <div className="flex-1 p-6">
84
+ <div className="h-40 rounded-lg border border-border bg-surface animate-pulse" />
85
+ </div>
86
+ </div>
87
+ )
88
+ }
89
+
90
+ return (
91
+ <div className="flex flex-col h-full">
92
+ {/* Header with breadcrumb + status bar */}
93
+ <div className="border-b border-border bg-surface px-6 py-4">
94
+ {/* Breadcrumb */}
95
+ <nav className="flex items-center gap-1 text-[11px] text-foreground-subtle mb-3">
96
+ <Link to="/cameras" className="hover:text-foreground transition-colors">
97
+ Cameras
98
+ </Link>
99
+ <ChevronRight className="h-3 w-3" />
100
+ <span className="text-foreground-subtle">{getProviderLabel(provider)}</span>
101
+ <ChevronRight className="h-3 w-3" />
102
+ <span className="text-foreground">{name}</span>
103
+ </nav>
104
+
105
+ {/* Status bar */}
106
+ <div className="flex items-center gap-3">
107
+ <ProviderIcon type={provider} size="lg" />
108
+ <div className="min-w-0">
109
+ <div className="flex items-center gap-2">
110
+ <h1 className="text-base font-semibold text-foreground truncate">{name}</h1>
111
+ <StatusBadge status={status} />
112
+ </div>
113
+ <div className="flex items-center gap-3 mt-1 text-[10px] text-foreground-subtle">
114
+ <span className="inline-flex items-center gap-1">
115
+ <span className={`h-1.5 w-1.5 rounded-full ${status === 'online' || status === 'running' ? 'bg-success' : 'bg-foreground-subtle/30'}`} />
116
+ {status === 'online' || status === 'running' ? 'Online' : 'Offline'}
117
+ </span>
118
+ <span>Phase: <span className="text-foreground">{phase}</span></span>
119
+ {priority && <span>Priority: <span className="text-foreground">{priority}</span></span>}
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </div>
124
+
125
+ {/* Top row: stream area + live events panel */}
126
+ <div className="flex border-b border-border">
127
+ {/* Stream area */}
128
+ <div className="flex-1 p-4 border-r border-border">
129
+ <StreamArea
130
+ deviceName={name}
131
+ detectionsToday={0}
132
+ inferenceMs={0}
133
+ activeTracks={0}
134
+ storageGb={0}
135
+ />
136
+ </div>
137
+
138
+ {/* Live Events panel */}
139
+ <div className="w-[300px] flex-shrink-0 bg-surface">
140
+ <LiveEventsPanel deviceId={deviceId ?? ''} />
141
+ </div>
142
+ </div>
143
+
144
+ {/* Tab bar */}
145
+ <div className="border-b border-border bg-surface px-6">
146
+ <div className="flex items-center gap-0 overflow-x-auto">
147
+ {TABS.map((tab) => {
148
+ const isActive = activeTab === tab.id
149
+ return (
150
+ <button
151
+ key={tab.id}
152
+ onClick={() => setActiveTab(tab.id)}
153
+ className={`relative flex-shrink-0 px-4 py-3 text-xs font-medium transition-colors ${
154
+ isActive
155
+ ? 'text-primary'
156
+ : 'text-foreground-subtle hover:text-foreground'
157
+ }`}
158
+ >
159
+ {tab.label}
160
+ {isActive && (
161
+ <span className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full bg-primary" />
162
+ )}
163
+ </button>
164
+ )
165
+ })}
166
+ </div>
167
+ </div>
168
+
169
+ {/* Tab content */}
170
+ <div className="flex-1 overflow-y-auto p-6">
171
+ {renderTabContent()}
172
+ </div>
173
+ </div>
174
+ )
175
+ }