mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 05:24:49 +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(' ')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user