Add editable system configuration flow
This commit is contained in:
273
src/SystemTab.tsx
Normal file
273
src/SystemTab.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import type { DashboardSnapshot, ProxyServiceRecord, SystemSettings, UpdateSystemInput } from './shared/contracts';
|
||||
import { getProtocolForCommand, validateSystemInput } from './shared/validation';
|
||||
|
||||
interface SystemTabProps {
|
||||
snapshot: DashboardSnapshot;
|
||||
onSaveSystem: (input: UpdateSystemInput) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function SystemTab({ snapshot, onSaveSystem }: SystemTabProps) {
|
||||
const [draft, setDraft] = useState<UpdateSystemInput>(() => cloneSystemSettings(snapshot.system));
|
||||
const [error, setError] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(cloneSystemSettings(snapshot.system));
|
||||
setError('');
|
||||
}, [snapshot.system]);
|
||||
|
||||
const updateService = (serviceId: string, updater: (service: ProxyServiceRecord) => ProxyServiceRecord) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
services: current.services.map((service) => (service.id === serviceId ? updater(service) : service)),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const validated = validateSystemInput(draft, snapshot.userRecords);
|
||||
await onSaveSystem(validated);
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : 'Unable to save system settings.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="system-editor" onSubmit={handleSubmit}>
|
||||
<section className="page-grid system-grid">
|
||||
<article className="panel-card">
|
||||
<div className="card-header">
|
||||
<h2>Runtime</h2>
|
||||
<span className="status-pill idle">editable</span>
|
||||
</div>
|
||||
<div className="system-fields">
|
||||
<label className="field-group">
|
||||
Public host
|
||||
<input
|
||||
value={draft.publicHost}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, publicHost: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field-group">
|
||||
Config mode
|
||||
<input
|
||||
value={draft.configMode}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, configMode: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field-group">
|
||||
Reload mode
|
||||
<input
|
||||
value={draft.reloadMode}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, reloadMode: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field-group">
|
||||
Storage mode
|
||||
<input
|
||||
value={draft.storageMode}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, storageMode: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="system-hint">
|
||||
Saving writes a new generated config and keeps existing user assignments on enabled assignable
|
||||
services only.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article className="panel-card">
|
||||
<div className="card-header">
|
||||
<h2>Services</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="button-secondary"
|
||||
onClick={() =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
services: [...current.services, createServiceDraft(current.services)],
|
||||
}))
|
||||
}
|
||||
>
|
||||
Add service
|
||||
</button>
|
||||
</div>
|
||||
<div className="service-editor-list">
|
||||
{draft.services.map((service, index) => (
|
||||
<section key={service.id} className="service-editor-row">
|
||||
<div className="service-editor-header">
|
||||
<div>
|
||||
<strong>Service {index + 1}</strong>
|
||||
<p>{service.id}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="button-secondary button-small"
|
||||
onClick={() =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
services: current.services.filter((entry) => entry.id !== service.id),
|
||||
}))
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<div className="service-editor-grid">
|
||||
<label className="field-group">
|
||||
Name
|
||||
<input
|
||||
value={service.name}
|
||||
onChange={(event) =>
|
||||
updateService(service.id, (current) => ({ ...current, name: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="field-group">
|
||||
Port
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={String(service.port)}
|
||||
onChange={(event) =>
|
||||
updateService(service.id, (current) => ({
|
||||
...current,
|
||||
port: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="field-group">
|
||||
Command
|
||||
<select
|
||||
value={service.command}
|
||||
onChange={(event) =>
|
||||
updateService(service.id, (current) => {
|
||||
const command = event.target.value as ProxyServiceRecord['command'];
|
||||
return {
|
||||
...current,
|
||||
command,
|
||||
protocol: getProtocolForCommand(command),
|
||||
assignable: command === 'admin' ? false : current.assignable,
|
||||
};
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="socks">socks</option>
|
||||
<option value="proxy">proxy</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field-group">
|
||||
Protocol
|
||||
<input value={service.protocol} readOnly />
|
||||
</label>
|
||||
<label className="field-group field-span-2">
|
||||
Description
|
||||
<input
|
||||
value={service.description}
|
||||
onChange={(event) =>
|
||||
updateService(service.id, (current) => ({
|
||||
...current,
|
||||
description: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="toggle-row">
|
||||
<label className="toggle-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={service.enabled}
|
||||
onChange={(event) =>
|
||||
updateService(service.id, (current) => ({
|
||||
...current,
|
||||
enabled: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
<label className="toggle-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={service.assignable}
|
||||
disabled={service.command === 'admin'}
|
||||
onChange={(event) =>
|
||||
updateService(service.id, (current) => ({
|
||||
...current,
|
||||
assignable: current.command === 'admin' ? false : event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
Assignable to users
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="system-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button-secondary"
|
||||
onClick={() => {
|
||||
setDraft(cloneSystemSettings(snapshot.system));
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button type="submit" disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save system'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel-card wide-card">
|
||||
<div className="card-header">
|
||||
<h2>Generated config</h2>
|
||||
</div>
|
||||
<pre>{snapshot.system.previewConfig}</pre>
|
||||
</article>
|
||||
</section>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function cloneSystemSettings(system: SystemSettings): UpdateSystemInput {
|
||||
return {
|
||||
publicHost: system.publicHost,
|
||||
configMode: system.configMode,
|
||||
reloadMode: system.reloadMode,
|
||||
storageMode: system.storageMode,
|
||||
services: system.services.map((service) => ({ ...service })),
|
||||
};
|
||||
}
|
||||
|
||||
function createServiceDraft(existingServices: ProxyServiceRecord[]): ProxyServiceRecord {
|
||||
const usedPorts = new Set(existingServices.map((service) => service.port));
|
||||
let port = 1080;
|
||||
|
||||
while (usedPorts.has(port) && port < 65535) {
|
||||
port += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `service-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: `Service ${existingServices.length + 1}`,
|
||||
command: 'socks',
|
||||
protocol: 'socks5',
|
||||
description: 'Additional SOCKS5 entrypoint managed from the panel.',
|
||||
port,
|
||||
enabled: true,
|
||||
assignable: true,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user