Files
harbor-net/src/web/components/ClientOverviewPage.jsx
Dmitriy Petrov 41922ad30b
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 1s
Add direct gateway forwarding when VPN is off
2026-07-11 11:37:08 +03:00

447 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useRef, useState } from 'react';
import { api } from '../api.js';
import {
connectionAction,
copyText,
formatConnectionDuration,
localProxyUrls,
subscriptionDomain,
subscriptionDaysLeft,
subscriptionUsage,
} from '../utils/clientControls.js';
import { formatBytes } from '../utils/format.js';
export function ClientOverviewPage({
state,
busy,
error,
subscriptionUrl,
setSubscriptionUrl,
servers,
pendingTag,
setPendingTag,
onFetchSubscription,
onRefreshSubscriptionInfo,
onForgetSubscription,
onApply,
onRestart,
onStop,
}) {
const isGateway = state?.mode === 'gateway';
const connected = Boolean(state?.singboxRunning);
const hasSubscription = Boolean(state?.hasSubscription);
const selectedTag = pendingTag || state?.selectedTag || '';
const showPower = hasSubscription && Boolean(selectedTag);
const canStart = Boolean(selectedTag || state?.configExists);
const [now, setNow] = useState(Date.now());
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
const [pings, setPings] = useState({});
const [accessTab, setAccessTab] = useState('gateway');
const [copyFeedback, setCopyFeedback] = useState(null);
const [refreshingInfo, setRefreshingInfo] = useState(false);
const [usageUpdated, setUsageUpdated] = useState(false);
const [serverRevealVersion, setServerRevealVersion] = useState(0);
const [serversLeaving, setServersLeaving] = useState(false);
const subscriptionInputRef = useRef(null);
const subscriptionRef = useRef(null);
const copyTimerRef = useRef(null);
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
const usage = subscriptionUsage(state?.userInfo);
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
const hasUsage = Boolean(
state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo),
);
useEffect(() => {
setNow(Date.now());
if (!connected || !state?.singboxStartedAt) return undefined;
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 (!hasSubscription) setEditingSubscription(true);
}, [hasSubscription]);
useEffect(() => {
if (!editingSubscription || !state?.hasSubscription || subscriptionUrl) return undefined;
const timer = setTimeout(() => setEditingSubscription(false), 5000);
return () => clearTimeout(timer);
}, [editingSubscription, state?.hasSubscription, subscriptionUrl]);
useEffect(() => {
if (!editingSubscription || !hasSubscription) return undefined;
const closeOnOutsideClick = (event) => {
if (subscriptionRef.current?.contains(event.target)) return;
setSubscriptionUrl('');
setEditingSubscription(false);
};
document.addEventListener('pointerdown', closeOnOutsideClick);
return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
}, [editingSubscription, hasSubscription, setSubscriptionUrl]);
useEffect(() => {
if (!state?.hasSubscription) return undefined;
const refresh = () => onRefreshSubscriptionInfo().catch(() => {});
refresh();
const timer = setInterval(refresh, 60_000);
return () => clearInterval(timer);
}, [state?.hasSubscription]);
useEffect(() => {
const from = displayedUsed;
const to = usage.used;
if (from === to) return undefined;
const startedAt = performance.now();
let frame;
const tick = (now) => {
const progress = Math.min(1, (now - startedAt) / 900);
const eased = 1 - Math.pow(1 - progress, 4);
setDisplayedUsed(from + (to - from) * eased);
if (progress < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [usage.used]);
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
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 selectServer(tag) {
setPendingTag(tag);
if (connected && tag) onApply(tag);
}
async function submitSubscription(event) {
event.preventDefault();
if (!subscriptionUrl.trim()) return;
await onFetchSubscription();
setSubscriptionUrl('');
setEditingSubscription(false);
}
async function copyProxy(kind) {
const value = kind === 'gateway' ? gatewayAddress : proxyUrls[kind];
clearTimeout(copyTimerRef.current);
setCopyFeedback({ kind, failed: false });
copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800);
try {
await copyText(value);
} catch {
setCopyFeedback({ kind, failed: true });
}
}
async function refreshInfo() {
const startedAt = performance.now();
setRefreshingInfo(true);
try {
await onRefreshSubscriptionInfo();
setUsageUpdated(false);
requestAnimationFrame(() => setUsageUpdated(true));
setTimeout(() => setUsageUpdated(false), 900);
setServersLeaving(true);
await new Promise((resolve) => setTimeout(resolve, 420 + Math.max(0, servers.length - 1) * 90));
setServerRevealVersion((version) => version + 1);
setServersLeaving(false);
} finally {
const elapsed = performance.now() - startedAt;
const completeCyclesAt = Math.max(900, Math.ceil(elapsed / 900) * 900);
await new Promise((resolve) => setTimeout(resolve, completeCyclesAt - elapsed));
setRefreshingInfo(false);
}
}
return (
<div className="client-shell">
<main className={`client-panel${showPower ? '' : ' is-setup'}`}>
{showPower && (
<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 className="client-state-copy" aria-live="polite">
<h2 key={connected ? 'connected' : 'disconnected'} id="connection-title">
{connected ? 'VPN включён' : 'VPN выключен'}
</h2>
<div className="client-state-detail">
{connected ? (
<time key="duration" className="client-duration">
{formatConnectionDuration(state?.singboxStartedAt, now)}
</time>
) : (
<p key="hint">
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
</p>
)}
</div>
</div>
<section className={`client-proxies${isGateway ? ' has-tabs' : ''}`} aria-label={isGateway ? 'Gateway и Local Proxy' : 'Локальный прокси'}>
{isGateway && (
<div className="client-access-tabs" role="tablist" aria-label="Способ подключения">
{[
['gateway', 'Gateway'],
['proxy', 'Local Proxy'],
].map(([tab, label]) => (
<button
className={`client-access-tab${accessTab === tab ? ' is-active' : ''}`}
type="button"
role="tab"
key={tab}
aria-selected={accessTab === tab}
onClick={() => setAccessTab(tab)}
>
{label}
</button>
))}
</div>
)}
{(isGateway ? accessTab === 'gateway' : false) ? (
<div className="client-access-point" role="tabpanel" key="gateway">
<strong className="client-proxy-address">{gatewayAddress}</strong>
<button
className={`client-copy-button${copyFeedback?.kind === 'gateway' ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать Gateway: ${gatewayAddress}`}
onClick={() => copyProxy('gateway')}
>
<span className="client-copy-label">КОПИРОВАТЬ</span>
{copyFeedback?.kind === 'gateway' && <span className="client-copy-feedback">{copyFeedback.failed ? 'Error' : 'Copied'}</span>}
</button>
</div>
) : (
<div className="client-access-point" role={isGateway ? 'tabpanel' : undefined} key="proxy">
{!isGateway && <span className="client-proxy-label">Адрес</span>}
<strong className="client-proxy-address">
{proxyUrls.http.replace(/^https?:\/\//, '')}
</strong>
<div className="client-proxy-actions">
{[
['socks5', 'SOCKS5'],
['http', 'HTTP'],
].map(([kind, label]) => (
<button
className={`client-copy-button${copyFeedback?.kind === kind ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
key={kind}
aria-label={`Скопировать ${label}: ${proxyUrls[kind]}`}
onClick={() => copyProxy(kind)}
>
<span className="client-copy-label">{label}</span>
{copyFeedback?.kind === kind && <span className="client-copy-feedback">{copyFeedback.failed ? 'Error' : 'Copied'}</span>}
</button>
))}
</div>
</div>
)}
</section>
</section>
)}
{error && <p className="client-error" role="alert">{error}</p>}
<div className="client-form">
<div
ref={subscriptionRef}
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
>
<div
className="client-subscription-summary"
aria-hidden={editingSubscription}
inert={editingSubscription ? true : undefined}
>
<div className="client-subscription-heading">
<span>Ваша подписка</span>
<button
className="client-subscription-refresh"
type="button"
aria-label="Обновить статистику подписки"
title="Обновить статистику"
disabled={refreshingInfo}
onClick={refreshInfo}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
</svg>
</button>
<button
className="client-subscription-delete"
type="button"
aria-label="Удалить подписку"
title="Удалить подписку"
disabled={busy}
onClick={onForgetSubscription}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h16M9 7V4h6v3m-9 0 1 13h10l1-13M10 11v5M14 11v5" />
</svg>
</button>
</div>
<button
className="client-subscription-domain-button"
type="button"
tabIndex={editingSubscription ? -1 : 0}
onClick={() => setEditingSubscription(true)}
>
<strong>{subscriptionDomain(state?.subscriptionHost)}</strong>
</button>
</div>
<form
className="client-subscription-edit"
autoComplete="off"
aria-hidden={!editingSubscription}
inert={!editingSubscription ? true : undefined}
onSubmit={submitSubscription}
>
<input
ref={subscriptionInputRef}
id="subscription-url"
type="url"
inputMode="url"
autoComplete="off"
tabIndex={editingSubscription ? 0 : -1}
aria-label="Ссылка подписки"
placeholder="Вставьте ссылку подписки"
className={subscriptionUrl ? 'has-value' : ''}
value={subscriptionUrl}
onChange={(event) => setSubscriptionUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape' && state?.hasSubscription) {
setSubscriptionUrl('');
setEditingSubscription(false);
}
}}
/>
{subscriptionUrl && (
<span className="client-subscription-domain">
{subscriptionDomain(subscriptionUrl)}
</span>
)}
{subscriptionUrl.trim() && (
<button type="submit" aria-label="Сохранить подписку" disabled={busy}></button>
)}
</form>
</div>
{hasSubscription && hasUsage && (
<section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки">
<span>Использовано</span>
<strong>
{formatBytes(displayedUsed)}
<small> / {usage.total ? formatBytes(usage.total) : 'без лимита'}</small>
</strong>
{usage.percent !== null && (
<div
className="client-usage-bar"
role="progressbar"
aria-label="Использованный трафик"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={Math.round(usage.percent)}
>
<i style={{ width: `${usage.percent}%` }} />
</div>
)}
<div className="client-usage-details">
{usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) && (
<span>
до {usage.expiresAt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })}
{' · '}{subscriptionDaysLeft(usage.expiresAt)}
</span>
)}
</div>
</section>
)}
{hasSubscription && (
<section className="client-servers" aria-label="Выберите сервер">
{!showPower && <span className="client-server-prompt">Выберите сервер</span>}
<div
className={`client-server-grid${serversLeaving ? ' is-leaving' : ''}`}
key={`${serverKey}:${serverRevealVersion}`}
>
{servers.map((server, index) => {
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'
: '';
return (
<button
className={`client-server ${selected ? 'is-selected' : ''}`}
type="button"
key={server.tag}
disabled={busy}
aria-pressed={selected}
style={{ '--server-index': index }}
onClick={() => selectServer(server.tag)}
>
<strong>{server.tag}</strong>
<small className={pingClass}>{pingText}</small>
</button>
);
})}
</div>
</section>
)}
</div>
</main>
</div>
);
}