@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.
- package/dist/assets/index-BoVZEQ1j.js +598 -0
- package/dist/assets/index-DwSc8ann.css +1 -0
- package/{index.html → dist/index.html} +3 -1
- package/dist/server/addon.d.ts +11 -0
- package/dist/server/addon.js +50 -0
- package/dist/server/addon.js.map +1 -0
- package/package.json +4 -1
- package/src/App.tsx +0 -71
- package/src/components/addons/AddonCard.tsx +0 -355
- package/src/components/addons/AddonUploadZone.tsx +0 -69
- package/src/components/addons/CapabilityBadge.tsx +0 -55
- package/src/components/addons/CapabilityMap.tsx +0 -133
- package/src/components/addons/UpdatesList.tsx +0 -108
- package/src/components/agents/AgentCard.tsx +0 -281
- package/src/components/agents/AgentLogs.tsx +0 -231
- package/src/components/agents/ProcessList.tsx +0 -127
- package/src/components/agents/ProcessTree.tsx +0 -369
- package/src/components/agents/TaskList.tsx +0 -68
- package/src/components/cameras/CameraCard.tsx +0 -60
- package/src/components/cameras/LiveEventsPanel.tsx +0 -91
- package/src/components/cameras/ProviderSection.tsx +0 -50
- package/src/components/cameras/StreamArea.tsx +0 -107
- package/src/components/cameras/tabs/AddonsTab.tsx +0 -113
- package/src/components/cameras/tabs/CameraEventsTab.tsx +0 -129
- package/src/components/cameras/tabs/PipelineTab.tsx +0 -118
- package/src/components/cameras/tabs/StreamsTab.tsx +0 -114
- package/src/components/dashboard/BlockPicker.tsx +0 -54
- package/src/components/dashboard/BlockWrapper.tsx +0 -97
- package/src/components/dashboard/DashboardGrid.tsx +0 -160
- package/src/components/dashboard/block-registry.ts +0 -15
- package/src/components/dashboard/blocks/PipelineStagesBlock.tsx +0 -39
- package/src/components/dashboard/blocks/StorageBlock.tsx +0 -66
- package/src/components/dashboard/blocks/SystemStatusBlock.tsx +0 -67
- package/src/components/dashboard/blocks/index.ts +0 -32
- package/src/components/device/DeviceHeader.tsx +0 -116
- package/src/components/device/FloatingPanel.tsx +0 -132
- package/src/components/device/FloatingPanelManager.tsx +0 -167
- package/src/components/device/PanelContent.tsx +0 -196
- package/src/components/device/QuickConfigWizard.tsx +0 -507
- package/src/components/device/tabs/DetectionConfigTab.tsx +0 -96
- package/src/components/device/tabs/EventsTab.tsx +0 -19
- package/src/components/device/tabs/LogsTab.tsx +0 -22
- package/src/components/device/tabs/OverviewTab.tsx +0 -104
- package/src/components/device/tabs/ProviderSettingsTab.tsx +0 -34
- package/src/components/device/tabs/RecordingTab.tsx +0 -47
- package/src/components/device/tabs/ReplTab.tsx +0 -153
- package/src/components/device/tabs/TrackTrailTab.tsx +0 -49
- package/src/components/device/tabs/ZonesTab.tsx +0 -98
- package/src/components/device/zone-editor/ZoneCanvas.tsx +0 -354
- package/src/components/device/zone-editor/ZoneForm.tsx +0 -128
- package/src/components/device/zone-editor/ZoneList.tsx +0 -150
- package/src/components/form-builder/FormBuilder.tsx +0 -135
- package/src/components/form-builder/FormField.tsx +0 -732
- package/src/components/form-builder/ModelSelector.tsx +0 -239
- package/src/components/integrations/AddDeviceDialog.tsx +0 -205
- package/src/components/integrations/CompactDeviceCard.tsx +0 -35
- package/src/components/integrations/DeviceCard.tsx +0 -29
- package/src/components/integrations/DeviceDiscoveryStep.tsx +0 -105
- package/src/components/integrations/DeviceGrid.tsx +0 -79
- package/src/components/integrations/DeviceGroupHeader.tsx +0 -17
- package/src/components/integrations/DiscoveredDeviceCard.tsx +0 -26
- package/src/components/integrations/IntegrationCard.tsx +0 -40
- package/src/components/integrations/IntegrationWizard.tsx +0 -172
- package/src/components/integrations/ProviderConfigForm.tsx +0 -89
- package/src/components/integrations/ProviderPicker.tsx +0 -91
- package/src/components/integrations/SnapshotPopover.tsx +0 -68
- package/src/components/metrics/AgentLoad.tsx +0 -105
- package/src/components/metrics/IntegrationUsage.tsx +0 -73
- package/src/components/metrics/PipelineStatus.tsx +0 -74
- package/src/components/metrics/ProcessResources.tsx +0 -123
- package/src/components/pipeline/PhaseSettings.tsx +0 -131
- package/src/components/shared/CapabilityBadges.tsx +0 -30
- package/src/components/shared/ProviderIcon.tsx +0 -42
- package/src/components/shared/StatusBadge.tsx +0 -23
- package/src/components/shared/WebRtcPlayer.tsx +0 -211
- package/src/components/timeline/EventMarker.tsx +0 -32
- package/src/components/timeline/TimelineBar.tsx +0 -131
- package/src/components/ui/ConfirmDialog.tsx +0 -115
- package/src/components/ui/ToastContainer.tsx +0 -92
- package/src/contexts/auth-context.tsx +0 -91
- package/src/hooks/useBackendClient.ts +0 -6
- package/src/hooks/useTheme.ts +0 -1
- package/src/i18n/en.json +0 -164
- package/src/i18n/index.ts +0 -29
- package/src/i18n/it.json +0 -164
- package/src/index.css +0 -63
- package/src/layouts/AddonPageLoader.tsx +0 -120
- package/src/layouts/AppLayout.tsx +0 -254
- package/src/layouts/ProtectedRoute.tsx +0 -25
- package/src/lib/addon-page-context.ts +0 -29
- package/src/lib/backend.ts +0 -16
- package/src/main.tsx +0 -21
- package/src/pages/AccessDenied.tsx +0 -22
- package/src/pages/Cameras.tsx +0 -127
- package/src/pages/Dashboard.tsx +0 -6
- package/src/pages/DeviceDetail.tsx +0 -175
- package/src/pages/IntegrationDetail.tsx +0 -222
- package/src/pages/Integrations.tsx +0 -333
- package/src/pages/Login.tsx +0 -106
- package/src/pages/Metrics.tsx +0 -18
- package/src/pages/PipelineConfig.tsx +0 -282
- package/src/pages/Showroom.tsx +0 -351
- package/src/pages/Timeline.tsx +0 -269
- package/src/pages/system/Addons.tsx +0 -396
- package/src/pages/system/Agents.tsx +0 -362
- package/src/pages/system/Logs.tsx +0 -131
- package/src/pages/system/Models.tsx +0 -102
- package/src/pages/system/Processes.tsx +0 -129
- package/src/pages/system/Repl.tsx +0 -148
- package/src/pages/system/Settings.tsx +0 -168
- package/src/pages/system/Users.tsx +0 -174
- package/src/server/addon.ts +0 -54
- package/src/types/config-ui.ts +0 -28
- package/src/types/dashboard.ts +0 -39
- package/tsconfig.json +0 -29
- package/tsconfig.server.json +0 -16
- package/tsup.config.ts +0 -20
- package/vite.config.ts +0 -68
- /package/{public → dist}/brand/logo-dark.svg +0 -0
- /package/{public → dist}/brand/logo-horizontal-dark.svg +0 -0
- /package/{public → dist}/brand/logo-horizontal-light.svg +0 -0
- /package/{public → dist}/brand/logo-light.svg +0 -0
- /package/{public → dist}/brand/logo-wide-dark.svg +0 -0
- /package/{public → dist}/brand/logo-wide-light.svg +0 -0
- /package/{public → dist}/favicon.svg +0 -0
- /package/{public → dist}/vendor/react-jsx-runtime.mjs +0 -0
- /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
|
-
}
|
package/src/lib/backend.ts
DELETED
|
@@ -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
|
-
}
|
package/src/pages/Cameras.tsx
DELETED
|
@@ -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
|
-
}
|
package/src/pages/Dashboard.tsx
DELETED
|
@@ -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
|
-
}
|