From ab44626a0f16f699bfe9c5c82b4abc84d6f879f3 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Wed, 20 May 2026 09:31:14 +0300 Subject: [PATCH] feat: simplify mac client interface --- src/web/components/ClientOverviewPage.jsx | 533 ++++++++++------------ src/web/styles.css | 369 ++++++++++----- src/web/utils/clientRoute.js | 57 +++ test/web/client-route.test.js | 92 ++++ 4 files changed, 630 insertions(+), 421 deletions(-) create mode 100644 src/web/utils/clientRoute.js create mode 100644 test/web/client-route.test.js diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index d1bf8b0..7b0c5ee 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -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 ( -
-
- {label} -
{value}
-
- -
+ ); } -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 ( -
-
- {view.badge} -

{view.title}

-

{view.hint}

+
+
+ +
+
Текущий маршрут
+

{route.title}

+

{route.description}

+
-
+
- Активный сервер - - {sharedProxy - ? `${state?.clientSettings?.sharedProxy?.host}:${state?.clientSettings?.sharedProxy?.port}` - : homeBypass - ? 'Не используется дома' - : activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'} - + Куда + {route.target} + {route.targetDetail}
- Трафик - {traffic} + Локальный proxy + {route.localProxy} + HTTP и SOCKS5
- Применено - {state?.appliedAt ? formatRelative(state.appliedAt) : 'ещё нет'} + Сервис + {statusLabel} + {state?.appliedAt ? `применено ${formatRelative(state.appliedAt)}` : 'нет примененного config'}
); } -function ClientSetup({ +function RouteLine({ route }) { + return ( +
+ {route.path.map((item, index) => ( + + {item} + {index < route.path.length - 1 && } + + ))} +
+ ); +} + +function ModeButton({ active, selected, title, subtitle, onClick, disabled }) { + return ( + + ); +} + +function GatewaySettings({ settings, busy, onCheck }) { + const [draftUrl, setDraftUrl] = useState(settings?.sharedProxyControlUrl || ''); + const sharedProxy = settings?.sharedProxy; + + useEffect(() => { + setDraftUrl(settings?.sharedProxyControlUrl || ''); + }, [settings?.sharedProxyControlUrl]); + + return ( +
+
+ +
+ setDraftUrl(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && draftUrl && onCheck(draftUrl)} + /> + +
+
+ {sharedProxy && ( +
+ Найден общий proxy + {sharedProxy.host}:{sharedProxy.port} +
+ )} +
+ ); +} + +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 ( -
-
-

Подключение

- {state?.hasSubscription && Подписка загружена} -
- +
- -
+ +
setSubscriptionUrl(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && subscriptionUrl && onFetchSubscription()} /> -
-
-
+
-
- - {sharedProxy - ? 'Включён общий gateway: локальный VPN-сервер не используется.' - : homeBypass - ? 'Домашний режим включён: proxy-трафик сейчас идёт напрямую без VPN.' - : 'В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.'} - + {activeServer && Выбран {flagFor(activeServer)} {activeServer.tag}}
); } -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 ( -
-
-

Общий gateway

- - {enabled ? 'Подключён' : 'Не выбран'} - +
+
+ Прямой режим +

Приложения продолжают использовать локальный proxy, но трафик идет без VPN и без gateway.

-

- Укажите адрес серверного UI. Mac-клиент проверит /api/shared-proxy и переключит локальный proxy на общий gateway. -

-
- -
- setDraftUrl(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && draftUrl && onCheck(draftUrl)} - /> - -
-
- {sharedProxy && ( -
- -
- )} - {enabled && ( - - )} +
); } -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 ( -
-
-

Локальный proxy

- 127.0.0.1:{port} + ); } 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 ( -
- - - -
- + + + +
+
+
+ + + { + setSetupMode('direct'); + enableDirect(); + }} + /> +
+ + {setupMode === 'gateway' && ( + + )} + {setupMode === 'vpn' && ( + + )} + {setupMode === 'direct' && ( + + )} +
+ + - -
-
- -
- +
); } diff --git a/src/web/styles.css b/src/web/styles.css index c6de219..0e88c78 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -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; } } diff --git a/src/web/utils/clientRoute.js b/src/web/utils/clientRoute.js new file mode 100644 index 0000000..ca4eb5d --- /dev/null +++ b/src/web/utils/clientRoute.js @@ -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"], + }; +} diff --git a/test/web/client-route.test.js b/test/web/client-route.test.js new file mode 100644 index 0000000..55c7205 --- /dev/null +++ b/test/web/client-route.test.js @@ -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, "выберите режим"); +});