优化ui
This commit is contained in:
+328
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user