From 5a3e4c51f9cd259aeeaa4cf86ec7a252e162daf0 Mon Sep 17 00:00:00 2001 From: Samuel James <143277412+SamuelSJames@users.noreply.github.com> Date: Sat, 20 Jun 2026 08:30:21 -0400 Subject: [PATCH] Fix integration save data loss; add SSH host card collapse (#16) * Add editable display-name field to generic integrations Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop, Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate from the host/IP field, mirroring the SSH host rename pattern. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4 * Surface the new-integration name field as a labeled input The name field for new generic integrations was a faint header input with only placeholder text, easy to miss. Move it into the form grid as a proper labeled "Name" field next to the other connection fields. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4 * Add file upload for SSH private key and certificate fields Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting its contents into the Private Key / OPKSSH Certificate fields. * Fix SSH private key paste corrupting multi-line PEM format Private Key and Certificate fields were single-line elements, which strip newlines on paste and corrupt PEM-formatted keys (causing 'Unsupported key format' errors). Render them as multi-line textareas instead so pasted keys keep their line breaks. * Fix integration save wiping untouched config fields The PUT /api/integrations/:id route fully overwrites config_json with whatever config object is sent (no merge), but buildPayload only included fields the user had actually edited. Saving after editing just one field (e.g. pasting a new SSH key) silently dropped every other config field. Merge the existing integration's config into the payload before sending. * Add collapse/expand for SSH host cards Click the chevron to collapse a host's card once it's configured. Collapsed cards keep all field state in memory (just hidden), and auto-collapse after a successful Save. * Install openssh-client in backend image for certificate-auth SSH Certificate-based SSH connections shell out to the system ssh binary via node-pty (ssh2 has no OpenSSH certificate support), but the alpine runtime image never installed openssh-client. This caused 'execvp(3) failed: No such file or directory' for any host with an OPKSSH certificate configured. --------- Co-authored-by: Claude --- backend/Dockerfile | 4 ++- src/pages/Settings.tsx | 80 +++++++++++++++++++++++++++--------------- 2 files changed, 55 insertions(+), 29 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 76bbd15..a28ef01 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -12,7 +12,9 @@ WORKDIR /app ENV NODE_ENV=production # Toolchain is needed again here: production deps are reinstalled fresh, and the # native modules (better-sqlite3, ssh2, node-pty) compile from source on install. -RUN apk add --no-cache python3 make g++ +# openssh-client provides the `ssh` binary, which node-pty shells out to for +# certificate-based auth (ssh2 has no OpenSSH certificate support). +RUN apk add --no-cache python3 make g++ openssh-client COPY package.json package-lock.json* ./ RUN npm install --omit=dev COPY --from=build /app/dist ./dist diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index a7dcfc0..073ee52 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -15,6 +15,8 @@ import { Upload, Trash2, Camera, + ChevronDown, + ChevronRight, } from 'lucide-react' const navSections = [ @@ -383,10 +385,20 @@ function SshHostsSection() { const [drafts, setDrafts] = useState>>({}) const [statusMsg, setStatusMsg] = useState>({}) const [busy, setBusy] = useState>(new Set()) + const [collapsed, setCollapsed] = useState>(new Set()) const [newDrafts, setNewDrafts] = useState<{ key: number; values: Record }[]>([]) const nextNewKey = useRef(-1) const fileInputRefs = useRef>({}) + function toggleCollapsed(id: number) { + setCollapsed((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + useEffect(() => { refresh() }, []) @@ -436,8 +448,8 @@ function SshHostsSection() { { key: 'sessionLogging', label: 'Record session to disk' }, ] - function buildPayload(fields: FieldDef[], values: Record) { - const config: Record = {} + function buildPayload(fields: FieldDef[], values: Record, existing?: Integration) { + const config: Record = { ...(existing?.config ?? {}) } const secrets: Record = {} for (const f of fields) { const value = values[f.key] @@ -453,11 +465,12 @@ function SshHostsSection() { setStatusMsg((prev) => ({ ...prev, [host.id]: '' })) try { const draft = drafts[host.id] ?? {} - const { config, secrets } = buildPayload(fieldsWithJumpHost(), draft) + const { config, secrets } = buildPayload(fieldsWithJumpHost(), draft, host) const name = draft.__name?.trim() const { integration } = await api.updateIntegration(host.id, { ...(name ? { name } : {}), config, secrets }) setHosts((prev) => (prev ?? []).map((h) => (h.id === integration.id ? integration : h))) setStatusMsg((prev) => ({ ...prev, [host.id]: 'Saved' })) + setCollapsed((prev) => new Set(prev).add(host.id)) } catch (err) { setStatusMsg((prev) => ({ ...prev, [host.id]: err instanceof ApiError ? err.message : 'Save failed' })) } finally { @@ -624,10 +637,17 @@ function SshHostsSection() { {hosts.map((host) => { const online = host.status === 'connected' const draft = drafts[host.id] ?? {} + const isCollapsed = collapsed.has(host.id) return (
-
-
+
+
+
{statusMsg[host.id] && {statusMsg[host.id]}} - + {!isCollapsed && ( + + )}
-
-
- - setDraftField(host.id, '__name', e.target.value)} - placeholder="Not configured" - /> + {!isCollapsed && ( +
+
+ + setDraftField(host.id, '__name', e.target.value)} + placeholder="Not configured" + /> +
+ {renderFields(fieldsWithJumpHost(), draft, (k, v) => setDraftField(host.id, k, v), host.id, host, host.id)}
- {renderFields(fieldsWithJumpHost(), draft, (k, v) => setDraftField(host.id, k, v), host.id, host, host.id)} -
+ )}
) })} @@ -782,8 +806,8 @@ function IntegrationsSection() { setNewDrafts((prev) => prev.filter((d) => d.id !== id)) } - function buildPayload(def: (typeof integrationTypeDefs)[number], values: Record) { - const config: Record = {} + function buildPayload(def: (typeof integrationTypeDefs)[number], values: Record, existing?: Integration) { + const config: Record = { ...(existing?.config ?? {}) } const secrets: Record = {} for (const f of def.fields) { const value = values[f.key] @@ -799,7 +823,7 @@ function IntegrationsSection() { setBusyFlag(rowKey, true) setStatusMsg((prev) => ({ ...prev, [rowKey]: '' })) try { - const { config, secrets } = buildPayload(def, editDrafts[existing.id] ?? {}) + const { config, secrets } = buildPayload(def, editDrafts[existing.id] ?? {}, existing) const name = editDrafts[existing.id]?.__name?.trim() const { integration } = await api.updateIntegration(existing.id, { ...(name ? { name } : {}), config, secrets }) setIntegrations((prev) => (prev ?? []).map((i) => (i.id === integration.id ? integration : i)))