Add gateway auto toggle for client mode
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 1s

This commit is contained in:
2026-07-11 15:17:07 +03:00
parent 51312d51cd
commit 0ab912c64c
7 changed files with 197 additions and 14 deletions

View File

@@ -101,6 +101,7 @@ function App() {
onApply={(tag) => run(() => api.apply(tag))}
onRestart={() => run(api.singbox.restart)}
onStop={() => run(api.singbox.stop)}
onSetGatewayAuto={(enabled) => run(() => api.gatewayAuto.setEnabled(enabled))}
/>
</main>
</div>

View File

@@ -32,6 +32,12 @@ export const api = {
method: 'POST',
body: JSON.stringify({ selectedTag }),
}),
gatewayAuto: {
setEnabled: (enabled) => request('/api/gateway-auto', {
method: 'POST',
body: JSON.stringify({ enabled }),
}),
},
singbox: {
stop: () => request('/api/singbox/stop', { method: 'POST' }),
restart: () => request('/api/singbox/restart', { method: 'POST' }),

View File

@@ -73,20 +73,45 @@ function DurationPart({ name, children }) {
);
}
function HarborBrand({ isGateway }) {
function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, busy, onSetGatewayAuto }) {
const product = isGateway ? 'Gateway' : 'Connect';
const switchable = !isGateway && gatewayAvailable;
const label = gatewayDirect
? 'Игнорировать Harbor Gateway и использовать локальный VPN'
: 'Использовать обнаруженный Harbor Gateway';
const content = <div className="harbor-brand-content">
<svg viewBox="0 0 32 32" aria-hidden="true">
<circle cx="16" cy="6" r="3" />
<path d="M16 9v15M10 14h12" />
<path className="harbor-anchor-left" d="M16 28C11 28 8.4 25.2 6.3 22v-3.3M3.8 21.4l2.5-2.7 2.5 2.7" />
<path className="harbor-anchor-right" d="M16 28C21 28 23.6 25.2 25.7 22v-3.3M23.2 21.4l2.5-2.7 2.5 2.7" />
</svg>
<span className="harbor-brand-name">
<strong>Harbor</strong>
{switchable ? <span className="harbor-mode-stack" aria-hidden="true">
<em className="harbor-mode-connect">Connect</em>
<em className="harbor-mode-gateway">Gateway</em>
</span> : <em>{product}</em>}
</span>
{switchable && <svg className="harbor-mode-swap" viewBox="0 0 18 18" aria-hidden="true">
<path d="M3 6h10m-3-3 3 3-3 3M15 12H5m3 3-3-3 3-3" />
</svg>}
</div>;
return (
<div className={`harbor-brand is-${product.toLowerCase()}`} aria-label={`Harbor ${product}`}>
<div className="harbor-brand-content">
<svg viewBox="0 0 32 32" aria-hidden="true">
<circle cx="16" cy="6" r="3" />
<path d="M16 9v15M10 14h12" />
<path className="harbor-anchor-left" d="M16 28C11 28 8.4 25.2 6.3 22v-3.3M3.8 21.4l2.5-2.7 2.5 2.7" />
<path className="harbor-anchor-right" d="M16 28C21 28 23.6 25.2 25.7 22v-3.3M23.2 21.4l2.5-2.7 2.5 2.7" />
</svg>
<span><strong>Harbor</strong> <em>{product}</em></span>
</div>
<div className={`harbor-brand is-${product.toLowerCase()}${switchable ? ` is-switchable${gatewayDirect ? ' is-gateway-active' : ''}` : ''}`}>
{switchable ? <button
className="harbor-brand-control"
type="button"
aria-label={label}
aria-pressed={gatewayDirect}
title={label}
disabled={busy}
onClick={() => onSetGatewayAuto(!gatewayDirect)}
>
{content}
</button> : <div aria-label={`Harbor ${product}`}>{content}</div>}
</div>
);
}
@@ -106,9 +131,11 @@ export function ClientOverviewPage({
onApply,
onRestart,
onStop,
onSetGatewayAuto,
}) {
const isGateway = state?.mode === 'gateway';
const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct';
const gatewayAvailable = !isGateway && Boolean(state?.gatewayAuto?.available);
const connected = Boolean(state?.singboxRunning);
const hasSubscription = Boolean(state?.hasSubscription);
const selectedTag = pendingTag || state?.selectedTag || '';
@@ -387,7 +414,13 @@ export function ClientOverviewPage({
return (
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
<HarborBrand isGateway={isGateway} />
<HarborBrand
isGateway={isGateway}
gatewayAvailable={gatewayAvailable}
gatewayDirect={gatewayDirect}
busy={busy}
onSetGatewayAuto={onSetGatewayAuto}
/>
{hasSubscription && subscriptionContentReady && <button
ref={instructionsToggleRef}
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}

View File

@@ -105,6 +105,31 @@ p {
pointer-events: none;
}
.harbor-brand.is-switchable {
pointer-events: auto;
}
.harbor-brand-control {
appearance: none;
border: 0;
border-radius: 7px;
padding: 3px 5px;
background: transparent;
color: inherit;
font: inherit;
letter-spacing: inherit;
cursor: pointer;
}
.harbor-brand-control:focus-visible {
outline: 1px solid color-mix(in srgb, var(--harbor-gateway) 55%, transparent);
outline-offset: 4px;
}
.harbor-brand-control:disabled {
cursor: wait;
}
.client-shell.is-first-run .harbor-brand {
transform: translate(-50%, calc(-50% - 72px)) scale(1.35);
}
@@ -153,6 +178,12 @@ p {
font-weight: 800;
}
.harbor-brand-name {
display: flex;
align-items: center;
gap: 0.45em;
}
.harbor-brand em {
color: var(--harbor-connect);
font-style: normal;
@@ -163,6 +194,61 @@ p {
color: var(--harbor-gateway);
}
.harbor-mode-stack {
position: relative;
display: inline-block;
width: 7.2em;
height: 1.25em;
}
.harbor-mode-stack em {
position: absolute;
inset: 0 auto auto 0;
transition: color 420ms ease, opacity 420ms ease, filter 420ms ease, transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
}
.harbor-mode-connect {
z-index: 2;
color: var(--harbor-connect);
}
.harbor-mode-gateway {
z-index: 1;
color: var(--harbor-gateway);
opacity: 0.2;
filter: blur(0.4px);
transform: translate(0.42em, 0.34em) scale(0.96);
}
.harbor-brand.is-gateway-active .harbor-mode-connect {
z-index: 1;
opacity: 0.18;
filter: blur(0.4px);
transform: translate(0.42em, 0.34em) scale(0.96);
}
.harbor-brand.is-gateway-active .harbor-mode-gateway {
z-index: 2;
opacity: 1;
filter: none;
transform: none;
}
.harbor-brand .harbor-mode-swap {
width: 14px;
height: 14px;
opacity: 0.42;
stroke-width: 1.35;
transition: color 320ms ease, opacity 320ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
.harbor-brand-control:hover .harbor-mode-swap,
.harbor-brand-control:focus-visible .harbor-mode-swap {
color: var(--harbor-gateway);
opacity: 1;
transform: translateX(1px);
}
.client-instructions-toggle {
position: fixed;
top: 50%;
@@ -1529,6 +1615,11 @@ p {
animation: none;
}
.harbor-mode-stack em,
.harbor-brand .harbor-mode-swap {
transition: none;
}
.client-state-copy h2,
.client-state-detail > * {
animation: none;