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

@@ -33,10 +33,6 @@ function App() {
proxyDefaultMode: 'vpn',
devices: [],
});
const [clientSettings, setClientSettings] = useState({
homeBypassEnabled: false,
sharedProxyEnabled: false,
});
const [selectedTag, setSelectedTag] = useState('');
const [pendingTag, setPendingTag] = useState('');
const [busy, setBusy] = useState(false);
@@ -82,7 +78,6 @@ function App() {
proxyDefaultMode: 'vpn',
devices: data.devices || [],
});
setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
setSelectedTag((prev) => prev || data.selectedTag || '');
setPendingTag((prev) => prev || data.selectedTag || '');
}
@@ -109,7 +104,9 @@ function App() {
setError('');
try {
const result = await fn();
if (!quiet && label) pushToast({ kind: 'success', title: label });
if (!quiet && label && state?.mode !== 'client') {
pushToast({ kind: 'success', title: label });
}
return result;
} catch (err) {
setError(err.message);
@@ -125,9 +122,12 @@ function App() {
return withBusy('Подписка обновлена', async () => {
const data = await api.subscription.fetch(subscriptionUrl || state?.subscriptionHost || '');
setServers(data.servers || []);
if (!selectedTag && data.servers?.length) {
setSelectedTag(data.servers[0].tag);
setPendingTag(data.servers[0].tag);
if (data.servers?.length) {
const nextTag = data.servers.some((server) => server.tag === selectedTag)
? selectedTag
: data.servers[0].tag;
setSelectedTag(nextTag);
setPendingTag(nextTag);
}
await loadState();
});
@@ -158,7 +158,7 @@ function App() {
});
setApplyStatus('idle');
if (previous && previous !== target) {
if (state?.mode !== 'client' && previous && previous !== target) {
setRollbackOffer({ from: target, to: previous, expiresAt: Date.now() + ROLLBACK_WINDOW_MS });
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
rollbackTimerRef.current = setTimeout(() => setRollbackOffer(null), ROLLBACK_WINDOW_MS);
@@ -180,8 +180,8 @@ function App() {
}
// === sing-box control ===
async function stopSingbox() {
if (!confirm('Остановить sing-box? Трафик через шлюз перестанет ходить.')) return;
async function stopSingbox(confirmFirst = true) {
if (confirmFirst && !confirm('Остановить sing-box? Трафик через шлюз перестанет ходить.')) return;
return withBusy('Остановлено', async () => { await api.singbox.stop(); await loadState(); });
}
async function restartSingbox() {
@@ -278,22 +278,6 @@ function App() {
saveDevicesConfig(nextConfig);
}
async function saveClientSettings(nextSettings) {
return withBusy(null, async () => {
const data = await api.clientSettings.save(nextSettings);
setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
await loadState();
}, { quiet: true });
}
async function checkSharedProxy(url) {
return withBusy('Общий proxy подключён', async () => {
const data = await api.clientSettings.checkSharedProxy(url);
setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
await loadState();
});
}
// === Rules CRUD ===
function emptyRule() {
return {
@@ -410,16 +394,20 @@ function App() {
};
// === Render ===
if (!state) return <div className="app-loading">VPN</div>;
return (
<div className="app">
<Topbar
state={state}
status={status}
activeServer={activeServer}
dirty={dirty}
onRestart={restartSingbox}
onTryApply={rollback}
/>
<div className={`app${isClientMode ? ' client-app' : ''}`}>
{!isClientMode && (
<Topbar
state={state}
status={status}
activeServer={activeServer}
dirty={dirty}
onRestart={restartSingbox}
onTryApply={rollback}
/>
)}
<div className={`app-body${isClientMode ? ' client-mode' : ''}`}>
{!isClientMode && <Sidebar active={page} onChange={navigate} badges={sidebarBadges} mode={state?.mode} />}
@@ -437,13 +425,10 @@ function App() {
servers={servers}
pendingTag={pendingTag}
setPendingTag={setPendingTag}
clientSettings={clientSettings}
onSaveClientSettings={saveClientSettings}
onCheckSharedProxy={checkSharedProxy}
onFetchSubscription={fetchSubscription}
onApply={applyServer}
onRestart={restartSingbox}
onStop={stopSingbox}
onStop={() => stopSingbox(false)}
/>
) : (
<OverviewPage

View File

@@ -44,20 +44,6 @@ export const api = {
}),
},
clientSettings: {
get: () => request("/api/client-settings"),
save: (clientSettings) =>
request("/api/client-settings", {
method: "PUT",
body: JSON.stringify({ clientSettings }),
}),
checkSharedProxy: (url) =>
request("/api/client-settings/shared-proxy/check", {
method: "POST",
body: JSON.stringify({ url }),
}),
},
ruleSets: {
get: () => request("/api/rule-sets"),
save: (ruleSets) =>

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>
);

View File

@@ -63,6 +63,16 @@ html, body, #root {
height: 100%;
}
.app-loading {
min-height: 100%;
display: grid;
place-items: center;
font: 700 16px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
color: light-dark(oklch(0.42 0.01 145), oklch(0.76 0.01 145));
background: light-dark(oklch(0.965 0.006 145), oklch(0.18 0.012 145));
color-scheme: light dark;
}
body {
margin: 0;
font-family: var(--font-ui);
@@ -830,269 +840,418 @@ code, .mono {
/* ============ Client overview ============ */
.app.client-app {
--client-bg: oklch(0.965 0.006 145);
--client-panel: oklch(0.995 0.003 145);
--client-control: oklch(0.955 0.006 145);
--client-border: oklch(0.86 0.012 145);
--client-text: oklch(0.24 0.014 145);
--client-muted: oklch(0.53 0.014 145);
--client-accent: oklch(0.62 0.14 151);
--client-accent-soft: oklch(0.92 0.045 151);
grid-template-rows: 1fr;
color-scheme: light;
background: var(--client-bg);
}
.app-body.client-mode {
background: var(--client-bg);
}
.client-mode .app-main {
max-width: 1180px;
width: 100%;
margin: 0 auto;
padding-top: 18px;
display: grid;
place-items: center;
padding: 48px 24px 32px;
overflow-y: auto;
}
.client-dashboard {
.client-shell {
width: min(100%, 440px);
display: grid;
gap: 12px;
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
color: var(--client-text);
}
.client-status-panel {
.client-panel {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(420px, 0.8fr);
gap: 16px;
padding: 18px;
background: #101820;
border: 1px solid #263442;
border-radius: 8px;
gap: 30px;
padding: 24px 0;
}
.client-status-panel.connected { border-color: rgba(109, 255, 157, 0.46); }
.client-status-panel.stopped { border-color: rgba(255, 209, 102, 0.42); }
.client-status-panel.empty { border-color: rgba(142, 212, 255, 0.32); }
.client-status-main {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 14px;
.client-power-section p {
color: var(--client-muted);
font-size: 11px;
}
.client-status-dot {
width: 12px;
height: 12px;
margin-top: 9px;
.client-power-section {
display: grid;
justify-items: center;
gap: 18px;
text-align: center;
}
.client-power {
width: 96px;
height: 96px;
display: grid;
place-items: center;
border: 1px solid var(--client-border);
border-radius: 50%;
background: var(--subtle);
box-shadow: 0 0 0 6px rgba(111, 140, 124, 0.12);
flex: 0 0 12px;
}
.client-status-dot.connected {
background: var(--success);
box-shadow: 0 0 0 6px rgba(109, 255, 157, 0.12);
}
.client-status-dot.stopped {
background: var(--warning);
box-shadow: 0 0 0 6px rgba(255, 209, 102, 0.12);
}
.client-status-dot.empty {
background: var(--info);
box-shadow: 0 0 0 6px rgba(142, 212, 255, 0.12);
}
.client-eyebrow {
color: var(--subtle);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.client-status-main h1 {
margin: 2px 0 4px;
font-size: 30px;
line-height: 1.08;
letter-spacing: 0;
}
.client-status-main p {
margin: 0;
color: var(--muted);
background: var(--client-control);
color: var(--client-muted);
cursor: pointer;
transition: color 180ms ease, background 180ms ease, border-color 180ms ease, transform 180ms ease;
}
.client-status-facts {
.client-power:hover:not(:disabled) {
transform: translateY(-2px);
color: var(--client-text);
border-color: var(--client-muted);
}
.client-power[aria-checked='true'] {
color: var(--client-accent);
background: var(--client-accent-soft);
border-color: var(--client-accent);
}
.client-power:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.client-power svg {
width: 40px;
height: 40px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
}
.client-power-section h2 {
font: 700 18px/1.3 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
letter-spacing: -0.04em;
}
.client-duration {
display: block;
margin-top: 8px;
color: var(--client-text);
font-size: 14px;
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
}
.client-power-section p {
min-height: 17px;
margin-top: 5px;
}
.client-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.client-status-facts > div {
min-width: 0;
padding: 12px;
background: #0b1219;
border: 1px solid #253341;
border-radius: 8px;
}
.client-status-facts small,
.client-current-target small,
.client-panel-title {
display: block;
color: var(--subtle);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.client-status-facts strong,
.client-current-target strong {
display: block;
margin: 3px 0;
overflow-wrap: anywhere;
}
.client-status-facts span {
color: var(--muted);
font-size: 12px;
gap: 36px;
}
.client-route-line {
display: flex;
.client-subscription {
min-height: 88px;
display: grid;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: #0b1219;
border: 1px solid #253341;
border-radius: 8px;
color: var(--muted);
overflow-x: auto;
white-space: nowrap;
}
.client-route-line span {
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
}
.client-route-line b {
color: var(--subtle);
font-weight: 600;
justify-items: center;
}
.client-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 12px;
align-items: start;
.client-subscription-edit,
.client-subscription-summary {
grid-area: 1 / 1;
width: 100%;
border: 0;
background: transparent;
transition: opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), filter 650ms cubic-bezier(0.16, 1, 0.3, 1), transform 650ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-main-panel,
.client-side-panel {
background: #101820;
border: 1px solid #263442;
border-radius: 8px;
padding: 14px;
.client-subscription-edit {
width: min(100%, 380px);
min-height: 44px;
display: block;
position: relative;
opacity: 0;
filter: blur(16px);
transform: translateY(6px) scale(0.96);
pointer-events: none;
}
.client-main-panel {
.client-subscription-edit::before,
.client-subscription-edit::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 1px;
pointer-events: none;
}
.client-subscription-edit::before {
background: var(--client-border);
}
.client-subscription-edit::after {
background: var(--client-accent);
opacity: 0.72;
filter: blur(0.5px);
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
}
.client-subscription.is-editing .client-subscription-edit {
opacity: 1;
filter: blur(0);
transform: translateY(0);
pointer-events: auto;
}
.client-subscription-edit input {
width: 100%;
min-width: 0;
height: 44px;
padding: 0 42px;
border: 0;
outline: 0;
background: transparent;
color: var(--client-text);
caret-color: var(--client-accent);
font-size: 12px;
text-align: center;
}
.client-subscription-edit input::placeholder {
color: var(--client-muted);
}
.client-subscription-edit button {
position: absolute;
top: 4px;
right: 0;
width: 36px;
height: 36px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-accent);
font-size: 19px;
font-weight: 700;
cursor: pointer;
}
.client-subscription-summary {
min-height: 88px;
display: flex;
flex-direction: column;
gap: 14px;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 2px;
color: var(--client-muted);
text-align: center;
cursor: pointer;
}
.client-mode-grid {
.client-subscription.is-editing .client-subscription-summary {
opacity: 0;
filter: blur(18px);
transform: translateY(-4px) scale(1.06);
pointer-events: none;
}
.client-subscription-summary span {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.client-subscription-summary strong {
color: var(--client-text);
font-size: 28px;
font-weight: 600;
letter-spacing: -0.055em;
opacity: 0.88;
text-shadow: 0 0 20px oklch(0.62 0.05 151 / 0.24);
transition: opacity 300ms cubic-bezier(0.16, 1, 0.3, 1), text-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-summary:hover strong {
opacity: 1;
text-shadow: 0 0 28px oklch(0.7 0.08 151 / 0.42);
}
.client-servers {
display: block;
}
.client-server-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.client-mode-button {
.client-server {
min-width: 0;
text-align: left;
padding: 12px;
border: 1px solid #2a3948;
border-radius: 8px;
background: #0b1219;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease;
}
.client-mode-button:hover:not(:disabled) {
border-color: #4c6d88;
background: #101c27;
}
.client-mode-button.selected {
border-color: var(--info);
background: rgba(142, 212, 255, 0.08);
}
.client-mode-button.active {
border-color: var(--success);
background: rgba(109, 255, 157, 0.11);
}
.client-mode-button strong,
.client-mode-button span {
min-height: 56px;
display: flex;
min-width: 0;
flex-direction: column;
justify-content: center;
gap: 3px;
padding: 7px 4px 8px;
border: 0;
border-bottom: 1px solid var(--client-border);
border-radius: 0;
background: transparent;
color: var(--client-text);
text-align: center;
cursor: pointer;
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
}
.client-server:hover:not(:disabled) {
transform: none;
background: transparent;
opacity: 0.82;
}
.client-server.is-selected {
border-bottom: 2px solid var(--client-accent);
background: transparent;
}
.client-server strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-mode-button strong {
font-size: 14px;
}
.client-mode-button span {
margin-top: 3px;
color: var(--muted);
font-size: 12px;
}
.client-mode-settings {
display: grid;
gap: 12px;
}
.client-mode-settings.direct {
grid-template-columns: minmax(0, 1fr) auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.client-mode-settings.direct p {
margin: 4px 0 0;
}
.client-inline-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.client-current-target {
padding: 10px 12px;
background: #0b1219;
border: 1px solid #253341;
border-radius: 8px;
}
.client-side-panel {
display: grid;
gap: 16px;
}
.client-copy-stack {
display: grid;
gap: 8px;
margin-top: 8px;
}
.client-copy {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 10px;
background: #0b1219;
border: 1px solid #253341;
border-radius: 8px;
cursor: pointer;
}
.client-copy span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: 12px;
}
.client-copy strong {
color: var(--accent);
overflow-wrap: anywhere;
font-size: 11px;
}
.client-port-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 980px) {
.client-status-panel,
.client-workspace {
.client-server small {
color: var(--client-muted);
font-size: 11px;
text-align: center;
}
.client-server small.good { color: var(--client-accent); }
.client-server small.medium { color: oklch(0.72 0.12 80); }
.client-server small.slow { color: oklch(0.65 0.16 30); }
.client-server:disabled {
cursor: wait;
}
.client-subscription-edit button:focus-visible,
.client-subscription-summary:focus-visible,
.client-server:focus-visible,
.client-power:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 3px;
}
.client-proxies {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
margin-top: 12px;
}
.client-proxies button {
min-width: 0;
display: grid;
gap: 4px;
padding: 6px 2px;
border: 0;
background: transparent;
color: var(--client-text);
text-align: left;
cursor: pointer;
opacity: 0.76;
transition: opacity 180ms ease, transform 180ms ease;
}
.client-proxies button:hover {
transform: translateY(-1px);
opacity: 1;
}
.client-proxies button.is-copied {
opacity: 1;
}
.client-proxies span {
color: var(--client-muted);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
}
.client-proxies strong {
overflow: hidden;
color: var(--client-text);
font-size: 12px;
font-weight: 500;
text-overflow: ellipsis;
text-shadow: 0 0 12px oklch(0.62 0.05 151 / 0.12);
white-space: nowrap;
}
.client-proxies button.is-copied span {
color: var(--client-accent);
}
.client-proxies button:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 3px;
}
@media (prefers-color-scheme: dark) {
.app.client-app {
--client-bg: oklch(0.18 0.012 145);
--client-panel: oklch(0.22 0.012 145);
--client-control: oklch(0.26 0.012 145);
--client-border: oklch(0.34 0.015 145);
--client-text: oklch(0.93 0.008 145);
--client-muted: oklch(0.68 0.012 145);
--client-accent: oklch(0.76 0.13 151);
--client-accent-soft: oklch(0.32 0.055 151);
color-scheme: dark;
}
}
@media (max-width: 560px) {
.client-mode .app-main {
padding: 20px 14px;
}
.client-panel {
gap: 26px;
padding: 20px 6px;
}
.client-server-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.client-proxies {
grid-template-columns: 1fr;
}
.client-status-facts,
.client-mode-grid {
grid-template-columns: 1fr;
}
.client-mode-settings.direct,
.client-inline-form {
grid-template-columns: 1fr;
}
@media (prefers-reduced-motion: reduce) {
.client-power,
.client-server,
.client-proxies button,
.client-subscription-edit,
.client-subscription-summary,
.client-subscription-summary strong {
transition: none;
}
}

View File

@@ -0,0 +1,31 @@
export function connectionAction({ connected, selectedTag, configExists }) {
if (connected) return { type: 'stop' };
if (selectedTag) return { type: 'apply', selectedTag };
if (configExists) return { type: 'restart' };
return null;
}
export function formatConnectionDuration(startedAt, now = Date.now()) {
const started = Date.parse(startedAt);
const totalSeconds = Number.isFinite(started)
? Math.max(0, Math.floor((now - started) / 1000))
: 0;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return [hours, minutes, seconds]
.map((part) => String(part).padStart(2, '0'))
.join(':');
}
export function subscriptionDomain(subscriptionHost) {
return String(subscriptionHost || '').split('/')[0];
}
export function localProxyUrls(port = 8082) {
return {
socks5: `socks5://127.0.0.1:${port}`,
http: `http://127.0.0.1:${port}`,
};
}

View File

@@ -1,57 +0,0 @@
export function resolveClientRoute({ state, activeServer } = {}) {
const settings = state?.clientSettings || {};
const localProxy = `127.0.0.1:${state?.proxyPort || settings.proxyPort || 8080}`;
const running = Boolean(state?.singboxRunning);
const hasConfig = Boolean(state?.configExists);
let mode = "none";
let target = "выберите режим";
let targetDetail = "Gateway, локальный VPN или напрямую";
let title = "Не подключено";
let description = "Выберите режим подключения и примените его.";
let pathTarget = "не выбран";
if (settings.sharedProxyEnabled && settings.sharedProxy) {
mode = "gateway";
target = `${settings.sharedProxy.host}:${settings.sharedProxy.port}`;
targetDetail = "общий gateway proxy";
title = running ? "Подключено к gateway" : "Gateway настроен, но остановлен";
description = "Локальный proxy на Mac отправляет трафик на серверный gateway.";
pathTarget = `Gateway ${target}`;
} else if (settings.homeBypassEnabled) {
mode = "direct";
target = "без VPN";
targetDetail = "прямое подключение";
title = running ? "Подключено напрямую" : "Direct настроен, но остановлен";
description = "Приложения используют локальный proxy, но трафик идет напрямую.";
pathTarget = "Direct";
} else if (state?.selectedTag) {
mode = "vpn";
target = activeServer?.tag || state.selectedTag;
targetDetail = "локальный VPN";
title = running ? "Подключено через VPN" : "VPN настроен, но остановлен";
description = "Локальный proxy на Mac отправляет трафик через выбранный VPN-сервер.";
pathTarget = `VPN ${target}`;
}
const status = running
? "connected"
: hasConfig && mode !== "none"
? "stopped"
: "empty";
if (status === "empty") {
title = "Не подключено";
}
return {
mode,
status,
localProxy,
title,
target,
targetDetail,
description,
path: ["Mac apps", localProxy, pathTarget, "Internet"],
};
}