Browse Source

Merge 578a9007f7 into 78b2838498

pull/2677/merge
Bernd Storath 10 hours ago
committed by GitHub
parent
commit
fb9a19a69b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 262
      src/app/components/Clients/TrafficReport.vue
  2. 64
      src/app/components/Form/QuotaField.vue
  3. 21
      src/app/pages/clients/[id].vue
  4. 51
      src/app/utils/math.ts
  5. 59
      src/app/utils/traffic.ts
  6. 24
      src/i18n/locales/en.json
  7. 24
      src/server/api/client/[clientId]/enable.post.ts
  8. 35
      src/server/api/client/[clientId]/traffic.get.ts
  9. 19
      src/server/database/migrations/0007_curved_wraith.sql
  10. 1154
      src/server/database/migrations/meta/0007_snapshot.json
  11. 7
      src/server/database/migrations/meta/_journal.json
  12. 3
      src/server/database/repositories/client/schema.ts
  13. 10
      src/server/database/repositories/client/types.ts
  14. 45
      src/server/database/repositories/traffic/schema.ts
  15. 199
      src/server/database/repositories/traffic/service.ts
  16. 22
      src/server/database/repositories/traffic/types.ts
  17. 1
      src/server/database/schema.ts
  18. 3
      src/server/database/sqlite.ts
  19. 53
      src/server/utils/WireGuard.ts
  20. 172
      src/server/utils/traffic.ts
  21. 19
      src/shared/utils/time.ts
  22. 22
      src/shared/utils/traffic.ts
  23. 9
      src/test/tsconfig.json
  24. 53
      src/test/unit/math.app.spec.ts
  25. 158
      src/test/unit/traffic-service.spec.ts
  26. 145
      src/test/unit/traffic.spec.ts
  27. 2
      src/vitest.config.ts

262
src/app/components/Clients/TrafficReport.vue

@ -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>

64
src/app/components/Form/QuotaField.vue

@ -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>

21
src/app/pages/clients/[id].vue

@ -29,6 +29,26 @@
:label="$t('client.expireDate')"
/>
</FormGroup>
<FormGroup>
<FormHeading :description="$t('client.trafficQuotasDesc')">
{{ $t('client.trafficQuotas') }}
</FormHeading>
<FormQuotaField
id="dailyQuota"
v-model="data.dailyQuota"
:label="$t('client.dailyQuota')"
/>
<FormQuotaField
id="weeklyQuota"
v-model="data.weeklyQuota"
:label="$t('client.weeklyQuota')"
/>
<FormQuotaField
id="monthlyQuota"
v-model="data.monthlyQuota"
:label="$t('client.monthlyQuota')"
/>
</FormGroup>
<FormGroup>
<FormHeading>{{ $t('client.address') }}</FormHeading>
<FormTextField
@ -206,6 +226,7 @@
</FormElement>
</PanelBody>
</Panel>
<ClientsTrafficReport :client-id="data.id" />
</main>
</template>

51
src/app/utils/math.ts

@ -1,3 +1,50 @@
export const GIBIBYTE_IN_BYTES = 1024 ** 3;
export function quotaBytesToGiB(bytes: number | null): number | null {
return bytes === null ? null : bytes / GIBIBYTE_IN_BYTES;
}
export function quotaBytesToGiBInput(bytes: number | null): string {
if (bytes === null) {
return '';
}
return quotaBytesToGiB(bytes)!
.toFixed(9)
.replace(/\.?0+$/, '');
}
export function quotaGiBToBytes(gibibytes: number | null): number | null {
if (gibibytes === null || gibibytes <= 0) {
return null;
}
const bytes = Math.round(gibibytes * GIBIBYTE_IN_BYTES);
return bytes > 0 ? bytes : null;
}
export function quotaGiBInputToBytes(
gibibytes: string
): number | null | undefined {
const value = gibibytes.trim();
if (value === '') {
return null;
}
const quotaGiB = Number(value);
if (!Number.isFinite(quotaGiB) || quotaGiB < 0) {
return undefined;
}
const quotaBytes = quotaGiBToBytes(quotaGiB);
if (quotaBytes !== null && !Number.isSafeInteger(quotaBytes)) {
return undefined;
}
return quotaBytes;
}
export function bytes(
bytes: number,
decimals = 2,
@ -10,8 +57,8 @@ export function bytes(
const dm =
decimals != null && !Number.isNaN(decimals) && decimals >= 0 ? decimals : 2;
const sizes = kib
? ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB', 'BiB']
: ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'BB'];
? ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB', 'RiB']
: ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'RB'];
let i = Math.floor(Math.log(bytes) / Math.log(k));
if (maxunit !== undefined) {
const index = sizes.indexOf(maxunit);

59
src/app/utils/traffic.ts

@ -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]);
}

24
src/i18n/locales/en.json

