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(' ')}
/>
);
}