Add subscription info refresh endpoint and UI stats
This commit is contained in:
@@ -5,7 +5,10 @@ import {
|
||||
formatConnectionDuration,
|
||||
localProxyUrls,
|
||||
subscriptionDomain,
|
||||
subscriptionDaysLeft,
|
||||
subscriptionUsage,
|
||||
} from '../utils/clientControls.js';
|
||||
import { formatBytes } from '../utils/format.js';
|
||||
|
||||
export function ClientOverviewPage({
|
||||
state,
|
||||
@@ -16,6 +19,7 @@ export function ClientOverviewPage({
|
||||
pendingTag,
|
||||
setPendingTag,
|
||||
onFetchSubscription,
|
||||
onRefreshSubscriptionInfo,
|
||||
onApply,
|
||||
onRestart,
|
||||
onStop,
|
||||
@@ -27,9 +31,19 @@ export function ClientOverviewPage({
|
||||
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
||||
const [pings, setPings] = useState({});
|
||||
const [copiedProxy, setCopiedProxy] = useState('');
|
||||
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 serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
|
||||
const proxyUrls = localProxyUrls(state?.proxyPort);
|
||||
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());
|
||||
@@ -74,6 +88,41 @@ export function ClientOverviewPage({
|
||||
return () => clearTimeout(timer);
|
||||
}, [editingSubscription, state?.hasSubscription, subscriptionUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingSubscription) 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, 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]);
|
||||
|
||||
async function toggleConnection() {
|
||||
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
|
||||
if (action?.type === 'stop') return onStop();
|
||||
@@ -98,10 +147,30 @@ export function ClientOverviewPage({
|
||||
try {
|
||||
await navigator.clipboard.writeText(proxyUrls[kind]);
|
||||
setCopiedProxy(kind);
|
||||
setTimeout(() => setCopiedProxy(''), 1400);
|
||||
setTimeout(() => setCopiedProxy(''), 800);
|
||||
} catch {
|
||||
setCopiedProxy('error');
|
||||
setTimeout(() => setCopiedProxy(''), 1400);
|
||||
setTimeout(() => setCopiedProxy(''), 800);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,16 +191,21 @@ export function ClientOverviewPage({
|
||||
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h2 id="connection-title">{connected ? 'VPN включён' : 'VPN выключен'}</h2>
|
||||
{connected && (
|
||||
<time className="client-duration">
|
||||
{formatConnectionDuration(state?.singboxStartedAt, now)}
|
||||
</time>
|
||||
)}
|
||||
{!connected && (
|
||||
<p>{canStart ? 'Нажмите, чтобы подключиться' : 'Добавьте ссылку и выберите сервер'}</p>
|
||||
)}
|
||||
<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" aria-label="Локальный прокси">
|
||||
@@ -151,8 +225,8 @@ export function ClientOverviewPage({
|
||||
aria-label={`Скопировать ${label}: ${proxyUrls[kind]}`}
|
||||
onClick={() => copyProxy(kind)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{copiedProxy === kind && <span className="client-copy-check">✓</span>}
|
||||
<span className="client-copy-label">{label}</span>
|
||||
{copiedProxy === kind && <span className="client-copy-feedback">Copied</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -160,18 +234,39 @@ export function ClientOverviewPage({
|
||||
</section>
|
||||
|
||||
<div className="client-form">
|
||||
<div className={`client-subscription ${editingSubscription ? 'is-editing' : ''}`}>
|
||||
<button
|
||||
<div
|
||||
ref={subscriptionRef}
|
||||
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="client-subscription-summary"
|
||||
type="button"
|
||||
aria-hidden={editingSubscription}
|
||||
inert={editingSubscription ? true : undefined}
|
||||
tabIndex={editingSubscription ? -1 : 0}
|
||||
onClick={() => setEditingSubscription(true)}
|
||||
>
|
||||
<span>Ваша подписка</span>
|
||||
<strong>{subscriptionDomain(state?.subscriptionHost)}</strong>
|
||||
</button>
|
||||
<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>
|
||||
</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"
|
||||
@@ -210,9 +305,42 @@ export function ClientOverviewPage({
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<section className="client-servers" aria-label="Серверы">
|
||||
<div className="client-server-grid">
|
||||
{servers.map((server) => {
|
||||
<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
|
||||
@@ -229,6 +357,7 @@ export function ClientOverviewPage({
|
||||
key={server.tag}
|
||||
disabled={busy}
|
||||
aria-pressed={selected}
|
||||
style={{ '--server-index': index }}
|
||||
onClick={() => selectServer(server.tag)}
|
||||
>
|
||||
<strong>{server.tag}</strong>
|
||||
|
||||
Reference in New Issue
Block a user