mirror of https://github.com/wg-easy/wg-easy
committed by
GitHub
27 changed files with 2629 additions and 7 deletions
@ -0,0 +1,262 @@ |
|||
<template> |
|||
<Panel class="mt-4"> |
|||
<PanelHead> |
|||
<PanelHeadTitle> |
|||
{{ $t('client.trafficUsage') }} |
|||
</PanelHeadTitle> |
|||
</PanelHead> |
|||
<PanelBody> |
|||
<div class="space-y-6"> |
|||
<p class="text-sm text-gray-500 dark:text-neutral-300"> |
|||
{{ $t('client.trafficUsageDesc') }} |
|||
</p> |
|||
|
|||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
|||
<div> |
|||
<FormLabel for="trafficPeriod"> |
|||
{{ $t('client.trafficPeriod') }} |
|||
</FormLabel> |
|||
<select |
|||
id="trafficPeriod" |
|||
v-model="period" |
|||
class="w-full rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200" |
|||
> |
|||
<option |
|||
v-for="option in periodOptions" |
|||
:key="option.value" |
|||
:value="option.value" |
|||
> |
|||
{{ option.label }} |
|||
</option> |
|||
</select> |
|||
</div> |
|||
|
|||
<div> |
|||
<FormLabel for="trafficDate"> |
|||
{{ $t('client.trafficDate') }} |
|||
</FormLabel> |
|||
<BaseInput |
|||
id="trafficDate" |
|||
v-model="date" |
|||
class="w-full" |
|||
type="date" |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="min-h-5 text-sm text-gray-500 dark:text-neutral-300"> |
|||
<span v-if="pending && report"> |
|||
{{ $t('client.trafficLoading') }} |
|||
</span> |
|||
<span |
|||
v-else-if="error && report" |
|||
class="text-red-800 dark:text-red-300" |
|||
> |
|||
{{ $t('client.trafficLoadError') }} |
|||
</span> |
|||
</div> |
|||
|
|||
<div |
|||
v-if="pending && !report" |
|||
class="flex min-h-96 items-center text-sm text-gray-500 dark:text-neutral-300" |
|||
> |
|||
{{ $t('client.trafficLoading') }} |
|||
</div> |
|||
<div |
|||
v-else-if="error && !report" |
|||
class="flex min-h-96 items-center text-sm text-red-800 dark:text-red-300" |
|||
> |
|||
{{ $t('client.trafficLoadError') }} |
|||
</div> |
|||
<template v-else-if="report"> |
|||
<div class="text-sm text-gray-500 dark:text-neutral-300"> |
|||
<span class="font-medium text-gray-700 dark:text-neutral-200"> |
|||
{{ $t('client.trafficRange') }}: |
|||
</span> |
|||
{{ |
|||
$t('client.trafficRangeValue', { |
|||
start: report.start, |
|||
end: report.endExclusive, |
|||
}) |
|||
}} |
|||
</div> |
|||
|
|||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"> |
|||
<div |
|||
v-for="stat in stats" |
|||
:key="stat.label" |
|||
class="rounded-lg border-2 border-gray-100 p-4 dark:border-neutral-800" |
|||
> |
|||
<div |
|||
class="text-xs uppercase tracking-wide text-gray-500 dark:text-neutral-400" |
|||
> |
|||
{{ stat.label }} |
|||
</div> |
|||
<div class="mt-1 text-lg font-medium"> |
|||
{{ stat.value }} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div |
|||
class="rounded-lg border-2 border-gray-100 p-4 dark:border-neutral-800" |
|||
> |
|||
<div class="flex items-center justify-between gap-3"> |
|||
<div> |
|||
<div |
|||
class="text-xs uppercase tracking-wide text-gray-500 dark:text-neutral-400" |
|||
> |
|||
{{ $t('client.trafficQuota') }} |
|||
</div> |
|||
<div class="mt-1 text-lg font-medium"> |
|||
{{ quotaLabel }} |
|||
</div> |
|||
</div> |
|||
|
|||
<span |
|||
v-if="report.exceeded" |
|||
class="rounded-full bg-red-100 px-3 py-1 text-sm text-red-800 dark:bg-red-950 dark:text-red-200" |
|||
> |
|||
{{ $t('client.trafficExceeded') }} |
|||
</span> |
|||
</div> |
|||
|
|||
<div v-if="quotaUsagePercent !== null" class="mt-4"> |
|||
<div class="h-2 overflow-hidden rounded-full bg-gray-100"> |
|||
<div |
|||
class="h-full rounded-full" |
|||
:class="report.exceeded ? 'bg-red-800' : 'bg-red-700'" |
|||
:style="{ width: `${quotaUsagePercent}%` }" |
|||
/> |
|||
</div> |
|||
<div class="mt-2 text-sm text-gray-500 dark:text-neutral-300"> |
|||
{{ quotaUsagePercentLabel }} {{ $t('client.trafficQuotaUsed') }} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div |
|||
class="h-72 overflow-auto rounded-lg border-2 border-gray-100 dark:border-neutral-800" |
|||
> |
|||
<table v-if="report.days.length > 0" class="w-full text-sm"> |
|||
<thead> |
|||
<tr class="border-b border-gray-100 dark:border-neutral-800"> |
|||
<th |
|||
class="sticky top-0 bg-white px-4 py-2 text-left font-medium dark:bg-neutral-700" |
|||
> |
|||
{{ $t('client.trafficDate') }} |
|||
</th> |
|||
<th |
|||
class="sticky top-0 bg-white px-4 py-2 text-right font-medium dark:bg-neutral-700" |
|||
> |
|||
{{ $t('client.trafficReceived') }} |
|||
</th> |
|||
<th |
|||
class="sticky top-0 bg-white px-4 py-2 text-right font-medium dark:bg-neutral-700" |
|||
> |
|||
{{ $t('client.trafficSent') }} |
|||
</th> |
|||
<th |
|||
class="sticky top-0 bg-white px-4 py-2 text-right font-medium dark:bg-neutral-700" |
|||
> |
|||
{{ $t('client.trafficTotal') }} |
|||
</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr |
|||
v-for="day in report.days" |
|||
:key="day.date" |
|||
class="border-b border-gray-100 last:border-b-0 dark:border-neutral-800" |
|||
> |
|||
<td class="px-4 py-2"> |
|||
{{ day.date }} |
|||
</td> |
|||
<td class="px-4 py-2 text-right"> |
|||
{{ formatTrafficReportDayBytes(day, 'receivedBytes') }} |
|||
</td> |
|||
<td class="px-4 py-2 text-right"> |
|||
{{ formatTrafficReportDayBytes(day, 'sentBytes') }} |
|||
</td> |
|||
<td class="py-2 pl-4 text-right"> |
|||
{{ formatTrafficReportDayBytes(day, 'totalBytes') }} |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
|
|||
<div |
|||
v-else |
|||
class="flex h-full items-center px-4 text-sm text-gray-500 dark:text-neutral-300" |
|||
> |
|||
{{ $t('client.trafficNoData') }} |
|||
</div> |
|||
</div> |
|||
</template> |
|||
</div> |
|||
</PanelBody> |
|||
</Panel> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
const props = defineProps<{ clientId: number }>(); |
|||
const { t } = useI18n(); |
|||
|
|||
const period = ref<TrafficPeriod>('daily'); |
|||
const date = ref(formatUtcDate(new Date())); |
|||
|
|||
const periodOptions = computed<Array<{ label: string; value: TrafficPeriod }>>( |
|||
() => [ |
|||
{ label: t('client.trafficPeriodDaily'), value: 'daily' }, |
|||
{ label: t('client.trafficPeriodWeekly'), value: 'weekly' }, |
|||
{ label: t('client.trafficPeriodMonthly'), value: 'monthly' }, |
|||
] |
|||
); |
|||
|
|||
const query = computed(() => |
|||
date.value |
|||
? { period: period.value, date: date.value } |
|||
: { period: period.value } |
|||
); |
|||
|
|||
const { |
|||
data: report, |
|||
error, |
|||
pending, |
|||
} = useFetch<TrafficReport>(() => `/api/client/${props.clientId}/traffic`, { |
|||
method: 'get', |
|||
params: query, |
|||
watch: [period, date], |
|||
}); |
|||
|
|||
const stats = computed(() => { |
|||
if (!report.value) { |
|||
return []; |
|||
} |
|||
|
|||
return getTrafficReportStats(report.value, t); |
|||
}); |
|||
|
|||
const quotaLabel = computed(() => { |
|||
if (!report.value) { |
|||
return t('client.trafficUnlimited'); |
|||
} |
|||
|
|||
return getTrafficQuotaLabel(report.value, t('client.trafficUnlimited')); |
|||
}); |
|||
|
|||
const quotaUsagePercent = computed(() => { |
|||
if (!report.value) { |
|||
return null; |
|||
} |
|||
|
|||
return getTrafficQuotaUsagePercent( |
|||
report.value.totalBytes, |
|||
report.value.quotaBytes |
|||
); |
|||
}); |
|||
|
|||
const quotaUsagePercentLabel = computed(() => |
|||
formatTrafficQuotaUsagePercent(quotaUsagePercent.value) |
|||
); |
|||
</script> |
|||
@ -0,0 +1,64 @@ |
|||
<template> |
|||
<div class="flex items-center"> |
|||
<FormLabel :for="id"> |
|||
{{ label }} |
|||
</FormLabel> |
|||
<BaseTooltip v-if="description" :text="description"> |
|||
<IconsInfo class="size-4" /> |
|||
</BaseTooltip> |
|||
</div> |
|||
<div |
|||
class="flex overflow-hidden rounded-lg border-2 border-gray-100 dark:border-neutral-800" |
|||
> |
|||
<BaseInput |
|||
:id="id" |
|||
:max="maximumGiB" |
|||
:min="minimumGiB" |
|||
:model-value="quotaInput" |
|||
:name="id" |
|||
step="any" |
|||
type="number" |
|||
class="w-full !border-0" |
|||
@blur="onBlur" |
|||
@focus="isFocused = true" |
|||
@update:model-value="updateQuotaInput" |
|||
/> |
|||
<div |
|||
class="flex h-full items-center bg-neutral-200 px-4 dark:bg-neutral-800" |
|||
> |
|||
<span>GiB</span> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts" setup> |
|||
defineProps<{ id: string; label: string; description?: string }>(); |
|||
|
|||
const quotaBytes = defineModel<number | null>({ required: true }); |
|||
const minimumGiB = quotaBytesToGiB(1)!; |
|||
const maximumGiB = quotaBytesToGiB(Number.MAX_SAFE_INTEGER)!; |
|||
|
|||
const isFocused = ref(false); |
|||
const quotaInput = ref(quotaBytesToGiBInput(quotaBytes.value)); |
|||
|
|||
watch(quotaBytes, (value) => { |
|||
if (!isFocused.value) { |
|||
quotaInput.value = quotaBytesToGiBInput(value); |
|||
} |
|||
}); |
|||
|
|||
function updateQuotaInput(value: unknown) { |
|||
const nextValue = typeof value === 'string' ? value : String(value ?? ''); |
|||
quotaInput.value = nextValue; |
|||
|
|||
const nextBytes = quotaGiBInputToBytes(nextValue); |
|||
if (nextBytes !== undefined) { |
|||
quotaBytes.value = nextBytes; |
|||
} |
|||
} |
|||
|
|||
function onBlur() { |
|||
isFocused.value = false; |
|||
quotaInput.value = quotaBytesToGiBInput(quotaBytes.value); |
|||
} |
|||
</script> |
|||
@ -0,0 +1,59 @@ |
|||
export type TrafficReportStat = { |
|||
label: string; |
|||
value: string; |
|||
}; |
|||
|
|||
export function formatTrafficBytes(value: number) { |
|||
return bytes(value, 2, true); |
|||
} |
|||
|
|||
export function getTrafficQuotaUsagePercent( |
|||
totalBytes: number, |
|||
quotaBytes: number | null |
|||
) { |
|||
if (quotaBytes === null) { |
|||
return null; |
|||
} |
|||
|
|||
return Math.min(100, (totalBytes / quotaBytes) * 100); |
|||
} |
|||
|
|||
export function getTrafficReportStats( |
|||
report: TrafficReport, |
|||
translate: (key: string) => string |
|||
): TrafficReportStat[] { |
|||
return [ |
|||
{ |
|||
label: translate('client.trafficReceived'), |
|||
value: formatTrafficBytes(report.receivedBytes), |
|||
}, |
|||
{ |
|||
label: translate('client.trafficSent'), |
|||
value: formatTrafficBytes(report.sentBytes), |
|||
}, |
|||
{ |
|||
label: translate('client.trafficTotal'), |
|||
value: formatTrafficBytes(report.totalBytes), |
|||
}, |
|||
]; |
|||
} |
|||
|
|||
export function getTrafficQuotaLabel( |
|||
report: TrafficReport, |
|||
unlimitedLabel: string |
|||
) { |
|||
return report.quotaBytes === null |
|||
? unlimitedLabel |
|||
: formatTrafficBytes(report.quotaBytes); |
|||
} |
|||
|
|||
export function formatTrafficQuotaUsagePercent(value: number | null) { |
|||
return value === null ? '' : `${Math.round(value)}%`; |
|||
} |
|||
|
|||
export function formatTrafficReportDayBytes( |
|||
day: TrafficReportDay, |
|||
field: 'receivedBytes' | 'sentBytes' | 'totalBytes' |
|||
) { |
|||
return formatTrafficBytes(day[field]); |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
import { createError, getValidatedQuery, getValidatedRouterParams } from 'h3'; |
|||
|
|||
import Database from '#server/utils/Database'; |
|||
import { definePermissionEventHandler } from '#server/utils/handler'; |
|||
import { validateZod } from '#server/utils/types'; |
|||
import { ClientGetSchema } from '#db/repositories/client/types'; |
|||
import { TrafficQuerySchema } from '#db/repositories/traffic/types'; |
|||
import type { TrafficReport } from '#db/repositories/traffic/types'; |
|||
|
|||
export default definePermissionEventHandler( |
|||
'clients', |
|||
'view', |
|||
async ({ event, checkPermissions }): Promise<TrafficReport> => { |
|||
const { clientId } = await getValidatedRouterParams( |
|||
event, |
|||
validateZod(ClientGetSchema, event) |
|||
); |
|||
const query = await getValidatedQuery( |
|||
event, |
|||
validateZod(TrafficQuerySchema, event) |
|||
); |
|||
|
|||
const client = await Database.clients.get(clientId); |
|||
checkPermissions(client); |
|||
|
|||
if (!client) { |
|||
throw createError({ |
|||
statusCode: 404, |
|||
statusMessage: 'Client not found', |
|||
}); |
|||
} |
|||
|
|||
return Database.traffic.getReport(client, query); |
|||
} |
|||
); |
|||
@ -0,0 +1,19 @@ |
|||
CREATE TABLE `client_traffic_state_table` ( |
|||
`client_id` integer PRIMARY KEY NOT NULL, |
|||
`transfer_rx` integer DEFAULT 0 NOT NULL, |
|||
`transfer_tx` integer DEFAULT 0 NOT NULL, |
|||
FOREIGN KEY (`client_id`) REFERENCES `clients_table`(`id`) ON UPDATE cascade ON DELETE cascade |
|||
); |
|||
--> statement-breakpoint |
|||
CREATE TABLE `client_traffic_usage_table` ( |
|||
`client_id` integer NOT NULL, |
|||
`date` text NOT NULL, |
|||
`received_bytes` integer DEFAULT 0 NOT NULL, |
|||
`sent_bytes` integer DEFAULT 0 NOT NULL, |
|||
PRIMARY KEY(`client_id`, `date`), |
|||
FOREIGN KEY (`client_id`) REFERENCES `clients_table`(`id`) ON UPDATE cascade ON DELETE cascade |
|||
); |
|||
--> statement-breakpoint |
|||
ALTER TABLE `clients_table` ADD `daily_quota` integer;--> statement-breakpoint |
|||
ALTER TABLE `clients_table` ADD `weekly_quota` integer;--> statement-breakpoint |
|||
ALTER TABLE `clients_table` ADD `monthly_quota` integer; |
|||
File diff suppressed because it is too large
@ -0,0 +1,45 @@ |
|||
import { relations } from 'drizzle-orm'; |
|||
import { int, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'; |
|||
|
|||
import { client } from '../client/schema'; |
|||
|
|||
export const trafficState = sqliteTable('client_traffic_state_table', { |
|||
clientId: int('client_id') |
|||
.primaryKey() |
|||
.references(() => client.id, { |
|||
onDelete: 'cascade', |
|||
onUpdate: 'cascade', |
|||
}), |
|||
transferRx: int('transfer_rx').notNull().default(0), |
|||
transferTx: int('transfer_tx').notNull().default(0), |
|||
}); |
|||
|
|||
export const trafficUsage = sqliteTable( |
|||
'client_traffic_usage_table', |
|||
{ |
|||
clientId: int('client_id') |
|||
.notNull() |
|||
.references(() => client.id, { |
|||
onDelete: 'cascade', |
|||
onUpdate: 'cascade', |
|||
}), |
|||
date: text().notNull(), |
|||
receivedBytes: int('received_bytes').notNull().default(0), |
|||
sentBytes: int('sent_bytes').notNull().default(0), |
|||
}, |
|||
(table) => [primaryKey({ columns: [table.clientId, table.date] })] |
|||
); |
|||
|
|||
export const trafficStateRelations = relations(trafficState, ({ one }) => ({ |
|||
client: one(client, { |
|||
fields: [trafficState.clientId], |
|||
references: [client.id], |
|||
}), |
|||
})); |
|||
|
|||
export const trafficUsageRelations = relations(trafficUsage, ({ one }) => ({ |
|||
client: one(client, { |
|||
fields: [trafficUsage.clientId], |
|||
references: [client.id], |
|||
}), |
|||
})); |
|||
@ -0,0 +1,199 @@ |
|||
import { and, asc, eq, gte, inArray, lt, sql } from 'drizzle-orm'; |
|||
|
|||
import { client } from '../client/schema'; |
|||
import type { ClientType } from '../client/types'; |
|||
|
|||
import { trafficState, trafficUsage } from './schema'; |
|||
import type { TrafficPeer, TrafficQueryType, TrafficReport } from './types'; |
|||
|
|||
import type { DBType } from '#db/sqlite'; |
|||
import type { ID } from '#server/utils/types'; |
|||
import { |
|||
buildTrafficReport, |
|||
calculateTrafficDelta, |
|||
getExceededTrafficQuotas, |
|||
getTrafficPeriodRange, |
|||
getTrafficQuotaBytes, |
|||
getTrafficQuotaEvaluationRange, |
|||
type TrafficPeriodRange, |
|||
type TrafficDay, |
|||
} from '#server/utils/traffic'; |
|||
import { formatUtcDate, parseUtcDate } from '#shared/utils/time'; |
|||
|
|||
async function getTrafficDays( |
|||
db: Pick<DBType, 'select'>, |
|||
clientId: ID, |
|||
range: TrafficPeriodRange |
|||
): Promise<TrafficDay[]> { |
|||
return db |
|||
.select({ |
|||
date: trafficUsage.date, |
|||
receivedBytes: trafficUsage.receivedBytes, |
|||
sentBytes: trafficUsage.sentBytes, |
|||
}) |
|||
.from(trafficUsage) |
|||
.where( |
|||
and( |
|||
eq(trafficUsage.clientId, clientId), |
|||
gte(trafficUsage.date, range.start), |
|||
lt(trafficUsage.date, range.endExclusive) |
|||
) |
|||
) |
|||
.orderBy(asc(trafficUsage.date)) |
|||
.execute(); |
|||
} |
|||
|
|||
export class TrafficService { |
|||
#db: DBType; |
|||
|
|||
constructor(db: DBType) { |
|||
this.#db = db; |
|||
} |
|||
|
|||
async record(peers: TrafficPeer[], now = new Date()) { |
|||
const peersByPublicKey = new Map( |
|||
peers.map((peer) => [peer.publicKey, peer]) |
|||
); |
|||
const date = formatUtcDate(now); |
|||
|
|||
return this.#db.transaction(async (tx) => { |
|||
const clients = await tx |
|||
.select({ |
|||
id: client.id, |
|||
publicKey: client.publicKey, |
|||
enabled: client.enabled, |
|||
dailyQuota: client.dailyQuota, |
|||
weeklyQuota: client.weeklyQuota, |
|||
monthlyQuota: client.monthlyQuota, |
|||
}) |
|||
.from(client) |
|||
.execute(); |
|||
const states = await tx.select().from(trafficState).execute(); |
|||
const statesByClientId = new Map( |
|||
states.map((state) => [state.clientId, state]) |
|||
); |
|||
|
|||
for (const clientRow of clients) { |
|||
const peer = peersByPublicKey.get(clientRow.publicKey); |
|||
const state = statesByClientId.get(clientRow.id); |
|||
const transferRx = peer?.transferRx ?? 0; |
|||
const transferTx = peer?.transferTx ?? 0; |
|||
const receivedBytes = peer |
|||
? calculateTrafficDelta(state?.transferRx ?? 0, transferRx) |
|||
: 0; |
|||
const sentBytes = peer |
|||
? calculateTrafficDelta(state?.transferTx ?? 0, transferTx) |
|||
: 0; |
|||
|
|||
if (receivedBytes > 0 || sentBytes > 0) { |
|||
await tx |
|||
.insert(trafficUsage) |
|||
.values({ |
|||
clientId: clientRow.id, |
|||
date, |
|||
receivedBytes, |
|||
sentBytes, |
|||
}) |
|||
.onConflictDoUpdate({ |
|||
target: [trafficUsage.clientId, trafficUsage.date], |
|||
set: { |
|||
receivedBytes: sql`${trafficUsage.receivedBytes} + ${receivedBytes}`, |
|||
sentBytes: sql`${trafficUsage.sentBytes} + ${sentBytes}`, |
|||
}, |
|||
}) |
|||
.execute(); |
|||
} |
|||
|
|||
await tx |
|||
.insert(trafficState) |
|||
.values({ clientId: clientRow.id, transferRx, transferTx }) |
|||
.onConflictDoUpdate({ |
|||
target: trafficState.clientId, |
|||
set: { transferRx, transferTx }, |
|||
}) |
|||
.execute(); |
|||
} |
|||
|
|||
const disabledClientIds: ID[] = []; |
|||
for (const clientRow of clients) { |
|||
if ( |
|||
!clientRow.enabled || |
|||
(clientRow.dailyQuota === null && |
|||
clientRow.weeklyQuota === null && |
|||
clientRow.monthlyQuota === null) |
|||
) { |
|||
continue; |
|||
} |
|||
|
|||
const days = await getTrafficDays( |
|||
tx, |
|||
clientRow.id, |
|||
getTrafficQuotaEvaluationRange(now) |
|||
); |
|||
if (getExceededTrafficQuotas(clientRow, days, now).length > 0) { |
|||
disabledClientIds.push(clientRow.id); |
|||
} |
|||
} |
|||
|
|||
if (disabledClientIds.length > 0) { |
|||
await tx |
|||
.update(client) |
|||
.set({ enabled: false }) |
|||
.where(inArray(client.id, disabledClientIds)) |
|||
.execute(); |
|||
} |
|||
|
|||
return disabledClientIds; |
|||
}); |
|||
} |
|||
|
|||
resetBaselines() { |
|||
return this.#db |
|||
.update(trafficState) |
|||
.set({ transferRx: 0, transferTx: 0 }) |
|||
.execute(); |
|||
} |
|||
|
|||
async getExceededQuotas(clientId: ID, now = new Date()) { |
|||
const clientRow = await this.#db.query.client.findFirst({ |
|||
where: eq(client.id, clientId), |
|||
columns: { |
|||
id: true, |
|||
enabled: true, |
|||
dailyQuota: true, |
|||
weeklyQuota: true, |
|||
monthlyQuota: true, |
|||
}, |
|||
}); |
|||
if (!clientRow) { |
|||
return []; |
|||
} |
|||
|
|||
const days = await getTrafficDays( |
|||
this.#db, |
|||
clientId, |
|||
getTrafficQuotaEvaluationRange(now) |
|||
); |
|||
return getExceededTrafficQuotas(clientRow, days, now); |
|||
} |
|||
|
|||
async getReport( |
|||
clientRow: Pick< |
|||
ClientType, |
|||
'id' | 'dailyQuota' | 'weeklyQuota' | 'monthlyQuota' |
|||
>, |
|||
{ period, date }: TrafficQueryType |
|||
): Promise<TrafficReport> { |
|||
const containingDate = date ? parseUtcDate(date)! : new Date(); |
|||
const range = getTrafficPeriodRange(period, containingDate); |
|||
const days = await getTrafficDays(this.#db, clientRow.id, range); |
|||
const quotaBytes = getTrafficQuotaBytes(clientRow, period); |
|||
|
|||
return buildTrafficReport({ |
|||
period, |
|||
quotaBytes, |
|||
range, |
|||
days, |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
import z from 'zod'; |
|||
|
|||
import { parseUtcDate } from '#shared/utils/time'; |
|||
import { TRAFFIC_PERIODS } from '#shared/utils/traffic'; |
|||
|
|||
export type { TrafficReport } from '#shared/utils/traffic'; |
|||
|
|||
export const TrafficQuerySchema = z.object({ |
|||
period: z.enum(TRAFFIC_PERIODS), |
|||
date: z |
|||
.string() |
|||
.refine((value) => parseUtcDate(value) !== null, 'Invalid UTC date') |
|||
.optional(), |
|||
}); |
|||
|
|||
export type TrafficQueryType = z.infer<typeof TrafficQuerySchema>; |
|||
|
|||
export type TrafficPeer = { |
|||
publicKey: string; |
|||
transferRx: number; |
|||
transferTx: number; |
|||
}; |
|||
@ -0,0 +1,172 @@ |
|||
import { formatUtcDate } from '#shared/utils/time'; |
|||
import { |
|||
TRAFFIC_PERIODS, |
|||
type TrafficPeriod, |
|||
type TrafficReport, |
|||
} from '#shared/utils/traffic'; |
|||
|
|||
export type TrafficPeriodRange = { |
|||
start: string; |
|||
endExclusive: string; |
|||
}; |
|||
|
|||
export type TrafficDay = { |
|||
date: string; |
|||
receivedBytes: number; |
|||
sentBytes: number; |
|||
}; |
|||
|
|||
export type TrafficTotals = { |
|||
receivedBytes: number; |
|||
sentBytes: number; |
|||
totalBytes: number; |
|||
}; |
|||
|
|||
export type TrafficQuotas = { |
|||
dailyQuota: number | null; |
|||
weeklyQuota: number | null; |
|||
monthlyQuota: number | null; |
|||
}; |
|||
|
|||
export const TRAFFIC_QUOTA_FIELD = { |
|||
daily: 'dailyQuota', |
|||
weekly: 'weeklyQuota', |
|||
monthly: 'monthlyQuota', |
|||
} as const satisfies Record<TrafficPeriod, keyof TrafficQuotas>; |
|||
|
|||
const DAY_MS = 24 * 60 * 60 * 1000; |
|||
|
|||
export function calculateTrafficDelta(previous: number, current: number) { |
|||
return current >= previous ? current - previous : current; |
|||
} |
|||
|
|||
export function getTrafficPeriodRange( |
|||
period: TrafficPeriod, |
|||
containingDate: Date |
|||
): TrafficPeriodRange { |
|||
const year = containingDate.getUTCFullYear(); |
|||
const month = containingDate.getUTCMonth(); |
|||
const day = containingDate.getUTCDate(); |
|||
let start: Date; |
|||
let endExclusive: Date; |
|||
|
|||
if (period === 'daily') { |
|||
start = new Date(Date.UTC(year, month, day)); |
|||
endExclusive = new Date(start.getTime() + DAY_MS); |
|||
} else if (period === 'weekly') { |
|||
const midnight = new Date(Date.UTC(year, month, day)); |
|||
const daysSinceMonday = (midnight.getUTCDay() + 6) % 7; |
|||
start = new Date(midnight.getTime() - daysSinceMonday * DAY_MS); |
|||
endExclusive = new Date(start.getTime() + 7 * DAY_MS); |
|||
} else { |
|||
start = new Date(Date.UTC(year, month, 1)); |
|||
endExclusive = new Date(Date.UTC(year, month + 1, 1)); |
|||
} |
|||
|
|||
return { |
|||
start: formatUtcDate(start), |
|||
endExclusive: formatUtcDate(endExclusive), |
|||
}; |
|||
} |
|||
|
|||
function getTrafficPeriodRanges(containingDate: Date) { |
|||
return { |
|||
daily: getTrafficPeriodRange('daily', containingDate), |
|||
weekly: getTrafficPeriodRange('weekly', containingDate), |
|||
monthly: getTrafficPeriodRange('monthly', containingDate), |
|||
} as const satisfies Record<TrafficPeriod, TrafficPeriodRange>; |
|||
} |
|||
|
|||
export function getTrafficQuotaEvaluationRange(containingDate: Date) { |
|||
const ranges = Object.values(getTrafficPeriodRanges(containingDate)); |
|||
const start = ranges.map((range) => range.start).sort()[0]!; |
|||
const endExclusive = ranges |
|||
.map((range) => range.endExclusive) |
|||
.sort() |
|||
.at(-1)!; |
|||
|
|||
return { start, endExclusive }; |
|||
} |
|||
|
|||
export function getTrafficQuotaField(period: TrafficPeriod) { |
|||
return TRAFFIC_QUOTA_FIELD[period]; |
|||
} |
|||
|
|||
export function getTrafficQuotaBytes( |
|||
quotas: TrafficQuotas, |
|||
period: TrafficPeriod |
|||
) { |
|||
return quotas[getTrafficQuotaField(period)]; |
|||
} |
|||
|
|||
export function getTrafficTotalBytes( |
|||
traffic: Pick<TrafficDay, 'receivedBytes' | 'sentBytes'> |
|||
) { |
|||
return traffic.receivedBytes + traffic.sentBytes; |
|||
} |
|||
|
|||
export function sumTrafficDays( |
|||
days: TrafficDay[], |
|||
range: TrafficPeriodRange |
|||
): TrafficTotals { |
|||
return days.reduce( |
|||
(result, day) => { |
|||
if (day.date >= range.start && day.date < range.endExclusive) { |
|||
result.receivedBytes += day.receivedBytes; |
|||
result.sentBytes += day.sentBytes; |
|||
result.totalBytes += getTrafficTotalBytes(day); |
|||
} |
|||
return result; |
|||
}, |
|||
{ receivedBytes: 0, sentBytes: 0, totalBytes: 0 } |
|||
); |
|||
} |
|||
|
|||
export function getExceededTrafficQuotas( |
|||
quotas: TrafficQuotas, |
|||
days: TrafficDay[], |
|||
date: Date |
|||
) { |
|||
return TRAFFIC_PERIODS.filter((period) => { |
|||
const quota = getTrafficQuotaBytes(quotas, period); |
|||
if (quota === null) { |
|||
return false; |
|||
} |
|||
|
|||
const usage = sumTrafficDays(days, getTrafficPeriodRange(period, date)); |
|||
return usage.totalBytes >= quota; |
|||
}); |
|||
} |
|||
|
|||
export function buildTrafficReport({ |
|||
period, |
|||
range, |
|||
quotaBytes, |
|||
days, |
|||
}: { |
|||
period: TrafficPeriod; |
|||
range: TrafficPeriodRange; |
|||
quotaBytes: number | null; |
|||
days: TrafficDay[]; |
|||
}): TrafficReport { |
|||
const reportDays = days |
|||
.filter((day) => day.date >= range.start && day.date < range.endExclusive) |
|||
.map((day) => ({ |
|||
...day, |
|||
totalBytes: getTrafficTotalBytes(day), |
|||
})) |
|||
.sort((a, b) => a.date.localeCompare(b.date)); |
|||
const usage = sumTrafficDays(reportDays, range); |
|||
|
|||
return { |
|||
period, |
|||
start: range.start, |
|||
endExclusive: range.endExclusive, |
|||
quotaBytes, |
|||
receivedBytes: usage.receivedBytes, |
|||
sentBytes: usage.sentBytes, |
|||
totalBytes: usage.totalBytes, |
|||
exceeded: quotaBytes !== null && usage.totalBytes >= quotaBytes, |
|||
days: reportDays, |
|||
}; |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
export const TRAFFIC_PERIODS = ['daily', 'weekly', 'monthly'] as const; |
|||
|
|||
export type TrafficPeriod = (typeof TRAFFIC_PERIODS)[number]; |
|||
|
|||
export type TrafficReportDay = { |
|||
date: string; |
|||
receivedBytes: number; |
|||
sentBytes: number; |
|||
totalBytes: number; |
|||
}; |
|||
|
|||
export type TrafficReport = { |
|||
period: TrafficPeriod; |
|||
start: string; |
|||
endExclusive: string; |
|||
quotaBytes: number | null; |
|||
receivedBytes: number; |
|||
sentBytes: number; |
|||
totalBytes: number; |
|||
exceeded: boolean; |
|||
days: TrafficReportDay[]; |
|||
}; |
|||
@ -0,0 +1,9 @@ |
|||
{ |
|||
"extends": "../.nuxt/tsconfig.server.json", |
|||
"compilerOptions": { |
|||
"paths": { |
|||
"#app/*": ["../app/*"] |
|||
} |
|||
}, |
|||
"include": ["./**/*.ts"] |
|||
} |
|||
@ -0,0 +1,53 @@ |
|||
import { describe, expect, test } from 'vitest'; |
|||
|
|||
import { |
|||
GIBIBYTE_IN_BYTES, |
|||
quotaBytesToGiB, |
|||
quotaBytesToGiBInput, |
|||
quotaGiBInputToBytes, |
|||
quotaGiBToBytes, |
|||
} from '#app/utils/math'; |
|||
|
|||
describe('quota conversions', () => { |
|||
test('keeps disabled quotas null', () => { |
|||
expect(quotaBytesToGiB(null)).toBeNull(); |
|||
expect(quotaBytesToGiBInput(null)).toBe(''); |
|||
expect(quotaGiBInputToBytes('')).toBeNull(); |
|||
expect(quotaGiBInputToBytes('0')).toBeNull(); |
|||
expect(quotaGiBToBytes(null)).toBeNull(); |
|||
expect(quotaGiBToBytes(0)).toBeNull(); |
|||
expect(quotaGiBToBytes(0.1 / GIBIBYTE_IN_BYTES)).toBeNull(); |
|||
}); |
|||
|
|||
test('converts whole and fractional GiB to bytes', () => { |
|||
expect(quotaGiBToBytes(1)).toBe(GIBIBYTE_IN_BYTES); |
|||
expect(quotaGiBToBytes(0.5)).toBe(GIBIBYTE_IN_BYTES / 2); |
|||
expect(quotaGiBToBytes(1 / GIBIBYTE_IN_BYTES)).toBe(1); |
|||
expect(quotaGiBInputToBytes('1')).toBe(GIBIBYTE_IN_BYTES); |
|||
expect(quotaGiBInputToBytes('0.5')).toBe(GIBIBYTE_IN_BYTES / 2); |
|||
}); |
|||
|
|||
test('rounds edited GiB values to the nearest byte', () => { |
|||
expect(quotaGiBToBytes(1.5 / GIBIBYTE_IN_BYTES)).toBe(2); |
|||
}); |
|||
|
|||
test.each([1, 150, GIBIBYTE_IN_BYTES, Number.MAX_SAFE_INTEGER])( |
|||
'round-trips the exact byte value %s', |
|||
(bytes) => { |
|||
expect(quotaGiBToBytes(quotaBytesToGiB(bytes))).toBe(bytes); |
|||
expect(quotaGiBInputToBytes(quotaBytesToGiBInput(bytes))).toBe(bytes); |
|||
} |
|||
); |
|||
|
|||
test('formats quota input values without noisy floating point decimals', () => { |
|||
expect(quotaBytesToGiBInput(GIBIBYTE_IN_BYTES)).toBe('1'); |
|||
expect(quotaBytesToGiBInput(GIBIBYTE_IN_BYTES / 2)).toBe('0.5'); |
|||
expect(quotaBytesToGiBInput(107374182)).toBe('0.1'); |
|||
}); |
|||
|
|||
test('ignores invalid quota input values', () => { |
|||
expect(quotaGiBInputToBytes('-1')).toBeUndefined(); |
|||
expect(quotaGiBInputToBytes('invalid')).toBeUndefined(); |
|||
expect(quotaGiBInputToBytes('9007199254740992')).toBeUndefined(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,158 @@ |
|||
import { randomUUID } from 'node:crypto'; |
|||
import { rm } from 'node:fs/promises'; |
|||
import { tmpdir } from 'node:os'; |
|||
import { join } from 'node:path'; |
|||
|
|||
import { createClient } from '@libsql/client'; |
|||
import { count, eq } from 'drizzle-orm'; |
|||
import { drizzle } from 'drizzle-orm/libsql'; |
|||
import { migrate } from 'drizzle-orm/libsql/migrator'; |
|||
import { afterEach, beforeEach, describe, expect, test } from 'vitest'; |
|||
|
|||
import { TrafficService } from '#server/database/repositories/traffic/service'; |
|||
import * as schema from '#server/database/schema'; |
|||
import type { DBType } from '#db/sqlite'; |
|||
import { roles } from '#shared/utils/permissions'; |
|||
|
|||
describe('TrafficService', () => { |
|||
let libsql: ReturnType<typeof createClient>; |
|||
let db: DBType; |
|||
let traffic: TrafficService; |
|||
let databasePath: string; |
|||
|
|||
beforeEach(async () => { |
|||
databasePath = join(tmpdir(), `wg-easy-traffic-${randomUUID()}.db`); |
|||
libsql = createClient({ url: `file:${databasePath}` }); |
|||
db = drizzle({ client: libsql, schema }); |
|||
await migrate(db, { |
|||
migrationsFolder: './server/database/migrations', |
|||
}); |
|||
traffic = new TrafficService(db); |
|||
}); |
|||
|
|||
afterEach(async () => { |
|||
libsql.close(); |
|||
await rm(databasePath, { force: true }); |
|||
}); |
|||
|
|||
async function createTestClient({ |
|||
dailyQuota = null, |
|||
weeklyQuota = null, |
|||
monthlyQuota = null, |
|||
}: { |
|||
dailyQuota?: number | null; |
|||
weeklyQuota?: number | null; |
|||
monthlyQuota?: number | null; |
|||
} = {}) { |
|||
const [testUser] = await db |
|||
.insert(schema.user) |
|||
.values({ |
|||
username: `test-${randomUUID()}`, |
|||
password: null, |
|||
name: 'Test User', |
|||
role: roles.ADMIN, |
|||
totpVerified: false, |
|||
enabled: true, |
|||
}) |
|||
.returning({ id: schema.user.id }); |
|||
const [testClient] = await db |
|||
.insert(schema.client) |
|||
.values({ |
|||
userId: testUser!.id, |
|||
interfaceId: 'wg0', |
|||
name: 'Test Client', |
|||
ipv4Address: '10.8.0.2', |
|||
ipv6Address: 'fdcc:ad94:bacf:61a3::2', |
|||
privateKey: 'private-key', |
|||
publicKey: 'public-key', |
|||
preSharedKey: 'pre-shared-key', |
|||
serverAllowedIps: [], |
|||
persistentKeepalive: 0, |
|||
mtu: 1420, |
|||
enabled: true, |
|||
dailyQuota, |
|||
weeklyQuota, |
|||
monthlyQuota, |
|||
}) |
|||
.returning({ id: schema.client.id }); |
|||
|
|||
return testClient!.id; |
|||
} |
|||
|
|||
test('accumulates deltas, handles resets, and reports sparse days', async () => { |
|||
const clientId = await createTestClient(); |
|||
|
|||
await traffic.record( |
|||
[{ publicKey: 'public-key', transferRx: 100, transferTx: 50 }], |
|||
new Date('2026-06-22T12:00:00Z') |
|||
); |
|||
await traffic.record( |
|||
[{ publicKey: 'public-key', transferRx: 140, transferTx: 70 }], |
|||
new Date('2026-06-22T12:01:00Z') |
|||
); |
|||
await traffic.record( |
|||
[{ publicKey: 'public-key', transferRx: 10, transferTx: 5 }], |
|||
new Date('2026-06-23T00:01:00Z') |
|||
); |
|||
|
|||
await expect( |
|||
traffic.getReport( |
|||
{ |
|||
id: clientId, |
|||
dailyQuota: null, |
|||
weeklyQuota: null, |
|||
monthlyQuota: null, |
|||
}, |
|||
{ period: 'weekly', date: '2026-06-23' } |
|||
) |
|||
).resolves.toMatchObject({ |
|||
receivedBytes: 150, |
|||
sentBytes: 75, |
|||
totalBytes: 225, |
|||
days: [ |
|||
{ |
|||
date: '2026-06-22', |
|||
receivedBytes: 140, |
|||
sentBytes: 70, |
|||
totalBytes: 210, |
|||
}, |
|||
{ |
|||
date: '2026-06-23', |
|||
receivedBytes: 10, |
|||
sentBytes: 5, |
|||
totalBytes: 15, |
|||
}, |
|||
], |
|||
}); |
|||
}); |
|||
|
|||
test('disables at the exact combined quota and cascades history', async () => { |
|||
const clientId = await createTestClient({ |
|||
dailyQuota: 150, |
|||
weeklyQuota: 1000, |
|||
}); |
|||
|
|||
await expect( |
|||
traffic.record( |
|||
[{ publicKey: 'public-key', transferRx: 100, transferTx: 50 }], |
|||
new Date('2026-06-22T12:00:00Z') |
|||
) |
|||
).resolves.toEqual([clientId]); |
|||
|
|||
const [client] = await db |
|||
.select({ enabled: schema.client.enabled }) |
|||
.from(schema.client) |
|||
.where(eq(schema.client.id, clientId)); |
|||
expect(client?.enabled).toBe(false); |
|||
|
|||
await db.delete(schema.client).where(eq(schema.client.id, clientId)); |
|||
const [usage] = await db |
|||
.select({ count: count() }) |
|||
.from(schema.trafficUsage); |
|||
const [state] = await db |
|||
.select({ count: count() }) |
|||
.from(schema.trafficState); |
|||
expect(usage?.count).toBe(0); |
|||
expect(state?.count).toBe(0); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,145 @@ |
|||
import { describe, expect, test } from 'vitest'; |
|||
|
|||
import { |
|||
buildTrafficReport, |
|||
calculateTrafficDelta, |
|||
getExceededTrafficQuotas, |
|||
getTrafficPeriodRange, |
|||
getTrafficQuotaBytes, |
|||
getTrafficQuotaField, |
|||
getTrafficTotalBytes, |
|||
sumTrafficDays, |
|||
} from '#server/utils/traffic'; |
|||
import { parseUtcDate } from '#shared/utils/time'; |
|||
|
|||
describe('traffic accounting', () => { |
|||
test('calculates increasing and reset counter deltas', () => { |
|||
expect(calculateTrafficDelta(100, 150)).toBe(50); |
|||
expect(calculateTrafficDelta(100, 100)).toBe(0); |
|||
expect(calculateTrafficDelta(100, 25)).toBe(25); |
|||
}); |
|||
|
|||
test('validates UTC date keys', () => { |
|||
expect(parseUtcDate('2024-02-29')).not.toBeNull(); |
|||
expect(parseUtcDate('2023-02-29')).toBeNull(); |
|||
expect(parseUtcDate('2024-2-9')).toBeNull(); |
|||
}); |
|||
|
|||
test('uses UTC calendar day boundaries', () => { |
|||
expect( |
|||
getTrafficPeriodRange('daily', new Date('2026-06-22T23:59:59Z')) |
|||
).toEqual({ start: '2026-06-22', endExclusive: '2026-06-23' }); |
|||
}); |
|||
|
|||
test('uses Monday-based weeks across month boundaries', () => { |
|||
expect( |
|||
getTrafficPeriodRange('weekly', new Date('2026-06-01T12:00:00Z')) |
|||
).toEqual({ start: '2026-06-01', endExclusive: '2026-06-08' }); |
|||
expect( |
|||
getTrafficPeriodRange('weekly', new Date('2026-05-31T12:00:00Z')) |
|||
).toEqual({ start: '2026-05-25', endExclusive: '2026-06-01' }); |
|||
}); |
|||
|
|||
test('uses calendar months including leap years', () => { |
|||
expect( |
|||
getTrafficPeriodRange('monthly', new Date('2024-02-29T12:00:00Z')) |
|||
).toEqual({ start: '2024-02-01', endExclusive: '2024-03-01' }); |
|||
}); |
|||
|
|||
test('sums only sparse days in the selected range', () => { |
|||
expect( |
|||
sumTrafficDays( |
|||
[ |
|||
{ date: '2026-05-31', receivedBytes: 100, sentBytes: 100 }, |
|||
{ date: '2026-06-01', receivedBytes: 10, sentBytes: 20 }, |
|||
{ date: '2026-06-03', receivedBytes: 30, sentBytes: 40 }, |
|||
], |
|||
{ start: '2026-06-01', endExclusive: '2026-06-04' } |
|||
) |
|||
).toEqual({ receivedBytes: 40, sentBytes: 60, totalBytes: 100 }); |
|||
}); |
|||
|
|||
test('maps periods to quota fields and byte values', () => { |
|||
const quotas = { |
|||
dailyQuota: 10, |
|||
weeklyQuota: 20, |
|||
monthlyQuota: null, |
|||
}; |
|||
|
|||
expect(getTrafficQuotaField('daily')).toBe('dailyQuota'); |
|||
expect(getTrafficQuotaField('weekly')).toBe('weeklyQuota'); |
|||
expect(getTrafficQuotaField('monthly')).toBe('monthlyQuota'); |
|||
expect(getTrafficQuotaBytes(quotas, 'weekly')).toBe(20); |
|||
expect(getTrafficQuotaBytes(quotas, 'monthly')).toBeNull(); |
|||
}); |
|||
|
|||
test('calculates combined traffic totals', () => { |
|||
expect(getTrafficTotalBytes({ receivedBytes: 40, sentBytes: 60 })).toBe( |
|||
100 |
|||
); |
|||
}); |
|||
|
|||
test('enforces simultaneous quotas at the exact combined limit', () => { |
|||
const date = new Date('2026-06-03T12:00:00Z'); |
|||
const days = [ |
|||
{ date: '2026-06-01', receivedBytes: 30, sentBytes: 20 }, |
|||
{ date: '2026-06-03', receivedBytes: 40, sentBytes: 10 }, |
|||
]; |
|||
|
|||
expect( |
|||
getExceededTrafficQuotas( |
|||
{ dailyQuota: 50, weeklyQuota: 100, monthlyQuota: 101 }, |
|||
days, |
|||
date |
|||
) |
|||
).toEqual(['daily', 'weekly']); |
|||
}); |
|||
|
|||
test('does not enforce disabled quotas', () => { |
|||
expect( |
|||
getExceededTrafficQuotas( |
|||
{ dailyQuota: null, weeklyQuota: null, monthlyQuota: null }, |
|||
[{ date: '2026-06-03', receivedBytes: 100, sentBytes: 100 }], |
|||
new Date('2026-06-03T12:00:00Z') |
|||
) |
|||
).toEqual([]); |
|||
}); |
|||
|
|||
test('builds sparse traffic reports with totals and quota state', () => { |
|||
expect( |
|||
buildTrafficReport({ |
|||
period: 'weekly', |
|||
range: { start: '2026-06-01', endExclusive: '2026-06-08' }, |
|||
quotaBytes: 100, |
|||
days: [ |
|||
{ date: '2026-06-03', receivedBytes: 30, sentBytes: 20 }, |
|||
{ date: '2026-05-31', receivedBytes: 100, sentBytes: 100 }, |
|||
{ date: '2026-06-01', receivedBytes: 10, sentBytes: 40 }, |
|||
], |
|||
}) |
|||
).toEqual({ |
|||
period: 'weekly', |
|||
start: '2026-06-01', |
|||
endExclusive: '2026-06-08', |
|||
quotaBytes: 100, |
|||
receivedBytes: 40, |
|||
sentBytes: 60, |
|||
totalBytes: 100, |
|||
exceeded: true, |
|||
days: [ |
|||
{ |
|||
date: '2026-06-01', |
|||
receivedBytes: 10, |
|||
sentBytes: 40, |
|||
totalBytes: 50, |
|||
}, |
|||
{ |
|||
date: '2026-06-03', |
|||
receivedBytes: 30, |
|||
sentBytes: 20, |
|||
totalBytes: 50, |
|||
}, |
|||
], |
|||
}); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue