+
Порт proxy
-
+
onSave({ ...settings, proxyPort: parsedDraftPort })}
+ disabled={busy || !dirty}
+ onClick={() => onSave({ ...settings, proxyPort: parsed })}
>
- Сохранить
+ Save
-
- Доступный диапазон: {range.start}–{range.end}
-
+
{range.start}–{range.end}
-
-
-
-
-
- );
-}
-
-function HomeBypassCard({ state, settings, busy, onSave }) {
- const enabled = Boolean(settings?.homeBypassEnabled);
- const sharedProxy = Boolean(settings?.sharedProxyEnabled);
- const port = settings?.proxyPort || state?.proxyPort || 8080;
-
- return (
-
-
-
Домашний режим
-
- {enabled ? 'Напрямую' : 'Через VPN'}
-
-
-
- Включайте дома: приложения продолжают использовать 127.0.0.1:{port}, но VPN не используется.
-
-
-
- Я дома
- {enabled ? 'Весь proxy-трафик идёт напрямую' : 'Весь proxy-трафик идёт через VPN'}
-
- onSave({
- ...settings,
- homeBypassEnabled: e.target.checked,
- sharedProxyEnabled: e.target.checked ? false : sharedProxy,
- })}
- />
-
-
- );
-}
-
-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 (
-
-
Цепочка подключения
-
- {steps.map((step, index) => (
-
-
- {step.label}
- {step.value}
-
- {index < steps.length - 1 && → }
-
- ))}
-
-
- );
-}
-
-function ClientActions({ state, busy, onRestart, onStop }) {
- return (
-
-
Управление
-
- Перезапустить
- Остановить
-
-
+
);
}
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, "выберите режим");
+});