feat: simplify mac client interface
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 9s
Build and Deploy Gateway / deploy (push) Successful in 0s

This commit is contained in:
2026-05-20 09:31:14 +03:00
parent 95edefa84f
commit ab44626a0f
4 changed files with 630 additions and 421 deletions

View File

@@ -1,119 +1,126 @@
import React, { useEffect, useMemo, useState } from 'react';
import { flagFor } from '../utils/country.js';
import { formatBytes, formatRelative } from '../utils/format.js';
import { formatRelative } from '../utils/format.js';
import { resolveClientRoute } from '../utils/clientRoute.js';
function CopyField({ label, value }) {
function CopyValue({ value }) {
const [copied, setCopied] = useState(false);
async function copy() {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1400);
setTimeout(() => setCopied(false), 1200);
}
return (
<div className="copy-field">
<div>
<small className="muted">{label}</small>
<div className="text-mono">{value}</div>
</div>
<button className="btn btn-secondary sm" onClick={copy}>
{copied ? 'Скопировано' : 'Копировать'}
</button>
</div>
<button className="client-copy" type="button" onClick={copy}>
<span>{value}</span>
<strong>{copied ? 'OK' : 'Copy'}</strong>
</button>
);
}
function ClientHero({ state, status, activeServer }) {
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
const sharedProxy = Boolean(state?.clientSettings?.sharedProxyEnabled);
const cfg = {
running: {
title: 'Локальный proxy работает',
hint: activeServer ? `Подключен сервер ${activeServer.tag}` : 'Сервер применён',
badge: 'Готов',
kind: 'success',
},
applying: {
title: 'Применяем сервер',
hint: 'sing-box перезапускается',
badge: 'Применяем',
kind: 'warning',
},
error: {
title: 'Нужна проверка',
hint: 'Откройте логи и config',
badge: 'Ошибка',
kind: 'danger',
},
stopped: {
title: 'Proxy остановлен',
hint: 'Конфиг есть, sing-box не запущен',
badge: 'Остановлен',
kind: 'warning',
},
no_config: {
title: 'Proxy ещё не настроен',
hint: 'Загрузите подписку и выберите сервер',
badge: 'Не настроен',
kind: 'neutral',
},
}[status] || {};
const view = sharedProxy
? {
...cfg,
title: 'Общий gateway proxy',
hint: 'Локальный proxy отправляет трафик на серверный gateway',
badge: 'Gateway',
kind: 'success',
}
: homeBypass
? {
...cfg,
title: 'Домашний режим: VPN выключен',
hint: 'Локальный proxy работает напрямую',
badge: 'Напрямую',
kind: 'info',
}
: cfg;
const userInfo = state?.userInfo;
const traffic = userInfo
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))}${userInfo.total ? ' / ' + formatBytes(userInfo.total) : ''}`
: 'нет данных';
function StatusPanel({ route, state }) {
const statusLabel = {
connected: 'Работает',
stopped: 'Остановлен',
empty: 'Не настроен',
}[route.status];
return (
<section className="client-hero">
<div className="client-hero-main">
<span className={`badge ${view.kind}`}>{view.badge}</span>
<h1>{view.title}</h1>
<p>{view.hint}</p>
<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-hero-meta">
<div className="client-status-facts">
<div>
<small className="muted">Активный сервер</small>
<strong>
{sharedProxy
? `${state?.clientSettings?.sharedProxy?.host}:${state?.clientSettings?.sharedProxy?.port}`
: homeBypass
? 'Не используется дома'
: activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'}
</strong>
<small>Куда</small>
<strong>{route.target}</strong>
<span>{route.targetDetail}</span>
</div>
<div>
<small className="muted">Трафик</small>
<strong>{traffic}</strong>
<small>Локальный proxy</small>
<strong>{route.localProxy}</strong>
<span>HTTP и SOCKS5</span>
</div>
<div>
<small className="muted">Применено</small>
<strong>{state?.appliedAt ? formatRelative(state.appliedAt) : 'ещё нет'}</strong>
<small>Сервис</small>
<strong>{statusLabel}</strong>
<span>{state?.appliedAt ? `применено ${formatRelative(state.appliedAt)}` : 'нет примененного config'}</span>
</div>
</div>
</section>
);
}
function ClientSetup({
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,
@@ -125,20 +132,13 @@ function ClientSetup({
onApply,
}) {
const selected = pendingTag || state?.selectedTag || '';
const canApply = selected && selected !== state?.selectedTag;
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
const sharedProxy = Boolean(state?.clientSettings?.sharedProxyEnabled);
const activeServer = servers.find((server) => server.tag === selected);
return (
<div className="card client-setup">
<div className="card-header">
<h2>Подключение</h2>
{state?.hasSubscription && <span className="badge success">Подписка загружена</span>}
</div>
<div className="client-mode-settings">
<div className="field">
<label className="field-label">URL подписки или VLESS-ссылка</label>
<div className="subscription-input">
<label className="field-label">Подписка или VLESS</label>
<div className="client-inline-form">
<input
className="input"
placeholder="https://… или vless://…"
@@ -146,15 +146,14 @@ function ClientSetup({
onChange={(e) => setSubscriptionUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && subscriptionUrl && onFetchSubscription()}
/>
<button className="btn btn-primary" disabled={busy || !subscriptionUrl} onClick={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="subscription-input">
<div className="client-inline-form">
<select
className="select"
value={selected}
@@ -168,77 +167,31 @@ function ClientSetup({
</option>
))}
</select>
<button className="btn btn-secondary" disabled={busy || !canApply} onClick={() => onApply(selected)}>
Применить
<button className="btn btn-primary" disabled={busy || !selected} onClick={() => onApply(selected)}>
Подключить
</button>
</div>
<small className="field-hint">
{sharedProxy
? 'Включён общий gateway: локальный VPN-сервер не используется.'
: homeBypass
? 'Домашний режим включён: proxy-трафик сейчас идёт напрямую без VPN.'
: 'В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.'}
</small>
{activeServer && <small className="field-hint">Выбран {flagFor(activeServer)} {activeServer.tag}</small>}
</div>
</div>
);
}
function SharedProxyCard({ settings, busy, onCheck, onSave }) {
const enabled = Boolean(settings?.sharedProxyEnabled);
const sharedProxy = settings?.sharedProxy;
const [draftUrl, setDraftUrl] = useState(settings?.sharedProxyControlUrl || '');
useEffect(() => {
setDraftUrl(settings?.sharedProxyControlUrl || '');
}, [settings?.sharedProxyControlUrl]);
function DirectSettings({ busy, onEnable }) {
return (
<div className="card">
<div className="card-header">
<h2>Общий gateway</h2>
<span className={`badge ${enabled ? 'success' : 'neutral'}`}>
{enabled ? 'Подключён' : 'Не выбран'}
</span>
<div className="client-mode-settings direct">
<div>
<strong>Прямой режим</strong>
<p className="muted">Приложения продолжают использовать локальный proxy, но трафик идет без VPN и без gateway.</p>
</div>
<p className="muted">
Укажите адрес серверного UI. Mac-клиент проверит <code>/api/shared-proxy</code> и переключит локальный proxy на общий gateway.
</p>
<div className="field">
<label className="field-label">Адрес gateway</label>
<div className="subscription-input">
<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="copy-stack" style={{ marginTop: 12 }}>
<CopyField label="Gateway SOCKS5" value={`socks5://${sharedProxy.host}:${sharedProxy.port}`} />
</div>
)}
{enabled && (
<button
className="btn btn-ghost sm"
style={{ marginTop: 12 }}
disabled={busy}
onClick={() => onSave({ ...settings, sharedProxyEnabled: false })}
>
Вернуться к локальному VPN
</button>
)}
<button className="btn btn-primary" disabled={busy} onClick={onEnable}>
Включить напрямую
</button>
</div>
);
}
function ProxyCard({ state, settings, busy, onSave }) {
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));
@@ -247,26 +200,22 @@ function ProxyCard({ state, settings, busy, onSave }) {
setDraftPort(String(port));
}, [port]);
const parsedDraftPort = Number.parseInt(draftPort, 10);
const portInvalid =
!Number.isInteger(parsedDraftPort) ||
parsedDraftPort < range.start ||
parsedDraftPort > range.end;
const portDirty = !portInvalid && parsedDraftPort !== port;
const urls = useMemo(() => ({
http: `http://127.0.0.1:${port}`,
socks: `socks5://127.0.0.1:${port}`,
}), [port]);
const parsed = Number.parseInt(draftPort, 10);
const invalid = !Number.isInteger(parsed) || parsed < range.start || parsed > range.end;
const dirty = !invalid && parsed !== port;
return (
<div className="card">
<div className="card-header">
<h2>Локальный proxy</h2>
<span className="badge info">127.0.0.1:{port}</span>
<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" style={{ marginBottom: 12 }}>
<div className="field">
<label className="field-label">Порт proxy</label>
<div className="subscription-input">
<div className="client-port-row">
<input
className="input"
type="number"
@@ -277,107 +226,20 @@ function ProxyCard({ state, settings, busy, onSave }) {
/>
<button
className="btn btn-secondary"
disabled={busy || !portDirty}
onClick={() => onSave({ ...settings, proxyPort: parsedDraftPort })}
disabled={busy || !dirty}
onClick={() => onSave({ ...settings, proxyPort: parsed })}
>
Сохранить
Save
</button>
</div>
<small className={portInvalid ? 'field-error' : 'field-hint'}>
Доступный диапазон: {range.start}{range.end}
</small>
<small className={invalid ? 'field-error' : 'field-hint'}>{range.start}{range.end}</small>
</div>
<div className="copy-stack">
<CopyField label="HTTP / HTTPS" value={urls.http} />
<CopyField label="SOCKS5" value={urls.socks} />
</div>
</div>
);
}
function HomeBypassCard({ state, settings, busy, onSave }) {
const enabled = Boolean(settings?.homeBypassEnabled);
const sharedProxy = Boolean(settings?.sharedProxyEnabled);
const port = settings?.proxyPort || state?.proxyPort || 8080;
return (
<div className="card">
<div className="card-header">
<h2>Домашний режим</h2>
<span className={`badge ${enabled ? 'info' : 'neutral'}`}>
{enabled ? 'Напрямую' : 'Через VPN'}
</span>
</div>
<p className="muted">
Включайте дома: приложения продолжают использовать <code>127.0.0.1:{port}</code>, но VPN не используется.
</p>
<label className="switch-row">
<span>
<strong>Я дома</strong>
<small>{enabled ? 'Весь proxy-трафик идёт напрямую' : 'Весь proxy-трафик идёт через VPN'}</small>
</span>
<input
type="checkbox"
checked={enabled}
disabled={busy}
onChange={(e) => onSave({
...settings,
homeBypassEnabled: e.target.checked,
sharedProxyEnabled: e.target.checked ? false : sharedProxy,
})}
/>
</label>
</div>
);
}
function ClientFlow({ state, activeServer }) {
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
const sharedProxy = Boolean(state?.clientSettings?.sharedProxyEnabled);
const shared = state?.clientSettings?.sharedProxy;
const steps = [
{ label: 'Mac', value: 'приложения' },
{ label: 'Локальный proxy', value: `127.0.0.1:${state?.proxyPort || 8080}` },
{
label: sharedProxy ? 'Общий gateway' : homeBypass ? 'Домашняя сеть' : 'VPN-сервер',
value: sharedProxy ? `${shared?.host}:${shared?.port}` : homeBypass ? 'напрямую' : activeServer?.tag || state?.selectedTag || 'не выбран',
},
{ label: 'Интернет', value: state?.singboxRunning ? sharedProxy ? 'через gateway' : homeBypass ? 'без VPN' : 'через VPN' : 'ожидает' },
];
return (
<div className="card">
<div className="card-header"><h2>Цепочка подключения</h2></div>
<div className="client-flow">
{steps.map((step, index) => (
<React.Fragment key={step.label}>
<div className="flow-node">
<small>{step.label}</small>
<strong>{step.value}</strong>
</div>
{index < steps.length - 1 && <span className="flow-arrow"></span>}
</React.Fragment>
))}
</div>
</div>
);
}
function ClientActions({ state, busy, onRestart, onStop }) {
return (
<div className="card">
<div className="card-header"><h2>Управление</h2></div>
<div className="btn-group">
<button className="btn btn-secondary" disabled={busy || !state?.configExists} onClick={onRestart}>Перезапустить</button>
<button className="btn btn-ghost" disabled={busy || !state?.singboxRunning} onClick={onStop}>Остановить</button>
</div>
</div>
</aside>
);
}
export function ClientOverviewPage({
state,
status,
activeServer,
busy,
subscriptionUrl,
@@ -390,47 +252,114 @@ export function ClientOverviewPage({
onCheckSharedProxy,
onFetchSubscription,
onApply,
onRestart,
onStop,
}) {
const route = useMemo(
() => resolveClientRoute({ state, activeServer }),
[state, activeServer],
);
const [setupMode, setSetupMode] = useState(route.mode === 'none' ? 'gateway' : route.mode);
useEffect(() => {
if (route.mode !== 'none') setSetupMode(route.mode);
}, [route.mode]);
function enableDirect() {
return onSaveClientSettings({
...clientSettings,
homeBypassEnabled: true,
sharedProxyEnabled: false,
});
}
function selectGateway() {
setSetupMode('gateway');
if (clientSettings?.sharedProxyControlUrl) {
return onCheckSharedProxy(clientSettings.sharedProxyControlUrl);
}
return null;
}
function selectVpn() {
setSetupMode('vpn');
if (state?.selectedTag) {
return onApply(state.selectedTag);
}
return onSaveClientSettings({
...clientSettings,
homeBypassEnabled: false,
sharedProxyEnabled: false,
});
}
return (
<div className="section-stack">
<ClientHero state={state} status={status} activeServer={activeServer} />
<ClientSetup
state={state}
servers={servers}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
pendingTag={pendingTag}
setPendingTag={setPendingTag}
busy={busy}
onFetchSubscription={onFetchSubscription}
onApply={onApply}
/>
<SharedProxyCard
settings={clientSettings}
busy={busy}
onCheck={onCheckSharedProxy}
onSave={onSaveClientSettings}
/>
<div className="grid-2">
<ProxyCard
<div className="client-dashboard">
<StatusPanel route={route} state={state} />
<RouteLine route={route} />
<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>
{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>
<ProxySettings
state={state}
settings={clientSettings}
busy={busy}
onSave={onSaveClientSettings}
/>
<HomeBypassCard state={state} settings={clientSettings} busy={busy} onSave={onSaveClientSettings} />
</div>
<div className="grid-2">
<ClientActions
state={state}
busy={busy}
onRestart={onRestart}
onStop={onStop}
/>
</div>
<ClientFlow state={state} activeServer={activeServer} />
</section>
</div>
);
}

View File

@@ -118,6 +118,9 @@ code, .mono {
}
.app-body.client-mode {
grid-template-columns: 1fr;
background:
radial-gradient(circle at 10% 0%, rgba(142, 212, 255, 0.08), transparent 28rem),
linear-gradient(180deg, #07110f 0%, #070d11 60%, #06090d 100%);
}
.app-main {
@@ -827,141 +830,269 @@ code, .mono {
/* ============ Client overview ============ */
.client-hero {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(260px, 0.8fr);
gap: var(--space-4);
align-items: stretch;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-card);
padding: var(--space-6);
box-shadow: var(--shadow-card);
}
.client-mode .app-main {
max-width: 1120px;
max-width: 1180px;
width: 100%;
margin: 0 auto;
padding-top: 18px;
}
.client-hero-main {
.client-dashboard {
display: grid;
gap: 12px;
}
.client-status-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;
}
.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;
flex-direction: column;
align-items: flex-start;
gap: var(--space-3);
gap: 14px;
}
.client-hero-main h1 {
font-size: 28px;
.client-status-dot {
width: 12px;
height: 12px;
margin-top: 9px;
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-hero-main p {
color: var(--muted);
}
.client-hero-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
margin-top: var(--space-2);
}
.client-hero-meta {
display: grid;
gap: var(--space-3);
align-content: center;
}
.client-hero-meta > div {
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-input);
background: var(--surface-2);
}
.client-hero-meta strong {
display: block;
margin-top: 4px;
overflow-wrap: anywhere;
}
.copy-stack {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.client-setup {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.switch-row {
margin-top: var(--space-4);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-input);
background: var(--surface-2);
}
.switch-row span {
display: flex;
flex-direction: column;
gap: 2px;
}
.switch-row input {
width: 44px;
height: 24px;
flex: 0 0 44px;
accent-color: var(--accent);
}
.copy-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-input);
}
.copy-field .text-mono {
margin-top: 4px;
overflow-wrap: anywhere;
}
.client-flow {
display: grid;
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr;
gap: var(--space-3);
align-items: stretch;
}
.flow-node {
min-width: 0;
padding: var(--space-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-input);
}
.flow-node strong {
display: block;
margin-top: 4px;
overflow-wrap: anywhere;
}
.flow-arrow {
display: flex;
align-items: center;
.client-status-main p {
margin: 0;
color: var(--muted);
}
@media (max-width: 900px) {
.client-hero {
.client-status-facts {
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;
}
.client-route-line {
display: flex;
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;
}
.client-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 12px;
align-items: start;
}
.client-main-panel,
.client-side-panel {
background: #101820;
border: 1px solid #263442;
border-radius: 8px;
padding: 14px;
}
.client-main-panel {
display: flex;
flex-direction: column;
gap: 14px;
}
.client-mode-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.client-mode-button {
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 {
display: flex;
min-width: 0;
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);
font-size: 11px;
}
.client-port-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
@media (max-width: 980px) {
.client-status-panel,
.client-workspace {
grid-template-columns: 1fr;
}
.client-flow {
.client-status-facts,
.client-mode-grid {
grid-template-columns: 1fr;
}
.flow-arrow {
justify-content: center;
transform: rotate(90deg);
}
.copy-field {
align-items: flex-start;
flex-direction: column;
.client-mode-settings.direct,
.client-inline-form {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,57 @@
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"],
};
}

View File

@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveClientRoute } from "../../src/web/utils/clientRoute.js";
test("shows gateway route as the active Mac connection", () => {
const route = resolveClientRoute({
state: {
singboxRunning: true,
proxyPort: 18080,
clientSettings: {
sharedProxyEnabled: true,
sharedProxy: { host: "192.168.50.111", port: 8080, protocol: "socks5" },
},
},
});
assert.equal(route.mode, "gateway");
assert.equal(route.status, "connected");
assert.equal(route.title, "Подключено к gateway");
assert.equal(route.target, "192.168.50.111:8080");
assert.deepEqual(route.path, [
"Mac apps",
"127.0.0.1:18080",
"Gateway 192.168.50.111:8080",
"Internet",
]);
});
test("shows local VPN route with selected server", () => {
const route = resolveClientRoute({
state: {
singboxRunning: true,
proxyPort: 8082,
selectedTag: "nl-amsterdam",
clientSettings: {},
},
activeServer: { tag: "nl-amsterdam", country: "NL" },
});
assert.equal(route.mode, "vpn");
assert.equal(route.status, "connected");
assert.equal(route.title, "Подключено через VPN");
assert.equal(route.target, "nl-amsterdam");
});
test("shows direct route when home mode is enabled", () => {
const route = resolveClientRoute({
state: {
singboxRunning: true,
proxyPort: 8082,
clientSettings: { homeBypassEnabled: true },
},
});
assert.equal(route.mode, "direct");
assert.equal(route.status, "connected");
assert.equal(route.title, "Подключено напрямую");
assert.equal(route.target, "без VPN");
});
test("shows configured but stopped route clearly", () => {
const route = resolveClientRoute({
state: {
singboxRunning: false,
configExists: true,
proxyPort: 8082,
selectedTag: "nl-amsterdam",
clientSettings: {},
},
});
assert.equal(route.mode, "vpn");
assert.equal(route.status, "stopped");
assert.equal(route.title, "VPN настроен, но остановлен");
});
test("shows missing setup when nothing is configured", () => {
const route = resolveClientRoute({
state: {
singboxRunning: false,
configExists: false,
proxyPort: 8082,
clientSettings: {},
},
});
assert.equal(route.mode, "none");
assert.equal(route.status, "empty");
assert.equal(route.title, "Не подключено");
assert.equal(route.target, "выберите режим");
});