@camstack/addon-admin-ui 0.1.2 → 0.1.4

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 (127) hide show
  1. package/dist/assets/index-BoVZEQ1j.js +598 -0
  2. package/dist/assets/index-DwSc8ann.css +1 -0
  3. package/{index.html → dist/index.html} +3 -1
  4. package/dist/server/addon.d.ts +11 -0
  5. package/dist/server/addon.js +50 -0
  6. package/dist/server/addon.js.map +1 -0
  7. package/package.json +4 -1
  8. package/src/App.tsx +0 -71
  9. package/src/components/addons/AddonCard.tsx +0 -355
  10. package/src/components/addons/AddonUploadZone.tsx +0 -69
  11. package/src/components/addons/CapabilityBadge.tsx +0 -55
  12. package/src/components/addons/CapabilityMap.tsx +0 -133
  13. package/src/components/addons/UpdatesList.tsx +0 -108
  14. package/src/components/agents/AgentCard.tsx +0 -281
  15. package/src/components/agents/AgentLogs.tsx +0 -231
  16. package/src/components/agents/ProcessList.tsx +0 -127
  17. package/src/components/agents/ProcessTree.tsx +0 -369
  18. package/src/components/agents/TaskList.tsx +0 -68
  19. package/src/components/cameras/CameraCard.tsx +0 -60
  20. package/src/components/cameras/LiveEventsPanel.tsx +0 -91
  21. package/src/components/cameras/ProviderSection.tsx +0 -50
  22. package/src/components/cameras/StreamArea.tsx +0 -107
  23. package/src/components/cameras/tabs/AddonsTab.tsx +0 -113
  24. package/src/components/cameras/tabs/CameraEventsTab.tsx +0 -129
  25. package/src/components/cameras/tabs/PipelineTab.tsx +0 -118
  26. package/src/components/cameras/tabs/StreamsTab.tsx +0 -114
  27. package/src/components/dashboard/BlockPicker.tsx +0 -54
  28. package/src/components/dashboard/BlockWrapper.tsx +0 -97
  29. package/src/components/dashboard/DashboardGrid.tsx +0 -160
  30. package/src/components/dashboard/block-registry.ts +0 -15
  31. package/src/components/dashboard/blocks/PipelineStagesBlock.tsx +0 -39
  32. package/src/components/dashboard/blocks/StorageBlock.tsx +0 -66
  33. package/src/components/dashboard/blocks/SystemStatusBlock.tsx +0 -67
  34. package/src/components/dashboard/blocks/index.ts +0 -32
  35. package/src/components/device/DeviceHeader.tsx +0 -116
  36. package/src/components/device/FloatingPanel.tsx +0 -132
  37. package/src/components/device/FloatingPanelManager.tsx +0 -167
  38. package/src/components/device/PanelContent.tsx +0 -196
  39. package/src/components/device/QuickConfigWizard.tsx +0 -507
  40. package/src/components/device/tabs/DetectionConfigTab.tsx +0 -96
  41. package/src/components/device/tabs/EventsTab.tsx +0 -19
  42. package/src/components/device/tabs/LogsTab.tsx +0 -22
  43. package/src/components/device/tabs/OverviewTab.tsx +0 -104
  44. package/src/components/device/tabs/ProviderSettingsTab.tsx +0 -34
  45. package/src/components/device/tabs/RecordingTab.tsx +0 -47
  46. package/src/components/device/tabs/ReplTab.tsx +0 -153
  47. package/src/components/device/tabs/TrackTrailTab.tsx +0 -49
  48. package/src/components/device/tabs/ZonesTab.tsx +0 -98
  49. package/src/components/device/zone-editor/ZoneCanvas.tsx +0 -354
  50. package/src/components/device/zone-editor/ZoneForm.tsx +0 -128
  51. package/src/components/device/zone-editor/ZoneList.tsx +0 -150
  52. package/src/components/form-builder/FormBuilder.tsx +0 -135
  53. package/src/components/form-builder/FormField.tsx +0 -732
  54. package/src/components/form-builder/ModelSelector.tsx +0 -239
  55. package/src/components/integrations/AddDeviceDialog.tsx +0 -205
  56. package/src/components/integrations/CompactDeviceCard.tsx +0 -35
  57. package/src/components/integrations/DeviceCard.tsx +0 -29
  58. package/src/components/integrations/DeviceDiscoveryStep.tsx +0 -105
  59. package/src/components/integrations/DeviceGrid.tsx +0 -79
  60. package/src/components/integrations/DeviceGroupHeader.tsx +0 -17
  61. package/src/components/integrations/DiscoveredDeviceCard.tsx +0 -26
  62. package/src/components/integrations/IntegrationCard.tsx +0 -40
  63. package/src/components/integrations/IntegrationWizard.tsx +0 -172
  64. package/src/components/integrations/ProviderConfigForm.tsx +0 -89
  65. package/src/components/integrations/ProviderPicker.tsx +0 -91
  66. package/src/components/integrations/SnapshotPopover.tsx +0 -68
  67. package/src/components/metrics/AgentLoad.tsx +0 -105
  68. package/src/components/metrics/IntegrationUsage.tsx +0 -73
  69. package/src/components/metrics/PipelineStatus.tsx +0 -74
  70. package/src/components/metrics/ProcessResources.tsx +0 -123
  71. package/src/components/pipeline/PhaseSettings.tsx +0 -131
  72. package/src/components/shared/CapabilityBadges.tsx +0 -30
  73. package/src/components/shared/ProviderIcon.tsx +0 -42
  74. package/src/components/shared/StatusBadge.tsx +0 -23
  75. package/src/components/shared/WebRtcPlayer.tsx +0 -211
  76. package/src/components/timeline/EventMarker.tsx +0 -32
  77. package/src/components/timeline/TimelineBar.tsx +0 -131
  78. package/src/components/ui/ConfirmDialog.tsx +0 -115
  79. package/src/components/ui/ToastContainer.tsx +0 -92
  80. package/src/contexts/auth-context.tsx +0 -91
  81. package/src/hooks/useBackendClient.ts +0 -6
  82. package/src/hooks/useTheme.ts +0 -1
  83. package/src/i18n/en.json +0 -164
  84. package/src/i18n/index.ts +0 -29
  85. package/src/i18n/it.json +0 -164
  86. package/src/index.css +0 -63
  87. package/src/layouts/AddonPageLoader.tsx +0 -120
  88. package/src/layouts/AppLayout.tsx +0 -254
  89. package/src/layouts/ProtectedRoute.tsx +0 -25
  90. package/src/lib/addon-page-context.ts +0 -29
  91. package/src/lib/backend.ts +0 -16
  92. package/src/main.tsx +0 -21
  93. package/src/pages/AccessDenied.tsx +0 -22
  94. package/src/pages/Cameras.tsx +0 -127
  95. package/src/pages/Dashboard.tsx +0 -6
  96. package/src/pages/DeviceDetail.tsx +0 -175
  97. package/src/pages/IntegrationDetail.tsx +0 -222
  98. package/src/pages/Integrations.tsx +0 -333
  99. package/src/pages/Login.tsx +0 -106
  100. package/src/pages/Metrics.tsx +0 -18
  101. package/src/pages/PipelineConfig.tsx +0 -282
  102. package/src/pages/Showroom.tsx +0 -351
  103. package/src/pages/Timeline.tsx +0 -269
  104. package/src/pages/system/Addons.tsx +0 -396
  105. package/src/pages/system/Agents.tsx +0 -362
  106. package/src/pages/system/Logs.tsx +0 -131
  107. package/src/pages/system/Models.tsx +0 -102
  108. package/src/pages/system/Processes.tsx +0 -129
  109. package/src/pages/system/Repl.tsx +0 -148
  110. package/src/pages/system/Settings.tsx +0 -168
  111. package/src/pages/system/Users.tsx +0 -174
  112. package/src/server/addon.ts +0 -54
  113. package/src/types/config-ui.ts +0 -28
  114. package/src/types/dashboard.ts +0 -39
  115. package/tsconfig.json +0 -29
  116. package/tsconfig.server.json +0 -16
  117. package/tsup.config.ts +0 -20
  118. package/vite.config.ts +0 -68
  119. /package/{public → dist}/brand/logo-dark.svg +0 -0
  120. /package/{public → dist}/brand/logo-horizontal-dark.svg +0 -0
  121. /package/{public → dist}/brand/logo-horizontal-light.svg +0 -0
  122. /package/{public → dist}/brand/logo-light.svg +0 -0
  123. /package/{public → dist}/brand/logo-wide-dark.svg +0 -0
  124. /package/{public → dist}/brand/logo-wide-light.svg +0 -0
  125. /package/{public → dist}/favicon.svg +0 -0
  126. /package/{public → dist}/vendor/react-jsx-runtime.mjs +0 -0
  127. /package/{public → dist}/vendor/react.mjs +0 -0
