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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <input> 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 <noreply@anthropic.com>
This commit is contained in:
Samuel James 2026-06-20 08:30:21 -04:00 committed by GitHub
parent b2b4709abe
commit 5a3e4c51f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 55 additions and 29 deletions

View file

@ -12,7 +12,9 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
# Toolchain is needed again here: production deps are reinstalled fresh, and the # Toolchain is needed again here: production deps are reinstalled fresh, and the
# native modules (better-sqlite3, ssh2, node-pty) compile from source on install. # 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* ./ COPY package.json package-lock.json* ./
RUN npm install --omit=dev RUN npm install --omit=dev
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist

View file

@ -15,6 +15,8 @@ import {
Upload, Upload,
Trash2, Trash2,
Camera, Camera,
ChevronDown,
ChevronRight,
} from 'lucide-react' } from 'lucide-react'
const navSections = [ const navSections = [
@ -383,10 +385,20 @@ function SshHostsSection() {
const [drafts, setDrafts] = useState<Record<number, Record<string, string>>>({}) const [drafts, setDrafts] = useState<Record<number, Record<string, string>>>({})
const [statusMsg, setStatusMsg] = useState<Record<number, string>>({}) const [statusMsg, setStatusMsg] = useState<Record<number, string>>({})
const [busy, setBusy] = useState<Set<number>>(new Set()) const [busy, setBusy] = useState<Set<number>>(new Set())
const [collapsed, setCollapsed] = useState<Set<number>>(new Set())
const [newDrafts, setNewDrafts] = useState<{ key: number; values: Record<string, string> }[]>([]) const [newDrafts, setNewDrafts] = useState<{ key: number; values: Record<string, string> }[]>([])
const nextNewKey = useRef(-1) const nextNewKey = useRef(-1)
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({}) const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({})
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(() => { useEffect(() => {
refresh() refresh()
}, []) }, [])
@ -436,8 +448,8 @@ function SshHostsSection() {
{ key: 'sessionLogging', label: 'Record session to disk' }, { key: 'sessionLogging', label: 'Record session to disk' },
] ]
function buildPayload(fields: FieldDef[], values: Record<string, string>) { function buildPayload(fields: FieldDef[], values: Record<string, string>, existing?: Integration) {
const config: Record<string, string> = {} const config: Record<string, string> = { ...(existing?.config ?? {}) }
const secrets: Record<string, string> = {} const secrets: Record<string, string> = {}
for (const f of fields) { for (const f of fields) {
const value = values[f.key] const value = values[f.key]
@ -453,11 +465,12 @@ function SshHostsSection() {
setStatusMsg((prev) => ({ ...prev, [host.id]: '' })) setStatusMsg((prev) => ({ ...prev, [host.id]: '' }))
try { try {
const draft = drafts[host.id] ?? {} const draft = drafts[host.id] ?? {}
const { config, secrets } = buildPayload(fieldsWithJumpHost(), draft) const { config, secrets } = buildPayload(fieldsWithJumpHost(), draft, host)
const name = draft.__name?.trim() const name = draft.__name?.trim()
const { integration } = await api.updateIntegration(host.id, { ...(name ? { name } : {}), config, secrets }) const { integration } = await api.updateIntegration(host.id, { ...(name ? { name } : {}), config, secrets })
setHosts((prev) => (prev ?? []).map((h) => (h.id === integration.id ? integration : h))) setHosts((prev) => (prev ?? []).map((h) => (h.id === integration.id ? integration : h)))
setStatusMsg((prev) => ({ ...prev, [host.id]: 'Saved' })) setStatusMsg((prev) => ({ ...prev, [host.id]: 'Saved' }))
setCollapsed((prev) => new Set(prev).add(host.id))
} catch (err) { } catch (err) {
setStatusMsg((prev) => ({ ...prev, [host.id]: err instanceof ApiError ? err.message : 'Save failed' })) setStatusMsg((prev) => ({ ...prev, [host.id]: err instanceof ApiError ? err.message : 'Save failed' }))
} finally { } finally {
@ -624,10 +637,17 @@ function SshHostsSection() {
{hosts.map((host) => { {hosts.map((host) => {
const online = host.status === 'connected' const online = host.status === 'connected'
const draft = drafts[host.id] ?? {} const draft = drafts[host.id] ?? {}
const isCollapsed = collapsed.has(host.id)
return ( return (
<div key={host.id} style={cardBase}> <div key={host.id} style={cardBase}>
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}> <div className="flex items-center justify-between" style={{ marginBottom: isCollapsed ? 0 : '16px' }}>
<div className="flex items-center gap-2.5"> <button
onClick={() => toggleCollapsed(host.id)}
className="flex items-center gap-2.5 cursor-pointer border-none bg-transparent"
style={{ padding: 0 }}
title={isCollapsed ? 'Expand' : 'Collapse'}
>
{isCollapsed ? <ChevronRight size={14} color="#7A7D85" /> : <ChevronDown size={14} color="#7A7D85" />}
<span <span
style={{ style={{
width: '8px', width: '8px',
@ -638,17 +658,19 @@ function SshHostsSection() {
}} }}
/> />
<span style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600 }}>{draft.__name ?? host.name}</span> <span style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600 }}>{draft.__name ?? host.name}</span>
</div> </button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{statusMsg[host.id] && <span style={{ fontSize: '11px', color: '#7A7D85' }}>{statusMsg[host.id]}</span>} {statusMsg[host.id] && <span style={{ fontSize: '11px', color: '#7A7D85' }}>{statusMsg[host.id]}</span>}
<button {!isCollapsed && (
onClick={() => handleSaveExisting(host)} <button
disabled={busy.has(host.id)} onClick={() => handleSaveExisting(host)}
className="cursor-pointer border-none" disabled={busy.has(host.id)}
style={{ fontSize: '11px', fontWeight: 600, color: '#0A0B0D', backgroundColor: '#C8A434', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(host.id) ? 0.6 : 1 }} className="cursor-pointer border-none"
> style={{ fontSize: '11px', fontWeight: 600, color: '#0A0B0D', backgroundColor: '#C8A434', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(host.id) ? 0.6 : 1 }}
Save >
</button> Save
</button>
)}
<button <button
onClick={() => handleTest(host)} onClick={() => handleTest(host)}
disabled={busy.has(host.id)} disabled={busy.has(host.id)}
@ -667,18 +689,20 @@ function SshHostsSection() {
</button> </button>
</div> </div>
</div> </div>
<div className="grid grid-cols-3 gap-4"> {!isCollapsed && (
<div> <div className="grid grid-cols-3 gap-4" style={{ marginTop: '16px' }}>
<label style={labelStyle}>Host Name</label> <div>
<input <label style={labelStyle}>Host Name</label>
style={inputStyle} <input
value={draft.__name ?? host.name} style={inputStyle}
onChange={(e) => setDraftField(host.id, '__name', e.target.value)} value={draft.__name ?? host.name}
placeholder="Not configured" onChange={(e) => setDraftField(host.id, '__name', e.target.value)}
/> placeholder="Not configured"
/>
</div>
{renderFields(fieldsWithJumpHost(), draft, (k, v) => setDraftField(host.id, k, v), host.id, host, host.id)}
</div> </div>
{renderFields(fieldsWithJumpHost(), draft, (k, v) => setDraftField(host.id, k, v), host.id, host, host.id)} )}
</div>
</div> </div>
) )
})} })}
@ -782,8 +806,8 @@ function IntegrationsSection() {
setNewDrafts((prev) => prev.filter((d) => d.id !== id)) setNewDrafts((prev) => prev.filter((d) => d.id !== id))
} }
function buildPayload(def: (typeof integrationTypeDefs)[number], values: Record<string, string>) { function buildPayload(def: (typeof integrationTypeDefs)[number], values: Record<string, string>, existing?: Integration) {
const config: Record<string, string> = {} const config: Record<string, string> = { ...(existing?.config ?? {}) }
const secrets: Record<string, string> = {} const secrets: Record<string, string> = {}
for (const f of def.fields) { for (const f of def.fields) {
const value = values[f.key] const value = values[f.key]
@ -799,7 +823,7 @@ function IntegrationsSection() {
setBusyFlag(rowKey, true) setBusyFlag(rowKey, true)
setStatusMsg((prev) => ({ ...prev, [rowKey]: '' })) setStatusMsg((prev) => ({ ...prev, [rowKey]: '' }))
try { 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 name = editDrafts[existing.id]?.__name?.trim()
const { integration } = await api.updateIntegration(existing.id, { ...(name ? { name } : {}), config, secrets }) const { integration } = await api.updateIntegration(existing.id, { ...(name ? { name } : {}), config, secrets })
setIntegrations((prev) => (prev ?? []).map((i) => (i.id === integration.id ? integration : i))) setIntegrations((prev) => (prev ?? []).map((i) => (i.id === integration.id ? integration : i)))