@ -102,6 +102,30 @@
"deleteDialog1": "Are you sure you want to delete",
"deleteDialog2": "This action cannot be undone.",
"enabled": "Enabled",
"trafficQuotas": "Traffic Quotas",
"trafficQuotasDesc": "Limits include combined upload and download. Blank for unlimited",
"dailyQuota": "Daily Quota",
"weeklyQuota": "Weekly Quota",
"monthlyQuota": "Monthly Quota",
"trafficUsage": "Traffic Usage",
"trafficUsageDesc": "Traffic history uses UTC calendar periods and quota status includes combined received and sent traffic.",
"trafficPeriod": "Period",
"trafficDate": "Date",
"trafficPeriodDaily": "Daily",
"trafficPeriodWeekly": "Weekly",
"trafficPeriodMonthly": "Monthly",
"trafficRange": "Range",
"trafficRangeValue": "{start} to {end} (exclusive, UTC)",
"trafficReceived": "Received",
"trafficSent": "Sent",
"trafficTotal": "Total",
"trafficQuota": "Quota",
"trafficUnlimited": "Unlimited",
"trafficQuotaUsed": "quota used",
"trafficExceeded": "Quota exceeded",
"trafficLoading": "Loading traffic usage...",
"trafficLoadError": "Traffic usage could not be loaded.",
"trafficNoData": "No traffic recorded for this period.",
"address": "Address",
"serverAllowedIps": "Server Allowed IPs",
"otlDesc": "Generate short one time link",

24
src/server/api/client/[clientId]/enable.post.ts

@ -18,11 +18,14 @@ export default definePermissionEventHandler(
const client = await Database.clients.get(clientId);
checkPermissions(client);
if (
client &&
client.expiresAt !== null &&
new Date() > new Date(client.expiresAt)
) {
if (!client) {
throw createError({
statusCode: 404,
statusMessage: 'Client not found',
});
}
if (client.expiresAt !== null && new Date() > new Date(client.expiresAt)) {
throw createError({
statusCode: 422,
statusMessage:
@ -31,6 +34,17 @@ export default definePermissionEventHandler(
});
}
await WireGuard.updateTrafficStats();
const exceededQuotas = await Database.traffic.getExceededQuotas(clientId);
if (exceededQuotas.length > 0) {
const periods = exceededQuotas.join(', ');
throw createError({
statusCode: 422,
statusMessage: `Client traffic quota exceeded: ${periods}`,
message: `Client traffic quota exceeded: ${periods}`,
});
}
await Database.clients.toggle(clientId, true);
await WireGuard.saveConfig();
return { success: true };

35
src/server/api/client/[clientId]/traffic.get.ts

@ -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);
}
);

19
src/server/database/migrations/0007_curved_wraith.sql

@ -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;

1154
src/server/database/migrations/meta/0007_snapshot.json

File diff suppressed because it is too large

7
src/server/database/migrations/meta/_journal.json

@ -50,6 +50,13 @@
"when": 1782122902196,
"tag": "0006_clear_leech",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1782131238811,
"tag": "0007_curved_wraith",
"breakpoints": true
}
]
}

3
src/server/database/repositories/client/schema.ts

@ -43,6 +43,9 @@ export const client = sqliteTable(
string[] | null
>(),
persistentKeepalive: int('persistent_keepalive').notNull(),
dailyQuota: int('daily_quota'),
weeklyQuota: int('weekly_quota'),
monthlyQuota: int('monthly_quota'),
mtu: int().notNull(),
jC: int('j_c'),
jMin: int('j_min'),

10
src/server/database/repositories/client/types.ts

@ -68,6 +68,13 @@ const serverAllowedIps = z.array(AddressSchema, {
message: t('zod.client.serverAllowedIps'),
});
const quota = z
.number()
.int()
.positive()
.max(Number.MAX_SAFE_INTEGER)
.nullable();
export const ClientCreateSchema = z.object({
name: name,
expiresAt: expiresAt,
@ -110,6 +117,9 @@ export const ClientUpdateSchema = schemaForType<UpdateClientType>()(
i4: ISchema,
i5: ISchema,
persistentKeepalive: PersistentKeepaliveSchema,
dailyQuota: quota,
weeklyQuota: quota,
monthlyQuota: quota,
serverEndpoint: AddressSchema.nullable(),
dns: DnsSchema.nullable(),
})

45
src/server/database/repositories/traffic/schema.ts

@ -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],
}),
}));

199
src/server/database/repositories/traffic/service.ts

@ -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,
});
}
}

22
src/server/database/repositories/traffic/types.ts

@ -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;
};

1
src/server/database/schema.ts

@ -4,5 +4,6 @@ export * from './repositories/general/schema';
export * from './repositories/hooks/schema';
export * from './repositories/interface/schema';
export * from './repositories/oneTimeLink/schema';
export * from './repositories/traffic/schema';
export * from './repositories/user/schema';
export * from './repositories/userConfig/schema';

3
src/server/database/sqlite.ts

