This commit is contained in:
2026-05-06 00:10:45 +08:00
parent 2e3eb4b8f8
commit 916a4f5ffc
19 changed files with 2431 additions and 723 deletions
+20
View File
@@ -0,0 +1,20 @@
export function formatLocalDateTime(value: string | null | undefined, fallback = "无") {
if (!value) {
return fallback;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return fallback;
}
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
}
+1
View File
@@ -221,6 +221,7 @@ async function initializeDb(db: Database.Database) {
settings.run("clash_template", defaultTemplate, now);
settings.run("extra_proxy_groups", "", now);
settings.run("profile_name", "FeieProxy", now);
settings.run("main_proxy_group_member_slugs_json", "[]", now);
settings.run("proxy_groups_json", "[]", now);
const existingAdminUser = db
+328 -1
View File
@@ -59,6 +59,14 @@ export type ProxyGroupConfig = {
memberSlugs: string[];
};
export type SubscriptionSettings = {
template: string;
extraProxyGroups: string;
profileName: string;
mainProxyGroupMemberSlugs: string[];
proxyGroups: ProxyGroupConfig[];
};
type AuditRecordInput = {
nodeId?: number | null;
userId?: number | null;
@@ -478,6 +486,93 @@ export async function listAudits(limit = 100) {
}>;
}
type DashboardTrafficTrendRow = {
bucket_start: string;
node_id: number;
auth_id: string;
tx_bytes: number;
rx_bytes: number;
total_bytes: number;
};
type DashboardLoadTrendRow = {
bucket_start: string;
node_id: number;
online_users: number;
stream_count: number;
};
type DashboardFailureTrendRow = {
bucket_start: string;
node_id: number;
failed_count: number;
};
type DashboardTopConsumerRow = {
node_id: number;
auth_id: string;
username: string;
tx_bytes: number;
rx_bytes: number;
total_bytes: number;
};
type DashboardTrafficUserOption = {
auth_id: string;
username: string;
total_bytes: number;
};
function createUtcBucketSeries(options: {
size: number;
unit: "hour" | "day";
}) {
const end = new Date();
if (options.unit === "hour") {
end.setUTCMinutes(0, 0, 0);
} else {
end.setUTCHours(0, 0, 0, 0);
}
return Array.from({ length: options.size }, (_, index) => {
const bucket = new Date(end);
const offset = options.size - index - 1;
if (options.unit === "hour") {
bucket.setUTCHours(bucket.getUTCHours() - offset);
} else {
bucket.setUTCDate(bucket.getUTCDate() - offset);
}
return bucket.toISOString();
});
}
function getUtcBucketKey(value: string, unit: "hour" | "day") {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
if (unit === "hour") {
date.setUTCMinutes(0, 0, 0);
} else {
date.setUTCHours(0, 0, 0, 0);
}
return date.toISOString();
}
function getSeriesMapKey(bucketStart: string, nodeId: number) {
return `${bucketStart}:${nodeId}`;
}
function getTrafficSeriesMapKey(bucketStart: string, nodeId: number, authId: string) {
return `${bucketStart}|${nodeId}|${authId}`;
}
export async function getDashboardMetrics() {
const db = await ensureDb();
@@ -625,6 +720,215 @@ export async function getDashboardMetrics() {
node_name: string;
}>;
const hourlyBuckets = createUtcBucketSeries({ size: 24, unit: "hour" });
const dailyBuckets = createUtcBucketSeries({ size: 14, unit: "day" });
const weeklyBuckets = createUtcBucketSeries({ size: 7, unit: "day" });
const trackedNodeIds = nodeHealth.map((node) => node.id);
const trafficCutoff = hourlyBuckets[0] ?? new Date().toISOString();
const failureCutoff = dailyBuckets[0] ?? new Date().toISOString();
const consumerCutoff = weeklyBuckets[0] ?? new Date().toISOString();
const trafficRows = db
.prepare(
`
SELECT
node_id,
auth_id,
window_ended_at,
tx_bytes,
rx_bytes
FROM traffic_ledgers
WHERE window_ended_at >= ?
`,
)
.all(trafficCutoff) as Array<{
node_id: number;
auth_id: string;
window_ended_at: string;
tx_bytes: number;
rx_bytes: number;
}>;
const trafficBucketSet = new Set(hourlyBuckets);
const trafficMap = new Map<string, { tx_bytes: number; rx_bytes: number }>();
for (const row of trafficRows) {
const bucketStart = getUtcBucketKey(row.window_ended_at, "hour");
if (!bucketStart || !trafficBucketSet.has(bucketStart)) {
continue;
}
const key = getTrafficSeriesMapKey(bucketStart, row.node_id, row.auth_id);
const existing = trafficMap.get(key) ?? { tx_bytes: 0, rx_bytes: 0 };
existing.tx_bytes += row.tx_bytes;
existing.rx_bytes += row.rx_bytes;
trafficMap.set(key, existing);
}
const trafficTrend: DashboardTrafficTrendRow[] = [];
for (const [key, counters] of trafficMap.entries()) {
const [bucketStart, nodeId, authId] = key.split("|");
trafficTrend.push({
bucket_start: bucketStart,
node_id: Number(nodeId),
auth_id: authId,
tx_bytes: counters.tx_bytes,
rx_bytes: counters.rx_bytes,
total_bytes: counters.tx_bytes + counters.rx_bytes,
});
}
const loadRows = db
.prepare(
`
SELECT
node_id,
finished_at,
online_users,
stream_count
FROM node_sync_records
WHERE ok = 1 AND finished_at >= ?
`,
)
.all(trafficCutoff) as Array<{
node_id: number;
finished_at: string;
online_users: number;
stream_count: number;
}>;
const loadBucketSet = new Set(hourlyBuckets);
const loadMap = new Map<string, { online_users: number; stream_count: number }>();
for (const row of loadRows) {
const bucketStart = getUtcBucketKey(row.finished_at, "hour");
if (!bucketStart || !loadBucketSet.has(bucketStart)) {
continue;
}
const key = getSeriesMapKey(bucketStart, row.node_id);
const existing = loadMap.get(key) ?? { online_users: 0, stream_count: 0 };
existing.online_users = Math.max(existing.online_users, row.online_users);
existing.stream_count = Math.max(existing.stream_count, row.stream_count);
loadMap.set(key, existing);
}
const loadTrend: DashboardLoadTrendRow[] = [];
for (const bucketStart of hourlyBuckets) {
for (const nodeId of trackedNodeIds) {
const snapshot = loadMap.get(getSeriesMapKey(bucketStart, nodeId)) ?? {
online_users: 0,
stream_count: 0,
};
loadTrend.push({
bucket_start: bucketStart,
node_id: nodeId,
online_users: snapshot.online_users,
stream_count: snapshot.stream_count,
});
}
}
const failureRows = db
.prepare(
`
SELECT
COALESCE(node_id, 0) AS node_id,
created_at
FROM auth_audits
WHERE ok = 0 AND created_at >= ?
`,
)
.all(failureCutoff) as Array<{
node_id: number;
created_at: string;
}>;
const failureBucketSet = new Set(dailyBuckets);
const failureMap = new Map<string, number>();
for (const row of failureRows) {
const bucketStart = getUtcBucketKey(row.created_at, "day");
if (!bucketStart || !failureBucketSet.has(bucketStart)) {
continue;
}
const key = getSeriesMapKey(bucketStart, row.node_id);
failureMap.set(key, (failureMap.get(key) ?? 0) + 1);
}
const failureNodeIds = trackedNodeIds.includes(0) ? trackedNodeIds : [0, ...trackedNodeIds];
const failureTrend: DashboardFailureTrendRow[] = [];
for (const bucketStart of dailyBuckets) {
for (const nodeId of failureNodeIds) {
failureTrend.push({
bucket_start: bucketStart,
node_id: nodeId,
failed_count: failureMap.get(getSeriesMapKey(bucketStart, nodeId)) ?? 0,
});
}
}
const topConsumerRows = db
.prepare(
`
SELECT
l.node_id,
l.auth_id,
COALESCE(u.username, l.auth_id) AS username,
SUM(l.tx_bytes) AS tx_bytes,
SUM(l.rx_bytes) AS rx_bytes,
SUM(l.tx_bytes + l.rx_bytes) AS total_bytes
FROM traffic_ledgers l
LEFT JOIN hy2_users u ON u.id = l.user_id
WHERE l.window_ended_at >= ?
GROUP BY l.node_id, l.auth_id, username
`,
)
.all(consumerCutoff) as DashboardTopConsumerRow[];
const trafficUserOptions = db
.prepare(
`
SELECT
auth_id,
username,
SUM(total_bytes) AS total_bytes
FROM (
SELECT
u.auth_id,
u.username,
COALESCE(SUM(l.tx_bytes + l.rx_bytes), 0) AS total_bytes
FROM hy2_users u
LEFT JOIN traffic_ledgers l
ON l.auth_id = u.auth_id AND l.window_ended_at >= ?
GROUP BY u.auth_id, u.username
UNION ALL
SELECT
l.auth_id,
l.auth_id AS username,
SUM(l.tx_bytes + l.rx_bytes) AS total_bytes
FROM traffic_ledgers l
LEFT JOIN hy2_users u ON u.auth_id = l.auth_id
WHERE u.id IS NULL AND l.window_ended_at >= ?
GROUP BY l.auth_id
)
GROUP BY auth_id, username
ORDER BY total_bytes DESC, username ASC
`,
)
.all(consumerCutoff, consumerCutoff) as DashboardTrafficUserOption[];
return {
totals,
topUsers,
@@ -632,6 +936,12 @@ export async function getDashboardMetrics() {
recentFailures,
recentUsage,
syncErrors,
trafficTrend,
trafficBuckets: hourlyBuckets,
trafficUserOptions,
loadTrend,
failureTrend,
topConsumerRows,
};
}
@@ -760,8 +1070,11 @@ export async function getSubscriptionSettings() {
template: map.get("clash_template") ?? "",
extraProxyGroups: map.get("extra_proxy_groups") ?? "",
profileName: map.get("profile_name") ?? "FeieProxy",
mainProxyGroupMemberSlugs: JSON.parse(
map.get("main_proxy_group_member_slugs_json") ?? "[]",
) as string[],
proxyGroups: JSON.parse(map.get("proxy_groups_json") ?? "[]") as ProxyGroupConfig[],
};
} satisfies SubscriptionSettings;
}
export async function saveSubscriptionSettings(input: {
@@ -799,6 +1112,20 @@ export async function saveProxyGroups(proxyGroups: ProxyGroupConfig[]) {
).run("proxy_groups_json", JSON.stringify(proxyGroups), now);
}
export async function saveMainProxyGroupMemberSlugs(memberSlugs: string[]) {
const db = await ensureDb();
const now = dbNow();
db.prepare(
`
INSERT INTO app_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at
`,
).run("main_proxy_group_member_slugs_json", JSON.stringify(memberSlugs), now);
}
export async function listSubscriptionNodes() {
const db = await ensureDb();
return db
+35 -2
View File
@@ -14,6 +14,7 @@ type ClashSettings = {
template: string;
extraProxyGroups: string;
profileName: string;
mainProxyGroupMemberSlugs: string[];
proxyGroups: ProxyGroupConfig[];
};
@@ -63,6 +64,34 @@ function renderGroupEntry(name: string, proxyNames: string[]) {
].join("\n");
}
function orderNodesBySlugs(nodes: Hy2NodeRow[], orderedSlugs: string[]) {
if (orderedSlugs.length === 0) {
return nodes;
}
const nodesBySlug = new Map(nodes.map((node) => [node.slug, node]));
const seen = new Set<string>();
const orderedNodes: Hy2NodeRow[] = [];
for (const slug of orderedSlugs) {
const node = nodesBySlug.get(slug);
if (!node || seen.has(slug)) {
continue;
}
orderedNodes.push(node);
seen.add(slug);
}
for (const node of nodes) {
if (!seen.has(node.slug)) {
orderedNodes.push(node);
}
}
return orderedNodes;
}
export function encodeRFC5987ValueChars(value: string) {
return encodeURIComponent(value)
.replace(/['()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
@@ -141,11 +170,15 @@ export function buildSubscriptionConfig(input: {
const eligibleNodes = input.nodes.filter(
(node) => node.enabled && node.server_host && node.server_port > 0,
);
const orderedEligibleNodes = orderNodesBySlugs(
eligibleNodes,
input.settings.mainProxyGroupMemberSlugs,
);
const nodeNameBySlug = new Map(
eligibleNodes.map((node) => [node.slug, node.client_name || node.name]),
);
const proxyNames = eligibleNodes.map((node) => node.client_name || node.name);
const proxiesBlock = eligibleNodes.map((node) => renderProxy(node, authString)).join("\n");
const proxyNames = orderedEligibleNodes.map((node) => node.client_name || node.name);
const proxiesBlock = orderedEligibleNodes.map((node) => renderProxy(node, authString)).join("\n");
const defaultTemplate = input.settings.template.trimEnd() || readDefaultTemplate();
const renderedGroups = [
renderGroupEntry(input.settings.profileName || "FeieProxy", proxyNames),