Add ServerPicker, CopyField, PortField and EnvEditor

One component per job, so pages stop hand-building them:
- ServerPicker: which machine; local first, capacity, capability filter
- CopyField: any value meant to be copied; secrets stay masked
- PortField: host ports checked live against /ports/check, naming the
  app that holds a taken port and offering the next free one
- EnvEditor: KEY=value rows for forms; a pasted .env fills the rows
Call sites move onto them in the following commits.
This commit is contained in:
Juan Denis committed 2026-10-03 22:50:21 -04:00
1 parent 2f52c87a39
commit 3f772c57f6
11 files changed
+730 -1

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
import { useState } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { CopyButton } from './CopyButton';
const MASK = '••••••••••••';
/**
* The one way to show a value someone will copy: commands, IPs, URLs,
* connection strings, tokens, passwords.
*
* <CopyField value="curl -fsSL https://… | bash" />
* <CopyField label="Password" value={password} secret />
* <CopyField value={dotenv} multiline />
*
* `secret` hides the value until the eye is pressed; copying works either way,
* so a secret never has to be shown to be used. Use the bare CopyButton only
* when the copied text is not on screen (a "Copy command" action).
*/
export default function CopyField({
value,
label,
secret = false,
multiline = false,
className,
onCopy,
}) {
const { t } = useTranslation();
const [revealed, setRevealed] = useState(false);
const text = value == null ? '' : String(value);
const hidden = secret && !revealed;
return (
<div className={cn('sk-copy-field', multiline && 'sk-copy-field--multiline', className)}>
{label && <span className="sk-copy-field__label">{label}</span>}
<div className="sk-copy-field__box">
{multiline
? <pre className="sk-copy-field__value">{hidden ? MASK : text}</pre>
: <code className="sk-copy-field__value" title={hidden ? undefined : text}>{hidden ? MASK : text}</code>}
<span className="sk-copy-field__actions">
{secret && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setRevealed((v) => !v)}
aria-label={revealed ? t('app.copyField.hide', 'Hide') : t('app.copyField.show', 'Show')}
title={revealed ? t('app.copyField.hide', 'Hide') : t('app.copyField.show', 'Show')}
>
{revealed ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
)}
<CopyButton
value={text}
label={label ? t('app.copyField.copyLabel', 'Copy {{label}}', { label }) : t('app.copyField.copy', 'Copy')}
copiedLabel={t('app.copyField.copied', 'Copied')}
onCopy={onCopy}
/>
</span>
</div>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { useState } from 'react';
import { Eye, EyeOff, Plus, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { parseDotenv, isValidEnvKey, looksSecret } from '../utils/dotenv';
/**
* The one KEY=value editor for forms (run a container, deploy a template,
* shared variable groups). The per-app variables table with history and
* import/export is EnvironmentVariables; this is its in-form sibling.
*
* <EnvEditor value={rows} onChange={setRows} />
*
* `value` is [{ key, value }]; convert with envToObject / envFromObject from
* utils/dotenv. Pasting a multi-line .env into any key box splits it into
* rows. Values whose key looks like a credential are masked until revealed.
*/
export default function EnvEditor({
value = [],
onChange,
disabled = false,
className,
}) {
const { t } = useTranslation();
const [revealed, setRevealed] = useState(() => new Set());
const rows = value.length ? value : [{ key: '', value: '' }];
const emit = (next) => onChange?.(next);
const update = (index, patch) => emit(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
const remove = (index) => emit(rows.filter((_, i) => i !== index));
const add = () => emit([...rows, { key: '', value: '' }]);
const onPaste = (index, event) => {
const text = event.clipboardData?.getData('text') || '';
if (!text.includes('\n') && !text.includes('=')) return;
const parsed = parseDotenv(text);
if (!parsed.length) return;
event.preventDefault();
const before = rows.slice(0, index).filter((r) => r.key || r.value);
const after = rows.slice(index + 1).filter((r) => r.key || r.value);
emit([...before, ...parsed, ...after]);
};
const counts = rows.reduce((acc, row) => {
const k = row.key.trim();
if (k) acc[k] = (acc[k] || 0) + 1;
return acc;
}, {});
const toggle = (index) => setRevealed((prev) => {
const next = new Set(prev);
if (next.has(index)) next.delete(index); else next.add(index);
return next;
});
return (
<div className={cn('sk-env-editor', className)}>
{rows.map((row, index) => {
const key = row.key.trim();
const badKey = key && !isValidEnvKey(key);
const duplicate = key && counts[key] > 1;
const masked = looksSecret(key) && !revealed.has(index);
return (
// Rows have no identity beyond their position while being typed.
<div className="sk-env-editor__row" key={index}>
<Input
value={row.key}
onChange={(e) => update(index, { key: e.target.value })}
onPaste={(e) => onPaste(index, e)}
placeholder="KEY"
disabled={disabled}
spellCheck={false}
autoComplete="off"
aria-invalid={Boolean(badKey || duplicate)}
aria-label={t('app.envEditor.key', 'Key')}
className="sk-env-editor__key"
/>
<Input
value={row.value}
onChange={(e) => update(index, { value: e.target.value })}
placeholder="value"
type={masked ? 'password' : 'text'}
disabled={disabled}
spellCheck={false}
autoComplete="off"
aria-label={t('app.envEditor.value', 'Value')}
className="sk-env-editor__value"
/>
{looksSecret(key) ? (
<Button type="button" variant="ghost" size="icon" onClick={() => toggle(index)}
aria-label={masked ? t('app.copyField.show', 'Show') : t('app.copyField.hide', 'Hide')}>
{masked ? <Eye size={14} /> : <EyeOff size={14} />}
</Button>
) : <span className="sk-env-editor__spacer" />}
<Button type="button" variant="ghost" size="icon" onClick={() => remove(index)}
disabled={disabled} aria-label={t('app.envEditor.remove', 'Remove {{key}}', { key: key || t('app.envEditor.row', 'row') })}>
<Trash2 size={14} />
</Button>
{(badKey || duplicate) && (
<p className="sk-env-editor__error">
{badKey
? t('app.envEditor.badKey', 'Use letters, digits and _, not starting with a digit.')
: t('app.envEditor.duplicate', '{{key}} is set more than once; the last one wins.', { key })}
</p>
)}
</div>
);
})}
<div className="sk-env-editor__foot">
<Button type="button" variant="outline" size="sm" onClick={add} disabled={disabled}>
<Plus size={14} /> {t('app.envEditor.add', 'Add variable')}
</Button>
<span className="sk-env-editor__hint">{t('app.envEditor.pasteHint', 'Paste a .env file into a key box to fill several rows.')}</span>
</div>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import api from '../services/api';
const MIN_PORT = 1;
const MAX_PORT = 65535;
const PRIVILEGED_LIMIT = 1024;
// Wait for typing to settle before asking the server about a port.
const CHECK_DELAY_MS = 350;
/**
* The one port input.
*
* <PortField value={port} onChange={setPort} /> // host port, checked
* <PortField value={cport} onChange={setCport} host={false} /> // container port
*
* Host ports (the default) are checked against the server as you type and
* say who holds a taken one ("Used by shop-api"), with a one-click next free
* port. `ownerAppId` marks the app being edited, so its own port reads as
* its own rather than taken. Container ports only get range checks: they
* live inside the container and never collide on the host.
*
* onChange receives a number, or '' while the field is empty.
*/
export default function PortField({
value,
onChange,
host = true,
ownerAppId = null,
allowPrivileged = false,
suggestFrom = 8000,
placeholder = '8080',
disabled = false,
required = false,
id,
className,
}) {
const { t } = useTranslation();
const fallbackId = useId();
const [check, setCheck] = useState(null);
const [suggestion, setSuggestion] = useState(null);
const raw = value === '' || value == null ? '' : String(value);
const port = raw === '' ? null : Number(raw);
const inRange = port != null && Number.isInteger(port) && port >= MIN_PORT && port <= MAX_PORT;
const privileged = inRange && port < PRIVILEGED_LIMIT;
useEffect(() => {
setCheck(null);
setSuggestion(null);
if (!host || !inRange || (privileged && !allowPrivileged)) return undefined;
let cancelled = false;
const timer = setTimeout(async () => {
try {
const res = await api.checkPort(port);
if (cancelled) return;
setCheck(res);
if (!res.free && !(ownerAppId != null && res.holder?.id === ownerAppId)) {
const next = await api.suggestPort(Math.max(port + 1, suggestFrom));
if (!cancelled) setSuggestion(next?.port ?? null);
}
} catch {
// The check is advisory; the deploy still validates the port.
}
}, CHECK_DELAY_MS);
return () => { cancelled = true; clearTimeout(timer); };
}, [host, port, inRange, privileged, allowPrivileged, ownerAppId, suggestFrom]);
const ownPort = check?.holder && ownerAppId != null && check.holder.id === ownerAppId;
let status = null;
if (raw !== '' && !inRange) {
status = { tone: 'error', text: t('app.portField.range', 'Ports run from 1 to 65535.') };
} else if (privileged && !allowPrivileged) {
status = { tone: 'error', text: t('app.portField.privileged', 'Ports below 1024 need root. Use 1024 or higher.') };
} else if (check && ownPort) {
status = { text: t('app.portField.ownPort', 'This app already uses {{port}}.', { port }) };
} else if (check?.holder?.kind === 'app') {
status = { tone: 'error', text: t('app.portField.usedByApp', 'Used by {{name}}.', { name: check.holder.name }) };
} else if (check?.holder) {
status = { tone: 'error', text: t('app.portField.usedByContainer', 'A running container already maps {{port}}.', { port }) };
} else if (check && !check.free) {
status = { tone: 'error', text: t('app.portField.bound', 'Something outside ServerKit is listening on {{port}}.', { port }) };
} else if (check?.free) {
status = { text: t('app.portField.free', '{{port}} is free.', { port }) };
}
return (
<div className={cn('sk-port-field', className)}>
<Input
id={id || fallbackId}
type="number"
inputMode="numeric"
min={allowPrivileged ? MIN_PORT : (host ? PRIVILEGED_LIMIT : MIN_PORT)}
max={MAX_PORT}
step={1}
value={raw}
onChange={(e) => onChange?.(e.target.value === '' ? '' : Number(e.target.value))}
placeholder={placeholder}
disabled={disabled}
required={required}
aria-invalid={status?.tone === 'error'}
className="sk-port-field__input"
/>
{status && (
<p className={cn('sk-port-field__status', status.tone === 'error' && 'is-error')}>
{status.text}
{suggestion && !ownPort && (
<Button
type="button"
variant="link"
size="sm"
className="sk-port-field__suggest"
onClick={() => onChange?.(suggestion)}
disabled={disabled}
>
{t('app.portField.useSuggestion', 'Use {{port}}', { port: suggestion })}
</Button>
)}
</p>
)}
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { useEffect, useMemo, useState } from 'react';
import { Server } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import ResourcePicker from './ResourcePicker';
import api from '../services/api';
import { formatBytes } from '../utils/formatBytes';
import { LOCAL_SERVER_ID } from '../utils/serverTarget';
// The panel's server list rarely changes while a page is open, and several
// pickers can mount at once, so they share one request per page load.
let serversRequest = null;
function loadServers() {
if (!serversRequest) {
serversRequest = api.getAvailableServers()
.then((data) => (Array.isArray(data) ? data : []))
.catch(() => {
serversRequest = null;
return [];
});
}
return serversRequest;
}
const enabledCapabilities = (server) => {
const caps = Object.entries(server?.capabilities || {})
.filter(([, enabled]) => enabled === true)
.map(([name]) => name);
if ((server?.is_local || server?.has_docker) && !caps.includes('docker')) caps.push('docker');
return caps;
};
function capacityLabel(server) {
const parts = [];
if (server.cpu_cores) parts.push(`${server.cpu_cores} vCPU`);
if (server.total_memory) parts.push(formatBytes(server.total_memory, { decimals: 0 }));
return parts.join(' · ');
}
/**
* The one server picker: "which machine does this run on".
*
* <ServerPicker value={serverId} onChange={(id, server) => …} capability="docker" />
*
* `value` is a server id, or LOCAL_SERVER_ID for the panel host. `onChange`
* gets the id and the full server row from /servers/available (allowed_paths,
* os_type, agent dirs, …) or null for an `extraOptions` entry. Offline
* servers are hidden unless `onlineOnly` is false; `capability` narrows the
* list to servers that can do the job.
*/
export default function ServerPicker({
value = LOCAL_SERVER_ID,
onChange,
capability,
includeLocal = true,
onlineOnly = true,
extraOptions = [],
label,
disabled = false,
className,
}) {
const { t } = useTranslation();
const [servers, setServers] = useState([]);
useEffect(() => {
let cancelled = false;
loadServers().then((rows) => { if (!cancelled) setServers(rows); });
return () => { cancelled = true; };
}, []);
const serverById = useMemo(
() => new Map(servers.map((server) => [String(server.id), server])),
[servers],
);
const localServer = servers.find((server) => server.is_local) || null;
const describe = (server) => {
const location = server.is_local
? t('app.serverPicker.thisPanel', 'this panel')
: (server.group_name || t('app.serverPicker.remote', 'remote'));
return [server.os_type ? `${location} · ${server.os_type}` : location, capacityLabel(server)]
.filter(Boolean).join(' · ');
};
const localOption = useMemo(() => ({
type: 'server-target',
id: LOCAL_SERVER_ID,
label: t('app.targetPicker.localThisServer', 'Local (this server)'),
sublabel: localServer ? describe(localServer) : '',
path: '/servers',
scope: {},
status: 'online',
capabilities: localServer ? enabledCapabilities(localServer) : ['docker'],
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [localServer, t]);
const staticOptions = useMemo(() => [
...(includeLocal ? [localOption] : []),
...extraOptions.map((option) => ({
type: 'server-target',
id: String(option.value),
label: option.label,
sublabel: option.sublabel || '',
path: '/servers',
scope: {},
status: null,
capabilities: [],
})),
], [extraOptions, includeLocal, localOption]);
const selected = useMemo(() => {
const id = String(value ?? LOCAL_SERVER_ID);
const fromStatic = staticOptions.find((option) => option.id === id);
if (fromStatic) return fromStatic;
const server = serverById.get(id);
if (!server) return null;
return {
type: 'server',
id,
label: server.name || server.hostname || id,
sublabel: describe(server),
path: `/servers/${id}`,
scope: {},
status: server.status || null,
capabilities: enabledCapabilities(server),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, staticOptions, serverById]);
const decorateOption = (resource) => {
const server = serverById.get(resource.id);
if (!server || resource.type !== 'server') return resource;
return {
...resource,
sublabel: describe(server),
capabilities: [...new Set([...resource.capabilities, ...enabledCapabilities(server)])],
};
};
const filterOption = (resource) => {
if (resource.type !== 'server') return true;
if (serverById.get(resource.id)?.is_local) return false; // shown as "Local"
if (onlineOnly && resource.status && resource.status !== 'online') return false;
return true;
};
const pickerLabel = label || t('app.serverPicker.server', 'Server');
return (
<ResourcePicker
value={selected}
onChange={(resource) => {
if (resource.type === 'server-target') {
onChange?.(resource.id, resource.id === LOCAL_SERVER_ID ? localServer : null);
return;
}
onChange?.(resource.id, serverById.get(resource.id) || null);
}}
types={['server']}
capabilities={capability ? [capability] : []}
staticOptions={staticOptions}
filterOption={filterOption}
decorateOption={decorateOption}
icon={Server}
disabled={disabled}
label={pickerLabel}
placeholder={pickerLabel}
searchPlaceholder={t('app.serverPicker.findAServer', 'Find a server…')}
emptyMessage={t('app.serverPicker.noServersMatch', 'No servers match.')}
className={['sk-server-picker', className].filter(Boolean).join(' ')}
/>
);
}
+31 -1
View File
@@ -1323,6 +1323,13 @@
"newChat": "New chat",
"noPastConversations": "No past conversations"
},
"copyField": {
"copied": "Copied",
"copy": "Copy",
"copyLabel": "Copy {{label}}",
"hide": "Hide",
"show": "Show"
},
"createTableModal": {
"aWholeNumberKeyBecomes": "A whole-number key becomes",
"copyThisInto": "Copy this into",
@@ -2113,6 +2120,16 @@
"theDeployPipelineIsPullingThe": "The deploy pipeline is pulling the image and provisioning the volume. The engine joins the tree once it reports healthy.",
"usePort": "Use port"
},
"envEditor": {
"add": "Add variable",
"badKey": "Use letters, digits and _, not starting with a digit.",
"duplicate": "{{key}} is set more than once; the last one wins.",
"key": "Key",
"pasteHint": "Paste a .env file into a key box to fill several rows.",
"remove": "Remove {{key}}",
"row": "row",
"value": "Value"
},
"environmentVariables": {
"addEnvironmentVariable": "Add Environment Variable",
"addOneHereOrImportAn": "Add one here, or import an existing .env file.",
@@ -3841,6 +3858,16 @@
"title": "Connection pooling",
"tradeoff": "Transaction pooling breaks session features: prepared statements in some drivers, LISTEN/NOTIFY, advisory locks and session SET. Apps keep the direct connection unless they switch."
},
"portField": {
"bound": "Something outside ServerKit is listening on {{port}}.",
"free": "{{port}} is free.",
"ownPort": "This app already uses {{port}}.",
"privileged": "Ports below 1024 need root. Use 1024 or higher.",
"range": "Ports run from 1 to 65535.",
"useSuggestion": "Use {{port}}",
"usedByApp": "Used by {{name}}.",
"usedByContainer": "A running container already maps {{port}}."
},
"previewDrawer": {
"copyPath": "Copy path",
"downloadFile": "Download File",
@@ -4811,7 +4838,10 @@
"deployToServer": "Deploy to server",
"filterServers": "Filter servers",
"findAServer": "Find a server…",
"noServersMatch": "No servers match."
"noServersMatch": "No servers match.",
"remote": "remote",
"server": "Server",
"thisPanel": "this panel"
},
"serverRestorePoints": {
"application": "Application",
+10
View File
@@ -603,3 +603,13 @@ export async function previewAppComposeSplit(appId) {
export async function applyAppComposeSplit(appId) {
return this.request(`/apps/${appId}/slots/compose-split`, { method: 'POST', body: { confirm: true } });
}
// Host-port checks behind the shared PortField: is it valid, free, and who
// holds it; and the next free port at or above `start`.
export async function checkPort(port) {
return this.request(`/ports/check?port=${encodeURIComponent(port)}`);
}
export async function suggestPort(start = 8000) {
return this.request(`/ports/suggest?start=${encodeURIComponent(start)}`);
}
+104
View File
@@ -0,0 +1,104 @@
// Shared field components: CopyField, PortField, EnvEditor, ServerPicker.
// One look per job; pages do not restyle these.
.sk-copy-field {
display: flex;
flex-direction: column;
gap: $space-1;
min-width: 0;
&__label { font-size: $font-size-xs; color: var(--text-dim); }
&__box {
display: flex;
align-items: flex-start;
gap: $space-1;
min-width: 0;
padding: 4px 4px 4px $space-3;
background: var(--surface-2);
border: 1px solid var(--border-soft);
border-radius: var(--radius-md);
}
&__value {
flex: 1;
min-width: 0;
align-self: center;
margin: 0;
padding: 0;
background: none;
font-family: var(--mono);
font-size: $font-size-sm;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&--multiline &__value {
padding-block: $space-1;
white-space: pre-wrap;
word-break: break-all;
max-height: 240px;
overflow: auto;
}
&__actions { display: inline-flex; flex-shrink: 0; }
}
.sk-port-field {
display: flex;
flex-direction: column;
gap: $space-1;
min-width: 0;
&__input { max-width: 160px; font-family: var(--mono); }
&__status {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: $space-2;
margin: 0;
font-size: $font-size-xs;
color: var(--text-dim);
&.is-error { color: var(--red); }
}
&__suggest { height: auto; padding: 0; font-size: $font-size-xs; }
}
.sk-env-editor {
display: flex;
flex-direction: column;
gap: $space-2;
&__row {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) auto auto;
align-items: center;
gap: $space-2;
}
&__key, &__value { font-family: var(--mono); }
&__spacer { width: 32px; }
&__error {
grid-column: 1 / -1;
margin: 0;
font-size: $font-size-xs;
color: var(--red);
}
&__foot {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: $space-3;
}
&__hint { font-size: $font-size-xs; color: var(--text-faint); }
}
.sk-server-picker { min-width: 220px; }
+1
View File
@@ -69,6 +69,7 @@
@import 'components/_detail-tabs';
@import 'components/_server-selector';
@import 'components/_domain-field'; // shared domain input (DomainField.jsx)
@import 'components/_fields'; // CopyField, PortField, EnvEditor, ServerPicker
@import 'components/_schedule-picker'; // shared cron picker (CronJobs, Backups, server-detail)
@import 'components/_contribution-graph';
@import 'components/_command-palette';
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseDotenv, envToObject, envFromObject, isValidEnvKey, looksSecret } from '../dotenv.js';
test('parseDotenv handles comments, export, quotes and = in values', () => {
const rows = parseDotenv('# db\nexport DB_HOST=localhost\nDB_PASS="p=ss word"\n\nEMPTY=\nnot a line\nURL=\'https://x/?a=1\'');
assert.deepEqual(rows, [
{ key: 'DB_HOST', value: 'localhost' },
{ key: 'DB_PASS', value: 'p=ss word' },
{ key: 'EMPTY', value: '' },
{ key: 'URL', value: 'https://x/?a=1' },
]);
});
test('object round trip keeps the last duplicate and drops blank keys', () => {
assert.deepEqual(envToObject([{ key: 'A', value: '1' }, { key: ' ', value: 'x' }, { key: 'A', value: '2' }]), { A: '2' });
assert.deepEqual(envFromObject({ A: 1, B: null }), [{ key: 'A', value: '1' }, { key: 'B', value: '' }]);
});
test('key validation and secret detection', () => {
assert.equal(isValidEnvKey('DB_HOST'), true);
assert.equal(isValidEnvKey('1BAD'), false);
assert.equal(isValidEnvKey('has-dash'), false);
assert.equal(looksSecret('STRIPE_SECRET_KEY'), true);
assert.equal(looksSecret('NODE_ENV'), false);
});
+46
View File
@@ -0,0 +1,46 @@
// KEY=value parsing shared by EnvEditor and anything that imports a .env.
const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
export function isValidEnvKey(key) {
return KEY.test(String(key || ''));
}
function unquote(value) {
const v = value.trim();
if (v.length >= 2 && ((v[0] === '"' && v.at(-1) === '"') || (v[0] === "'" && v.at(-1) === "'"))) {
return v.slice(1, -1);
}
return v;
}
// "export A=1\n# note\nB='two'" -> [{ key: 'A', value: '1' }, { key: 'B', value: 'two' }]
export function parseDotenv(text) {
return String(text || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#') && line.includes('='))
.map((line) => {
const body = line.replace(/^export\s+/, '');
const at = body.indexOf('=');
return { key: body.slice(0, at).trim(), value: unquote(body.slice(at + 1)) };
})
.filter((row) => row.key);
}
export function envToObject(rows) {
const out = {};
for (const { key, value } of rows || []) {
if (key && key.trim()) out[key.trim()] = value ?? '';
}
return out;
}
export function envFromObject(obj) {
return Object.entries(obj || {}).map(([key, value]) => ({ key, value: value == null ? '' : String(value) }));
}
// Names that look like credentials, masked by default in editors.
export function looksSecret(key) {
return /(PASS|SECRET|TOKEN|KEY|PRIVATE|CREDENTIAL|AUTH)/i.test(String(key || ''));
}
+28
View File
@@ -0,0 +1,28 @@
// What ServerPicker reports, turned into the operational "target" object the
// file manager and terminal tools pass around: { kind: 'local' } for the
// panel host, { kind: 'agent', server_id, … } for a remote server, or
// { kind: <id> } for a non-server source such as an S3 bucket.
export const LOCAL_SERVER_ID = 'local';
export function serverTarget(id, server) {
const key = String(id ?? LOCAL_SERVER_ID);
if (key === LOCAL_SERVER_ID) return { kind: 'local' };
if (!server) return { kind: key };
return {
kind: 'agent',
server_id: key,
name: server.name || server.hostname || key,
allowedPaths: server.allowed_paths || [],
os_type: server.os_type || null,
agentInstallDir: server.agent_install_dir || null,
agentConfigDir: server.agent_config_dir || null,
};
}
// The ServerPicker value for a target object.
export function targetServerId(target) {
if (!target || target.kind === 'local') return LOCAL_SERVER_ID;
if (target.kind === 'agent') return String(target.server_id);
return String(target.kind);
}