Add Gateway traffic totals and dashboard chart
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-08-08 00:23:55 +03:00
parent 39f3467f9b
commit 4c61a04dc7
10 changed files with 815 additions and 271 deletions
+207 -37
View File
@@ -11,13 +11,14 @@ import {
subscriptionDaysLeft,
subscriptionUsage,
} from '../utils/clientControls.js';
import { formatBytes } from '../utils/format.js';
import { formatBytes, formatByteString, formatLastSeen } from '../utils/format.js';
import { instructionBlocks } from '../instructions.js';
import { operationBlocked } from '../state/operations.js';
import { ConfirmationPopup } from './ConfirmationPopup.jsx';
import { DevicesPanel } from './DevicesPanel.jsx';
import { ConnectivityDiagnosticsPanel } from './ConnectivityDiagnosticsPanel.jsx';
import { ServerPicker } from './ServerPicker.jsx';
import { TrafficChart } from './TrafficChart.jsx';
import { ERROR_DEFINITIONS } from '../../shared/errors.js';
import { canAppendRouteRule } from '../../shared/routingRules.js';
import {
@@ -27,10 +28,11 @@ import {
} from '../../shared/versions.js';
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
const DEVICE_AUTO_REFRESH_MS = 15_000;
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
function CloudTooltip({ children }) {
return <span className="client-tooltip" role="tooltip">{children}</span>;
function CloudTooltip({ children, id }) {
return <span className="client-tooltip" id={id} role="tooltip">{children}</span>;
}
const VERSION_PARTS = [
@@ -616,8 +618,12 @@ export function ClientOverviewPage({
const connected = Boolean(state?.singboxRunning);
const hasSubscription = Boolean(state?.hasSubscription);
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
const showPower = hasSubscription && Boolean(selectedServerId);
const appliedServerId = state?.selection?.appliedServerId || '';
const appliedServer = servers.find(({ id }) => id === appliedServerId);
const desiredServer = servers.find(({ id }) => id === selectedServerId);
const showPower = isGateway || (hasSubscription && Boolean(selectedServerId));
const canStart = Boolean(selectedServerId || state?.configExists);
const powerUnavailable = isGateway && !connected && !canStart;
const [now, setNow] = useState(Date.now());
const [durationMode, setDurationMode] = useState(() => {
try {
@@ -641,9 +647,15 @@ export function ClientOverviewPage({
const [serverRevealVersion, setServerRevealVersion] = useState(0);
const [serversLeaving, setServersLeaving] = useState(false);
const [instructionsOpen, setInstructionsOpen] = useState(false);
const [subscriptionOpen, setSubscriptionOpen] = useState(false);
const [localRulesOpen, setLocalRulesOpen] = useState(false);
const [devicesOpen, setDevicesOpen] = useState(false);
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
const [deviceSnapshot, setDeviceSnapshot] = useState(null);
const [deviceStatus, setDeviceStatus] = useState('idle');
const [deviceError, setDeviceError] = useState(null);
const [devicesRefreshing, setDevicesRefreshing] = useState(false);
const [deviceRefreshCycle, setDeviceRefreshCycle] = useState(0);
const [localRulesDraft, setLocalRulesDraft] = useState([]);
const [localRulesRevision, setLocalRulesRevision] = useState(state?.route?.localRulesRevision || 0);
const [confirmingLocalRulesClose, setConfirmingLocalRulesClose] = useState(false);
@@ -656,6 +668,9 @@ export function ClientOverviewPage({
const instructionsPanelRef = useRef(null);
const instructionsToggleRef = useRef(null);
const instructionsCloseRef = useRef(null);
const subscriptionPanelRef = useRef(null);
const subscriptionToggleRef = useRef(null);
const subscriptionCloseRef = useRef(null);
const localRulesPanelRef = useRef(null);
const localRulesToggleRef = useRef(null);
const localRulesCloseRef = useRef(null);
@@ -666,7 +681,9 @@ export function ClientOverviewPage({
const diagnosticsToggleRef = useRef(null);
const diagnosticsCloseRef = useRef(null);
const localRulesBaselineRef = useRef('[]');
const confirmingDeleteRef = useRef(confirmingDelete);
const previousHasSubscriptionRef = useRef(hasSubscription);
confirmingDeleteRef.current = confirmingDelete;
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
const usage = subscriptionUsage(state?.userInfo);
@@ -724,14 +741,54 @@ export function ClientOverviewPage({
rule.enabled && !(state?.route?.activeLocalRules || []).some((active) => localRuleKey(active) === localRuleKey(rule))
)).length
: 0;
const globalTraffic = deviceSnapshot?.traffic;
const trafficSourceError = deviceSnapshot?.source?.traffic?.error
|| deviceSnapshot?.source?.traffic?.proxy?.error
|| (deviceStatus === 'error' ? deviceError : null);
const trafficFreshness = globalTraffic?.observedAt
? formatLastSeen(globalTraffic.observedAt, new Date(now)).relative
: 'Нет данных';
const switchingServer = Boolean(
selectedServerId && selectedServerId !== appliedServerId && desiredServer,
);
async function loadDevices(quiet = false, discover = false) {
if (!isGateway) return;
if (!quiet) setDeviceStatus(deviceSnapshot ? 'refreshing' : 'loading');
setDevicesRefreshing(true);
try {
const next = await (discover ? api.devices.refresh() : api.devices.list());
setDeviceSnapshot((current) => !current || next.revision > current.revision ? next : current);
setDeviceError(null);
setDeviceStatus('ready');
} catch (requestError) {
setDeviceError(requestError);
setDeviceStatus('error');
} finally {
setDevicesRefreshing(false);
setDeviceRefreshCycle((cycle) => cycle + 1);
}
}
useEffect(() => {
setNow(Date.now());
if (!connected || !state?.singboxStartedAt) return undefined;
if (!isGateway && (!connected || !state?.singboxStartedAt)) return undefined;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [connected, state?.singboxStartedAt]);
}, [isGateway, connected, state?.singboxStartedAt]);
useEffect(() => {
if (!isGateway) return undefined;
loadDevices();
return undefined;
}, [isGateway]);
useEffect(() => {
if (!isGateway || devicesRefreshing || deviceStatus === 'loading') return undefined;
const timer = setTimeout(() => loadDevices(true), DEVICE_AUTO_REFRESH_MS);
return () => clearTimeout(timer);
}, [isGateway, deviceRefreshCycle, devicesRefreshing, deviceStatus]);
useEffect(() => {
if (editingSubscription) subscriptionInputRef.current?.focus();
@@ -797,12 +854,14 @@ export function ClientOverviewPage({
useEffect(() => {
if (!hasSubscription) {
setEditingSubscription(true);
setInstructionsOpen(false);
setLocalRulesOpen(false);
setDevicesOpen(false);
setDiagnosticsOpen(false);
if (!isGateway) {
setInstructionsOpen(false);
setDevicesOpen(false);
setDiagnosticsOpen(false);
}
}
}, [hasSubscription]);
}, [hasSubscription, isGateway]);
useEffect(() => {
if (!editingSubscription || !state?.hasSubscription || subscriptionUrl) return undefined;
@@ -845,6 +904,29 @@ export function ClientOverviewPage({
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
useEffect(() => {
if (!subscriptionOpen) return undefined;
const frame = requestAnimationFrame(() => subscriptionCloseRef.current?.focus());
const closeSubscription = (event) => {
if (confirmingDeleteRef.current) return;
if (event.type === 'keydown' && event.key !== 'Escape') return;
if (event.type !== 'keydown' && (
subscriptionPanelRef.current?.contains(event.target) || subscriptionToggleRef.current?.contains(event.target)
)) return;
setSubscriptionOpen(false);
};
document.addEventListener('pointerdown', closeSubscription);
document.addEventListener('keydown', closeSubscription);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('pointerdown', closeSubscription);
document.removeEventListener('keydown', closeSubscription);
requestAnimationFrame(() => {
if (subscriptionPanelRef.current?.contains(document.activeElement)) subscriptionToggleRef.current?.focus();
});
};
}, [subscriptionOpen]);
useEffect(() => {
if (!instructionsOpen) return undefined;
const frame = requestAnimationFrame(() => instructionsCloseRef.current?.focus());
@@ -1048,6 +1130,7 @@ export function ClientOverviewPage({
function openLocalRules() {
const rules = state?.route?.localRules || [];
setSubscriptionOpen(false);
setInstructionsOpen(false);
setDevicesOpen(false);
setDiagnosticsOpen(false);
@@ -1111,9 +1194,26 @@ export function ClientOverviewPage({
document.startViewTransition(update);
}
const powerButton = <button
className="client-power"
type="button"
role="switch"
aria-checked={connected}
aria-label={isGateway
? connected ? 'Остановить VPN' : 'Запустить VPN'
: connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
disabled={connectionBlocked || (!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>;
return (
<div
className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro ? ' is-intro' : ''}`}
className={`client-shell${!isGateway && !hasSubscription ? ' is-first-run' : ''}${showIntro ? ' is-intro' : ''}`}
>
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
@@ -1126,7 +1226,27 @@ export function ClientOverviewPage({
blocked={gatewayAutoBlocked}
onSetGatewayAuto={onSetGatewayAuto}
/>
{hasSubscription && subscriptionContentReady && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
{(isGateway || (hasSubscription && subscriptionContentReady)) && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
{isGateway && <button
ref={subscriptionToggleRef}
className={`client-instructions-toggle client-subscription-toggle${subscriptionOpen ? ' is-open' : ''}`}
type="button"
aria-expanded={subscriptionOpen}
aria-controls="client-subscription-drawer"
aria-label={subscriptionOpen ? 'Закрыть подписку' : 'Управление подпиской'}
onClick={() => {
if (localRulesOpen && !requestCloseLocalRules()) return;
setInstructionsOpen(false);
setDevicesOpen(false);
setDiagnosticsOpen(false);
setSubscriptionOpen((open) => !open);
}}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 5h14v14H5zM8 9h8M8 13h5" />
</svg>
<span>Подписка</span>
</button>}
<button
ref={instructionsToggleRef}
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
@@ -1136,6 +1256,7 @@ export function ClientOverviewPage({
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
onClick={() => {
if (localRulesOpen && !requestCloseLocalRules()) return;
setSubscriptionOpen(false);
setDevicesOpen(false);
setDiagnosticsOpen(false);
setInstructionsOpen((open) => !open);
@@ -1156,6 +1277,7 @@ export function ClientOverviewPage({
aria-label={devicesOpen ? 'Закрыть устройства' : 'Устройства Gateway'}
onClick={() => {
if (localRulesOpen && !requestCloseLocalRules()) return;
setSubscriptionOpen(false);
setInstructionsOpen(false);
setDiagnosticsOpen(false);
setDevicesOpen((open) => !open);
@@ -1177,6 +1299,7 @@ export function ClientOverviewPage({
aria-label={diagnosticsOpen ? 'Закрыть диагностику' : 'Проверить маршруты'}
onClick={() => {
if (localRulesOpen && !requestCloseLocalRules()) return;
setSubscriptionOpen(false);
setInstructionsOpen(false);
setDevicesOpen(false);
setDiagnosticsOpen((open) => !open);
@@ -1192,11 +1315,13 @@ export function ClientOverviewPage({
ref={localRulesToggleRef}
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}${localRulesPendingRestart ? ' has-pending' : ''}`}
type="button"
disabled={gatewayDirect}
disabled={gatewayDirect || (isGateway && !hasSubscription)}
aria-expanded={localRulesOpen}
aria-controls="client-local-rules"
aria-label={gatewayDirect
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
aria-label={gatewayDirect || (isGateway && !hasSubscription)
? hasSubscription
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
: 'Локальные правила недоступны: сначала добавьте подписку'
: localRulesOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
onClick={() => localRulesOpen ? requestCloseLocalRules() : openLocalRules()}
>
@@ -1205,27 +1330,52 @@ export function ClientOverviewPage({
<circle className="client-rail-rule-knob is-top" cx="15" cy="7" r="2" />
<circle className="client-rail-rule-knob is-bottom" cx="9" cy="17" r="2" />
</svg>
<span>{gatewayDirect
? 'Локальные правила недоступны: сейчас работают правила Gateway'
<span>{gatewayDirect || (isGateway && !hasSubscription)
? hasSubscription
? 'Локальные правила недоступны: сейчас работают правила Gateway'
: 'Сначала добавьте подписку'
: localRulesPendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
</button>
</nav>}
<main className={`client-panel${showPower ? '' : ' is-setup'}${hasSubscription ? ' has-subscription' : ''}`}>
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway ? ' is-gateway-home' : ''}`}>
{isGateway && <section className="client-gateway-summary" aria-labelledby="gateway-summary-title">
<span className="client-gateway-summary-kicker">Сейчас</span>
<h2 id="gateway-summary-title">
{appliedServer?.label || 'VPN-сервер не используется'}
</h2>
<div className="client-gateway-route-slot" role="status" aria-live="polite">
{switchingServer && <span>Переключаем на {desiredServer.label}</span>}
</div>
<div className="client-gateway-traffic-heading">
<span>Учтено Harbor</span>
<strong>{formatByteString(globalTraffic?.totalBytes || '0')}</strong>
</div>
<div className="client-gateway-traffic-chart">
<TrafficChart
samples={globalTraffic?.history || []}
capacity={deviceSnapshot?.trafficHistoryCapacity || 120}
routeLabel="Gateway"
/>
</div>
<div className={`client-gateway-traffic-freshness${trafficSourceError ? ' is-stale' : ''}`} role="status" aria-live="polite">
{trafficSourceError
? `Трафик не обновляется · последние данные ${trafficFreshness}`
: globalTraffic?.observedAt ? `Обновлено ${trafficFreshness}` : 'Ожидаем первые данные трафика'}
</div>
</section>}
{showPower && (
<section className="client-power-section" aria-labelledby="connection-title">
<button
className="client-power"
type="button"
role="switch"
aria-checked={connected}
aria-label={connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
disabled={connectionBlocked || (!connected && !canStart)}
onClick={toggleConnection}
{isGateway ? <span
className="client-power-control client-tooltip-anchor"
tabIndex={powerUnavailable ? 0 : undefined}
aria-label={powerUnavailable ? 'VPN недоступен' : undefined}
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
>
<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>
{powerButton}
{powerUnavailable && <CloudTooltip id="gateway-power-unavailable">
Сначала добавьте подписку и выберите сервер
</CloudTooltip>}
</span> : powerButton}
<div className={`client-route-rules-pending${pendingLocalRulesCount ? ' is-visible' : ''}`} role="status" aria-live="polite">
{pendingLocalRulesCount > 0 && (
<>
@@ -1334,12 +1484,24 @@ export function ClientOverviewPage({
)}
<div
className={`client-form${subscriptionWaiting ? ' is-waiting' : ''}`}
aria-hidden={subscriptionWaiting}
ref={isGateway ? subscriptionPanelRef : undefined}
id={isGateway ? 'client-subscription-drawer' : undefined}
className={isGateway
? `client-drawer client-subscription-drawer${subscriptionOpen ? ' is-open' : ''}`
: `client-form${subscriptionWaiting ? ' is-waiting' : ''}`}
aria-label={isGateway ? 'Управление подпиской' : undefined}
aria-hidden={isGateway ? !subscriptionOpen : subscriptionWaiting}
aria-disabled={gatewayDirect}
inert={subscriptionWaiting || gatewayDirect ? true : undefined}
inert={(isGateway && !subscriptionOpen) || subscriptionWaiting || gatewayDirect ? true : undefined}
>
<div className="client-form-content">
{isGateway && <button
ref={subscriptionCloseRef}
className="client-drawer-close"
type="button"
aria-label="Закрыть подписку"
onClick={() => setSubscriptionOpen(false)}
>×</button>}
<div className={`client-form-content${isGateway ? ' client-drawer-sheet client-subscription-sheet' : ''}`}>
<div
ref={subscriptionRef}
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
@@ -1493,7 +1655,7 @@ export function ClientOverviewPage({
</div>
</main>
{hasSubscription && subscriptionContentReady && <aside
{(isGateway || (hasSubscription && subscriptionContentReady)) && <aside
ref={instructionsPanelRef}
id="client-instructions"
className={`client-drawer client-instructions${instructionsOpen ? ' is-open' : ''}`}
@@ -1530,14 +1692,22 @@ export function ClientOverviewPage({
</div>
</aside>}
{hasSubscription && subscriptionContentReady && isGateway && <DevicesPanel
{isGateway && <DevicesPanel
open={devicesOpen}
panelRef={devicesPanelRef}
closeRef={devicesCloseRef}
onClose={() => setDevicesOpen(false)}
snapshot={deviceSnapshot}
status={deviceStatus}
error={deviceError}
refreshing={devicesRefreshing}
refreshCycle={deviceRefreshCycle}
onLoad={loadDevices}
onSnapshot={setDeviceSnapshot}
onError={setDeviceError}
/>}
{hasSubscription && subscriptionContentReady && <ConnectivityDiagnosticsPanel
{(isGateway || (hasSubscription && subscriptionContentReady)) && <ConnectivityDiagnosticsPanel
isGateway={isGateway}
open={diagnosticsOpen}
panelRef={diagnosticsPanelRef}