@ -11,6 +11,7 @@ import { InterfaceService } from '#db/repositories/interface/service';
import { HooksService } from '#db/repositories/hooks/service';
import { OneTimeLinkService } from '#db/repositories/oneTimeLink/service';
import { ClientService } from '#db/repositories/client/service';
import { TrafficService } from '#db/repositories/traffic/service';
import * as schema from '#db/schema';
import { WG_ENV, WG_INITIAL_ENV } from '#server/utils/config';
@ -43,6 +44,7 @@ class DBService {
interfaces: InterfaceService;
hooks: HooksService;
oneTimeLinks: OneTimeLinkService;
traffic: TrafficService;
constructor(db: DBType) {
this.clients = new ClientService(db);
@ -52,6 +54,7 @@ class DBService {
this.interfaces = new InterfaceService(db);
this.hooks = new HooksService(db);
this.oneTimeLinks = new OneTimeLinkService(db);
this.traffic = new TrafficService(db);
}
}

53
src/server/utils/WireGuard.ts

@ -18,16 +18,59 @@ const generateRandomHeaderValue = () =>
Math.floor(Math.random() * 2147483642) + 5;
class WireGuard {
#trafficUpdate: Promise<ID[]> | null = null;
/**
* Save and sync config
*/
async saveConfig() {
const wgInterface = await Database.interfaces.get();
await this.#recordTraffic(wgInterface);
await this.#saveWireguardConfig(wgInterface);
await this.#syncWireguardConfig(wgInterface);
await this.#applyFirewallRules(wgInterface);
}
async #recordTraffic(wgInterface: InterfaceType) {
if (this.#trafficUpdate) {
return this.#trafficUpdate;
}
const update = (async () => {
const dump = await wg.dump(wgInterface.name);
const disabledClientIds = await Database.traffic.record(dump);
for (const clientId of disabledClientIds) {
WG_DEBUG(`Client ${clientId} exceeded a traffic quota.`);
}
return disabledClientIds;
})();
this.#trafficUpdate = update;
try {
return await update;
} finally {
if (this.#trafficUpdate === update) {
this.#trafficUpdate = null;
}
}
}
/**
* Record current traffic and immediately remove peers that exceeded a quota.
*/
async updateTrafficStats() {
const wgInterface = await Database.interfaces.get();
const disabledClientIds = await this.#recordTraffic(wgInterface);
if (disabledClientIds.length > 0) {
await this.#saveWireguardConfig(wgInterface);
await this.#syncWireguardConfig(wgInterface);
await this.#applyFirewallRules(wgInterface);
}
return disabledClientIds;
}
/**
* Apply firewall rules based on current config
*/
@ -245,6 +288,7 @@ class WireGuard {
throw err;
});
await this.#syncWireguardConfig(wgInterface);
await Database.traffic.resetBaselines();
WG_DEBUG(`Wireguard Interface ${wgInterface.name} started successfully.`);
// Check if firewall was enabled but iptables isn't available
@ -283,15 +327,24 @@ class WireGuard {
// Shutdown wireguard
async Shutdown() {
const wgInterface = await Database.interfaces.get();
await this.#recordTraffic(wgInterface).catch((err) => {
WG_DEBUG('Saving traffic stats before shutdown failed.');
console.error(err);
});
await wg.down(wgInterface.name).catch(() => {});
}
async Restart() {
const wgInterface = await Database.interfaces.get();
await this.#recordTraffic(wgInterface);
await this.#saveWireguardConfig(wgInterface);
await wg.restart(wgInterface.name);
await Database.traffic.resetBaselines();
await this.#applyFirewallRules(wgInterface);
}
async cronJob() {
await this.updateTrafficStats();
const clients = await Database.clients.getAll();
let needsSave = false;
// Expires Feature

172
src/server/utils/traffic.ts

@ -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,
};
}

19
src/shared/utils/time.ts

@ -1,3 +1,5 @@
const UTC_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export function isPeerConnected(client: { latestHandshakeAt: Date | null }) {
if (!client.latestHandshakeAt) {
return false;
@ -13,3 +15,20 @@ export function setIntervalImmediately(func: () => void, interval: number) {
func();
return setInterval(func, interval);
}
export function formatUtcDate(date: Date) {
return date.toISOString().slice(0, 10);
}
export function parseUtcDate(value: string) {
if (!UTC_DATE_PATTERN.test(value)) {
return null;
}
const date = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(date.getTime()) || formatUtcDate(date) !== value) {
return null;
}
return date;
}

22
src/shared/utils/traffic.ts

@ -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[];
};

9
src/test/tsconfig.json

@ -0,0 +1,9 @@
{
"extends": "../.nuxt/tsconfig.server.json",
"compilerOptions": {
"paths": {
"#app/*": ["../app/*"]
}
},
"include": ["./**/*.ts"]
}

53
src/test/unit/math.app.spec.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();
});
});

158
src/test/unit/traffic-service.spec.ts

@ -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);
});
});

145
src/test/unit/traffic.spec.ts

@ -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,
},
],
});
});
});

2
src/vitest.config.ts

@ -9,6 +9,8 @@ export default defineConfig({
resolve: {
alias: {
'#server': fileURLToPath(new URL('./server', import.meta.url)),
'#shared': fileURLToPath(new URL('./shared', import.meta.url)),
'#app': fileURLToPath(new URL('./app', import.meta.url)),
},
},
test: {

Loading…
Cancel
Save