Simplify proxy routing and configuration

This commit is contained in:
2026-07-11 04:42:16 +03:00
parent efa46d1ee5
commit d3b7f0d613
22 changed files with 786 additions and 787 deletions

View File

@@ -1,368 +1,235 @@
import React, { useEffect, useMemo, useState } from 'react';
import { flagFor } from '../utils/country.js';
import { formatRelative } from '../utils/format.js';
import { resolveClientRoute } from '../utils/clientRoute.js';
function CopyValue({ value }) {
const [copied, setCopied] = useState(false);
async function copy() {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
}
return (
<button className="client-copy" type="button" onClick={copy}>
<span>{value}</span>
<strong>{copied ? 'OK' : 'Copy'}</strong>
</button>
);
}
function StatusPanel({ route, state }) {
const statusLabel = {
connected: 'Работает',
stopped: 'Остановлен',
empty: 'Не настроен',
}[route.status];
return (
<section className={`client-status-panel ${route.status}`}>
<div className="client-status-main">
<span className={`client-status-dot ${route.status}`} />
<div>
<div className="client-eyebrow">Текущий маршрут</div>
<h1>{route.title}</h1>
<p>{route.description}</p>
</div>
</div>
<div className="client-status-facts">
<div>
<small>Куда</small>
<strong>{route.target}</strong>
<span>{route.targetDetail}</span>
</div>
<div>
<small>Локальный proxy</small>
<strong>{route.localProxy}</strong>
<span>HTTP и SOCKS5</span>
</div>
<div>
<small>Сервис</small>
<strong>{statusLabel}</strong>
<span>{state?.appliedAt ? `применено ${formatRelative(state.appliedAt)}` : 'нет примененного config'}</span>
</div>
</div>
</section>
);
}
function RouteLine({ route }) {
return (
<div className="client-route-line">
{route.path.map((item, index) => (
<React.Fragment key={`${item}-${index}`}>
<span>{item}</span>
{index < route.path.length - 1 && <b></b>}
</React.Fragment>
))}
</div>
);
}
function ModeButton({ active, selected, title, subtitle, onClick, disabled }) {
return (
<button
type="button"
className={`client-mode-button ${selected ? 'selected' : ''} ${active ? 'active' : ''}`}
disabled={disabled}
onClick={onClick}
>
<strong>{title}</strong>
<span>{subtitle}</span>
</button>
);
}
function GatewaySettings({ settings, busy, onCheck }) {
const [draftUrl, setDraftUrl] = useState(settings?.sharedProxyControlUrl || '');
const sharedProxy = settings?.sharedProxy;
useEffect(() => {
setDraftUrl(settings?.sharedProxyControlUrl || '');
}, [settings?.sharedProxyControlUrl]);
return (
<div className="client-mode-settings">
<div className="field">
<label className="field-label">Адрес gateway UI</label>
<div className="client-inline-form">
<input
className="input"
placeholder="http://192.168.50.111:3456"
value={draftUrl}
onChange={(e) => setDraftUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && draftUrl && onCheck(draftUrl)}
/>
<button className="btn btn-primary" disabled={busy || !draftUrl} onClick={() => onCheck(draftUrl)}>
Подключить
</button>
</div>
</div>
{sharedProxy && (
<div className="client-current-target">
<small>Найден общий proxy</small>
<strong>{sharedProxy.host}:{sharedProxy.port}</strong>
</div>
)}
</div>
);
}
function VpnSettings({
state,
servers,
subscriptionUrl,
setSubscriptionUrl,
pendingTag,
setPendingTag,
busy,
onFetchSubscription,
onApply,
}) {
const selected = pendingTag || state?.selectedTag || '';
const activeServer = servers.find((server) => server.tag === selected);
return (
<div className="client-mode-settings">
<div className="field">
<label className="field-label">Подписка или VLESS</label>
<div className="client-inline-form">
<input
className="input"
placeholder="https://… или vless://…"
value={subscriptionUrl}
onChange={(e) => setSubscriptionUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && subscriptionUrl && onFetchSubscription()}
/>
<button className="btn btn-secondary" disabled={busy || !subscriptionUrl} onClick={onFetchSubscription}>
Загрузить
</button>
</div>
</div>
<div className="field">
<label className="field-label">VPN-сервер</label>
<div className="client-inline-form">
<select
className="select"
value={selected}
disabled={!servers.length}
onChange={(e) => setPendingTag(e.target.value)}
>
<option value="">Выберите сервер</option>
{servers.map((server) => (
<option key={server.tag} value={server.tag}>
{flagFor(server)} {server.tag}
</option>
))}
</select>
<button className="btn btn-primary" disabled={busy || !selected} onClick={() => onApply(selected)}>
Подключить
</button>
</div>
{activeServer && <small className="field-hint">Выбран {flagFor(activeServer)} {activeServer.tag}</small>}
</div>
</div>
);
}
function DirectSettings({ busy, onEnable }) {
return (
<div className="client-mode-settings direct">
<div>
<strong>Прямой режим</strong>
<p className="muted">Приложения продолжают использовать локальный proxy, но трафик идет без VPN и без gateway.</p>
</div>
<button className="btn btn-primary" disabled={busy} onClick={onEnable}>
Включить напрямую
</button>
</div>
);
}
function ProxySettings({ state, settings, busy, onSave }) {
const range = state?.clientProxyPortRange || { start: 8080, end: 8090 };
const port = settings?.proxyPort || state?.proxyPort || 8080;
const [draftPort, setDraftPort] = useState(String(port));
useEffect(() => {
setDraftPort(String(port));
}, [port]);
const parsed = Number.parseInt(draftPort, 10);
const invalid = !Number.isInteger(parsed) || parsed < range.start || parsed > range.end;
const dirty = !invalid && parsed !== port;
const singlePublishedPort = range.start === range.end;
return (
<aside className="client-side-panel">
<div>
<div className="client-panel-title">Адрес для приложений</div>
<div className="client-copy-stack">
<CopyValue value={`http://127.0.0.1:${port}`} />
<CopyValue value={`socks5://127.0.0.1:${port}`} />
</div>
</div>
<div className="field">
<label className="field-label">Порт proxy</label>
<div className="client-port-row">
<input
className="input"
type="number"
min={range.start}
max={range.end}
value={draftPort}
disabled={singlePublishedPort}
onChange={(e) => setDraftPort(e.target.value)}
/>
<button
className="btn btn-secondary"
disabled={busy || singlePublishedPort || !dirty}
onClick={() => onSave({ ...settings, proxyPort: parsed })}
>
Save
</button>
</div>
<small className={invalid ? 'field-error' : 'field-hint'}>
{singlePublishedPort ? 'Порт задаётся установщиком' : `${range.start}${range.end}`}
</small>
</div>
</aside>
);
}
import React, { useEffect, useRef, useState } from 'react';
import { api } from '../api.js';
import {
connectionAction,
formatConnectionDuration,
localProxyUrls,
subscriptionDomain,
} from '../utils/clientControls.js';
export function ClientOverviewPage({
state,
activeServer,
busy,
subscriptionUrl,
setSubscriptionUrl,
servers,
pendingTag,
setPendingTag,
clientSettings,
onSaveClientSettings,
onCheckSharedProxy,
onFetchSubscription,
onApply,
onRestart,
onStop,
}) {
const route = useMemo(
() => resolveClientRoute({ state, activeServer }),
[state, activeServer],
);
const [setupMode, setSetupMode] = useState(route.mode === 'none' ? 'gateway' : route.mode);
const connected = Boolean(state?.singboxRunning);
const selectedTag = pendingTag || state?.selectedTag || '';
const canStart = Boolean(selectedTag || state?.configExists);
const [now, setNow] = useState(Date.now());
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
const [pings, setPings] = useState({});
const [copiedProxy, setCopiedProxy] = useState('');
const subscriptionInputRef = useRef(null);
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
const proxyUrls = localProxyUrls(state?.proxyPort);
useEffect(() => {
if (route.mode !== 'none') setSetupMode(route.mode);
}, [route.mode]);
setNow(Date.now());
if (!connected || !state?.singboxStartedAt) return undefined;
function enableDirect() {
return onSaveClientSettings({
...clientSettings,
homeBypassEnabled: true,
sharedProxyEnabled: false,
});
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [connected, state?.singboxStartedAt]);
useEffect(() => {
if (!servers.length) {
setPings({});
return undefined;
}
let cancelled = false;
setPings(Object.fromEntries(servers.map((server) => [server.tag, { checking: true }])));
api.servers.pingAll()
.then((data) => {
if (cancelled) return;
setPings(Object.fromEntries((data.results || []).map((ping) => [
String(ping.tag || '').trim(),
ping,
])));
})
.catch(() => {
if (!cancelled) {
setPings(Object.fromEntries(servers.map((server) => [server.tag, { ok: false }])));
}
});
return () => { cancelled = true; };
}, [serverKey]);
useEffect(() => {
if (editingSubscription) subscriptionInputRef.current?.focus();
}, [editingSubscription]);
useEffect(() => {
if (!editingSubscription || !state?.hasSubscription || subscriptionUrl) return undefined;
const timer = setTimeout(() => setEditingSubscription(false), 5000);
return () => clearTimeout(timer);
}, [editingSubscription, state?.hasSubscription, subscriptionUrl]);
async function toggleConnection() {
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
if (action?.type === 'stop') return onStop();
if (action?.type === 'apply') return onApply(action.selectedTag);
if (action?.type === 'restart') return onRestart();
}
function selectGateway() {
setSetupMode('gateway');
if (clientSettings?.sharedProxyControlUrl) {
return onCheckSharedProxy(clientSettings.sharedProxyControlUrl);
}
return null;
function selectServer(tag) {
setPendingTag(tag);
if (connected && tag) onApply(tag);
}
function selectVpn() {
setSetupMode('vpn');
if (state?.selectedTag) {
return onApply(state.selectedTag);
async function submitSubscription(event) {
event.preventDefault();
if (!subscriptionUrl.trim()) return;
await onFetchSubscription();
setSubscriptionUrl('');
setEditingSubscription(false);
}
async function copyProxy(kind) {
try {
await navigator.clipboard.writeText(proxyUrls[kind]);
setCopiedProxy(kind);
setTimeout(() => setCopiedProxy(''), 1400);
} catch {
setCopiedProxy('error');
setTimeout(() => setCopiedProxy(''), 1400);
}
return onSaveClientSettings({
...clientSettings,
homeBypassEnabled: false,
sharedProxyEnabled: false,
});
}
return (
<div className="client-dashboard">
<StatusPanel route={route} state={state} />
<RouteLine route={route} />
<div className="client-shell">
<main className="client-panel">
<section className="client-power-section" aria-labelledby="connection-title">
<button
className="client-power"
type="button"
role="switch"
aria-checked={connected}
aria-label={connected ? 'Выключить VPN' : 'Включить VPN'}
disabled={busy || (!connected && !canStart)}
onClick={toggleConnection}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
</svg>
</button>
<div>
<h2 id="connection-title">{connected ? 'VPN включён' : 'VPN выключен'}</h2>
{connected && (
<time className="client-duration">
{formatConnectionDuration(state?.singboxStartedAt, now)}
</time>
)}
{!connected && (
<p>{canStart ? 'Нажмите, чтобы подключиться' : 'Добавьте ссылку и выберите сервер'}</p>
)}
</div>
</section>
<section className="client-workspace">
<div className="client-main-panel">
<div className="client-mode-grid">
<ModeButton
active={route.mode === 'gateway'}
selected={setupMode === 'gateway'}
title="Общий gateway"
subtitle={clientSettings?.sharedProxy ? `${clientSettings.sharedProxy.host}:${clientSettings.sharedProxy.port}` : 'серверная proxy'}
disabled={busy}
onClick={selectGateway}
/>
<ModeButton
active={route.mode === 'vpn'}
selected={setupMode === 'vpn'}
title="Локальный VPN"
subtitle={state?.selectedTag || 'выбрать сервер'}
disabled={busy}
onClick={selectVpn}
/>
<ModeButton
active={route.mode === 'direct'}
selected={setupMode === 'direct'}
title="Напрямую"
subtitle="без VPN"
disabled={busy}
onClick={() => {
setSetupMode('direct');
enableDirect();
}}
/>
<div className="client-form">
<div className={`client-subscription ${editingSubscription ? 'is-editing' : ''}`}>
<button
className="client-subscription-summary"
type="button"
aria-hidden={editingSubscription}
inert={editingSubscription ? true : undefined}
tabIndex={editingSubscription ? -1 : 0}
onClick={() => setEditingSubscription(true)}
>
<span>Ваша подписка</span>
<strong>{subscriptionDomain(state?.subscriptionHost)}</strong>
</button>
<form
className="client-subscription-edit"
aria-hidden={!editingSubscription}
inert={!editingSubscription ? true : undefined}
onSubmit={submitSubscription}
>
<input
ref={subscriptionInputRef}
id="subscription-url"
type="url"
inputMode="url"
autoComplete="url"
tabIndex={editingSubscription ? 0 : -1}
aria-label="Ссылка подписки"
placeholder="Вставьте ссылку подписки"
value={subscriptionUrl}
onChange={(event) => setSubscriptionUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape' && state?.hasSubscription) {
setSubscriptionUrl('');
setEditingSubscription(false);
}
}}
/>
{subscriptionUrl.trim() && (
<button type="submit" aria-label="Сохранить подписку" disabled={busy}></button>
)}
</form>
</div>
{setupMode === 'gateway' && (
<GatewaySettings
settings={clientSettings}
busy={busy}
onCheck={onCheckSharedProxy}
/>
)}
{setupMode === 'vpn' && (
<VpnSettings
state={state}
servers={servers}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
pendingTag={pendingTag}
setPendingTag={setPendingTag}
busy={busy}
onFetchSubscription={onFetchSubscription}
onApply={onApply}
/>
)}
{setupMode === 'direct' && (
<DirectSettings busy={busy} onEnable={enableDirect} />
)}
</div>
<section className="client-servers" aria-label="Серверы">
<div className="client-server-grid">
{servers.map((server) => {
const ping = pings[server.tag];
const selected = server.tag === selectedTag;
const pingText = ping?.checking
? 'Проверка…'
: ping?.ok ? `${ping.latency} ms` : 'Недоступен';
const pingClass = ping?.ok
? ping.latency < 100 ? 'good' : ping.latency < 250 ? 'medium' : 'slow'
: '';
<ProxySettings
state={state}
settings={clientSettings}
busy={busy}
onSave={onSaveClientSettings}
/>
return (
<button
className={`client-server ${selected ? 'is-selected' : ''}`}
type="button"
key={server.tag}
disabled={busy}
aria-pressed={selected}
onClick={() => selectServer(server.tag)}
>
<strong>{server.tag}</strong>
<small className={pingClass}>{pingText}</small>
</button>
);
})}
</div>
</section>
</div>
</main>
<section className="client-proxies" aria-label="Локальный прокси">
{[
['socks5', 'SOCKS5'],
['http', 'HTTP'],
].map(([kind, label]) => (
<button
className={copiedProxy === kind ? 'is-copied' : ''}
type="button"
key={kind}
aria-label={`Скопировать ${label}: ${proxyUrls[kind]}`}
onClick={() => copyProxy(kind)}
>
<span>
{copiedProxy === kind
? 'Скопировано ✓'
: copiedProxy === 'error' ? 'Ошибка копирования' : label}
</span>
<strong>{proxyUrls[kind]}</strong>
</button>
))}
</section>
</div>
);