@@ -1,254 +0,0 @@
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: updateCount = 0 } = useQuery({
41
- queryKey: ['updates', 'count'],
42
- queryFn: async () => {
43
- const updates = await client.trpc.update.listUpdates.query()
44
- return updates.length
45
- },
46
- staleTime: 30_000,
47
- refetchInterval: 30_000,
48
- })
49
-
50
- const { data: addonPages } = useQuery({
51
- queryKey: ['addon-pages'],
52
- queryFn: async (): Promise<readonly AddonPageInfo[]> => {
53
- const raw = await client.trpc.addonPages.listPages.query()
54
- return flattenPages(raw as readonly unknown[])
55
- },
56
- staleTime: 60_000,
57
- })
58
-
59
- const handleLogout = () => {
60
- logout()
61
- navigate('/login')
62
- }
63
-
64
- const isActive = (path: string) =>
65
- location.pathname === path || location.pathname.startsWith(path + '/')
66
-
67
- const toggleLanguage = () => {
68
- const nextLng = i18n.language === 'en' ? 'it' : 'en'
69
- i18n.changeLanguage(nextLng)
70
- }
71
-
72
- const CAMERA_ITEMS = [
73
- { label: t('nav.cameras', 'Cameras'), icon: Camera, path: '/cameras' },
74
- { label: t('nav.integrations'), icon: Plug, path: '/integrations' },
75
- { label: t('nav.timeline', 'Timeline'), icon: Clock, path: '/timeline' },
76
- { label: t('nav.pipeline', 'Pipeline'), icon: GitBranch, path: '/pipeline' },
77
- ] as const
78
-
79
- const SYSTEM_ITEMS = [
80
- { label: t('system.addons'), icon: Puzzle, path: '/system/addons' },
81
- { label: t('system.agents', 'Agents & Processes'), icon: Server, path: '/system/agents' },
82
- { label: t('system.logs'), icon: ScrollText, path: '/system/logs' },
83
- { label: t('system.users'), icon: Users, path: '/system/users' },
84
- { label: t('system.settings'), icon: Wrench, path: '/system/settings' },
85
- ] as const
86
-
87
- const themeMode = theme?.mode ?? 'system'
88
- const THEME_LABELS: Record<string, string> = {
89
- dark: t('theme.dark'),
90
- light: t('theme.light'),
91
- system: t('theme.system'),
92
- }
93
-
94
- return (
95
- <div className="flex h-screen bg-background text-foreground">
96
- {/* Sidebar */}
97
- <aside className="flex w-48 flex-col border-r border-border bg-surface/80 backdrop-blur-sm shrink-0">
98
- {/* App logo */}
99
- <div className="flex h-14 items-center px-3 border-b border-border">
100
- <img
101
- src={theme?.resolvedMode === 'light' ? '/brand/logo-horizontal-light.svg' : '/brand/logo-horizontal-dark.svg'}
102
- alt="CamStack Admin"
103
- className="h-9"
104
- />
105
- </div>
106
-
107
- {/* Navigation */}
108
- <nav className="flex-1 overflow-y-auto py-3 px-2 space-y-0.5">
109
- {/* Dashboard */}
110
- {(() => {
111
- const active = isActive('/dashboard')
112
- return (
113
- <button
114
- onClick={() => navigate('/dashboard')}
115
- className={`flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-[13px] font-medium transition-all ${
116
- active
117
- ? 'bg-primary/12 text-primary shadow-sm shadow-primary/5'
118
- : 'text-foreground-subtle hover:bg-surface-hover hover:text-foreground'
119
- }`}
120
- >
121
- <LayoutDashboard className={`h-4 w-4 shrink-0 ${active ? 'text-primary' : ''}`} />
122
- {t('nav.dashboard')}
123
- </button>
124
- )
125
- })()}
126
-
127
- {/* Cameras section */}
128
- <div className="pt-4 pb-1.5 px-2.5">
129
- <span className="text-[10px] font-semibold uppercase tracking-widest text-foreground-subtle/60">
130
- {t('nav.camerasSection', 'Cameras')}
131
- </span>
132
- </div>
133
- {CAMERA_ITEMS.map((item) => {
134
- const Icon = item.icon
135
- const active = isActive(item.path)
136
- return (
137
- <button
138
- key={item.path}
139
- onClick={() => navigate(item.path)}
140
- className={`flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[12px] transition-all ${
141
- active
142
- ? 'bg-primary/12 text-primary'
143
- : 'text-foreground-subtle hover:bg-surface-hover hover:text-foreground'
144
- }`}
145
- >
146
- <Icon className={`h-3.5 w-3.5 shrink-0 ${active ? 'text-primary' : ''}`} />
147
- {item.label}
148
- </button>
149
- )
150
- })}
151
-
152
- {/* System section */}
153
- <div className="pt-4 pb-1.5 px-2.5">
154
- <span className="text-[10px] font-semibold uppercase tracking-widest text-foreground-subtle/60">
155
- {t('system.title')}
156
- </span>
157
- </div>
158
- {SYSTEM_ITEMS.map((item) => {
159
- const Icon = item.icon
160
- const active = isActive(item.path)
161
- const isAddons = item.path === '/system/addons'
162
- return (
163
- <button
164
- key={item.path}
165
- onClick={() => navigate(item.path)}
166
- className={`flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[12px] transition-all ${
167
- active
168
- ? 'bg-primary/12 text-primary'
169
- : 'text-foreground-subtle hover:bg-surface-hover hover:text-foreground'
170
- }`}
171
- >
172
- <Icon className={`h-3.5 w-3.5 shrink-0 ${active ? 'text-primary' : ''}`} />
173
- <span className="flex-1 text-left">{item.label}</span>
174
- {isAddons && updateCount > 0 && (
175
- <span className="ml-auto rounded-full bg-primary px-1.5 py-0.5 text-[9px] font-bold text-primary-foreground leading-none">
176
- {updateCount}
177
- </span>
178
- )}
179
- </button>
180
- )
181
- })}
182
-
183
- {/* Addon pages are rendered after system items via dynamic query */}
184
-
185
- {/* Dynamic addon pages section */}
186
- {addonPages && addonPages.length > 0 && (
187
- <div className="pt-4 pb-1.5 px-2.5">
188
- <span className="text-[10px] font-semibold uppercase tracking-widest text-foreground-subtle/60">
189
- {t('nav.addonPages', 'Addon Pages')}
190
- </span>
191
- </div>
192
- )}
193
- {addonPages?.map((page) => {
194
- const active = isActive(page.path)
195
- const Icon = ICON_MAP[page.icon] ?? Puzzle
196
- return (
197
- <button
198
- key={page.id}
199
- onClick={() => navigate(page.path)}
200
- className={`flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[12px] transition-all ${
201
- active
202
- ? 'bg-primary/12 text-primary'
203
- : 'text-foreground-subtle hover:bg-surface-hover hover:text-foreground'
204
- }`}
205
- >
206
- <Icon className={`h-3.5 w-3.5 shrink-0 ${active ? 'text-primary' : ''}`} />
207
- {page.label}
208
- </button>
209
- )
210
- })}
211
- </nav>
212
-
213
- {/* Bottom controls */}
214
- <div className="border-t border-border p-2 space-y-0.5">
215
- {/* Language toggle */}
216
- <button
217
- onClick={toggleLanguage}
218
- title={t('language.select')}
219
- 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"
220
- >
221
- <Languages className="h-3.5 w-3.5 shrink-0" />
222
- <span>{i18n.language === 'en' ? t('language.en') : t('language.it')}</span>
223
- </button>
224
-
225
- {/* Theme toggle */}
226
- <button
227
- onClick={() => theme?.toggleMode()}
228
- 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"
229
- >
230
- {(() => {
231
- const ThemeIcon = THEME_ICONS[themeMode]
232
- return <ThemeIcon className="h-3.5 w-3.5" />
233
- })()}
234
- <span>{THEME_LABELS[themeMode]}</span>
235
- </button>
236
-
237
- {/* Logout */}
238
- <button
239
- onClick={handleLogout}
240
- 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"
241
- >
242
- <LogOut className="h-3.5 w-3.5" />
243
- <span className="truncate">{user?.username ?? 'User'}</span>
244
- </button>
245
- </div>
246
- </aside>
247
-
248
- {/* Main content */}
249
- <main className="flex-1 overflow-auto bg-background">
250
- <Outlet />
251
- </main>
252
- </div>
253
- )
254
- }
@@ -1,25 +0,0 @@
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
- }
@@ -1,29 +0,0 @@
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
- }
@@ -1,16 +0,0 @@
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 DELETED
@@ -1,21 +0,0 @@
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
- )
@@ -1,22 +0,0 @@
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
- }
@@ -1,127 +0,0 @@
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 ?? []
26
- const providerList = providers ?? []
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 = (d.type ?? '').toLowerCase()
33
- const capabilities = d.capabilities ?? []
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 = (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, typeof deviceList>()
53
- for (const device of cameraDevices) {
54
- const providerId = 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) => p.id === providerId)
111
- return (
112
- <ProviderSection
113
- key={providerId}
114
- providerType={provider?.type ?? 'rtsp'}
115
- providerName={provider?.name ?? providerId}
116
- providerStatus={provider?.status?.connected ? 'running' : 'stopped'}
117
- devices={providerDevices.map((d) => ({
118
- id: d.id,
119
- name: d.name ?? d.id,
120
- status: d.state?.online ? 'online' : 'offline',
121
- }))}
122
- />
123
- )
124
- })}
125
- </div>
126
- )
127
- }
@@ -1,6 +0,0 @@
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
- }
@@ -1,175 +0,0 @@
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
- }