From 3f772c57f66b9e4199f4d06caf09b9bb94de690f Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sat, 3 Oct 2026 22:50:21 -0400 Subject: [PATCH] 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. --- frontend/src/components/CopyField.jsx | 65 +++++++ frontend/src/components/EnvEditor.jsx | 119 +++++++++++++ frontend/src/components/PortField.jsx | 126 ++++++++++++++ frontend/src/components/ServerPicker.jsx | 174 +++++++++++++++++++ frontend/src/i18n/locales/en.json | 32 +++- frontend/src/services/api/apps.js | 10 ++ frontend/src/styles/components/_fields.scss | 104 +++++++++++ frontend/src/styles/main.scss | 1 + frontend/src/utils/__tests__/dotenv.test.mjs | 26 +++ frontend/src/utils/dotenv.js | 46 +++++ frontend/src/utils/serverTarget.js | 28 +++ 11 files changed, 730 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/CopyField.jsx create mode 100644 frontend/src/components/EnvEditor.jsx create mode 100644 frontend/src/components/PortField.jsx create mode 100644 frontend/src/components/ServerPicker.jsx create mode 100644 frontend/src/styles/components/_fields.scss create mode 100644 frontend/src/utils/__tests__/dotenv.test.mjs create mode 100644 frontend/src/utils/dotenv.js create mode 100644 frontend/src/utils/serverTarget.js diff --git a/frontend/src/components/CopyField.jsx b/frontend/src/components/CopyField.jsx new file mode 100644 index 00000000..605e6695 --- /dev/null +++ b/frontend/src/components/CopyField.jsx @@ -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. + * + * + * + * + * + * `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 ( +
+ {label && {label}} +
+ {multiline + ?
{hidden ? MASK : text}
+ : {hidden ? MASK : text}} + + {secret && ( + + )} + + +
+
+ ); +} diff --git a/frontend/src/components/EnvEditor.jsx b/frontend/src/components/EnvEditor.jsx new file mode 100644 index 00000000..94e31ed6 --- /dev/null +++ b/frontend/src/components/EnvEditor.jsx @@ -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. + * + * + * + * `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 ( +
+ {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. +
+ 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" + /> + 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) ? ( + + ) : } + + {(badKey || duplicate) && ( +

+ {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 })} +

+ )} +
+ ); + })} +
+ + {t('app.envEditor.pasteHint', 'Paste a .env file into a key box to fill several rows.')} +
+
+ ); +} diff --git a/frontend/src/components/PortField.jsx b/frontend/src/components/PortField.jsx new file mode 100644 index 00000000..61d716ad --- /dev/null +++ b/frontend/src/components/PortField.jsx @@ -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. + * + * // host port, checked + * // 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 ( +
+ 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 && ( +

+ {status.text} + {suggestion && !ownPort && ( + + )} +

+ )} +
+ ); +} diff --git a/frontend/src/components/ServerPicker.jsx b/frontend/src/components/ServerPicker.jsx new file mode 100644 index 00000000..03d4524d --- /dev/null +++ b/frontend/src/components/ServerPicker.jsx @@ -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". + * + * …} 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 ( + { + 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(' ')} + /> + ); +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0c47c801..5045a5ff 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/services/api/apps.js b/frontend/src/services/api/apps.js index c8858052..69cb48e8 100644 --- a/frontend/src/services/api/apps.js +++ b/frontend/src/services/api/apps.js @@ -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)}`); +} diff --git a/frontend/src/styles/components/_fields.scss b/frontend/src/styles/components/_fields.scss new file mode 100644 index 00000000..4512e245 --- /dev/null +++ b/frontend/src/styles/components/_fields.scss @@ -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; } diff --git a/frontend/src/styles/main.scss b/frontend/src/styles/main.scss index 6f04402d..0f2abf57 100644 --- a/frontend/src/styles/main.scss +++ b/frontend/src/styles/main.scss @@ -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'; diff --git a/frontend/src/utils/__tests__/dotenv.test.mjs b/frontend/src/utils/__tests__/dotenv.test.mjs new file mode 100644 index 00000000..9a57bc6c --- /dev/null +++ b/frontend/src/utils/__tests__/dotenv.test.mjs @@ -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); +}); diff --git a/frontend/src/utils/dotenv.js b/frontend/src/utils/dotenv.js new file mode 100644 index 00000000..471ced55 --- /dev/null +++ b/frontend/src/utils/dotenv.js @@ -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 || '')); +} diff --git a/frontend/src/utils/serverTarget.js b/frontend/src/utils/serverTarget.js new file mode 100644 index 00000000..7ea796f8 --- /dev/null +++ b/frontend/src/utils/serverTarget.js @@ -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: } 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); +}