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