Handle rejected subscriptions and add local client compose
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-07-13 20:23:37 +03:00
parent 8c19f2cba9
commit 56f5e408e3
10 changed files with 306 additions and 443 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { api } from '../api.js';
import {
@@ -8,6 +8,7 @@ import {
copyText,
isSubscriptionUrlValid,
localProxyUrls,
routePresentation,
subscriptionDomain,
subscriptionDaysLeft,
subscriptionUsage,
@@ -503,96 +504,53 @@ function AnimatedSeconds({ value, padded = true }) {
));
}
function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }) {
const [modeAnimating, setModeAnimating] = useState(false);
const [arrowTurns, setArrowTurns] = useState(gatewayDirect ? 0.5 : 0);
const stopModeAnimationRef = useRef(false);
const previousGatewayDirectRef = useRef(gatewayDirect);
function HarborBrand({ isGateway }) {
const product = isGateway ? 'Gateway' : 'Connect';
const switchable = !isGateway && gatewayAvailable;
const label = gatewayDirect
? 'Игнорировать Harbor Gateway и использовать локальный VPN'
: 'Использовать обнаруженный Harbor Gateway';
useLayoutEffect(() => {
if (previousGatewayDirectRef.current === gatewayDirect) return;
previousGatewayDirectRef.current = gatewayDirect;
setArrowTurns((turns) => turns + 0.5);
}, [gatewayDirect]);
function startModeAnimation() {
stopModeAnimationRef.current = false;
setModeAnimating(true);
}
function finishModeAnimation() {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setModeAnimating(false);
return;
}
stopModeAnimationRef.current = true;
}
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-control">
<span className="harbor-mode-stack" aria-hidden="true">
<em className="harbor-mode-connect">Connect</em>
<em className="harbor-mode-gateway"><span>Gateway</span></em>
</span>
<svg
className="harbor-mode-swap"
viewBox="0 0 18 18"
aria-hidden="true"
style={{ '--harbor-arrow-turn': `${arrowTurns}turn` }}
>
<g className="is-connect"><path d="M3 6h10m-3-3 3 3-3 3" /></g>
<g className="is-gateway"><path d="M15 12H5m3 3-3-3 3-3" /></g>
</svg>
<span id="harbor-mode-tooltip" className="harbor-mode-tooltip" role="tooltip">
<strong>{gatewayDirect ? 'Harbor Gateway активен' : 'Harbor Gateway доступен'}</strong>
<span>{gatewayDirect
? 'Трафик идёт через Gateway в этой сети. Нажмите, чтобы использовать локальный VPN.'
: 'Сейчас используется локальный VPN. Нажмите, чтобы направить трафик через Gateway.'}</span>
</span>
</span> : <em>{product}</em>}
</span>
</div>;
return (
<div className={`harbor-brand is-${product.toLowerCase()}${switchable ? ` is-switchable${gatewayDirect ? ' is-gateway-active' : ''}` : ''}`}>
{switchable ? <button
className={`harbor-brand-control${modeAnimating ? ' is-mode-animating' : ''}`}
type="button"
aria-label={label}
aria-describedby="harbor-mode-tooltip"
aria-pressed={gatewayDirect}
disabled={blocked}
onPointerEnter={startModeAnimation}
onPointerLeave={finishModeAnimation}
onFocus={startModeAnimation}
onBlur={finishModeAnimation}
onAnimationIteration={(event) => {
if (event.animationName === 'harbor-mode-float-front' && stopModeAnimationRef.current) {
stopModeAnimationRef.current = false;
setModeAnimating(false);
}
}}
onClick={() => onSetGatewayAuto(!gatewayDirect)}
>
{content}
</button> : <div aria-label={`Harbor ${product}`}>{content}</div>}
<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 className="harbor-brand-name"><strong>Harbor</strong><em>{product}</em></span>
</div>
</div>
);
}
function RouteStatus({ route, blocked, onSetGatewayAuto }) {
const presentation = routePresentation(route);
const verifiedAt = route?.lastVerifiedAt && Number.isFinite(Date.parse(route.lastVerifiedAt))
? new Date(route.lastVerifiedAt)
: null;
return <section className={`client-route-status is-${presentation.tone}`} aria-label="Маршрут Connect">
<span>Маршрут</span>
<strong aria-live="polite">{presentation.title}</strong>
<small>{presentation.detail}</small>
{route?.gatewayAddress && <div className="client-route-gateway">
{route.gatewayUiOrigin
? <a href={route.gatewayUiOrigin}>Harbor Gateway · {route.gatewayAddress}</a>
: <span>Harbor Gateway · {route.gatewayAddress}</span>}
{verifiedAt && <time dateTime={route.lastVerifiedAt}>
Проверен {verifiedAt.toLocaleString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
</time>}
</div>}
<label className="client-route-auto">
<input
type="checkbox"
checked={route?.autoEnabled !== false}
disabled={blocked}
onChange={(event) => onSetGatewayAuto(event.target.checked)}
/>
<span>Использовать домашний Gateway автоматически</span>
</label>
</section>;
}
export function ClientOverviewPage({
state,
versionInfo,
@@ -614,8 +572,13 @@ export function ClientOverviewPage({
onDismissError,
}) {
const isGateway = state?.mode === 'gateway';
const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct';
const gatewayAvailable = !isGateway && Boolean(state?.gatewayAuto?.available);
const route = {
...(state?.route || {}),
autoEnabled: state?.route?.autoEnabled ?? state?.gatewayAuto?.enabled ?? true,
gatewayUiOrigin: state?.route?.gatewayUiOrigin || state?.gatewayAuto?.uiOrigin || null,
};
const gatewayDirect = !isGateway && route.mode === 'gateway-direct';
const gatewayStale = gatewayDirect && route.reason === 'gateway-stale';
const connected = Boolean(state?.singboxRunning);
const hasSubscription = Boolean(state?.hasSubscription);
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
@@ -667,7 +630,7 @@ export function ClientOverviewPage({
const usage = subscriptionUsage(state?.userInfo);
const duration = connectionDurationParts(state?.singboxStartedAt, now);
const connectionTitle = connected
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
? gatewayStale ? 'Gateway проверяется' : gatewayDirect ? 'Gateway подключён' : 'VPN включён'
: 'Подключение выключено';
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
const hasUsage = Boolean(
@@ -1055,19 +1018,13 @@ export function ClientOverviewPage({
return (
<div
className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}
className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}${gatewayDirect ? ' is-gateway-route' : ''}`}
>
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
</div>
<HarborBrand
isGateway={isGateway}
gatewayAvailable={gatewayAvailable}
gatewayDirect={gatewayDirect}
blocked={gatewayAutoBlocked}
onSetGatewayAuto={onSetGatewayAuto}
/>
<HarborBrand isGateway={isGateway} />
{hasSubscription && subscriptionContentReady && <button
ref={instructionsToggleRef}
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
@@ -1136,7 +1093,8 @@ export function ClientOverviewPage({
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
<span className={connected && !gatewayDirect ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
<span className={connected && gatewayDirect ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
<span className={connected && gatewayDirect && !gatewayStale ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
<span className={connected && gatewayStale ? 'is-active' : ''} aria-hidden="true">Gateway проверяется</span>
</h2>
<div className="client-state-detail">
{connected ? (
@@ -1187,6 +1145,12 @@ export function ClientOverviewPage({
</div>
</div>
{!isGateway && <RouteStatus
route={route}
blocked={gatewayAutoBlocked}
onSetGatewayAuto={onSetGatewayAuto}
/>}
<section className={`client-proxies${isGateway ? ' has-tabs' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
{isGateway && (
<div className="client-access-tabs" role="tablist" aria-label="Способ подключения">
@@ -1235,14 +1199,6 @@ export function ClientOverviewPage({
aria-labelledby={isGateway ? 'client-access-tab-proxy' : undefined}
hidden={isGateway && accessTab !== 'proxy'}
>
{!isGateway && (
<span className={`client-proxy-label${gatewayDirect ? ' is-gateway' : ''}`}>
<span className={!gatewayDirect ? 'is-active' : ''}>Локальный VPN</span>
<span className={gatewayDirect ? 'is-active' : ''}>
Через <a href={`http://${state.gatewayAuto.address}:3456`}>Harbor Gateway</a> · {state.gatewayAuto.address}
</span>
</span>
)}
<strong className="client-proxy-address">
{proxyUrls.http.replace(/^https?:\/\//, '')}
</strong>