mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-03 02:26:43 +09:00
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:
1 parent
2f52c87a39
commit
3f772c57f6
11 files changed
+730
-1
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(' ')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 || ''));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user