Simplify proxy routing and configuration
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
PORT=3456
|
||||
APP_MODE=gateway
|
||||
CLIENT_UI_PORT=3456
|
||||
CLIENT_PROXY_PORT_START=8080
|
||||
CLIENT_PROXY_PORT_END=8090
|
||||
CLIENT_PROXY_PORT_START=8082
|
||||
CLIENT_PROXY_PORT_END=8082
|
||||
BASE_IMAGE=debian:bookworm-slim
|
||||
SINGBOX_VERSION=1.12.13
|
||||
INSTALL_RUNTIME_DEPS=true
|
||||
|
||||
@@ -40,7 +40,7 @@ RUN chmod +x /entrypoint.client.sh \
|
||||
|
||||
ENV APP_MODE=client \
|
||||
PORT=3456 \
|
||||
PROXY_PORT=8080 \
|
||||
PROXY_PORT=8082 \
|
||||
PROXY_BIND_IP=0.0.0.0 \
|
||||
DATA_DIR=/var/lib/vpn-proxy \
|
||||
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
||||
@@ -49,6 +49,6 @@ ENV APP_MODE=client \
|
||||
ROUTING_RU_DIRECT=true \
|
||||
LOG_LEVEL=info
|
||||
|
||||
EXPOSE 3456 8080
|
||||
EXPOSE 3456 8082
|
||||
|
||||
ENTRYPOINT ["dumb-init", "/entrypoint.client.sh"]
|
||||
|
||||
35
PRODUCT.md
Normal file
35
PRODUCT.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Users
|
||||
|
||||
macOS users without networking or Docker expertise. They open the client only to add a VPN subscription, choose a server, and turn the connection on or off.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
Provide a small, dependable control surface for the Docker-based macOS VPN client. Success means the current connection state is obvious and the full everyday workflow fits on one screen.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
Soft, calm, precise. Familiar to macOS users, with sharper geometry and a quiet monospace character.
|
||||
|
||||
## Anti-references
|
||||
|
||||
Not an admin dashboard, network console, settings maze, or enclosing card. Avoid sidebars, technical route diagrams, framed content areas, decorative effects, and controls for gateway or server administration.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- One screen, one primary action.
|
||||
- Use plain language and hide implementation details.
|
||||
- Make connection state unmistakable without relying on color alone.
|
||||
- Prefer native controls and predictable macOS behavior.
|
||||
- Show saved subscriptions as a domain, not as a credential-like URL.
|
||||
- Make servers directly selectable instead of hiding them in a dropdown.
|
||||
- Keep advanced and server-only features out of the client path.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
Support keyboard navigation, visible focus, sufficient contrast, system light and dark themes, and reduced motion preferences.
|
||||
@@ -13,7 +13,7 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/inst
|
||||
После запуска по умолчанию:
|
||||
|
||||
- UI: `http://127.0.0.1:3456`
|
||||
- HTTP/SOCKS proxy: `127.0.0.1:8080` по умолчанию; установщик интерактивно спросит proxy-порт и опубликует только его
|
||||
- HTTP/SOCKS proxy: `127.0.0.1:8082` по умолчанию; установщик интерактивно спросит proxy-порт и опубликует только его
|
||||
|
||||
Установщик интерактивно спросит proxy-порт. Если стандартный UI-порт `3456` занят другим контейнером, установщик попросит выбрать свободный UI-порт. Для неинтерактивного запуска можно задать порты заранее; тогда вопросы не появятся:
|
||||
|
||||
@@ -393,8 +393,8 @@ UI доступен на `http://<gateway-ip>:3456`.
|
||||
| `CLIENT_UI_PORT` | `3456` | Host-порт UI для `docker-compose.client.yml` |
|
||||
| `VPN_PROXY_CLIENT_UI_PORT` | unset | UI-порт для macOS installer; записывается в `CLIENT_UI_PORT` |
|
||||
| `VPN_PROXY_CLIENT_PORT` | unset | Proxy-порт для macOS installer; записывает `CLIENT_PROXY_PORT` и single-port `CLIENT_PROXY_PORT_START/END` |
|
||||
| `CLIENT_PROXY_PORT` | `8080` | Единственный host/container proxy-порт для `docker-compose.client.yml` |
|
||||
| `CLIENT_PROXY_PORT_START` | `8080` | Совместимость со старым env; в client compose считается тем же одиночным proxy-портом |
|
||||
| `CLIENT_PROXY_PORT` | `8082` | Единственный host/container proxy-порт для `docker-compose.client.yml` |
|
||||
| `CLIENT_PROXY_PORT_START` | `8082` | Совместимость со старым env; в client compose считается тем же одиночным proxy-портом |
|
||||
| `CLIENT_PROXY_PORT_END` | same as start | Совместимость со старым env; по умолчанию не расширяет Docker-публикацию в диапазон |
|
||||
| `SHARED_PROXY_HOST` | unset | Явный host/IP, который gateway отдаёт в `/api/shared-proxy`; если не задан, берётся Host заголовок запроса |
|
||||
| `PORT` | `3456` | Порт веб-интерфейса |
|
||||
|
||||
@@ -9,9 +9,9 @@ services:
|
||||
environment:
|
||||
APP_MODE: client
|
||||
PORT: ${PORT:-3456}
|
||||
PROXY_PORT: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}
|
||||
CLIENT_PROXY_PORT_START: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}
|
||||
CLIENT_PROXY_PORT_END: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}
|
||||
PROXY_PORT: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}
|
||||
CLIENT_PROXY_PORT_START: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}
|
||||
CLIENT_PROXY_PORT_END: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}
|
||||
PROXY_BIND_IP: 0.0.0.0
|
||||
DATA_DIR: /var/lib/vpn-proxy
|
||||
SING_BOX_CONFIG: /etc/sing-box/config.json
|
||||
@@ -29,7 +29,7 @@ services:
|
||||
no_proxy: "localhost,127.0.0.1,host.docker.internal"
|
||||
ports:
|
||||
- "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}"
|
||||
- "127.0.0.1:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}"
|
||||
- "127.0.0.1:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}"
|
||||
volumes:
|
||||
- vpn-proxy-client-data:/var/lib/vpn-proxy
|
||||
- sing-box-client-cache:/var/lib/sing-box
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${PORT:-3456}"
|
||||
PROXY_PORT="${PROXY_PORT:-8080}"
|
||||
PROXY_PORT="${PROXY_PORT:-8082}"
|
||||
DATA_DIR="${DATA_DIR:-/var/lib/vpn-proxy}"
|
||||
SING_BOX_CONFIG="${SING_BOX_CONFIG:-/etc/sing-box/config.json}"
|
||||
SING_BOX_CACHE="${SING_BOX_CACHE:-/var/lib/sing-box/cache.db}"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VPN Proxy Gateway</title>
|
||||
<title>VPN</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -5,7 +5,7 @@ INSTALL_DIR="${VPN_PROXY_INSTALL_DIR:-$HOME/.vpn-proxy-client}"
|
||||
REPO_URL="${VPN_PROXY_REPO_URL:-https://git.dokops.ru/dokril/vpn-proxy.git}"
|
||||
BRANCH="${VPN_PROXY_BRANCH:-master}"
|
||||
COMPOSE_FILE="docker-compose.client.yml"
|
||||
DEFAULT_PROXY_PORT="8080"
|
||||
DEFAULT_PROXY_PORT="8082"
|
||||
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
|
||||
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}"
|
||||
CLIENT_CONTAINER_NAME="vpn-proxy-client"
|
||||
|
||||
@@ -5,7 +5,10 @@ const parsePort = (value, fallback) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) ? parsed : fallback;
|
||||
};
|
||||
const proxyPort = parsePort(process.env.PROXY_PORT, 8080);
|
||||
const proxyPort = parsePort(
|
||||
process.env.PROXY_PORT,
|
||||
process.env.APP_MODE === "client" ? 8082 : 8080,
|
||||
);
|
||||
const clientProxyPortStart = parsePort(
|
||||
process.env.CLIENT_PROXY_PORT_START,
|
||||
proxyPort,
|
||||
|
||||
@@ -607,7 +607,7 @@ function publicState() {
|
||||
const customRules = readJson(settings.customRulesPath, []);
|
||||
const deviceProfiles = readDeviceProfiles();
|
||||
const clientSettings = readClientSettings();
|
||||
const { subscriptionUrl, ...rest } = state;
|
||||
const { subscriptionUrl, servers = [], ...rest } = state;
|
||||
return {
|
||||
mode: settings.appMode,
|
||||
port: settings.port,
|
||||
@@ -640,6 +640,10 @@ function publicState() {
|
||||
directBypassAvailable: IPSET_AVAILABLE,
|
||||
sourceBypassCidrs: sourceBypassCidrs(deviceProfiles),
|
||||
...rest,
|
||||
servers: servers.map((server) => ({
|
||||
...server,
|
||||
tag: String(server.tag || '').trim(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1324,7 +1328,7 @@ async function handleApi(req, res) {
|
||||
servers.map(async (server) => {
|
||||
const ping = await tcpPing(server.server, server.server_port, 3000);
|
||||
return {
|
||||
tag: server.tag,
|
||||
tag: String(server.tag || '').trim(),
|
||||
...ping,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -123,7 +123,7 @@ export function parseSubscriptionBody(body) {
|
||||
const servers = outbounds
|
||||
.filter((outbound) => PROXY_TYPES.has(outbound.type))
|
||||
.map((outbound) => ({
|
||||
tag: outbound.tag || `${outbound.type}-${outbound.server || 'server'}`,
|
||||
tag: String(outbound.tag || `${outbound.type}-${outbound.server || 'server'}`).trim(),
|
||||
type: outbound.type,
|
||||
server: outbound.server || 'unknown',
|
||||
server_port: outbound.server_port || 443,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
31
src/web/utils/clientControls.js
Normal file
31
src/web/utils/clientControls.js
Normal 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}`,
|
||||
};
|
||||
}
|
||||
@@ -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"],
|
||||
};
|
||||
}
|
||||
@@ -43,3 +43,23 @@ test("client proxy range defaults to the single configured proxy port", async ()
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("client proxy defaults to 8082 when no port is configured", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
APP_MODE: "client",
|
||||
PROXY_PORT: undefined,
|
||||
CLIENT_PROXY_PORT_START: undefined,
|
||||
CLIENT_PROXY_PORT_END: undefined,
|
||||
},
|
||||
async () => {
|
||||
const { settings } = await import(
|
||||
`../../src/server/config.js?client-default-port=${Date.now()}`
|
||||
);
|
||||
|
||||
assert.equal(settings.proxyPort, 8082);
|
||||
assert.equal(settings.clientProxyPortStart, 8082);
|
||||
assert.equal(settings.clientProxyPortEnd, 8082);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ test("client mode exposes only the local mixed proxy inbound", () => {
|
||||
["mixed-in"],
|
||||
);
|
||||
assert.equal(config.inbounds[0].type, "mixed");
|
||||
assert.equal(config.inbounds[0].listen_port, 8080);
|
||||
assert.equal(config.inbounds[0].listen_port, 8082);
|
||||
});
|
||||
|
||||
test("client mode routes mixed proxy fallback to the selected VPN", () => {
|
||||
@@ -92,7 +92,7 @@ test("client mode ignores saved proxy port outside the published single port", (
|
||||
|
||||
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
|
||||
|
||||
assert.equal(config.inbounds[0].listen_port, 8080);
|
||||
assert.equal(config.inbounds[0].listen_port, 8082);
|
||||
assert.deepEqual(config.route.rules, [
|
||||
{ inbound: ["mixed-in"], outbound: "test-vpn" },
|
||||
]);
|
||||
|
||||
12
test/server/subscription.test.js
Normal file
12
test/server/subscription.test.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { parseSubscriptionBody } from '../../src/server/subscription.js';
|
||||
|
||||
test('subscription server tags are trimmed for selection', () => {
|
||||
const { servers } = parseSubscriptionBody(JSON.stringify({
|
||||
outbounds: [{ type: 'vless', tag: 'de-frankfurt ', server: 'de.example', server_port: 443 }],
|
||||
}));
|
||||
|
||||
assert.equal(servers[0].tag, 'de-frankfurt');
|
||||
});
|
||||
46
test/web/client-controls.test.js
Normal file
46
test/web/client-controls.test.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
connectionAction,
|
||||
formatConnectionDuration,
|
||||
localProxyUrls,
|
||||
subscriptionDomain,
|
||||
} from '../../src/web/utils/clientControls.js';
|
||||
|
||||
test('connection button chooses the only valid client action', () => {
|
||||
assert.deepEqual(connectionAction({ connected: true }), { type: 'stop' });
|
||||
assert.deepEqual(connectionAction({ selectedTag: 'nl-amsterdam' }), {
|
||||
type: 'apply',
|
||||
selectedTag: 'nl-amsterdam',
|
||||
});
|
||||
assert.deepEqual(connectionAction({ configExists: true }), { type: 'restart' });
|
||||
assert.equal(connectionAction({}), null);
|
||||
});
|
||||
|
||||
test('connection duration is derived from the backend start time', () => {
|
||||
const startedAt = '2026-07-10T10:00:00.000Z';
|
||||
const now = Date.parse('2026-07-11T12:03:04.900Z');
|
||||
|
||||
assert.equal(formatConnectionDuration(startedAt, now), '26:03:04');
|
||||
assert.equal(formatConnectionDuration(null, now), '00:00:00');
|
||||
});
|
||||
|
||||
test('saved subscription is reduced to its public domain', () => {
|
||||
assert.equal(subscriptionDomain('sub.example.com/…'), 'sub.example.com');
|
||||
assert.equal(subscriptionDomain(''), '');
|
||||
});
|
||||
|
||||
test('local proxy exposes both supported URLs', () => {
|
||||
assert.deepEqual(localProxyUrls(18080), {
|
||||
socks5: 'socks5://127.0.0.1:18080',
|
||||
http: 'http://127.0.0.1:18080',
|
||||
});
|
||||
});
|
||||
|
||||
test('local proxy defaults to the macOS client port', () => {
|
||||
assert.deepEqual(localProxyUrls(), {
|
||||
socks5: 'socks5://127.0.0.1:8082',
|
||||
http: 'http://127.0.0.1:8082',
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
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, "выберите режим");
|
||||
});
|
||||
Reference in New Issue
Block a user