Add Mac client mode and simplify local proxy UI

This commit is contained in:
2026-05-19 13:12:39 +03:00
parent 2ef1e09986
commit d02dbe10de
22 changed files with 924 additions and 70 deletions

View File

@@ -6,6 +6,7 @@ import { Topbar } from './components/Topbar.jsx';
import { Sidebar } from './components/Sidebar.jsx';
import { StatusPane } from './components/StatusPane.jsx';
import { OverviewPage } from './components/OverviewPage.jsx';
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
import { ServersPage } from './components/ServersPage.jsx';
import { RoutingPage } from './components/RoutingPage.jsx';
import { LogsPage } from './components/LogsPage.jsx';
@@ -87,6 +88,12 @@ function App() {
return () => clearInterval(timer);
}, []);
useEffect(() => {
if (state?.mode === 'client' && page !== 'overview') {
navigate('overview');
}
}, [state?.mode, page]);
useEffect(() => () => {
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
@@ -352,6 +359,7 @@ function App() {
() => servers.find((s) => s.tag === state?.selectedTag) || null,
[servers, state?.selectedTag],
);
const isClientMode = state?.mode === 'client';
const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving';
const dirtyDevices = Boolean(
@@ -380,24 +388,42 @@ function App() {
onTryApply={rollback}
/>
<div className="app-body">
<Sidebar active={page} onChange={navigate} badges={sidebarBadges} />
<div className={`app-body${isClientMode ? ' client-mode' : ''}`}>
{!isClientMode && <Sidebar active={page} onChange={navigate} badges={sidebarBadges} mode={state?.mode} />}
<main className="app-main">
{page === 'overview' && (
<OverviewPage
state={state}
status={status}
busy={busy}
onRestart={restartSingbox}
onStop={stopSingbox}
onShowConfig={() => setConfigOpen(true)}
onNav={navigate}
onBypassToggle={toggleBypass}
onFlushDirectCache={flushDirectCache}
/>
{(page === 'overview' || isClientMode) && (
isClientMode ? (
<ClientOverviewPage
state={state}
status={status}
activeServer={activeServer}
busy={busy}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
servers={servers}
pendingTag={pendingTag}
setPendingTag={setPendingTag}
onFetchSubscription={fetchSubscription}
onApply={applyServer}
onRestart={restartSingbox}
onStop={stopSingbox}
/>
) : (
<OverviewPage
state={state}
status={status}
busy={busy}
onRestart={restartSingbox}
onStop={stopSingbox}
onShowConfig={() => setConfigOpen(true)}
onNav={navigate}
onBypassToggle={toggleBypass}
onFlushDirectCache={flushDirectCache}
/>
)
)}
{page === 'servers' && (
{page === 'servers' && !isClientMode && (
<ServersPage
state={state}
servers={servers}
@@ -413,7 +439,7 @@ function App() {
pushToast={pushToast}
/>
)}
{page === 'routing' && (
{page === 'routing' && !isClientMode && (
<RoutingPage
rules={customRules}
saveStatus={rulesSaveStatus}
@@ -431,8 +457,8 @@ function App() {
onRemoveDevice={removeDevice}
/>
)}
{page === 'logs' && <LogsPage devices={devicesConfig.devices} />}
{page === 'settings' && (
{page === 'logs' && !isClientMode && <LogsPage devices={devicesConfig.devices} />}
{page === 'settings' && !isClientMode && (
<SettingsPage
state={state}
subscriptionUrl={subscriptionUrl}
@@ -489,13 +515,15 @@ function App() {
)}
</main>
<StatusPane
state={state}
busy={busy}
onStop={stopSingbox}
onRestart={restartSingbox}
onShowConfig={() => setConfigOpen(true)}
/>
{!isClientMode && (
<StatusPane
state={state}
busy={busy}
onStop={stopSingbox}
onRestart={restartSingbox}
onShowConfig={() => setConfigOpen(true)}
/>
)}
</div>
<ConfigViewer open={configOpen} onClose={() => setConfigOpen(false)} />

View File

@@ -0,0 +1,256 @@
import React, { useMemo, useState } from 'react';
import { flagFor } from '../utils/country.js';
import { formatBytes, formatRelative } from '../utils/format.js';
function CopyField({ label, value }) {
const [copied, setCopied] = useState(false);
async function copy() {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1400);
}
return (
<div className="copy-field">
<div>
<small className="muted">{label}</small>
<div className="text-mono">{value}</div>
</div>
<button className="btn btn-secondary sm" onClick={copy}>
{copied ? 'Скопировано' : 'Копировать'}
</button>
</div>
);
}
function ClientHero({ state, status, activeServer }) {
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 userInfo = state?.userInfo;
const traffic = userInfo
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))}${userInfo.total ? ' / ' + formatBytes(userInfo.total) : ''}`
: 'нет данных';
return (
<section className="client-hero">
<div className="client-hero-main">
<span className={`badge ${cfg.kind}`}>{cfg.badge}</span>
<h1>{cfg.title}</h1>
<p>{cfg.hint}</p>
</div>
<div className="client-hero-meta">
<div>
<small className="muted">Активный сервер</small>
<strong>{activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'}</strong>
</div>
<div>
<small className="muted">Трафик</small>
<strong>{traffic}</strong>
</div>
<div>
<small className="muted">Применено</small>
<strong>{state?.appliedAt ? formatRelative(state.appliedAt) : 'ещё нет'}</strong>
</div>
</div>
</section>
);
}
function ClientSetup({
state,
servers,
subscriptionUrl,
setSubscriptionUrl,
pendingTag,
setPendingTag,
busy,
onFetchSubscription,
onApply,
}) {
const selected = pendingTag || state?.selectedTag || '';
const canApply = selected && selected !== state?.selectedTag;
return (
<div className="card client-setup">
<div className="card-header">
<h2>Подключение</h2>
{state?.hasSubscription && <span className="badge success">Подписка загружена</span>}
</div>
<div className="field">
<label className="field-label">URL подписки или VLESS-ссылка</label>
<div className="subscription-input">
<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-primary" disabled={busy || !subscriptionUrl} onClick={onFetchSubscription}>
Загрузить
</button>
</div>
</div>
<div className="field">
<label className="field-label">VPN-сервер</label>
<div className="subscription-input">
<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-secondary" disabled={busy || !canApply} onClick={() => onApply(selected)}>
Применить
</button>
</div>
<small className="field-hint">
В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.
</small>
</div>
</div>
);
}
function ProxyCard({ state }) {
const port = state?.proxyPort || 8080;
const urls = useMemo(() => ({
http: `http://127.0.0.1:${port}`,
socks: `socks5://127.0.0.1:${port}`,
}), [port]);
return (
<div className="card">
<div className="card-header">
<h2>Локальный proxy</h2>
<span className="badge info">127.0.0.1:{port}</span>
</div>
<div className="copy-stack">
<CopyField label="HTTP / HTTPS" value={urls.http} />
<CopyField label="SOCKS5" value={urls.socks} />
</div>
</div>
);
}
function ClientFlow({ state, activeServer }) {
const steps = [
{ label: 'Mac', value: 'приложения' },
{ label: 'Локальный proxy', value: `127.0.0.1:${state?.proxyPort || 8080}` },
{ label: 'VPN-сервер', value: activeServer?.tag || state?.selectedTag || 'не выбран' },
{ label: 'Интернет', value: state?.singboxRunning ? 'через VPN' : 'ожидает' },
];
return (
<div className="card">
<div className="card-header"><h2>Цепочка подключения</h2></div>
<div className="client-flow">
{steps.map((step, index) => (
<React.Fragment key={step.label}>
<div className="flow-node">
<small>{step.label}</small>
<strong>{step.value}</strong>
</div>
{index < steps.length - 1 && <span className="flow-arrow"></span>}
</React.Fragment>
))}
</div>
</div>
);
}
function ClientActions({ state, busy, onRestart, onStop }) {
return (
<div className="card">
<div className="card-header"><h2>Управление</h2></div>
<div className="btn-group">
<button className="btn btn-secondary" disabled={busy || !state?.configExists} onClick={onRestart}>Перезапустить</button>
<button className="btn btn-ghost" disabled={busy || !state?.singboxRunning} onClick={onStop}>Остановить</button>
</div>
</div>
);
}
export function ClientOverviewPage({
state,
status,
activeServer,
busy,
subscriptionUrl,
setSubscriptionUrl,
servers,
pendingTag,
setPendingTag,
onFetchSubscription,
onApply,
onRestart,
onStop,
}) {
return (
<div className="section-stack">
<ClientHero state={state} status={status} activeServer={activeServer} />
<ClientSetup
state={state}
servers={servers}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
pendingTag={pendingTag}
setPendingTag={setPendingTag}
busy={busy}
onFetchSubscription={onFetchSubscription}
onApply={onApply}
/>
<div className="grid-2">
<ProxyCard state={state} />
<ClientActions
state={state}
busy={busy}
onRestart={onRestart}
onStop={onStop}
/>
</div>
<ClientFlow state={state} activeServer={activeServer} />
</div>
);
}

View File

@@ -741,18 +741,16 @@ function RuleSetsCard({ pushToast }) {
}
function PortsCard({ state }) {
const isClient = state?.mode === 'client';
return (
<div className="card">
<div className="card-header"><h2>Порты и маршруты</h2></div>
<div className="card-header"><h2>{isClient ? 'Локальные порты' : 'Порты и маршруты'}</h2></div>
<div className="kv-list">
<div className="row"><span className="key">UI</span><span className="val text-mono">:{state?.port || 3456}</span></div>
<div className="row"><span className="key">Mixed proxy (http+socks5)</span><span className="val text-mono">{state?.proxyBindIp || '0.0.0.0'}:{state?.proxyPort || 8080}</span></div>
<div className="row"><span className="key">TProxy</span><span className="val text-mono">:{state?.tproxyPort || 7895}</span></div>
<div className="row"><span className="key">HTTP/SOCKS proxy</span><span className="val text-mono">{isClient ? '127.0.0.1' : state?.proxyBindIp || '0.0.0.0'}:{state?.proxyPort || 8080}</span></div>
{!isClient && <div className="row"><span className="key">TProxy</span><span className="val text-mono">:{state?.tproxyPort || 7895}</span></div>}
<div className="row"><span className="key">RU direct (geoip-ru)</span><span className="val">{state?.routingRuDirect ? 'включено' : 'выключено'}</span></div>
</div>
<small className="muted" style={{ display: 'block', marginTop: 10 }}>
Эти параметры задаются в config.js на сервере.
</small>
</div>
);
}

View File

@@ -8,10 +8,14 @@ const NAV = [
{ id: 'settings', label: 'Настройки', ico: '⚙' },
];
export function Sidebar({ active, onChange, badges = {} }) {
export function Sidebar({ active, onChange, badges = {}, mode = 'gateway' }) {
const items = mode === 'client'
? NAV.filter((item) => item.id !== 'routing')
: NAV;
return (
<nav className="sidebar">
{NAV.map((item) => {
{items.map((item) => {
const badge = badges[item.id];
return (
<button

View File

@@ -25,11 +25,13 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))}${userInfo.total ? ' / ' + formatBytes(userInfo.total) : ''}`
: null;
const isClient = state?.mode === 'client';
return (
<header className="topbar">
<div className="topbar-brand">
<span className="logo-dot" />
VPN Gateway
{state?.mode === 'client' ? 'VPN Client' : 'VPN Gateway'}
</div>
<div className="topbar-status">
@@ -52,10 +54,10 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
</div>
<div className="topbar-actions">
{dirty && (
{!isClient && dirty && (
<span className="badge warning"> Несохранённые изменения</span>
)}
{state?.previousTag && (
{!isClient && state?.previousTag && (
<button className="btn btn-ghost sm" onClick={onTryApply} title="Откатить">
Откат
</button>
@@ -66,7 +68,7 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
disabled={!state?.configExists}
title="Перезапустить sing-box"
>
Restart
Перезапуск
</button>
</div>
</header>

View File

@@ -116,6 +116,9 @@ code, .mono {
grid-template-columns: var(--sidebar-w) 1fr var(--status-w);
min-height: 0;
}
.app-body.client-mode {
grid-template-columns: 1fr;
}
.app-main {
padding: var(--space-6);
@@ -129,6 +132,7 @@ code, .mono {
}
@media (max-width: 768px) {
.app-body { grid-template-columns: 1fr; }
.app-body.client-mode { grid-template-columns: 1fr; }
.sidebar { display: none; }
.app-main { padding: var(--space-4); }
}
@@ -821,6 +825,124 @@ code, .mono {
}
.subscription-input .input { flex: 1; }
/* ============ 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;
width: 100%;
margin: 0 auto;
}
.client-hero-main {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-3);
}
.client-hero-main h1 {
font-size: 28px;
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);
}
.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;
color: var(--muted);
}
@media (max-width: 900px) {
.client-hero {
grid-template-columns: 1fr;
}
.client-flow {
grid-template-columns: 1fr;
}
.flow-arrow {
justify-content: center;
transform: rotate(90deg);
}
.copy-field {
align-items: flex-start;
flex-direction: column;
}
}
/* For drawer rule editor */
.field-row {
display: grid;