Skip to content

Commit e69b1bd

Browse files
committed
chore: update nodes usage response structure
1 parent e14033e commit e69b1bd

File tree

6 files changed

+198
-64
lines changed

6 files changed

+198
-64
lines changed

libs/contract/commands/nodes/stats/get-nodes-usage-by-range.command.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,27 @@ export namespace GetNodesUsageByRangeCommand {
2121
export type RequestQuery = z.infer<typeof RequestQuerySchema>;
2222

2323
export const ResponseSchema = z.object({
24-
response: z.array(
25-
z.object({
26-
nodeUuid: z.string().uuid(),
27-
nodeName: z.string(),
28-
nodeCountryCode: z.string(),
29-
total: z.number(),
30-
totalDownload: z.number(),
31-
totalUpload: z.number(),
32-
humanReadableTotal: z.string(),
33-
humanReadableTotalDownload: z.string(),
34-
humanReadableTotalUpload: z.string(),
35-
date: z.string().transform((str) => new Date(str)),
36-
}),
37-
),
24+
response: z.object({
25+
categories: z.array(z.string()),
26+
sparklineData: z.array(z.number()),
27+
topNodes: z.array(
28+
z.object({
29+
uuid: z.string().uuid(),
30+
name: z.string(),
31+
countryCode: z.string(),
32+
total: z.number(),
33+
}),
34+
),
35+
series: z.array(
36+
z.object({
37+
uuid: z.string().uuid(),
38+
name: z.string(),
39+
countryCode: z.string(),
40+
total: z.number(),
41+
data: z.array(z.number()),
42+
}),
43+
),
44+
}),
3845
});
3946

4047
export type Response = z.infer<typeof ResponseSchema>;

libs/contract/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@remnawave/backend-contract",
3-
"version": "2.3.61",
3+
"version": "2.3.64",
44
"public": true,
55
"license": "AGPL-3.0-only",
66
"description": "A contract library for Remnawave Backend. It can be used in backend and frontend.",
Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
export interface IGetNodesUsageByRange {
2-
nodeName: string;
3-
nodeUuid: string;
4-
nodeCountryCode: string;
2+
uuid: string;
3+
name: string;
4+
countryCode: string;
55
total: bigint;
6-
totalDownload: bigint;
7-
totalUpload: bigint;
8-
date: string;
6+
data: bigint[];
7+
}
8+
9+
export interface ITopNode {
10+
uuid: string;
11+
name: string;
12+
countryCode: string;
13+
total: number;
914
}
Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,37 @@
1-
import { prettyBytesUtil } from '@common/utils/bytes';
2-
3-
import { IGetNodesUsageByRange } from '../interfaces';
1+
import { IGetNodesUsageByRange, ITopNode } from '../interfaces';
42

53
export class GetNodesUsageByRangeResponseModel {
6-
nodeUuid: string;
7-
nodeName: string;
8-
nodeCountryCode: string;
9-
total: number;
10-
totalDownload: number;
11-
totalUpload: number;
12-
humanReadableTotal: string;
13-
humanReadableTotalDownload: string;
14-
humanReadableTotalUpload: string;
15-
date: Date;
4+
public readonly categories: string[];
5+
public readonly series: {
6+
uuid: string;
7+
name: string;
8+
countryCode: string;
9+
total: number;
10+
data: number[];
11+
}[];
12+
public readonly sparklineData: number[];
13+
public readonly topNodes: {
14+
uuid: string;
15+
name: string;
16+
countryCode: string;
17+
total: number;
18+
}[];
1619

17-
constructor(data: IGetNodesUsageByRange) {
18-
this.nodeUuid = data.nodeUuid;
19-
this.nodeName = data.nodeName;
20-
this.nodeCountryCode = data.nodeCountryCode;
21-
this.total = Number(data.total);
22-
this.totalDownload = Number(data.totalDownload);
23-
this.totalUpload = Number(data.totalUpload);
24-
this.date = new Date(data.date);
25-
this.humanReadableTotal = prettyBytesUtil(this.total, true, 3, true);
26-
this.humanReadableTotalDownload = prettyBytesUtil(this.totalDownload, true, 3, true);
27-
this.humanReadableTotalUpload = prettyBytesUtil(this.totalUpload, true, 3, true);
20+
constructor(data: {
21+
categories: string[];
22+
series: IGetNodesUsageByRange[];
23+
sparklineData: number[];
24+
topNodes: ITopNode[];
25+
}) {
26+
this.categories = data.categories;
27+
this.series = data.series.map((item) => ({
28+
uuid: item.uuid,
29+
name: item.name,
30+
countryCode: item.countryCode,
31+
total: Number(item.total),
32+
data: item.data.map((item) => Number(item)),
33+
}));
34+
this.sparklineData = data.sparklineData;
35+
this.topNodes = data.topNodes;
2836
}
2937
}

src/modules/nodes-usage-history/nodes-usage-history.service.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,49 @@ export class NodesUsageHistoryService {
1616
async getNodesUsageByRange(
1717
start: Date,
1818
end: Date,
19-
): Promise<TResult<GetNodesUsageByRangeResponseModel[]>> {
19+
): Promise<TResult<GetNodesUsageByRangeResponseModel>> {
2020
try {
2121
const startDate = dayjs(start).utc().toDate();
2222
const endDate = dayjs(end).utc().toDate();
2323

24+
const dates = this.generateDateArray(startDate, endDate);
25+
26+
const dailyTraffic = await this.nodeUsageHistoryRepository.getDailyTrafficSum(
27+
startDate,
28+
endDate,
29+
dates,
30+
);
31+
32+
const topNodes = await this.nodeUsageHistoryRepository.getTopNodesByTraffic(
33+
startDate,
34+
endDate,
35+
);
36+
2437
const nodesUsage = await this.nodeUsageHistoryRepository.getNodesUsageByRange(
2538
startDate,
2639
endDate,
40+
dates,
2741
);
2842

2943
return ok(
30-
nodesUsage.map((nodeUsage) => new GetNodesUsageByRangeResponseModel(nodeUsage)),
44+
new GetNodesUsageByRangeResponseModel({
45+
categories: dates,
46+
series: nodesUsage,
47+
sparklineData: dailyTraffic,
48+
topNodes: topNodes,
49+
}),
3150
);
3251
} catch (error) {
3352
this.logger.error(error);
34-
return fail(ERRORS.GET_NODES_USAGE_BY_RANGE_ERROR);
53+
return fail(ERRORS.INTERNAL_SERVER_ERROR);
3554
}
3655
}
56+
57+
private generateDateArray(start: Date, end: Date): string[] {
58+
const startDate = dayjs(start).utc().startOf('day');
59+
const endDate = dayjs(end).utc().startOf('day');
60+
const days = endDate.diff(startDate, 'day') + 1;
61+
62+
return Array.from({ length: days }, (_, i) => startDate.add(i, 'day').format('YYYY-MM-DD'));
63+
}
3764
}

src/modules/nodes-usage-history/repositories/nodes-usage-history.repository.ts

Lines changed: 103 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
1+
import { Prisma } from '@prisma/client';
2+
13
import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma';
24
import { TransactionHost } from '@nestjs-cls/transactional';
35
import { Injectable } from '@nestjs/common';
46

7+
import { TxKyselyService } from '@common/database/tx-kysely.service';
58
import { ICrudHistoricalRecords } from '@common/types/crud-port';
69

710
import { NodesUsageHistoryEntity } from '../entities/nodes-usage-history.entity';
11+
import { IGet7DaysStats, IGetNodesUsageByRange, ITopNode } from '../interfaces';
812
import { NodesUsageHistoryConverter } from '../nodes-usage-history.converter';
9-
import { IGet7DaysStats, IGetNodesUsageByRange } from '../interfaces';
1013
import { Get7DaysStatsBuilder } from '../builders';
1114

1215
@Injectable()
1316
export class NodesUsageHistoryRepository implements ICrudHistoricalRecords<NodesUsageHistoryEntity> {
1417
constructor(
1518
private readonly prisma: TransactionHost<TransactionalAdapterPrisma>,
19+
private readonly qb: TxKyselyService,
1620
private readonly converter: NodesUsageHistoryConverter,
1721
) {}
1822

@@ -77,23 +81,106 @@ export class NodesUsageHistoryRepository implements ICrudHistoricalRecords<Nodes
7781
return result;
7882
}
7983

80-
public async getNodesUsageByRange(start: Date, end: Date): Promise<IGetNodesUsageByRange[]> {
81-
return await this.prisma.tx.$queryRaw<IGetNodesUsageByRange[]>`
84+
public async getNodesUsageByRange(
85+
start: Date,
86+
end: Date,
87+
dates: string[],
88+
): Promise<IGetNodesUsageByRange[]> {
89+
const query = Prisma.sql`
90+
WITH daily_usage AS (
91+
SELECT
92+
n.uuid,
93+
n.name,
94+
n.country_code,
95+
DATE_TRUNC('day', h.created_at)::date AS date,
96+
SUM(h.total_bytes) AS bytes
97+
FROM nodes n
98+
INNER JOIN nodes_usage_history h ON h.node_uuid = n.uuid
99+
WHERE
100+
h.created_at >= ${start}
101+
AND h.created_at <= ${end}
102+
GROUP BY n.uuid, n.name, n.country_code, DATE_TRUNC('day', h.created_at)
103+
),
104+
nodes_with_totals AS (
105+
SELECT
106+
uuid,
107+
name,
108+
country_code,
109+
SUM(bytes) AS total_bytes
110+
FROM daily_usage
111+
GROUP BY uuid, name, country_code
112+
)
113+
SELECT
114+
nt.uuid as "uuid",
115+
nt.name as "name",
116+
nt.country_code as "countryCode",
117+
nt.total_bytes as "total",
118+
ARRAY_AGG(
119+
COALESCE(du.bytes, 0)
120+
ORDER BY d.ord
121+
) AS "data"
122+
FROM nodes_with_totals nt
123+
CROSS JOIN unnest(${dates}::date[]) WITH ORDINALITY AS d(date, ord)
124+
LEFT JOIN daily_usage du
125+
ON du.uuid = nt.uuid
126+
AND du.date = d.date
127+
GROUP BY nt.uuid, nt.name, nt.country_code, nt.total_bytes
128+
ORDER BY nt.total_bytes DESC;
129+
`;
130+
131+
return await this.prisma.tx.$queryRaw<IGetNodesUsageByRange[]>(query);
132+
}
133+
134+
public async getTopNodesByTraffic(
135+
start: Date,
136+
end: Date,
137+
limit: number = 5,
138+
): Promise<ITopNode[]> {
139+
const result = await this.qb.kysely
140+
.selectFrom('nodes as n')
141+
.innerJoin('nodesUsageHistory as h', 'h.nodeUuid', 'n.uuid')
142+
.select([
143+
'n.uuid',
144+
'n.name',
145+
'n.countryCode',
146+
(eb) => eb.fn.sum<bigint>('h.totalBytes').as('total'),
147+
])
148+
.where('h.createdAt', '>=', start)
149+
.where('h.createdAt', '<=', end)
150+
.groupBy(['n.uuid', 'n.name', 'n.countryCode'])
151+
.orderBy((eb) => eb.fn.sum<bigint>('h.totalBytes'), 'desc')
152+
.limit(limit)
153+
.execute();
154+
155+
return result.map((item) => ({
156+
uuid: item.uuid,
157+
name: item.name,
158+
countryCode: item.countryCode,
159+
total: Number(item.total),
160+
}));
161+
}
162+
163+
public async getDailyTrafficSum(start: Date, end: Date, dates: string[]): Promise<number[]> {
164+
const query = Prisma.sql`
165+
WITH daily_traffic AS (
166+
SELECT
167+
DATE_TRUNC('day', created_at AT TIME ZONE 'UTC')::date AS date,
168+
SUM(total_bytes) AS bytes
169+
FROM nodes_usage_history
170+
WHERE
171+
created_at >= ${start}
172+
AND created_at <= ${end}
173+
GROUP BY DATE_TRUNC('day', created_at AT TIME ZONE 'UTC')
174+
)
82175
SELECT
83-
n.uuid as "nodeUuid",
84-
n.name as "nodeName",
85-
n.country_code as "nodeCountryCode",
86-
COALESCE(SUM(h."total_bytes"), 0) as total,
87-
COALESCE(SUM(h."download_bytes"), 0) as "totalDownload",
88-
COALESCE(SUM(h."upload_bytes"), 0) as "totalUpload",
89-
DATE_TRUNC('day', h."created_at") as "date"
90-
FROM nodes n
91-
INNER JOIN "nodes_usage_history" h ON h."node_uuid" = n.uuid
92-
AND h."created_at" >= ${start}
93-
AND h."created_at" <= ${end}
94-
GROUP BY n.uuid, n.name, n.country_code, DATE_TRUNC('day', h."created_at")
95-
ORDER BY "date" ASC
176+
COALESCE(dt.bytes, 0) AS value
177+
FROM unnest(${dates}::date[]) WITH ORDINALITY AS d(date, ord)
178+
LEFT JOIN daily_traffic dt ON dt.date = d.date
179+
ORDER BY d.ord;
96180
`;
181+
182+
const result = await this.prisma.tx.$queryRaw<Array<{ value: bigint }>>(query);
183+
return result.map((item) => Number(item.value));
97184
}
98185

99186
public async getSumLifetime(): Promise<string> {

0 commit comments

Comments
 (0)