diff --git a/src/server/index.js b/src/server/index.js
index c378869..3a2665d 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -4,7 +4,7 @@ import path from "node:path";
import crypto from "node:crypto";
import { spawn, spawnSync } from "node:child_process";
import { settings } from "./config.js";
-import { fetchSubscription } from "./subscription.js";
+import { fetchSubscription, fetchSubscriptionInfo } from "./subscription.js";
import os from "node:os";
import {
buildGatewayConfig,
@@ -1424,6 +1424,19 @@ async function handleApi(req, res) {
return sendJson(res, 200, { success: true, ...parsed });
}
+ if (req.method === "POST" && req.url === "/api/subscription/refresh-info") {
+ const prevState = readJson(settings.statePath, {});
+ if (!prevState.subscriptionUrl) {
+ return sendJson(res, 400, { success: false, error: "Подписка не настроена" });
+ }
+
+ const info = await fetchSubscriptionInfo(prevState.subscriptionUrl);
+ writeJson(settings.statePath, { ...prevState, ...info });
+ const cached = readJson(settings.subscriptionCachePath, null);
+ if (cached) writeJson(settings.subscriptionCachePath, { ...cached, ...info });
+ return sendJson(res, 200, { success: true, ...info });
+ }
+
if (req.method === "DELETE" && req.url === "/api/subscription") {
if (fs.existsSync(settings.subscriptionCachePath))
fs.rmSync(settings.subscriptionCachePath);
diff --git a/src/server/subscription.js b/src/server/subscription.js
index 39621df..a3a7529 100644
--- a/src/server/subscription.js
+++ b/src/server/subscription.js
@@ -136,7 +136,7 @@ export function parseSubscriptionBody(body) {
return { config: parsedConfig, servers };
}
-export async function fetchSubscription(url) {
+async function requestSubscription(url) {
let parsedUrl;
try {
parsedUrl = new URL(url);
@@ -157,6 +157,21 @@ export async function fetchSubscription(url) {
throw new Error(`Subscription request failed: HTTP ${response.status}`);
}
+ return response;
+}
+
+export async function fetchSubscriptionInfo(url) {
+ const response = await requestSubscription(url);
+ await response.body?.cancel();
+ return {
+ userInfo: parseUserInfo(response.headers.get('subscription-userinfo')),
+ fetchedAt: new Date().toISOString(),
+ };
+}
+
+export async function fetchSubscription(url) {
+ const response = await requestSubscription(url);
+
const body = await response.text();
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
const parsed = parseSubscriptionBody(body);
diff --git a/src/web/App.jsx b/src/web/App.jsx
index 9b5670c..1a43f66 100644
--- a/src/web/App.jsx
+++ b/src/web/App.jsx
@@ -133,6 +133,12 @@ function App() {
});
}
+ async function refreshSubscriptionInfo() {
+ const data = await api.subscription.refreshInfo();
+ setState((prev) => prev ? { ...prev, userInfo: data.userInfo, fetchedAt: data.fetchedAt } : prev);
+ return data;
+ }
+
async function forgetSubscription() {
if (!confirm('Удалить подписку и остановить sing-box?')) return;
return withBusy('Подписка удалена', async () => {
@@ -426,6 +432,7 @@ function App() {
pendingTag={pendingTag}
setPendingTag={setPendingTag}
onFetchSubscription={fetchSubscription}
+ onRefreshSubscriptionInfo={refreshSubscriptionInfo}
onApply={applyServer}
onRestart={restartSingbox}
onStop={() => stopSingbox(false)}
diff --git a/src/web/api.js b/src/web/api.js
index 8d39055..24cdd9e 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -65,6 +65,7 @@ export const api = {
method: "POST",
body: JSON.stringify({ url }),
}),
+ refreshInfo: () => request("/api/subscription/refresh-info", { method: "POST" }),
forget: () => request("/api/subscription", { method: "DELETE" }),
},
diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx
index 83e95e3..fa99ca8 100644
--- a/src/web/components/ClientOverviewPage.jsx
+++ b/src/web/components/ClientOverviewPage.jsx
@@ -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({
{canStart ? 'Нажмите, чтобы подключиться' : 'Добавьте ссылку и выберите сервер'}
- )} ++ {canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'} +
+ )} +