@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,239 +0,0 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
2
|
-
import { Info, ExternalLink, Check, Cpu } from 'lucide-react'
|
|
3
|
-
import type { ConfigModelSelectorField, ModelCatalogEntry } from '../../types/config-ui'
|
|
4
|
-
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
// Types
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
|
|
9
|
-
type TabId = 'catalog' | 'custom'
|
|
10
|
-
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
// Sub-components
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
|
|
15
|
-
function SegmentedControl({
|
|
16
|
-
tabs,
|
|
17
|
-
active,
|
|
18
|
-
onChange,
|
|
19
|
-
}: {
|
|
20
|
-
tabs: { id: TabId; label: string }[]
|
|
21
|
-
active: TabId
|
|
22
|
-
onChange: (id: TabId) => void
|
|
23
|
-
}) {
|
|
24
|
-
return (
|
|
25
|
-
<div className="flex rounded-lg border border-border bg-surface p-0.5 gap-0.5">
|
|
26
|
-
{tabs.map((tab) => (
|
|
27
|
-
<button
|
|
28
|
-
key={tab.id}
|
|
29
|
-
type="button"
|
|
30
|
-
onClick={() => onChange(tab.id)}
|
|
31
|
-
className={[
|
|
32
|
-
'flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors',
|
|
33
|
-
active === tab.id
|
|
34
|
-
? 'bg-primary/12 text-primary'
|
|
35
|
-
: 'text-foreground-subtle hover:text-foreground hover:bg-surface-hover',
|
|
36
|
-
].join(' ')}
|
|
37
|
-
>
|
|
38
|
-
{tab.label}
|
|
39
|
-
</button>
|
|
40
|
-
))}
|
|
41
|
-
</div>
|
|
42
|
-
)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function formatSize(entry: ModelCatalogEntry): string {
|
|
46
|
-
const formats = entry.formats
|
|
47
|
-
if (!formats) return ''
|
|
48
|
-
const sizes = Object.values(formats)
|
|
49
|
-
.filter((f): f is { url: string; sizeMB: number } => f !== undefined && f.sizeMB > 0)
|
|
50
|
-
.map((f) => f.sizeMB)
|
|
51
|
-
if (sizes.length === 0) return ''
|
|
52
|
-
const min = Math.min(...sizes)
|
|
53
|
-
const max = Math.max(...sizes)
|
|
54
|
-
if (min === max) return `${min} MB`
|
|
55
|
-
return `${min}-${max} MB`
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function formatDimensions(entry: ModelCatalogEntry): string {
|
|
59
|
-
return `${entry.inputSize.width}x${entry.inputSize.height}`
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function formatFormats(entry: ModelCatalogEntry): string[] {
|
|
63
|
-
if (!entry.formats) return []
|
|
64
|
-
return Object.keys(entry.formats).filter((k) => entry.formats[k as keyof typeof entry.formats] !== undefined)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
// Model catalog card
|
|
69
|
-
// ---------------------------------------------------------------------------
|
|
70
|
-
|
|
71
|
-
function ModelCard({
|
|
72
|
-
entry,
|
|
73
|
-
selected,
|
|
74
|
-
onSelect,
|
|
75
|
-
disabled,
|
|
76
|
-
}: {
|
|
77
|
-
entry: ModelCatalogEntry
|
|
78
|
-
selected: boolean
|
|
79
|
-
onSelect: () => void
|
|
80
|
-
disabled?: boolean
|
|
81
|
-
}) {
|
|
82
|
-
const size = formatSize(entry)
|
|
83
|
-
const dims = formatDimensions(entry)
|
|
84
|
-
const formats = formatFormats(entry)
|
|
85
|
-
|
|
86
|
-
return (
|
|
87
|
-
<button
|
|
88
|
-
type="button"
|
|
89
|
-
onClick={onSelect}
|
|
90
|
-
disabled={disabled}
|
|
91
|
-
className={[
|
|
92
|
-
'w-full text-left rounded-lg border p-3 transition-all',
|
|
93
|
-
selected
|
|
94
|
-
? 'border-primary bg-primary/5 ring-1 ring-primary/30'
|
|
95
|
-
: 'border-border bg-background hover:border-foreground-subtle/40 hover:bg-surface-hover/50',
|
|
96
|
-
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
|
97
|
-
].join(' ')}
|
|
98
|
-
>
|
|
99
|
-
<div className="flex items-start justify-between gap-2">
|
|
100
|
-
<div className="flex-1 min-w-0">
|
|
101
|
-
<div className="flex items-center gap-2">
|
|
102
|
-
<span className="text-xs font-semibold text-foreground truncate">{entry.name}</span>
|
|
103
|
-
{selected && <Check className="h-3.5 w-3.5 text-primary shrink-0" />}
|
|
104
|
-
</div>
|
|
105
|
-
<p className="text-[10px] text-foreground-subtle mt-0.5 line-clamp-2">{entry.description}</p>
|
|
106
|
-
</div>
|
|
107
|
-
</div>
|
|
108
|
-
|
|
109
|
-
{/* Metadata row */}
|
|
110
|
-
<div className="flex items-center gap-3 mt-2 flex-wrap">
|
|
111
|
-
<span className="inline-flex items-center gap-1 text-[10px] text-foreground-subtle">
|
|
112
|
-
<Cpu className="h-3 w-3" />
|
|
113
|
-
{dims}
|
|
114
|
-
</span>
|
|
115
|
-
{size && (
|
|
116
|
-
<span className="text-[10px] text-foreground-subtle">{size}</span>
|
|
117
|
-
)}
|
|
118
|
-
{formats.length > 0 && (
|
|
119
|
-
<div className="flex gap-1">
|
|
120
|
-
{formats.map((fmt) => (
|
|
121
|
-
<span
|
|
122
|
-
key={fmt}
|
|
123
|
-
className="text-[9px] uppercase rounded bg-surface border border-border px-1 py-0.5 text-foreground-subtle font-mono"
|
|
124
|
-
>
|
|
125
|
-
{fmt}
|
|
126
|
-
</span>
|
|
127
|
-
))}
|
|
128
|
-
</div>
|
|
129
|
-
)}
|
|
130
|
-
{entry.labels.length > 0 && (
|
|
131
|
-
<span className="text-[10px] text-foreground-subtle">
|
|
132
|
-
{entry.labels.length} label{entry.labels.length !== 1 ? 's' : ''}
|
|
133
|
-
</span>
|
|
134
|
-
)}
|
|
135
|
-
</div>
|
|
136
|
-
</button>
|
|
137
|
-
)
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// ---------------------------------------------------------------------------
|
|
141
|
-
// Main ModelSelector component
|
|
142
|
-
// ---------------------------------------------------------------------------
|
|
143
|
-
|
|
144
|
-
interface ModelSelectorProps {
|
|
145
|
-
field: ConfigModelSelectorField
|
|
146
|
-
value: unknown
|
|
147
|
-
onChange: (v: unknown) => void
|
|
148
|
-
disabled?: boolean
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export function ModelSelector({ field, value, onChange, disabled }: ModelSelectorProps) {
|
|
152
|
-
const catalog = field.catalog ?? []
|
|
153
|
-
const hasCatalog = catalog.length > 0
|
|
154
|
-
const allowCustomPath = field.allowCustomPath !== false || field.allowCustom === true
|
|
155
|
-
|
|
156
|
-
const tabs: { id: TabId; label: string }[] = [
|
|
157
|
-
...(hasCatalog ? [{ id: 'catalog' as TabId, label: `Models (${catalog.length})` }] : []),
|
|
158
|
-
...(allowCustomPath ? [{ id: 'custom' as TabId, label: 'Custom Path' }] : []),
|
|
159
|
-
]
|
|
160
|
-
|
|
161
|
-
const [activeTab, setActiveTab] = useState<TabId>(hasCatalog ? 'catalog' : 'custom')
|
|
162
|
-
|
|
163
|
-
const currentValue = value === undefined || value === null ? '' : String(value)
|
|
164
|
-
|
|
165
|
-
return (
|
|
166
|
-
<div className="space-y-3">
|
|
167
|
-
{tabs.length > 1 && (
|
|
168
|
-
<SegmentedControl tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
|
169
|
-
)}
|
|
170
|
-
|
|
171
|
-
{/* Catalog tab */}
|
|
172
|
-
{activeTab === 'catalog' && hasCatalog && (
|
|
173
|
-
<div className="space-y-1.5 max-h-[320px] overflow-y-auto pr-1">
|
|
174
|
-
{catalog.map((entry) => (
|
|
175
|
-
<ModelCard
|
|
176
|
-
key={entry.id}
|
|
177
|
-
entry={entry}
|
|
178
|
-
selected={currentValue === entry.id}
|
|
179
|
-
onSelect={() => onChange(entry.id)}
|
|
180
|
-
disabled={disabled || field.disabled}
|
|
181
|
-
/>
|
|
182
|
-
))}
|
|
183
|
-
</div>
|
|
184
|
-
)}
|
|
185
|
-
|
|
186
|
-
{/* Fallback when no catalog is available */}
|
|
187
|
-
{activeTab === 'catalog' && !hasCatalog && (
|
|
188
|
-
<div className="rounded-md border border-info/30 bg-info/5 px-3 py-2 flex items-start gap-2">
|
|
189
|
-
<Info className="h-3.5 w-3.5 text-info shrink-0 mt-0.5" />
|
|
190
|
-
<p className="text-[11px] text-foreground-subtle leading-relaxed">
|
|
191
|
-
No predefined models available. Use the Custom Path tab to specify a model file path or URL.
|
|
192
|
-
</p>
|
|
193
|
-
</div>
|
|
194
|
-
)}
|
|
195
|
-
|
|
196
|
-
{/* Custom path tab */}
|
|
197
|
-
{activeTab === 'custom' && allowCustomPath && (
|
|
198
|
-
<div className="space-y-2">
|
|
199
|
-
<input
|
|
200
|
-
type="text"
|
|
201
|
-
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:ring-1 focus:ring-primary/30 outline-none disabled:opacity-50"
|
|
202
|
-
placeholder="Enter path like /data/models/custom.onnx or https://..."
|
|
203
|
-
value={currentValue}
|
|
204
|
-
disabled={disabled || field.disabled}
|
|
205
|
-
onChange={(e) => onChange(e.target.value)}
|
|
206
|
-
/>
|
|
207
|
-
{field.acceptFormats && field.acceptFormats.length > 0 && (
|
|
208
|
-
<p className="text-[10px] text-foreground-subtle">
|
|
209
|
-
Accepted formats: {field.acceptFormats.join(', ')}
|
|
210
|
-
</p>
|
|
211
|
-
)}
|
|
212
|
-
</div>
|
|
213
|
-
)}
|
|
214
|
-
|
|
215
|
-
{/* Footer: compatibility note + conversion guide */}
|
|
216
|
-
{(field.compatibilityNote || field.conversionGuideUrl) && (
|
|
217
|
-
<div className="flex items-start justify-between gap-3 pt-1">
|
|
218
|
-
{field.compatibilityNote && (
|
|
219
|
-
<p className="flex items-start gap-1 text-[10px] text-foreground-subtle">
|
|
220
|
-
<Info className="h-3 w-3 mt-0.5 shrink-0" />
|
|
221
|
-
{field.compatibilityNote}
|
|
222
|
-
</p>
|
|
223
|
-
)}
|
|
224
|
-
{field.conversionGuideUrl && (
|
|
225
|
-
<a
|
|
226
|
-
href={field.conversionGuideUrl}
|
|
227
|
-
target="_blank"
|
|
228
|
-
rel="noopener noreferrer"
|
|
229
|
-
className="flex items-center gap-1 text-[11px] text-primary hover:underline shrink-0"
|
|
230
|
-
>
|
|
231
|
-
How to convert your model
|
|
232
|
-
<ExternalLink className="h-3 w-3" />
|
|
233
|
-
</a>
|
|
234
|
-
)}
|
|
235
|
-
</div>
|
|
236
|
-
)}
|
|
237
|
-
</div>
|
|
238
|
-
)
|
|
239
|
-
}
|
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
2
|
-
import { useTranslation } from 'react-i18next'
|
|
3
|
-
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
4
|
-
import { X, Plus, Trash2, ChevronDown } from 'lucide-react'
|
|
5
|
-
import { useBackendClient } from '../../hooks/useBackendClient'
|
|
6
|
-
|
|
7
|
-
interface AddDeviceDialogProps {
|
|
8
|
-
open: boolean
|
|
9
|
-
providerId: string
|
|
10
|
-
onClose: () => void
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function AddDeviceDialog({ open, providerId, onClose }: AddDeviceDialogProps) {
|
|
14
|
-
const { t } = useTranslation()
|
|
15
|
-
const client = useBackendClient()
|
|
16
|
-
const queryClient = useQueryClient()
|
|
17
|
-
|
|
18
|
-
const [name, setName] = useState('')
|
|
19
|
-
const [snapshotUrl, setSnapshotUrl] = useState('')
|
|
20
|
-
const [streamUrls, setStreamUrls] = useState<string[]>([''])
|
|
21
|
-
const [testResults, setTestResults] = useState<Record<number, { codec?: string; resolution?: string; fps?: number; audio?: boolean; error?: string }>>({})
|
|
22
|
-
|
|
23
|
-
const createMutation = useMutation({
|
|
24
|
-
mutationFn: (config: Record<string, unknown>) => client.createDevice(providerId, config),
|
|
25
|
-
onSuccess: () => {
|
|
26
|
-
queryClient.invalidateQueries({ queryKey: ['devices'] })
|
|
27
|
-
},
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
const testMutation = useMutation({
|
|
31
|
-
mutationFn: async () => {
|
|
32
|
-
const results: Record<number, { codec?: string; resolution?: string; fps?: number; error?: string }> = {}
|
|
33
|
-
for (let i = 0; i < streamUrls.length; i++) {
|
|
34
|
-
const url = streamUrls[i]
|
|
35
|
-
if (!url.trim()) continue
|
|
36
|
-
try {
|
|
37
|
-
// TODO: Replace with dedicated ffprobe endpoint when available
|
|
38
|
-
await client.trpc.providerConfig.testConnection.mutate({
|
|
39
|
-
type: 'rtsp',
|
|
40
|
-
url: url,
|
|
41
|
-
})
|
|
42
|
-
results[i] = { codec: 'OK', resolution: 'Connected' }
|
|
43
|
-
} catch (err) {
|
|
44
|
-
results[i] = { error: String(err) }
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
setTestResults(results)
|
|
48
|
-
},
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
function addStreamUrl() {
|
|
52
|
-
setStreamUrls((prev) => [...prev, ''])
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function removeStreamUrl(index: number) {
|
|
56
|
-
setStreamUrls((prev) => prev.filter((_, i) => i !== index))
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function updateStreamUrl(index: number, value: string) {
|
|
60
|
-
setStreamUrls((prev) => prev.map((url, i) => (i === index ? value : url)))
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function handleSave(andNew: boolean) {
|
|
64
|
-
const config = {
|
|
65
|
-
name,
|
|
66
|
-
snapshotUrl: snapshotUrl || undefined,
|
|
67
|
-
streams: streamUrls.filter((url) => url.trim()),
|
|
68
|
-
}
|
|
69
|
-
await createMutation.mutateAsync(config)
|
|
70
|
-
if (andNew) {
|
|
71
|
-
setName('')
|
|
72
|
-
setSnapshotUrl('')
|
|
73
|
-
setStreamUrls([''])
|
|
74
|
-
} else {
|
|
75
|
-
onClose()
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (!open) return null
|
|
80
|
-
|
|
81
|
-
return (
|
|
82
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
83
|
-
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
|
84
|
-
<div className="relative z-10 w-full max-w-md rounded-xl border border-border bg-background shadow-2xl">
|
|
85
|
-
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
|
86
|
-
<h2 className="text-sm font-semibold text-foreground">{t('integrations.addDevice')}</h2>
|
|
87
|
-
<button onClick={onClose} className="rounded-md p-1 text-foreground-subtle hover:text-foreground hover:bg-surface-hover transition-colors">
|
|
88
|
-
<X className="h-4 w-4" />
|
|
89
|
-
</button>
|
|
90
|
-
</div>
|
|
91
|
-
|
|
92
|
-
<div className="px-5 py-4 space-y-3">
|
|
93
|
-
<div>
|
|
94
|
-
<label className="block text-[11px] font-medium text-foreground mb-1">{t('integrations.name')}</label>
|
|
95
|
-
<input
|
|
96
|
-
type="text"
|
|
97
|
-
value={name}
|
|
98
|
-
onChange={(e) => setName(e.target.value)}
|
|
99
|
-
placeholder={t('integrations.namePlaceholder')}
|
|
100
|
-
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-xs text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-primary/50"
|
|
101
|
-
/>
|
|
102
|
-
</div>
|
|
103
|
-
|
|
104
|
-
<div>
|
|
105
|
-
<label className="block text-[11px] font-medium text-foreground mb-1">{t('integrations.snapshotUrlOptional')}</label>
|
|
106
|
-
<input
|
|
107
|
-
type="text"
|
|
108
|
-
value={snapshotUrl}
|
|
109
|
-
onChange={(e) => setSnapshotUrl(e.target.value)}
|
|
110
|
-
placeholder="http://192.168.1.100/snapshot.jpg"
|
|
111
|
-
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-xs text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-primary/50"
|
|
112
|
-
/>
|
|
113
|
-
</div>
|
|
114
|
-
|
|
115
|
-
<div>
|
|
116
|
-
<div className="flex items-center justify-between mb-1">
|
|
117
|
-
<label className="text-[11px] font-medium text-foreground">{t('integrations.streamUrls')}</label>
|
|
118
|
-
<button
|
|
119
|
-
onClick={addStreamUrl}
|
|
120
|
-
className="flex items-center gap-1 text-[10px] text-primary hover:text-primary/80 transition-colors"
|
|
121
|
-
>
|
|
122
|
-
<Plus className="h-3 w-3" /> {t('integrations.addStream')}
|
|
123
|
-
</button>
|
|
124
|
-
</div>
|
|
125
|
-
<div className="space-y-2">
|
|
126
|
-
{streamUrls.map((url, index) => (
|
|
127
|
-
<div key={index} className="flex items-center gap-2">
|
|
128
|
-
<input
|
|
129
|
-
type="text"
|
|
130
|
-
value={url}
|
|
131
|
-
onChange={(e) => updateStreamUrl(index, e.target.value)}
|
|
132
|
-
placeholder="rtsp://192.168.1.100:554/stream1"
|
|
133
|
-
className="flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-xs text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-primary/50"
|
|
134
|
-
/>
|
|
135
|
-
{streamUrls.length > 1 && (
|
|
136
|
-
<button
|
|
137
|
-
onClick={() => removeStreamUrl(index)}
|
|
138
|
-
className="rounded-md p-1.5 text-foreground-subtle hover:text-danger hover:bg-danger/10 transition-colors"
|
|
139
|
-
>
|
|
140
|
-
<Trash2 className="h-3.5 w-3.5" />
|
|
141
|
-
</button>
|
|
142
|
-
)}
|
|
143
|
-
</div>
|
|
144
|
-
))}
|
|
145
|
-
</div>
|
|
146
|
-
</div>
|
|
147
|
-
|
|
148
|
-
{Object.keys(testResults).length > 0 && (
|
|
149
|
-
<div className="space-y-1">
|
|
150
|
-
{Object.entries(testResults).map(([idx, result]) => (
|
|
151
|
-
<div key={idx} className={`rounded-lg border px-3 py-1.5 text-[10px] ${
|
|
152
|
-
result.error ? 'border-danger/30 bg-danger/5 text-danger' : 'border-success/30 bg-success/5 text-success'
|
|
153
|
-
}`}>
|
|
154
|
-
{result.error ? `${t('integrations.stream', { number: Number(idx) + 1 })} ${result.error}` : `${t('integrations.stream', { number: Number(idx) + 1 })} ${result.codec} · ${result.resolution}`}
|
|
155
|
-
</div>
|
|
156
|
-
))}
|
|
157
|
-
</div>
|
|
158
|
-
)}
|
|
159
|
-
</div>
|
|
160
|
-
|
|
161
|
-
<div className="flex items-center justify-between border-t border-border px-5 py-3">
|
|
162
|
-
<button
|
|
163
|
-
onClick={onClose}
|
|
164
|
-
className="rounded-lg border border-border px-3 py-1.5 text-xs text-foreground-subtle hover:text-foreground transition-colors"
|
|
165
|
-
>
|
|
166
|
-
{t('integrations.cancel')}
|
|
167
|
-
</button>
|
|
168
|
-
<div className="flex items-center gap-2">
|
|
169
|
-
<button
|
|
170
|
-
onClick={() => testMutation.mutate()}
|
|
171
|
-
disabled={streamUrls.every((u) => !u.trim()) || testMutation.isPending}
|
|
172
|
-
className="rounded-lg border border-border px-3 py-1.5 text-xs text-foreground hover:bg-surface-hover transition-colors disabled:opacity-50"
|
|
173
|
-
>
|
|
174
|
-
{testMutation.isPending ? t('integrations.testing') : t('integrations.test')}
|
|
175
|
-
</button>
|
|
176
|
-
<button
|
|
177
|
-
onClick={() => handleSave(false)}
|
|
178
|
-
disabled={!name.trim() || streamUrls.every((u) => !u.trim()) || createMutation.isPending}
|
|
179
|
-
className="rounded-l-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground shadow-sm disabled:opacity-50"
|
|
180
|
-
>
|
|
181
|
-
{createMutation.isPending ? t('integrations.saving') : t('integrations.save')}
|
|
182
|
-
</button>
|
|
183
|
-
<div className="relative group">
|
|
184
|
-
<button
|
|
185
|
-
disabled={!name.trim() || streamUrls.every((u) => !u.trim())}
|
|
186
|
-
className="rounded-r-lg border-l border-primary-foreground/20 bg-primary px-2 py-1.5 text-primary-foreground disabled:opacity-50"
|
|
187
|
-
>
|
|
188
|
-
<ChevronDown className="h-3 w-3" />
|
|
189
|
-
</button>
|
|
190
|
-
<div className="absolute right-0 bottom-full mb-1 hidden group-hover:block">
|
|
191
|
-
<button
|
|
192
|
-
onClick={() => handleSave(true)}
|
|
193
|
-
disabled={!name.trim() || streamUrls.every((u) => !u.trim())}
|
|
194
|
-
className="whitespace-nowrap rounded-lg border border-border bg-surface px-3 py-1.5 text-xs text-foreground shadow-lg hover:bg-surface-hover disabled:opacity-50"
|
|
195
|
-
>
|
|
196
|
-
{t('integrations.saveAndNew')}
|
|
197
|
-
</button>
|
|
198
|
-
</div>
|
|
199
|
-
</div>
|
|
200
|
-
</div>
|
|
201
|
-
</div>
|
|
202
|
-
</div>
|
|
203
|
-
</div>
|
|
204
|
-
)
|
|
205
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { useNavigate } from 'react-router-dom'
|
|
2
|
-
import { Camera } from 'lucide-react'
|
|
3
|
-
import { SnapshotPopover } from './SnapshotPopover'
|
|
4
|
-
|
|
5
|
-
interface CompactDeviceCardProps {
|
|
6
|
-
id: string
|
|
7
|
-
name: string
|
|
8
|
-
status: string
|
|
9
|
-
snapshotUrl?: string | null
|
|
10
|
-
resolution?: string | null
|
|
11
|
-
codec?: string | null
|
|
12
|
-
fps?: number | null
|
|
13
|
-
lastSnapshot?: string | null
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function CompactDeviceCard({ id, name, status, snapshotUrl, resolution, codec, fps, lastSnapshot }: CompactDeviceCardProps) {
|
|
17
|
-
const navigate = useNavigate()
|
|
18
|
-
|
|
19
|
-
return (
|
|
20
|
-
<div
|
|
21
|
-
onClick={() => navigate(`/devices/${id}`)}
|
|
22
|
-
className="flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2.5 cursor-pointer hover:border-foreground-subtle/30 transition-colors"
|
|
23
|
-
>
|
|
24
|
-
<Camera className="h-3.5 w-3.5 text-foreground-subtle flex-shrink-0" />
|
|
25
|
-
<span className="text-[11px] font-semibold text-foreground flex-1 truncate">{name}</span>
|
|
26
|
-
<SnapshotPopover deviceName={name} snapshotUrl={snapshotUrl} status={status} resolution={resolution} codec={codec} fps={fps} lastSnapshot={lastSnapshot} />
|
|
27
|
-
<span
|
|
28
|
-
className={`h-1.5 w-1.5 rounded-full flex-shrink-0 ${
|
|
29
|
-
status === 'online' ? 'bg-success' : 'bg-danger'
|
|
30
|
-
}`}
|
|
31
|
-
title={status === 'online' ? 'Online' : 'Offline'}
|
|
32
|
-
/>
|
|
33
|
-
</div>
|
|
34
|
-
)
|
|
35
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { useNavigate } from 'react-router-dom'
|
|
2
|
-
import { StatusBadge } from '../shared/StatusBadge'
|
|
3
|
-
import { CapabilityBadges } from '../shared/CapabilityBadges'
|
|
4
|
-
|
|
5
|
-
interface DeviceCardProps {
|
|
6
|
-
id: string
|
|
7
|
-
name: string
|
|
8
|
-
status: string
|
|
9
|
-
capabilities: string[]
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function DeviceCard({ id, name, status, capabilities }: DeviceCardProps) {
|
|
13
|
-
const navigate = useNavigate()
|
|
14
|
-
|
|
15
|
-
return (
|
|
16
|
-
<button
|
|
17
|
-
onClick={() => navigate(`/devices/${id}`)}
|
|
18
|
-
className="flex items-center gap-3 rounded-lg border border-border bg-surface p-3 text-left hover:border-primary/30 hover:shadow-md hover:shadow-black/5 transition-all w-full"
|
|
19
|
-
>
|
|
20
|
-
<div className="flex-1 min-w-0">
|
|
21
|
-
<p className="text-xs font-semibold text-foreground truncate">{name}</p>
|
|
22
|
-
<div className="mt-1.5">
|
|
23
|
-
<CapabilityBadges capabilities={capabilities} />
|
|
24
|
-
</div>
|
|
25
|
-
</div>
|
|
26
|
-
<StatusBadge status={status} />
|
|
27
|
-
</button>
|
|
28
|
-
)
|
|
29
|
-
}
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
2
|
-
import { useTranslation } from 'react-i18next'
|
|
3
|
-
import { useQuery } from '@tanstack/react-query'
|
|
4
|
-
import { Camera, Check } from 'lucide-react'
|
|
5
|
-
import { useBackendClient } from '../../hooks/useBackendClient'
|
|
6
|
-
|
|
7
|
-
interface DeviceDiscoveryStepProps {
|
|
8
|
-
providerId: string
|
|
9
|
-
onImport: (externalIds: readonly string[]) => void
|
|
10
|
-
onSkip: () => void
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function DeviceDiscoveryStep({ providerId, onImport, onSkip }: DeviceDiscoveryStepProps) {
|
|
14
|
-
const { t } = useTranslation()
|
|
15
|
-
const client = useBackendClient()
|
|
16
|
-
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set())
|
|
17
|
-
|
|
18
|
-
const { data: discovered, isLoading } = useQuery({
|
|
19
|
-
queryKey: ['discover-devices', providerId],
|
|
20
|
-
queryFn: () => client.discoverDevices(providerId),
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
function toggleDevice(externalId: string) {
|
|
24
|
-
setSelected((prev) => {
|
|
25
|
-
const next = new Set(prev)
|
|
26
|
-
if (next.has(externalId)) {
|
|
27
|
-
next.delete(externalId)
|
|
28
|
-
} else {
|
|
29
|
-
next.add(externalId)
|
|
30
|
-
}
|
|
31
|
-
return next
|
|
32
|
-
})
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (isLoading) {
|
|
36
|
-
return (
|
|
37
|
-
<div className="flex flex-col items-center py-12">
|
|
38
|
-
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
39
|
-
<p className="mt-3 text-xs text-foreground-subtle">{t('integrations.searchingDevices')}</p>
|
|
40
|
-
</div>
|
|
41
|
-
)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const devices = discovered ?? []
|
|
45
|
-
|
|
46
|
-
return (
|
|
47
|
-
<div className="space-y-4">
|
|
48
|
-
<p className="text-xs text-foreground-subtle">
|
|
49
|
-
{devices.length > 0
|
|
50
|
-
? t('integrations.foundDevices', { count: devices.length })
|
|
51
|
-
: t('integrations.noDevicesFound')}
|
|
52
|
-
</p>
|
|
53
|
-
|
|
54
|
-
{devices.length > 0 && (
|
|
55
|
-
<div className="space-y-1.5 max-h-64 overflow-y-auto">
|
|
56
|
-
{devices.map((device) => {
|
|
57
|
-
const id = String((device as any).externalId ?? (device as any).id ?? '')
|
|
58
|
-
const name = String((device as any).name ?? id)
|
|
59
|
-
const isSelected = selected.has(id)
|
|
60
|
-
|
|
61
|
-
return (
|
|
62
|
-
<button
|
|
63
|
-
key={id}
|
|
64
|
-
onClick={() => toggleDevice(id)}
|
|
65
|
-
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
|
|
66
|
-
isSelected
|
|
67
|
-
? 'border-primary/40 bg-primary/5'
|
|
68
|
-
: 'border-border bg-surface hover:border-foreground-subtle/30'
|
|
69
|
-
}`}
|
|
70
|
-
>
|
|
71
|
-
<div
|
|
72
|
-
className={`flex h-5 w-5 items-center justify-center rounded border flex-shrink-0 ${
|
|
73
|
-
isSelected ? 'border-primary bg-primary' : 'border-border'
|
|
74
|
-
}`}
|
|
75
|
-
>
|
|
76
|
-
{isSelected && <Check className="h-3 w-3 text-primary-foreground" />}
|
|
77
|
-
</div>
|
|
78
|
-
<Camera className="h-3.5 w-3.5 text-foreground-subtle flex-shrink-0" />
|
|
79
|
-
<span className="text-xs font-medium text-foreground truncate">{name}</span>
|
|
80
|
-
</button>
|
|
81
|
-
)
|
|
82
|
-
})}
|
|
83
|
-
</div>
|
|
84
|
-
)}
|
|
85
|
-
|
|
86
|
-
<div className="flex items-center justify-between pt-2">
|
|
87
|
-
<button
|
|
88
|
-
onClick={onSkip}
|
|
89
|
-
className="rounded-lg border border-border px-3 py-1.5 text-xs text-foreground-subtle hover:text-foreground transition-colors"
|
|
90
|
-
>
|
|
91
|
-
{t('integrations.skip')}
|
|
92
|
-
</button>
|
|
93
|
-
{devices.length > 0 && (
|
|
94
|
-
<button
|
|
95
|
-
onClick={() => onImport(Array.from(selected))}
|
|
96
|
-
disabled={selected.size === 0}
|
|
97
|
-
className="rounded-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground shadow-sm disabled:opacity-50"
|
|
98
|
-
>
|
|
99
|
-
{t('integrations.importSelected')} ({selected.size})
|
|
100
|
-
</button>
|
|
101
|
-
)}
|
|
102
|
-
</div>
|
|
103
|
-
</div>
|
|
104
|
-
)
|
|
105
|
-
}
|