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)))