365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { DashboardSnapshot, ProxyServiceRecord, SystemSettings, UpdateSystemInput } from './shared/contracts';
|
|
import type { PanelLanguage, PanelPreferences, PanelTheme } from './lib/panelPreferences';
|
|
import { getPanelText, getThemeLabel } from './lib/panelText';
|
|
import { getProtocolForCommand, validateSystemInput } from './shared/validation';
|
|
|
|
interface SystemTabProps {
|
|
snapshot: DashboardSnapshot;
|
|
preferences: PanelPreferences;
|
|
onPreferencesChange: (next: PanelPreferences) => void;
|
|
onSaveSystem: (input: UpdateSystemInput) => Promise<void>;
|
|
}
|
|
|
|
export default function SystemTab({
|
|
snapshot,
|
|
preferences,
|
|
onPreferencesChange,
|
|
onSaveSystem,
|
|
}: SystemTabProps) {
|
|
const [draft, setDraft] = useState<UpdateSystemInput>(() => cloneSystemSettings(snapshot.system));
|
|
const [error, setError] = useState('');
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [removeServiceId, setRemoveServiceId] = useState<string | null>(null);
|
|
const lastAppliedSystemKey = useRef(serializeSystemSettings(cloneSystemSettings(snapshot.system)));
|
|
const text = getPanelText(preferences.language);
|
|
|
|
useEffect(() => {
|
|
const incomingDraft = cloneSystemSettings(snapshot.system);
|
|
const incomingKey = serializeSystemSettings(incomingDraft);
|
|
const draftKey = serializeSystemSettings(draft);
|
|
|
|
if (incomingKey === lastAppliedSystemKey.current) {
|
|
return;
|
|
}
|
|
|
|
if (draftKey === lastAppliedSystemKey.current || draftKey === incomingKey) {
|
|
setDraft(incomingDraft);
|
|
setError('');
|
|
}
|
|
|
|
lastAppliedSystemKey.current = incomingKey;
|
|
}, [draft, snapshot.system]);
|
|
|
|
const linkedUsersByService = useMemo(() => {
|
|
const result = new Map<string, string[]>();
|
|
|
|
snapshot.userRecords.forEach((user) => {
|
|
const usernames = result.get(user.serviceId) ?? [];
|
|
usernames.push(user.username);
|
|
result.set(user.serviceId, usernames);
|
|
});
|
|
|
|
return result;
|
|
}, [snapshot.userRecords]);
|
|
|
|
const removeTarget = draft.services.find((service) => service.id === removeServiceId) ?? null;
|
|
const removeTargetUsers = removeTarget ? linkedUsersByService.get(removeTarget.id) ?? [] : [];
|
|
|
|
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 nextServiceIds = new Set(draft.services.map((service) => service.id));
|
|
const remainingUsers = snapshot.userRecords.filter((user) => nextServiceIds.has(user.serviceId));
|
|
const validated = validateSystemInput(draft, remainingUsers);
|
|
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 single-column system-grid">
|
|
<article className="panel-card">
|
|
<div className="card-header">
|
|
<h2>{text.settings.panelTitle}</h2>
|
|
</div>
|
|
<div className="panel-settings-grid">
|
|
<label className="field-group panel-settings-wide">
|
|
<span>{text.settings.proxyHost}</span>
|
|
<input
|
|
value={draft.publicHost}
|
|
onChange={(event) =>
|
|
setDraft((current) => ({
|
|
...current,
|
|
publicHost: event.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="field-group compact-field">
|
|
<span>{text.common.language}</span>
|
|
<select
|
|
value={preferences.language}
|
|
onChange={(event) =>
|
|
onPreferencesChange({
|
|
...preferences,
|
|
language: event.target.value as PanelLanguage,
|
|
})
|
|
}
|
|
>
|
|
<option value="en">{text.common.english}</option>
|
|
<option value="ru">{text.common.russian}</option>
|
|
</select>
|
|
</label>
|
|
<label className="field-group compact-field">
|
|
<span>{text.common.theme}</span>
|
|
<select
|
|
value={preferences.theme}
|
|
onChange={(event) =>
|
|
onPreferencesChange({
|
|
...preferences,
|
|
theme: event.target.value as PanelTheme,
|
|
})
|
|
}
|
|
>
|
|
<option value="light">{getThemeLabel(preferences.language, 'light')}</option>
|
|
<option value="dark">{getThemeLabel(preferences.language, 'dark')}</option>
|
|
<option value="system">{getThemeLabel(preferences.language, 'system')}</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="panel-card">
|
|
<div className="card-header">
|
|
<h2>{text.settings.title}</h2>
|
|
<button
|
|
type="button"
|
|
className="button-secondary"
|
|
onClick={() =>
|
|
setDraft((current) => ({
|
|
...current,
|
|
services: [...current.services, createServiceDraft(current.services)],
|
|
}))
|
|
}
|
|
>
|
|
{text.common.addService}
|
|
</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>
|
|
{text.settings.serviceLabel} {index + 1}
|
|
</strong>
|
|
<p>{service.id}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="button-secondary button-small"
|
|
onClick={() => setRemoveServiceId(service.id)}
|
|
>
|
|
{text.common.remove}
|
|
</button>
|
|
</div>
|
|
<div className="service-editor-grid">
|
|
<label className="field-group">
|
|
{text.settings.name}
|
|
<input
|
|
value={service.name}
|
|
onChange={(event) =>
|
|
updateService(service.id, (current) => ({ ...current, name: event.target.value }))
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="field-group">
|
|
{text.settings.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">
|
|
{text.settings.serviceType}
|
|
<select
|
|
value={service.command}
|
|
onChange={(event) =>
|
|
updateService(service.id, (current) => {
|
|
const command = event.target.value as ProxyServiceRecord['command'];
|
|
return {
|
|
...current,
|
|
command,
|
|
protocol: getProtocolForCommand(command),
|
|
};
|
|
})
|
|
}
|
|
>
|
|
<option value="socks">{text.settings.typeSocks}</option>
|
|
<option value="proxy">{text.settings.typeProxy}</option>
|
|
</select>
|
|
</label>
|
|
<label className="field-group field-span-2">
|
|
{text.settings.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,
|
|
}))
|
|
}
|
|
/>
|
|
{text.common.enabled}
|
|
</label>
|
|
<label className="toggle-check">
|
|
<input
|
|
type="checkbox"
|
|
checked={service.assignable}
|
|
onChange={(event) =>
|
|
updateService(service.id, (current) => ({
|
|
...current,
|
|
assignable: event.target.checked,
|
|
}))
|
|
}
|
|
/>
|
|
{text.common.assignable}
|
|
</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));
|
|
lastAppliedSystemKey.current = serializeSystemSettings(cloneSystemSettings(snapshot.system));
|
|
setError('');
|
|
}}
|
|
>
|
|
{text.common.reset}
|
|
</button>
|
|
<button type="submit" disabled={isSaving}>
|
|
{isSaving ? `${text.common.save}...` : text.common.saveSettings}
|
|
</button>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="panel-card wide-card">
|
|
<div className="card-header">
|
|
<h2>{text.settings.generatedConfig}</h2>
|
|
</div>
|
|
<pre>{snapshot.system.previewConfig}</pre>
|
|
</article>
|
|
</section>
|
|
</form>
|
|
|
|
{removeTarget ? (
|
|
<div className="modal-backdrop" role="presentation" onClick={() => setRemoveServiceId(null)}>
|
|
<section
|
|
aria-labelledby="remove-service-title"
|
|
aria-modal="true"
|
|
className="modal-card confirm-card"
|
|
role="dialog"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="modal-header">
|
|
<h2 id="remove-service-title">{text.settings.serviceRemoveTitle}</h2>
|
|
</div>
|
|
<p className="confirm-copy">
|
|
<strong>{removeTarget.name}</strong>{' '}
|
|
{removeTargetUsers.length > 0 ? text.settings.removeWarningUsers : text.settings.removeWarningNone}
|
|
</p>
|
|
{removeTargetUsers.length > 0 ? (
|
|
<p className="confirm-copy">
|
|
{text.settings.removeWarningCount} {removeTargetUsers.join(', ')}
|
|
</p>
|
|
) : null}
|
|
<div className="modal-actions">
|
|
<button type="button" className="button-secondary" onClick={() => setRemoveServiceId(null)}>
|
|
{text.common.cancel}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="button-danger"
|
|
onClick={() => {
|
|
setDraft((current) => ({
|
|
...current,
|
|
services: current.services.filter((service) => service.id !== removeTarget.id),
|
|
}));
|
|
setRemoveServiceId(null);
|
|
}}
|
|
>
|
|
{text.common.remove}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
function serializeSystemSettings(system: UpdateSystemInput): string {
|
|
return JSON.stringify(system);
|
|
}
|