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:
parent
b2b4709abe
commit
5a3e4c51f9
2 changed files with 55 additions and 29 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,9 +658,10 @@ 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>}
|
||||||
|
{!isCollapsed && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSaveExisting(host)}
|
onClick={() => handleSaveExisting(host)}
|
||||||
disabled={busy.has(host.id)}
|
disabled={busy.has(host.id)}
|
||||||
|
|
@ -649,6 +670,7 @@ function SshHostsSection() {
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTest(host)}
|
onClick={() => handleTest(host)}
|
||||||
disabled={busy.has(host.id)}
|
disabled={busy.has(host.id)}
|
||||||
|
|
@ -667,7 +689,8 @@ function SshHostsSection() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
{!isCollapsed && (
|
||||||
|
<div className="grid grid-cols-3 gap-4" style={{ marginTop: '16px' }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>Host Name</label>
|
<label style={labelStyle}>Host Name</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -679,6 +702,7 @@ function SshHostsSection() {
|
||||||
</div>
|
</div>
|
||||||
{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)}
|
||||||
</div>
|
</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)))
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue