@camstack/addon-admin-ui 0.1.1 → 0.1.3
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-DjELGD4R.css +1 -0
- package/dist/assets/index-w55PwKyu.js +598 -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 +5 -1
- package/src/App.tsx +0 -71
- package/src/components/addons/AddonCard.tsx +0 -339
- package/src/components/addons/AddonUploadZone.tsx +0 -307
- package/src/components/addons/CapabilityBadge.tsx +0 -55
- package/src/components/addons/CapabilityMap.tsx +0 -133
- package/src/components/addons/UpdatesList.tsx +0 -119
- 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 -171
- 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 -113
- package/src/components/metrics/IntegrationUsage.tsx +0 -90
- package/src/components/metrics/PipelineStatus.tsx +0 -105
- package/src/components/metrics/ProcessResources.tsx +0 -139
- 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 -238
- 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 -224
- package/src/pages/Integrations.tsx +0 -330
- 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 -525
- 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 -210
- 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,224 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,330 +0,0 @@
|
|
|
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
|
-
}
|
package/src/pages/Login.tsx
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
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
|
-
}
|
package/src/pages/Metrics.tsx
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
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
|
-
}
|