@camstack/addon-admin-ui 0.1.1 → 0.1.2
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/package.json +2 -1
- package/src/components/addons/AddonCard.tsx +36 -20
- package/src/components/addons/AddonUploadZone.tsx +26 -264
- package/src/components/addons/UpdatesList.tsx +9 -20
- package/src/components/integrations/DeviceDiscoveryStep.tsx +2 -2
- package/src/components/integrations/IntegrationWizard.tsx +2 -1
- package/src/components/metrics/AgentLoad.tsx +4 -12
- package/src/components/metrics/IntegrationUsage.tsx +4 -21
- package/src/components/metrics/PipelineStatus.tsx +25 -56
- package/src/components/metrics/ProcessResources.tsx +3 -19
- package/src/layouts/AppLayout.tsx +17 -1
- package/src/pages/Cameras.tsx +14 -14
- package/src/pages/IntegrationDetail.tsx +10 -12
- package/src/pages/Integrations.tsx +21 -18
- package/src/pages/system/Addons.tsx +89 -218
- package/src/types/config-ui.ts +28 -210
|
@@ -1,30 +1,6 @@
|
|
|
1
1
|
import { useQuery } from '@tanstack/react-query'
|
|
2
2
|
import { useBackendClient } from '../../hooks/useBackendClient'
|
|
3
3
|
|
|
4
|
-
interface PipelineEntry {
|
|
5
|
-
deviceId: string
|
|
6
|
-
status: {
|
|
7
|
-
active: boolean
|
|
8
|
-
decode: {
|
|
9
|
-
fps: number
|
|
10
|
-
droppedFrames: number
|
|
11
|
-
totalFrames: number
|
|
12
|
-
}
|
|
13
|
-
videoConsumers: number
|
|
14
|
-
audioConsumers: number
|
|
15
|
-
uptime: number
|
|
16
|
-
source: {
|
|
17
|
-
protocol: string
|
|
18
|
-
connected: boolean
|
|
19
|
-
} | null
|
|
20
|
-
outputs: Array<{
|
|
21
|
-
format: string
|
|
22
|
-
consumers: number
|
|
23
|
-
active: boolean
|
|
24
|
-
}>
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
4
|
export function PipelineStatus() {
|
|
29
5
|
const client = useBackendClient()
|
|
30
6
|
|
|
@@ -34,9 +10,8 @@ export function PipelineStatus() {
|
|
|
34
10
|
refetchInterval: 5_000,
|
|
35
11
|
})
|
|
36
12
|
|
|
37
|
-
const pipelines =
|
|
38
|
-
const
|
|
39
|
-
const totalFps = activePipelines.reduce((sum, p) => sum + (p.status.decode?.fps ?? 0), 0)
|
|
13
|
+
const pipelines = data ?? []
|
|
14
|
+
const withSlot = pipelines.filter((p) => p.slot != null)
|
|
40
15
|
|
|
41
16
|
return (
|
|
42
17
|
<div className="rounded-lg border border-border bg-surface p-4">
|
|
@@ -61,42 +36,36 @@ export function PipelineStatus() {
|
|
|
61
36
|
{/* Aggregate */}
|
|
62
37
|
<div className="flex items-center gap-3 mb-3 pb-3 border-b border-border">
|
|
63
38
|
<span className="text-xs text-foreground-subtle">
|
|
64
|
-
<span className="text-foreground font-semibold">{
|
|
39
|
+
<span className="text-foreground font-semibold">{withSlot.length}</span> active
|
|
65
40
|
{' '}/ <span className="text-foreground font-semibold">{pipelines.length}</span> total
|
|
66
41
|
</span>
|
|
67
|
-
<span className="text-xs text-foreground-subtle">
|
|
68
|
-
<span className="text-foreground font-semibold tabular-nums">
|
|
69
|
-
{totalFps.toFixed(1)}
|
|
70
|
-
</span>{' '}
|
|
71
|
-
total FPS
|
|
72
|
-
</span>
|
|
73
42
|
</div>
|
|
74
43
|
|
|
75
44
|
{/* Pipeline list */}
|
|
76
45
|
<div className="space-y-1.5">
|
|
77
|
-
{pipelines.map((p) =>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
</
|
|
46
|
+
{pipelines.map((p) => {
|
|
47
|
+
const isActive = p.slot != null
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
key={p.id}
|
|
51
|
+
className="flex items-center justify-between gap-2 text-xs"
|
|
52
|
+
>
|
|
53
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
54
|
+
<span
|
|
55
|
+
className={`h-2 w-2 flex-shrink-0 rounded-full ${
|
|
56
|
+
isActive ? 'bg-success' : 'bg-foreground-subtle/40'
|
|
57
|
+
}`}
|
|
58
|
+
/>
|
|
59
|
+
<span className="text-foreground truncate font-medium">{p.id}</span>
|
|
60
|
+
</div>
|
|
61
|
+
<div className="flex items-center gap-3 flex-shrink-0">
|
|
62
|
+
<span className="text-foreground-subtle">
|
|
63
|
+
{isActive ? p.slot! : 'idle'}
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
97
66
|
</div>
|
|
98
|
-
|
|
99
|
-
)
|
|
67
|
+
)
|
|
68
|
+
})}
|
|
100
69
|
</div>
|
|
101
70
|
</>
|
|
102
71
|
)}
|
|
@@ -1,24 +1,8 @@
|
|
|
1
1
|
import { useQuery } from '@tanstack/react-query'
|
|
2
2
|
import { useBackendClient } from '../../hooks/useBackendClient'
|
|
3
|
+
import type { BackendClient } from '@camstack/sdk'
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
pid: number
|
|
6
|
-
cpu: number
|
|
7
|
-
memory: number
|
|
8
|
-
uptime: number
|
|
9
|
-
restartCount: number
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface ProcessEntry {
|
|
13
|
-
id: string
|
|
14
|
-
label: string
|
|
15
|
-
state: string
|
|
16
|
-
pid?: number
|
|
17
|
-
stats?: ProcessStats
|
|
18
|
-
restartCount: number
|
|
19
|
-
lastCrashAt?: number
|
|
20
|
-
lastCrashError?: string
|
|
21
|
-
}
|
|
5
|
+
type ProcessEntry = Awaited<ReturnType<BackendClient['listProcesses']>>[number]
|
|
22
6
|
|
|
23
7
|
function formatMemoryMB(bytes?: number): string {
|
|
24
8
|
if (bytes == null) return '—'
|
|
@@ -56,7 +40,7 @@ export function ProcessResources() {
|
|
|
56
40
|
refetchInterval: 5_000,
|
|
57
41
|
})
|
|
58
42
|
|
|
59
|
-
const processes =
|
|
43
|
+
const processes = data ?? []
|
|
60
44
|
const runningCount = processes.filter((p) => p.state === 'running').length
|
|
61
45
|
|
|
62
46
|
const totalCpu = processes.reduce((sum, p) => sum + (p.stats?.cpu ?? 0), 0)
|
|
@@ -37,6 +37,16 @@ export function AppLayout() {
|
|
|
37
37
|
const { t, i18n } = useTranslation()
|
|
38
38
|
const client = getBackendClient()
|
|
39
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
|
+
|
|
40
50
|
const { data: addonPages } = useQuery({
|
|
41
51
|
queryKey: ['addon-pages'],
|
|
42
52
|
queryFn: async (): Promise<readonly AddonPageInfo[]> => {
|
|
@@ -148,6 +158,7 @@ export function AppLayout() {
|
|
|
148
158
|
{SYSTEM_ITEMS.map((item) => {
|
|
149
159
|
const Icon = item.icon
|
|
150
160
|
const active = isActive(item.path)
|
|
161
|
+
const isAddons = item.path === '/system/addons'
|
|
151
162
|
return (
|
|
152
163
|
<button
|
|
153
164
|
key={item.path}
|
|
@@ -159,7 +170,12 @@ export function AppLayout() {
|
|
|
159
170
|
}`}
|
|
160
171
|
>
|
|
161
172
|
<Icon className={`h-3.5 w-3.5 shrink-0 ${active ? 'text-primary' : ''}`} />
|
|
162
|
-
{item.label}
|
|
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
|
+
)}
|
|
163
179
|
</button>
|
|
164
180
|
)
|
|
165
181
|
})}
|
package/src/pages/Cameras.tsx
CHANGED
|
@@ -22,15 +22,15 @@ export function CamerasPage() {
|
|
|
22
22
|
queryFn: () => client.listProviders(),
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
const deviceList =
|
|
26
|
-
const providerList =
|
|
25
|
+
const deviceList = devices ?? []
|
|
26
|
+
const providerList = providers ?? []
|
|
27
27
|
|
|
28
28
|
// Filter camera-type devices only and apply search
|
|
29
29
|
const cameraDevices = useMemo(() => {
|
|
30
30
|
const searchLower = search.toLowerCase()
|
|
31
31
|
return deviceList.filter((d) => {
|
|
32
|
-
const type =
|
|
33
|
-
const capabilities =
|
|
32
|
+
const type = (d.type ?? '').toLowerCase()
|
|
33
|
+
const capabilities = d.capabilities ?? []
|
|
34
34
|
const isCamera =
|
|
35
35
|
type === 'camera' ||
|
|
36
36
|
type.includes('camera') ||
|
|
@@ -42,16 +42,16 @@ export function CamerasPage() {
|
|
|
42
42
|
if (!include) return false
|
|
43
43
|
if (!searchLower) return true
|
|
44
44
|
|
|
45
|
-
const name =
|
|
45
|
+
const name = (d.name ?? d.id ?? '').toLowerCase()
|
|
46
46
|
return name.includes(searchLower)
|
|
47
47
|
})
|
|
48
48
|
}, [deviceList, search])
|
|
49
49
|
|
|
50
50
|
// Group by provider
|
|
51
51
|
const groupedByProvider = useMemo(() => {
|
|
52
|
-
const groups = new Map<string,
|
|
52
|
+
const groups = new Map<string, typeof deviceList>()
|
|
53
53
|
for (const device of cameraDevices) {
|
|
54
|
-
const providerId =
|
|
54
|
+
const providerId = device.providerId ?? ''
|
|
55
55
|
if (!groups.has(providerId)) {
|
|
56
56
|
groups.set(providerId, [])
|
|
57
57
|
}
|
|
@@ -107,17 +107,17 @@ export function CamerasPage() {
|
|
|
107
107
|
|
|
108
108
|
{/* Provider sections */}
|
|
109
109
|
{!isLoading && Array.from(groupedByProvider.entries()).map(([providerId, providerDevices]) => {
|
|
110
|
-
const provider = providerList.find((p) =>
|
|
110
|
+
const provider = providerList.find((p) => p.id === providerId)
|
|
111
111
|
return (
|
|
112
112
|
<ProviderSection
|
|
113
113
|
key={providerId}
|
|
114
|
-
providerType={
|
|
115
|
-
providerName={
|
|
116
|
-
providerStatus={
|
|
114
|
+
providerType={provider?.type ?? 'rtsp'}
|
|
115
|
+
providerName={provider?.name ?? providerId}
|
|
116
|
+
providerStatus={provider?.status?.connected ? 'running' : 'stopped'}
|
|
117
117
|
devices={providerDevices.map((d) => ({
|
|
118
|
-
id:
|
|
119
|
-
name:
|
|
120
|
-
status:
|
|
118
|
+
id: d.id,
|
|
119
|
+
name: d.name ?? d.id,
|
|
120
|
+
status: d.state?.online ? 'online' : 'offline',
|
|
121
121
|
}))}
|
|
122
122
|
/>
|
|
123
123
|
)
|
|
@@ -84,24 +84,22 @@ export function IntegrationDetailPage() {
|
|
|
84
84
|
)
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
const devices = (
|
|
88
|
-
.filter((d) =>
|
|
87
|
+
const devices = (allDevices ?? [])
|
|
88
|
+
.filter((d) => d.providerId === integrationId)
|
|
89
89
|
|
|
90
90
|
const activeDevices = devices.map((d) => ({
|
|
91
|
-
id:
|
|
92
|
-
name:
|
|
93
|
-
status:
|
|
94
|
-
snapshotUrl:
|
|
91
|
+
id: d.id,
|
|
92
|
+
name: d.name ?? d.id,
|
|
93
|
+
status: d.state?.online ? 'online' : 'offline',
|
|
94
|
+
snapshotUrl: null as string | null,
|
|
95
95
|
}))
|
|
96
96
|
|
|
97
|
-
const discoveredDevices = (discovered ?? []).map((d
|
|
98
|
-
externalId:
|
|
99
|
-
name:
|
|
97
|
+
const discoveredDevices = (discovered ?? []).map((d) => ({
|
|
98
|
+
externalId: d.externalId,
|
|
99
|
+
name: d.name ?? d.externalId,
|
|
100
100
|
}))
|
|
101
101
|
|
|
102
|
-
const providerStatus =
|
|
103
|
-
? (provider.status as any).connected ? 'running' : 'stopped'
|
|
104
|
-
: String(provider.status ?? 'stopped')
|
|
102
|
+
const providerStatus = provider.status.connected ? 'running' : 'stopped'
|
|
105
103
|
|
|
106
104
|
const isAutoDiscovery = provider.discoveryMode !== 'manual'
|
|
107
105
|
const isManual = provider.discoveryMode === 'manual'
|
|
@@ -30,8 +30,11 @@ const TYPE_FILTERS: Array<{ id: DeviceTypeFilter; label: string; icon: React.Com
|
|
|
30
30
|
{ id: 'other', label: 'Other', icon: HelpCircle },
|
|
31
31
|
]
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
type DeviceEntry = Awaited<ReturnType<ReturnType<typeof useBackendClient>['listDevices']>>[number]
|
|
34
|
+
type ProviderEntry = Awaited<ReturnType<ReturnType<typeof useBackendClient>['listProviders']>>[number]
|
|
35
|
+
|
|
36
|
+
function getDeviceType(device: DeviceEntry): string {
|
|
37
|
+
const type = (device.type ?? '').toLowerCase()
|
|
35
38
|
if (type.includes('camera') || type === 'camera') return 'camera'
|
|
36
39
|
if (type.includes('sensor')) return 'sensor'
|
|
37
40
|
if (type.includes('light')) return 'light'
|
|
@@ -73,8 +76,8 @@ export function IntegrationsPage() {
|
|
|
73
76
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['providers'] }),
|
|
74
77
|
})
|
|
75
78
|
|
|
76
|
-
const providerList =
|
|
77
|
-
const deviceList =
|
|
79
|
+
const providerList = providers ?? []
|
|
80
|
+
const deviceList = devices ?? []
|
|
78
81
|
|
|
79
82
|
// Filter devices
|
|
80
83
|
const filteredDevices = useMemo(() => {
|
|
@@ -82,7 +85,7 @@ export function IntegrationsPage() {
|
|
|
82
85
|
return deviceList.filter((d) => {
|
|
83
86
|
if (typeFilter !== 'all' && getDeviceType(d) !== typeFilter) return false
|
|
84
87
|
if (searchLower) {
|
|
85
|
-
const name =
|
|
88
|
+
const name = (d.name ?? d.id ?? '').toLowerCase()
|
|
86
89
|
if (!name.includes(searchLower)) return false
|
|
87
90
|
}
|
|
88
91
|
return true
|
|
@@ -91,18 +94,18 @@ export function IntegrationsPage() {
|
|
|
91
94
|
|
|
92
95
|
// Group devices
|
|
93
96
|
const grouped = useMemo(() => {
|
|
94
|
-
const groups = new Map<string,
|
|
97
|
+
const groups = new Map<string, DeviceEntry[]>()
|
|
95
98
|
for (const device of filteredDevices) {
|
|
96
99
|
let key: string
|
|
97
100
|
switch (groupBy) {
|
|
98
101
|
case 'provider':
|
|
99
|
-
key =
|
|
102
|
+
key = device.providerId ?? 'unknown'
|
|
100
103
|
break
|
|
101
104
|
case 'type':
|
|
102
105
|
key = getDeviceType(device)
|
|
103
106
|
break
|
|
104
107
|
case 'status':
|
|
105
|
-
key =
|
|
108
|
+
key = device.state?.online ? 'online' : 'offline'
|
|
106
109
|
break
|
|
107
110
|
}
|
|
108
111
|
if (!groups.has(key)) {
|
|
@@ -127,24 +130,24 @@ export function IntegrationsPage() {
|
|
|
127
130
|
|
|
128
131
|
function getGroupLabel(key: string): string {
|
|
129
132
|
if (groupBy === 'provider') {
|
|
130
|
-
const provider = providerList.find((p) =>
|
|
131
|
-
return provider ?
|
|
133
|
+
const provider = providerList.find((p) => p.id === key)
|
|
134
|
+
return provider ? (provider.name ?? key) : key
|
|
132
135
|
}
|
|
133
136
|
return key.charAt(0).toUpperCase() + key.slice(1)
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
function getGroupProviderType(key: string): string {
|
|
137
140
|
if (groupBy === 'provider') {
|
|
138
|
-
const provider = providerList.find((p) =>
|
|
139
|
-
return
|
|
141
|
+
const provider = providerList.find((p) => p.id === key)
|
|
142
|
+
return provider?.type ?? 'rtsp'
|
|
140
143
|
}
|
|
141
144
|
return 'rtsp'
|
|
142
145
|
}
|
|
143
146
|
|
|
144
147
|
function getGroupProviderStatus(key: string): string {
|
|
145
148
|
if (groupBy === 'provider') {
|
|
146
|
-
const provider = providerList.find((p) =>
|
|
147
|
-
return
|
|
149
|
+
const provider = providerList.find((p) => p.id === key)
|
|
150
|
+
return provider?.status?.connected ? 'running' : 'stopped'
|
|
148
151
|
}
|
|
149
152
|
return ''
|
|
150
153
|
}
|
|
@@ -297,11 +300,11 @@ export function IntegrationsPage() {
|
|
|
297
300
|
|
|
298
301
|
{/* Device rows */}
|
|
299
302
|
{groupDevices.map((device) => {
|
|
300
|
-
const deviceId =
|
|
301
|
-
const deviceName =
|
|
303
|
+
const deviceId = device.id
|
|
304
|
+
const deviceName = device.name ?? device.id
|
|
302
305
|
const deviceType = getDeviceType(device)
|
|
303
|
-
const deviceStatus =
|
|
304
|
-
const phase =
|
|
306
|
+
const deviceStatus = device.state?.online ? 'online' : 'offline'
|
|
307
|
+
const phase = (device as Record<string, unknown>).phase as string ?? '—'
|
|
305
308
|
|
|
306
309
|
return (
|
|
307
310
|
<button
|