447 lines
18 KiB
JavaScript
447 lines
18 KiB
JavaScript
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>
|
||
);
|
||
}
|