Simplify proxy routing and configuration
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user