diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index ca3cdfb..f6a5947 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -35,17 +35,40 @@ const accentColors = [ { name: 'Red', color: '#E74C3C' }, ] -type FieldDef = { key: string; label: string; secret?: boolean } +type FieldDef = { key: string; label: string; secret?: boolean; hint?: string; placeholder?: string } -const integrationTypeDefs: { type: string; name: string; fields: FieldDef[] }[] = [ - { type: 'proxmox', name: 'Proxmox', fields: [{ key: 'baseUrl', label: 'Host URL' }, { key: 'apiKey', label: 'API Token', secret: true }] }, - { type: 'docker', name: 'Docker', fields: [{ key: 'baseUrl', label: 'Socket / Remote URL' }] }, - { type: 'netbird', name: 'NetBird', fields: [{ key: 'apiKey', label: 'API Key', secret: true }] }, - { type: 'cloudflare', name: 'Cloudflare', fields: [{ key: 'apiKey', label: 'API Token', secret: true }, { key: 'zoneId', label: 'Zone ID' }] }, - { type: 'aws', name: 'AWS', fields: [{ key: 'accessKey', label: 'Access Key' }, { key: 'secretKey', label: 'Secret', secret: true }, { key: 'region', label: 'Region' }] }, - { type: 'uptime_kuma', name: 'Uptime Kuma', fields: [{ key: 'baseUrl', label: 'URL' }, { key: 'apiKey', label: 'API Key', secret: true }] }, +const integrationTypeDefs: { type: string; name: string; multiInstance?: boolean; fields: FieldDef[] }[] = [ + { type: 'proxmox', name: 'Proxmox', multiInstance: true, fields: [ + { key: 'baseUrl', label: 'Host URL', hint: 'e.g. https://192.168.1.10:8006', placeholder: 'https://192.168.1.10:8006' }, + { + key: 'apiKey', + label: 'API Token', + secret: true, + hint: 'Must be the FULL token string from Datacenter → Permissions → API Tokens, in the form USER@REALM!TOKENID=SECRET — not just the secret.', + placeholder: 'root@pam!archnest=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + }, + ] }, + { type: 'docker', name: 'Docker', multiInstance: true, fields: [ + { key: 'baseUrl', label: 'Socket / Remote URL', hint: 'Unix socket path or remote daemon URL, e.g. unix:///var/run/docker.sock or tcp://host:2375', placeholder: 'unix:///var/run/docker.sock' }, + ] }, + { type: 'netbird', name: 'NetBird', fields: [ + { key: 'apiKey', label: 'API Key', secret: true, hint: 'Personal access token from NetBird dashboard → Settings → Access Tokens.' }, + ] }, + { type: 'cloudflare', name: 'Cloudflare', fields: [ + { key: 'apiKey', label: 'API Token', secret: true, hint: 'A scoped API token (not your Global API Key) from My Profile → API Tokens.' }, + { key: 'zoneId', label: 'Zone ID', hint: 'Found on the domain overview page in the Cloudflare dashboard.' }, + ] }, + { type: 'aws', name: 'AWS', multiInstance: true, fields: [ + { key: 'accessKey', label: 'Access Key ID', hint: 'IAM user access key, e.g. AKIAIOSFODNN7EXAMPLE' }, + { key: 'secretKey', label: 'Secret Access Key', secret: true, hint: 'IAM user secret key — paired with the Access Key ID above.' }, + { key: 'region', label: 'Region', hint: 'e.g. us-east-1', placeholder: 'us-east-1' }, + ] }, + { type: 'uptime_kuma', name: 'Uptime Kuma', fields: [ + { key: 'baseUrl', label: 'URL', placeholder: 'https://uptime.example.com' }, + { key: 'apiKey', label: 'API Key', secret: true }, + ] }, { type: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] }, - { type: 'remote_desktop', name: 'Remote Desktop', fields: [ + { type: 'remote_desktop', name: 'Remote Desktop', multiInstance: true, fields: [ { key: 'protocol', label: 'Protocol (rdp / vnc / telnet)' }, { key: 'hostname', label: 'Hostname' }, { key: 'port', label: 'Port' }, @@ -651,12 +674,16 @@ function SshHostsSection() { ) } +type NewIntegrationDraft = { id: number; type: string; values: Record } + function IntegrationsSection() { const [integrations, setIntegrations] = useState(null) const [revealed, setRevealed] = useState>(new Set()) - const [drafts, setDrafts] = useState>>({}) + const [editDrafts, setEditDrafts] = useState>>({}) + const [newDrafts, setNewDrafts] = useState([]) const [statusMsg, setStatusMsg] = useState>({}) const [busy, setBusy] = useState>(new Set()) + const nextDraftId = useRef(-1) useEffect(() => { api.listIntegrations().then(({ integrations }) => setIntegrations(integrations)) @@ -671,65 +698,101 @@ function IntegrationsSection() { }) } - function setBusyFlag(type: string, value: boolean) { + function setBusyFlag(rowKey: string, value: boolean) { setBusy((prev) => { const next = new Set(prev) - if (value) next.add(type) - else next.delete(type) + if (value) next.add(rowKey) + else next.delete(rowKey) return next }) } - function setDraftField(type: string, fieldKey: string, value: string) { - setDrafts((prev) => ({ ...prev, [type]: { ...prev[type], [fieldKey]: value } })) + function setEditField(integrationId: number, fieldKey: string, value: string) { + setEditDrafts((prev) => ({ ...prev, [integrationId]: { ...prev[integrationId], [fieldKey]: value } })) } - async function handleSave(def: (typeof integrationTypeDefs)[number], existing: Integration | undefined) { - setBusyFlag(def.type, true) - setStatusMsg((prev) => ({ ...prev, [def.type]: '' })) + function setNewDraftField(draftId: number, fieldKey: string, value: string) { + setNewDrafts((prev) => prev.map((d) => (d.id === draftId ? { ...d, values: { ...d.values, [fieldKey]: value } } : d))) + } + + function addNewDraft(type: string) { + const id = nextDraftId.current-- + setNewDrafts((prev) => [...prev, { id, type, values: {} }]) + } + + function removeNewDraft(id: number) { + setNewDrafts((prev) => prev.filter((d) => d.id !== id)) + } + + function buildPayload(def: (typeof integrationTypeDefs)[number], values: Record) { + const config: Record = {} + const secrets: Record = {} + for (const f of def.fields) { + const value = values[f.key] + if (value === undefined) continue + if (f.secret) secrets[f.key] = value + else config[f.key] = value + } + return { config, secrets } + } + + async function handleSaveExisting(def: (typeof integrationTypeDefs)[number], existing: Integration) { + const rowKey = `e-${existing.id}` + setBusyFlag(rowKey, true) + setStatusMsg((prev) => ({ ...prev, [rowKey]: '' })) try { - const draft = drafts[def.type] ?? {} - const config: Record = {} - const secrets: Record = {} - for (const f of def.fields) { - const value = draft[f.key] - if (value === undefined) continue - if (f.secret) secrets[f.key] = value - else config[f.key] = value - } - let integration: Integration - if (existing) { - ;({ integration } = await api.updateIntegration(existing.id, { config, secrets })) - } else { - ;({ integration } = await api.createIntegration({ type: def.type, name: def.name, config, secrets })) - } - setIntegrations((prev) => { - const others = (prev ?? []).filter((i) => i.id !== integration.id) - return [...others, integration] - }) - setStatusMsg((prev) => ({ ...prev, [def.type]: 'Saved' })) + const { config, secrets } = buildPayload(def, editDrafts[existing.id] ?? {}) + const { integration } = await api.updateIntegration(existing.id, { config, secrets }) + setIntegrations((prev) => (prev ?? []).map((i) => (i.id === integration.id ? integration : i))) + setStatusMsg((prev) => ({ ...prev, [rowKey]: 'Saved' })) } catch (err) { - setStatusMsg((prev) => ({ ...prev, [def.type]: err instanceof ApiError ? err.message : 'Save failed' })) + setStatusMsg((prev) => ({ ...prev, [rowKey]: err instanceof ApiError ? err.message : 'Save failed' })) } finally { - setBusyFlag(def.type, false) + setBusyFlag(rowKey, false) } } - async function handleTest(def: (typeof integrationTypeDefs)[number], existing: Integration | undefined) { - if (!existing) { - setStatusMsg((prev) => ({ ...prev, [def.type]: 'Save the integration before testing' })) - return + async function handleSaveNew(def: (typeof integrationTypeDefs)[number], draft: NewIntegrationDraft) { + const rowKey = `n-${draft.id}` + setBusyFlag(rowKey, true) + setStatusMsg((prev) => ({ ...prev, [rowKey]: '' })) + try { + const { config, secrets } = buildPayload(def, draft.values) + const { integration } = await api.createIntegration({ type: def.type, name: def.name, config, secrets }) + setIntegrations((prev) => [...(prev ?? []), integration]) + removeNewDraft(draft.id) + } catch (err) { + setStatusMsg((prev) => ({ ...prev, [rowKey]: err instanceof ApiError ? err.message : 'Save failed' })) + } finally { + setBusyFlag(rowKey, false) } - setBusyFlag(def.type, true) + } + + async function handleTest(existing: Integration) { + const rowKey = `e-${existing.id}` + setBusyFlag(rowKey, true) try { const result = await api.testIntegration(existing.id) - setStatusMsg((prev) => ({ ...prev, [def.type]: result.message })) + setStatusMsg((prev) => ({ ...prev, [rowKey]: result.message })) const { integrations } = await api.listIntegrations() setIntegrations(integrations) } catch (err) { - setStatusMsg((prev) => ({ ...prev, [def.type]: err instanceof ApiError ? err.message : 'Test failed' })) + setStatusMsg((prev) => ({ ...prev, [rowKey]: err instanceof ApiError ? err.message : 'Test failed' })) } finally { - setBusyFlag(def.type, false) + setBusyFlag(rowKey, false) + } + } + + async function handleDelete(existing: Integration) { + if (!window.confirm(`Remove this ${existing.name} integration?`)) return + const rowKey = `e-${existing.id}` + setBusyFlag(rowKey, true) + try { + await api.deleteIntegration(existing.id) + setIntegrations((prev) => (prev ?? []).filter((i) => i.id !== existing.id)) + } catch (err) { + setStatusMsg((prev) => ({ ...prev, [rowKey]: err instanceof ApiError ? err.message : 'Delete failed' })) + setBusyFlag(rowKey, false) } } @@ -741,6 +804,43 @@ function IntegrationsSection() { ) } + function renderFields(def: (typeof integrationTypeDefs)[number], rowKey: string, getValue: (f: FieldDef) => string, onChange: (f: FieldDef, value: string) => void, placeholderForSecret: string) { + return ( +
+ {def.fields.map((f) => { + const key = `${rowKey}-${f.key}` + const isRevealed = revealed.has(key) + return ( +
+ +
+ onChange(f, e.target.value)} + placeholder={f.secret ? placeholderForSecret : f.placeholder ?? 'Not configured'} + /> + {f.secret && ( + + )} +
+ {f.hint && ( +

{f.hint}

+ )} +
+ ) + })} +
+ ) + } + return (
@@ -751,95 +851,131 @@ function IntegrationsSection() {

Other Integrations

{integrationTypeDefs.map((def) => { - const existing = integrations.find((i) => i.type === def.type) - const online = existing?.status === 'connected' - const draft = drafts[def.type] ?? {} + const existingRows = integrations.filter((i) => i.type === def.type) + const draftRows = newDrafts.filter((d) => d.type === def.type) + const canAddAnother = def.multiInstance || existingRows.length + draftRows.length === 0 return ( -
-
-
- - {def.name} -
-
- {statusMsg[def.type] && ( - {statusMsg[def.type]} - )} - - -
-
-
- {def.fields.map((f) => { - const key = `${def.type}-${f.key}` - const isRevealed = revealed.has(key) - const savedValue = f.secret ? '' : existing?.config[f.key] ?? '' - const value = draft[f.key] ?? savedValue - return ( -
- -
- setDraftField(def.type, f.key, e.target.value)} - placeholder={f.secret && existing ? '••••••••••••' : 'Not configured'} +
+ {existingRows.map((existing) => { + const rowKey = `e-${existing.id}` + const online = existing.status === 'connected' + const draft = editDrafts[existing.id] ?? {} + return ( +
+
+
+ - {f.secret && ( - + {existing.name || def.name} + {existing.config.baseUrl || existing.config.hostname ? ( + {existing.config.baseUrl || existing.config.hostname} + ) : null} +
+
+ {statusMsg[rowKey] && ( + {statusMsg[rowKey]} )} + + +
- ) - })} -
+ {renderFields( + def, + rowKey, + (f) => draft[f.key] ?? (f.secret ? '' : existing.config[f.key] ?? ''), + (f, value) => setEditField(existing.id, f.key, value), + '••••••••••••', + )} +
+ ) + })} + + {draftRows.map((draft) => { + const rowKey = `n-${draft.id}` + return ( +
+
+ New {def.name} +
+ {statusMsg[rowKey] && ( + {statusMsg[rowKey]} + )} + + +
+
+ {renderFields( + def, + rowKey, + (f) => draft.values[f.key] ?? '', + (f, value) => setNewDraftField(draft.id, f.key, value), + '', + )} +
+ ) + })} + + {existingRows.length === 0 && draftRows.length === 0 && ( + + )} + {canAddAnother && (existingRows.length > 0 || draftRows.length > 0) && ( + + )}
) })}