@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,224 @@
1
+ import { useState } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { useParams } from 'react-router-dom'
4
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
5
+ import { RefreshCw, Settings, Plus, Play, Square } from 'lucide-react'
6
+ import { useBackendClient } from '../hooks/useBackendClient'
7
+ import { StatusBadge } from '../components/shared/StatusBadge'
8
+ import { DeviceGrid } from '../components/integrations/DeviceGrid'
9
+ import { AddDeviceDialog } from '../components/integrations/AddDeviceDialog'
10
+
11
+ export function IntegrationDetailPage() {
12
+ const { t } = useTranslation()
13
+ const { integrationId } = useParams<{ integrationId: string }>()
14
+ const client = useBackendClient()
15
+ const queryClient = useQueryClient()
16
+ const [showAddDevice, setShowAddDevice] = useState(false)
17
+ const [showConfig, setShowConfig] = useState(false)
18
+
19
+ const { data: provider, isLoading: providerLoading } = useQuery({
20
+ queryKey: ['provider', integrationId],
21
+ queryFn: () => client.getProvider(integrationId!),
22
+ enabled: !!integrationId,
23
+ })
24
+
25
+ const { data: allDevices } = useQuery({
26
+ queryKey: ['devices'],
27
+ queryFn: () => client.listDevices(),
28
+ refetchInterval: 5_000,
29
+ })
30
+
31
+ const { data: discovered, refetch: refetchDiscovery } = useQuery({
32
+ queryKey: ['discover-devices', integrationId],
33
+ queryFn: () => client.discoverDevices(integrationId!),
34
+ enabled: !!integrationId && provider?.discoveryMode !== 'manual',
35
+ })
36
+
37
+ const startMutation = useMutation({
38
+ mutationFn: () => client.startProvider(integrationId!),
39
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['provider', integrationId] }),
40
+ })
41
+
42
+ const stopMutation = useMutation({
43
+ mutationFn: () => client.stopProvider(integrationId!),
44
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['provider', integrationId] }),
45
+ })
46
+
47
+ const adoptMutation = useMutation({
48
+ mutationFn: (externalId: string) => client.adoptDevice(integrationId!, externalId),
49
+ onSuccess: () => {
50
+ queryClient.invalidateQueries({ queryKey: ['devices'] })
51
+ queryClient.invalidateQueries({ queryKey: ['discover-devices', integrationId] })
52
+ },
53
+ })
54
+
55
+ const [importingIds, setImportingIds] = useState<Set<string>>(new Set())
56
+
57
+ async function handleImport(externalId: string) {
58
+ setImportingIds((prev) => new Set([...prev, externalId]))
59
+ try {
60
+ await adoptMutation.mutateAsync(externalId)
61
+ } finally {
62
+ setImportingIds((prev) => {
63
+ const next = new Set(prev)
64
+ next.delete(externalId)
65
+ return next
66
+ })
67
+ }
68
+ }
69
+
70
+ if (providerLoading) {
71
+ return (
72
+ <div className="p-6">
73
+ <div className="h-16 rounded-lg bg-surface animate-pulse mb-4" />
74
+ <div className="h-40 rounded-lg bg-surface animate-pulse" />
75
+ </div>
76
+ )
77
+ }
78
+
79
+ if (!provider) {
80
+ return (
81
+ <div className="flex items-center justify-center py-20 text-foreground-subtle text-sm">
82
+ {t('integrations.integrationNotFound')}
83
+ </div>
84
+ )
85
+ }
86
+
87
+ const devices = ((allDevices ?? []) as unknown as Array<Record<string, unknown>>)
88
+ .filter((d) => String(d.providerId) === integrationId)
89
+
90
+ const activeDevices = devices.map((d) => ({
91
+ id: String(d.id),
92
+ name: String(d.name ?? d.id),
93
+ status: String(d.status ?? 'offline'),
94
+ snapshotUrl: d.snapshotUrl ? String(d.snapshotUrl) : null,
95
+ }))
96
+
97
+ const discoveredDevices = (discovered ?? []).map((d: any) => ({
98
+ externalId: String(d.externalId ?? d.id),
99
+ name: String(d.name ?? d.externalId ?? d.id),
100
+ }))
101
+
102
+ const providerStatus = typeof provider.status === 'object'
103
+ ? (provider.status as any).connected ? 'running' : 'stopped'
104
+ : String(provider.status ?? 'stopped')
105
+
106
+ const isAutoDiscovery = provider.discoveryMode !== 'manual'
107
+ const isManual = provider.discoveryMode === 'manual'
108
+
109
+ return (
110
+ <div className="p-6 space-y-5">
111
+ {/* Header */}
112
+ <div className="flex items-center justify-between">
113
+ <div className="flex items-center gap-3">
114
+ <img
115
+ src={client.getAddonAssetUrl(provider.type, 'assets/icon.svg')}
116
+ alt=""
117
+ className="h-8 w-8 rounded-lg"
118
+ onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
119
+ />
120
+ <div>
121
+ <h1 className="text-lg font-semibold text-foreground">{provider.name}</h1>
122
+ <p className="text-[11px] text-foreground-subtle mt-0.5">
123
+ {provider.type} · {activeDevices.length} {t('integrations.devices')}
124
+ </p>
125
+ </div>
126
+ <StatusBadge status={providerStatus} />
127
+ </div>
128
+ <div className="flex items-center gap-2">
129
+ {isAutoDiscovery && (
130
+ <button
131
+ onClick={() => refetchDiscovery()}
132
+ className="flex items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5 text-[11px] text-foreground hover:bg-surface-hover transition-colors"
133
+ >
134
+ <RefreshCw className="h-3 w-3" />
135
+ {t('integrations.rediscover')}
136
+ </button>
137
+ )}
138
+ {/* TODO: open provider config dialog */}
139
+ <button
140
+ onClick={() => setShowConfig(true)}
141
+ className="flex items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5 text-[11px] text-foreground-subtle hover:text-foreground hover:bg-surface-hover transition-colors"
142
+ >
143
+ <Settings className="h-3 w-3" />
144
+ {t('integrations.config')}
145
+ </button>
146
+ {providerStatus === 'running' ? (
147
+ <button
148
+ onClick={() => stopMutation.mutate()}
149
+ className="rounded-lg border border-border bg-surface p-1.5 text-foreground-subtle hover:text-danger hover:bg-danger/10 transition-colors"
150
+ title="Stop"
151
+ >
152
+ <Square className="h-3.5 w-3.5" />
153
+ </button>
154
+ ) : (
155
+ <button
156
+ onClick={() => startMutation.mutate()}
157
+ className="rounded-lg border border-border bg-surface p-1.5 text-foreground-subtle hover:text-success hover:bg-success/10 transition-colors"
158
+ title="Start"
159
+ >
160
+ <Play className="h-3.5 w-3.5" />
161
+ </button>
162
+ )}
163
+ </div>
164
+ </div>
165
+
166
+ {/* Device grid */}
167
+ <DeviceGrid
168
+ activeDevices={activeDevices}
169
+ discoveredDevices={isAutoDiscovery ? discoveredDevices : undefined}
170
+ onImport={handleImport}
171
+ importingIds={importingIds}
172
+ />
173
+
174
+ {/* FAB */}
175
+ <button
176
+ onClick={() => {
177
+ if (isManual) {
178
+ setShowAddDevice(true)
179
+ } else {
180
+ refetchDiscovery()
181
+ }
182
+ }}
183
+ className="fixed bottom-6 right-6 flex h-12 w-12 items-center justify-center rounded-full bg-primary shadow-lg shadow-primary/30 hover:shadow-primary/40 transition-shadow"
184
+ title={isManual ? t('integrations.addDevice') : t('integrations.rediscover')}
185
+ >
186
+ {isManual ? (
187
+ <Plus className="h-5 w-5 text-primary-foreground" />
188
+ ) : (
189
+ <RefreshCw className="h-5 w-5 text-primary-foreground" />
190
+ )}
191
+ </button>
192
+
193
+ {showAddDevice && integrationId && (
194
+ <AddDeviceDialog
195
+ open={showAddDevice}
196
+ providerId={integrationId}
197
+ onClose={() => setShowAddDevice(false)}
198
+ />
199
+ )}
200
+
201
+ {/* TODO: replace with a real FormBuilder config dialog */}
202
+ {showConfig && (
203
+ <div
204
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
205
+ onClick={() => setShowConfig(false)}
206
+ >
207
+ <div
208
+ className="rounded-xl border border-border bg-surface p-6 shadow-xl w-80"
209
+ onClick={(e) => e.stopPropagation()}
210
+ >
211
+ <h2 className="text-sm font-semibold text-foreground mb-2">{t('integrations.providerConfig')}</h2>
212
+ <p className="text-[11px] text-foreground-subtle">{t('integrations.configComingSoon')}</p>
213
+ <button
214
+ onClick={() => setShowConfig(false)}
215
+ className="mt-4 w-full rounded-lg bg-primary px-3 py-1.5 text-[11px] text-primary-foreground hover:bg-primary/90 transition-colors"
216
+ >
217
+ Close
218
+ </button>
219
+ </div>
220
+ </div>
221
+ )}
222
+ </div>
223
+ )
224
+ }
@@ -0,0 +1,330 @@
1
+ import { useState, useMemo } from 'react'
2
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
3
+ import { useNavigate } from 'react-router-dom'
4
+ import {
5
+ Plus,
6
+ Search,
7
+ ChevronDown,
8
+ ChevronRight,
9
+ Settings,
10
+ Camera,
11
+ Lightbulb,
12
+ ToggleLeft,
13
+ Radio,
14
+ HelpCircle,
15
+ } from 'lucide-react'
16
+ import { useBackendClient } from '../hooks/useBackendClient'
17
+ import { ProviderIcon, getProviderLabel } from '../components/shared/ProviderIcon'
18
+ import { StatusBadge } from '../components/shared/StatusBadge'
19
+ import { IntegrationWizard } from '../components/integrations/IntegrationWizard'
20
+
21
+ type DeviceTypeFilter = 'all' | 'camera' | 'sensor' | 'light' | 'switch' | 'other'
22
+ type GroupBy = 'provider' | 'type' | 'status'
23
+
24
+ const TYPE_FILTERS: Array<{ id: DeviceTypeFilter; label: string; icon: React.ComponentType<{ className?: string }> }> = [
25
+ { id: 'all', label: 'All', icon: HelpCircle },
26
+ { id: 'camera', label: 'Camera', icon: Camera },
27
+ { id: 'sensor', label: 'Sensor', icon: Radio },
28
+ { id: 'light', label: 'Light', icon: Lightbulb },
29
+ { id: 'switch', label: 'Switch', icon: ToggleLeft },
30
+ { id: 'other', label: 'Other', icon: HelpCircle },
31
+ ]
32
+
33
+ function getDeviceType(device: Record<string, unknown>): string {
34
+ const type = String(device.type ?? '').toLowerCase()
35
+ if (type.includes('camera') || type === 'camera') return 'camera'
36
+ if (type.includes('sensor')) return 'sensor'
37
+ if (type.includes('light')) return 'light'
38
+ if (type.includes('switch')) return 'switch'
39
+ if (type) return 'other'
40
+ return 'camera' // default for untyped devices
41
+ }
42
+
43
+ export function IntegrationsPage() {
44
+ const client = useBackendClient()
45
+ const queryClient = useQueryClient()
46
+ const navigate = useNavigate()
47
+
48
+ const [wizardOpen, setWizardOpen] = useState(false)
49
+ const [search, setSearch] = useState('')
50
+ const [typeFilter, setTypeFilter] = useState<DeviceTypeFilter>('all')
51
+ const [groupBy, setGroupBy] = useState<GroupBy>('provider')
52
+ const [expandedSections, setExpandedSections] = useState<ReadonlySet<string>>(new Set(['__all__']))
53
+
54
+ const { data: providers, isLoading: providersLoading } = useQuery({
55
+ queryKey: ['providers'],
56
+ queryFn: () => client.listProviders(),
57
+ refetchInterval: 5_000,
58
+ })
59
+
60
+ const { data: devices, isLoading: devicesLoading } = useQuery({
61
+ queryKey: ['devices'],
62
+ queryFn: () => client.listDevices(),
63
+ refetchInterval: 5_000,
64
+ })
65
+
66
+ const startMutation = useMutation({
67
+ mutationFn: (id: string) => client.startProvider(id),
68
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['providers'] }),
69
+ })
70
+
71
+ const stopMutation = useMutation({
72
+ mutationFn: (id: string) => client.stopProvider(id),
73
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['providers'] }),
74
+ })
75
+
76
+ const providerList = (providers ?? []) as unknown as Array<Record<string, unknown>>
77
+ const deviceList = (devices ?? []) as unknown as Array<Record<string, unknown>>
78
+
79
+ // Filter devices
80
+ const filteredDevices = useMemo(() => {
81
+ const searchLower = search.toLowerCase()
82
+ return deviceList.filter((d) => {
83
+ if (typeFilter !== 'all' && getDeviceType(d) !== typeFilter) return false
84
+ if (searchLower) {
85
+ const name = String(d.name ?? d.id ?? '').toLowerCase()
86
+ if (!name.includes(searchLower)) return false
87
+ }
88
+ return true
89
+ })
90
+ }, [deviceList, typeFilter, search])
91
+
92
+ // Group devices
93
+ const grouped = useMemo(() => {
94
+ const groups = new Map<string, Array<Record<string, unknown>>>()
95
+ for (const device of filteredDevices) {
96
+ let key: string
97
+ switch (groupBy) {
98
+ case 'provider':
99
+ key = String(device.providerId ?? 'unknown')
100
+ break
101
+ case 'type':
102
+ key = getDeviceType(device)
103
+ break
104
+ case 'status':
105
+ key = String(device.status ?? 'offline')
106
+ break
107
+ }
108
+ if (!groups.has(key)) {
109
+ groups.set(key, [])
110
+ }
111
+ groups.get(key)!.push(device)
112
+ }
113
+ return groups
114
+ }, [filteredDevices, groupBy])
115
+
116
+ function toggleSection(sectionId: string) {
117
+ setExpandedSections((prev) => {
118
+ const next = new Set(prev)
119
+ if (next.has(sectionId)) {
120
+ next.delete(sectionId)
121
+ } else {
122
+ next.add(sectionId)
123
+ }
124
+ return next
125
+ })
126
+ }
127
+
128
+ function getGroupLabel(key: string): string {
129
+ if (groupBy === 'provider') {
130
+ const provider = providerList.find((p) => String(p.id) === key)
131
+ return provider ? String(provider.name ?? key) : key
132
+ }
133
+ return key.charAt(0).toUpperCase() + key.slice(1)
134
+ }
135
+
136
+ function getGroupProviderType(key: string): string {
137
+ if (groupBy === 'provider') {
138
+ const provider = providerList.find((p) => String(p.id) === key)
139
+ return String(provider?.type ?? 'rtsp')
140
+ }
141
+ return 'rtsp'
142
+ }
143
+
144
+ function getGroupProviderStatus(key: string): string {
145
+ if (groupBy === 'provider') {
146
+ const provider = providerList.find((p) => String(p.id) === key)
147
+ return String(provider?.status ?? 'stopped')
148
+ }
149
+ return ''
150
+ }
151
+
152
+ const isLoading = providersLoading || devicesLoading
153
+
154
+ return (
155
+ <div className="p-6 space-y-4">
156
+ {/* Header */}
157
+ <div className="flex items-center justify-between gap-4">
158
+ <h1 className="text-lg font-semibold text-foreground">Integrations</h1>
159
+ <button
160
+ onClick={() => setWizardOpen(true)}
161
+ className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground shadow-sm"
162
+ >
163
+ <Plus className="h-3.5 w-3.5" />
164
+ New Integration
165
+ </button>
166
+ </div>
167
+
168
+ {/* Toolbar: search + filters + group by */}
169
+ <div className="flex items-center gap-3 flex-wrap">
170
+ {/* Search */}
171
+ <div className="relative">
172
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-foreground-subtle" />
173
+ <input
174
+ type="text"
175
+ value={search}
176
+ onChange={(e) => setSearch(e.target.value)}
177
+ placeholder="Search devices..."
178
+ 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"
179
+ />
180
+ </div>
181
+
182
+ {/* Type filter chips */}
183
+ <div className="flex items-center gap-1">
184
+ {TYPE_FILTERS.map(({ id, label }) => (
185
+ <button
186
+ key={id}
187
+ onClick={() => setTypeFilter(id)}
188
+ className={`rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors ${
189
+ typeFilter === id
190
+ ? 'bg-primary/15 text-primary border border-primary/30'
191
+ : 'bg-surface border border-border text-foreground-subtle hover:text-foreground'
192
+ }`}
193
+ >
194
+ {label}
195
+ </button>
196
+ ))}
197
+ </div>
198
+
199
+ {/* Group by selector */}
200
+ <div className="flex items-center gap-1.5 ml-auto">
201
+ <span className="text-[10px] text-foreground-subtle">Group by:</span>
202
+ <select
203
+ value={groupBy}
204
+ onChange={(e) => setGroupBy(e.target.value as GroupBy)}
205
+ className="rounded-lg border border-border bg-surface px-2 py-1 text-[11px] text-foreground focus:outline-none focus:border-primary/50"
206
+ >
207
+ <option value="provider">Provider</option>
208
+ <option value="type">Type</option>
209
+ <option value="status">Status</option>
210
+ </select>
211
+ </div>
212
+ </div>
213
+
214
+ {/* Loading state */}
215
+ {isLoading && (
216
+ <div className="space-y-2">
217
+ {[1, 2, 3].map((i) => (
218
+ <div key={i} className="h-14 rounded-lg border border-border bg-surface animate-pulse" />
219
+ ))}
220
+ </div>
221
+ )}
222
+
223
+ {/* Empty state */}
224
+ {!isLoading && filteredDevices.length === 0 && (
225
+ <div className="flex flex-col items-center py-20 text-foreground-subtle">
226
+ <p className="text-sm">No devices found.</p>
227
+ <p className="text-xs mt-1">
228
+ {search || typeFilter !== 'all'
229
+ ? 'Try adjusting your filters'
230
+ : 'Start an integration to discover devices.'}
231
+ </p>
232
+ </div>
233
+ )}
234
+
235
+ {/* Collapsible sections */}
236
+ {!isLoading && Array.from(grouped.entries()).map(([key, groupDevices]) => {
237
+ const isExpanded = expandedSections.has(key) || expandedSections.has('__all__')
238
+ const providerType = getGroupProviderType(key)
239
+ const providerStatus = getGroupProviderStatus(key)
240
+ const providerId = groupBy === 'provider' ? key : null
241
+
242
+ return (
243
+ <div key={key} className="rounded-lg border border-border bg-surface overflow-hidden">
244
+ {/* Section header */}
245
+ <button
246
+ onClick={() => toggleSection(key)}
247
+ className="w-full flex items-center gap-3 px-4 py-3 hover:bg-surface-hover transition-colors"
248
+ >
249
+ {isExpanded ? (
250
+ <ChevronDown className="h-3.5 w-3.5 text-foreground-subtle flex-shrink-0" />
251
+ ) : (
252
+ <ChevronRight className="h-3.5 w-3.5 text-foreground-subtle flex-shrink-0" />
253
+ )}
254
+
255
+ {groupBy === 'provider' && <ProviderIcon type={providerType} size="sm" />}
256
+
257
+ {groupBy === 'provider' ? (
258
+ <span
259
+ className="text-xs font-semibold text-foreground hover:text-primary hover:underline cursor-pointer"
260
+ onClick={(e) => { e.stopPropagation(); navigate(`/integrations/${key}`) }}
261
+ >
262
+ {getGroupLabel(key)}
263
+ </span>
264
+ ) : (
265
+ <span className="text-xs font-semibold text-foreground">{getGroupLabel(key)}</span>
266
+ )}
267
+ <span className="text-[10px] text-foreground-subtle">
268
+ {groupDevices.length} device{groupDevices.length !== 1 ? 's' : ''}
269
+ </span>
270
+
271
+ {providerStatus && <StatusBadge status={providerStatus} />}
272
+
273
+ <div className="ml-auto flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
274
+ {providerId && (
275
+ <button
276
+ className="p-1.5 rounded-md text-foreground-subtle hover:text-foreground hover:bg-surface-hover transition-colors"
277
+ title="Provider settings"
278
+ >
279
+ <Settings className="h-3.5 w-3.5" />
280
+ </button>
281
+ )}
282
+ </div>
283
+ </button>
284
+
285
+ {/* Device table */}
286
+ {isExpanded && (
287
+ <div className="border-t border-border">
288
+ {/* Table header */}
289
+ <div className="grid grid-cols-[1fr_80px_80px_80px_100px_100px] gap-2 px-4 py-2 text-[10px] font-medium text-foreground-subtle uppercase tracking-wider border-b border-border bg-background">
290
+ <span>Name</span>
291
+ <span>Type</span>
292
+ <span>Status</span>
293
+ <span>Phase</span>
294
+ <span>Today</span>
295
+ <span>Last Event</span>
296
+ </div>
297
+
298
+ {/* Device rows */}
299
+ {groupDevices.map((device) => {
300
+ const deviceId = String(device.id)
301
+ const deviceName = String(device.name ?? device.id)
302
+ const deviceType = getDeviceType(device)
303
+ const deviceStatus = String(device.status ?? 'offline')
304
+ const phase = String(device.phase ?? '—')
305
+
306
+ return (
307
+ <button
308
+ key={deviceId}
309
+ onClick={() => navigate(`/devices/${deviceId}`)}
310
+ className="w-full grid grid-cols-[1fr_80px_80px_80px_100px_100px] gap-2 px-4 py-2.5 text-left hover:bg-surface-hover transition-colors border-b border-border last:border-b-0"
311
+ >
312
+ <span className="text-xs font-medium text-foreground truncate">{deviceName}</span>
313
+ <span className="text-[11px] text-foreground-subtle capitalize">{deviceType}</span>
314
+ <span><StatusBadge status={deviceStatus} /></span>
315
+ <span className="text-[11px] text-foreground-subtle">{phase}</span>
316
+ <span className="text-[11px] text-foreground-subtle">—</span>
317
+ <span className="text-[11px] text-foreground-subtle">—</span>
318
+ </button>
319
+ )
320
+ })}
321
+ </div>
322
+ )}
323
+ </div>
324
+ )
325
+ })}
326
+
327
+ <IntegrationWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
328
+ </div>
329
+ )
330
+ }
@@ -0,0 +1,106 @@
1
+ import { useState } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { Eye, EyeOff } from 'lucide-react'
4
+ import { useThemeMode } from '@camstack/ui'
5
+ import { useAuth } from '../contexts/auth-context'
6
+
7
+ export function LoginPage() {
8
+ const { login } = useAuth()
9
+ const theme = useThemeMode()
10
+ const navigate = useNavigate()
11
+ const [username, setUsername] = useState('')
12
+ const [password, setPassword] = useState('')
13
+ const [showPassword, setShowPassword] = useState(false)
14
+ const [error, setError] = useState('')
15
+ const [submitting, setSubmitting] = useState(false)
16
+
17
+ const handleSubmit = async (e: React.FormEvent) => {
18
+ e.preventDefault()
19
+ setError('')
20
+ setSubmitting(true)
21
+ try {
22
+ await login(username, password)
23
+ navigate('/dashboard', { replace: true })
24
+ } catch (err) {
25
+ setError(err instanceof Error ? err.message : 'Login failed')
26
+ } finally {
27
+ setSubmitting(false)
28
+ }
29
+ }
30
+
31
+ return (
32
+ <div className="flex min-h-screen items-center justify-center bg-background p-4">
33
+ <div className="w-full max-w-sm">
34
+ {/* Logo */}
35
+ <div className="flex justify-center mb-8">
36
+ <img
37
+ src={theme?.resolvedMode === 'light' ? '/brand/logo-horizontal-light.svg' : '/brand/logo-horizontal-dark.svg'}
38
+ alt="CamStack Admin"
39
+ className="h-12"
40
+ />
41
+ </div>
42
+
43
+ {/* Form */}
44
+ <form onSubmit={handleSubmit} className="space-y-4 rounded-xl border border-border bg-surface p-6 shadow-xl shadow-black/10">
45
+ {error && (
46
+ <div className="rounded-md bg-danger/10 border border-danger/20 px-3 py-2 text-xs text-danger">
47
+ {error}
48
+ </div>
49
+ )}
50
+
51
+ <div className="space-y-1.5">
52
+ <label className="text-xs font-medium text-foreground-subtle">Username</label>
53
+ <input
54
+ type="text"
55
+ placeholder="admin"
56
+ value={username}
57
+ onChange={(e) => setUsername(e.target.value)}
58
+ className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-foreground-disabled focus:border-primary focus:ring-1 focus:ring-primary/30 outline-none transition-all"
59
+ autoFocus
60
+ />
61
+ </div>
62
+
63
+ <div className="space-y-1.5">
64
+ <label className="text-xs font-medium text-foreground-subtle">Password</label>
65
+ <div className="relative">
66
+ <input
67
+ type={showPassword ? 'text' : 'password'}
68
+ placeholder="Enter password"
69
+ value={password}
70
+ onChange={(e) => setPassword(e.target.value)}
71
+ className="w-full rounded-lg border border-border bg-background px-3 py-2.5 pr-10 text-sm text-foreground placeholder:text-foreground-disabled focus:border-primary focus:ring-1 focus:ring-primary/30 outline-none transition-all"
72
+ />
73
+ <button
74
+ type="button"
75
+ onClick={() => setShowPassword(!showPassword)}
76
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 text-foreground-subtle hover:text-foreground transition-colors"
77
+ tabIndex={-1}
78
+ >
79
+ {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
80
+ </button>
81
+ </div>
82
+ </div>
83
+
84
+ <button
85
+ type="submit"
86
+ disabled={submitting || !username || !password}
87
+ className="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground shadow-md shadow-primary/20 hover:shadow-lg hover:shadow-primary/30 disabled:opacity-50 disabled:shadow-none transition-all"
88
+ >
89
+ {submitting ? (
90
+ <span className="flex items-center justify-center gap-2">
91
+ <span className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground" />
92
+ Logging in...
93
+ </span>
94
+ ) : (
95
+ 'Log in'
96
+ )}
97
+ </button>
98
+ </form>
99
+
100
+ <p className="mt-6 text-center text-[10px] text-foreground-subtle/50">
101
+ CamStack v0.1.0
102
+ </p>
103
+ </div>
104
+ </div>
105
+ )
106
+ }
@@ -0,0 +1,18 @@
1
+ import { PipelineStatus } from '../components/metrics/PipelineStatus'
2
+ import { IntegrationUsage } from '../components/metrics/IntegrationUsage'
3
+ import { ProcessResources } from '../components/metrics/ProcessResources'
4
+ import { AgentLoad } from '../components/metrics/AgentLoad'
5
+
6
+ export function MetricsPage() {
7
+ return (
8
+ <div className="p-6 space-y-4">
9
+ <h1 className="text-lg font-semibold text-foreground">Metrics</h1>
10
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
11
+ <PipelineStatus />
12
+ <IntegrationUsage />
13
+ <ProcessResources />
14
+ <AgentLoad />
15
+ </div>
16
+ </div>
17
+ )
18
+ }