diff --git a/src/app/components/Clients/TrafficReport.vue b/src/app/components/Clients/TrafficReport.vue
index ceb14268..a31ece96 100644
--- a/src/app/components/Clients/TrafficReport.vue
+++ b/src/app/components/Clients/TrafficReport.vue
@@ -173,13 +173,13 @@
{{ day.date }}
- {{ bytes(day.receivedBytes, 2, true) }}
+ {{ formatTrafficReportDayBytes(day, 'receivedBytes') }}
|
- {{ bytes(day.sentBytes, 2, true) }}
+ {{ formatTrafficReportDayBytes(day, 'sentBytes') }}
|
- {{ bytes(day.totalBytes, 2, true) }}
+ {{ formatTrafficReportDayBytes(day, 'totalBytes') }}
|
@@ -203,7 +203,7 @@ const props = defineProps<{ clientId: number }>();
const { t } = useI18n();
const period = ref('daily');
-const date = ref(formatTrafficDateInput());
+const date = ref(formatUtcDate(new Date()));
const periodOptions = computed>(
() => [
@@ -234,28 +234,15 @@ const stats = computed(() => {
return [];
}
- return [
- {
- label: t('client.trafficReceived'),
- value: bytes(report.value.receivedBytes, 2, true),
- },
- {
- label: t('client.trafficSent'),
- value: bytes(report.value.sentBytes, 2, true),
- },
- {
- label: t('client.trafficTotal'),
- value: bytes(report.value.totalBytes, 2, true),
- },
- ];
+ return getTrafficReportStats(report.value, t);
});
const quotaLabel = computed(() => {
- if (!report.value || report.value.quotaBytes === null) {
+ if (!report.value) {
return t('client.trafficUnlimited');
}
- return bytes(report.value.quotaBytes, 2, true);
+ return getTrafficQuotaLabel(report.value, t('client.trafficUnlimited'));
});
const quotaUsagePercent = computed(() => {
@@ -270,8 +257,6 @@ const quotaUsagePercent = computed(() => {
});
const quotaUsagePercentLabel = computed(() =>
- quotaUsagePercent.value === null
- ? ''
- : `${Math.round(quotaUsagePercent.value)}%`
+ formatTrafficQuotaUsagePercent(quotaUsagePercent.value)
);
diff --git a/src/app/utils/math.ts b/src/app/utils/math.ts
index 5e4c670a..1705d76e 100644
--- a/src/app/utils/math.ts
+++ b/src/app/utils/math.ts
@@ -57,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);
diff --git a/src/app/utils/traffic.ts b/src/app/utils/traffic.ts
index 0703c1c3..6e03c149 100644
--- a/src/app/utils/traffic.ts
+++ b/src/app/utils/traffic.ts
@@ -1,12 +1,10 @@
-import { formatUtcDate } from '#shared/utils/time';
-export type {
- TrafficPeriod,
- TrafficReport,
- TrafficReportDay,
-} from '#shared/utils/traffic';
+export type TrafficReportStat = {
+ label: string;
+ value: string;
+};
-export function formatTrafficDateInput(date = new Date()) {
- return formatUtcDate(date);
+export function formatTrafficBytes(value: number) {
+ return bytes(value, 2, true);
}
export function getTrafficQuotaUsagePercent(
@@ -19,3 +17,43 @@ export function getTrafficQuotaUsagePercent(
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]);
+}
diff --git a/src/server/database/repositories/traffic/service.ts b/src/server/database/repositories/traffic/service.ts
index e18b0bb0..071e2888 100644
--- a/src/server/database/repositories/traffic/service.ts
+++ b/src/server/database/repositories/traffic/service.ts
@@ -9,46 +9,22 @@ 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,
- sumTrafficDays,
+ getTrafficQuotaBytes,
+ getTrafficQuotaEvaluationRange,
+ type TrafficPeriodRange,
type TrafficDay,
} from '#server/utils/traffic';
import { formatUtcDate, parseUtcDate } from '#shared/utils/time';
-import type { TrafficPeriod } from '#shared/utils/traffic';
-
-type QuotaClient = Pick<
- ClientType,
- 'id' | 'enabled' | 'dailyQuota' | 'weeklyQuota' | 'monthlyQuota'
->;
-
-const quotaField = {
- daily: 'dailyQuota',
- weekly: 'weeklyQuota',
- monthly: 'monthlyQuota',
-} as const satisfies Record;
-
-function quotaRanges(date: Date) {
- return {
- daily: getTrafficPeriodRange('daily', date),
- weekly: getTrafficPeriodRange('weekly', date),
- monthly: getTrafficPeriodRange('monthly', date),
- };
-}
-async function getRelevantDays(
+async function getTrafficDays(
db: Pick,
clientId: ID,
- date: Date
+ range: TrafficPeriodRange
): Promise {
- const ranges = Object.values(quotaRanges(date));
- const start = ranges.map((range) => range.start).sort()[0]!;
- const endExclusive = ranges
- .map((range) => range.endExclusive)
- .sort()
- .at(-1)!;
-
return db
.select({
date: trafficUsage.date,
@@ -59,10 +35,11 @@ async function getRelevantDays(
.where(
and(
eq(trafficUsage.clientId, clientId),
- gte(trafficUsage.date, start),
- lt(trafficUsage.date, endExclusive)
+ gte(trafficUsage.date, range.start),
+ lt(trafficUsage.date, range.endExclusive)
)
)
+ .orderBy(asc(trafficUsage.date))
.execute();
}
@@ -148,7 +125,11 @@ export class TrafficService {
continue;
}
- const days = await getRelevantDays(tx, clientRow.id, now);
+ const days = await getTrafficDays(
+ tx,
+ clientRow.id,
+ getTrafficQuotaEvaluationRange(now)
+ );
if (getExceededTrafficQuotas(clientRow, days, now).length > 0) {
disabledClientIds.push(clientRow.id);
}
@@ -188,7 +169,11 @@ export class TrafficService {
return [];
}
- const days = await getRelevantDays(this.#db, clientId, now);
+ const days = await getTrafficDays(
+ this.#db,
+ clientId,
+ getTrafficQuotaEvaluationRange(now)
+ );
return getExceededTrafficQuotas(clientRow, days, now);
}
@@ -201,39 +186,14 @@ export class TrafficService {
): Promise {
const containingDate = date ? parseUtcDate(date)! : new Date();
const range = getTrafficPeriodRange(period, containingDate);
- const days = await this.#db
- .select({
- date: trafficUsage.date,
- receivedBytes: trafficUsage.receivedBytes,
- sentBytes: trafficUsage.sentBytes,
- })
- .from(trafficUsage)
- .where(
- and(
- eq(trafficUsage.clientId, clientRow.id),
- gte(trafficUsage.date, range.start),
- lt(trafficUsage.date, range.endExclusive)
- )
- )
- .orderBy(asc(trafficUsage.date))
- .execute();
- const usage = sumTrafficDays(days, range);
- const quotaBytes = clientRow[quotaField[period]];
- const totalBytes = usage.receivedBytes + usage.sentBytes;
+ const days = await getTrafficDays(this.#db, clientRow.id, range);
+ const quotaBytes = getTrafficQuotaBytes(clientRow, period);
- return {
+ return buildTrafficReport({
period,
- start: range.start,
- endExclusive: range.endExclusive,
quotaBytes,
- receivedBytes: usage.receivedBytes,
- sentBytes: usage.sentBytes,
- totalBytes,
- exceeded: quotaBytes !== null && totalBytes >= quotaBytes,
- days: days.map((day) => ({
- ...day,
- totalBytes: day.receivedBytes + day.sentBytes,
- })),
- };
+ range,
+ days,
+ });
}
}
diff --git a/src/server/utils/traffic.ts b/src/server/utils/traffic.ts
index f0d35b90..171f898c 100644
--- a/src/server/utils/traffic.ts
+++ b/src/server/utils/traffic.ts
@@ -1,5 +1,14 @@
import { formatUtcDate } from '#shared/utils/time';
-import { TRAFFIC_PERIODS, type TrafficPeriod } from '#shared/utils/traffic';
+import {
+ TRAFFIC_PERIODS,
+ type TrafficPeriod,
+ type TrafficReport,
+} from '#shared/utils/traffic';
+
+export type TrafficPeriodRange = {
+ start: string;
+ endExclusive: string;
+};
export type TrafficDay = {
date: string;
@@ -7,18 +16,34 @@ export type TrafficDay = {
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;
+
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();
@@ -44,23 +69,56 @@ export function getTrafficPeriodRange(
};
}
-export function calculateTrafficDelta(previous: number, current: number) {
- return current >= previous ? current - previous : current;
+function getTrafficPeriodRanges(containingDate: Date) {
+ return {
+ daily: getTrafficPeriodRange('daily', containingDate),
+ weekly: getTrafficPeriodRange('weekly', containingDate),
+ monthly: getTrafficPeriodRange('monthly', containingDate),
+ } as const satisfies Record;
+}
+
+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
+) {
+ return traffic.receivedBytes + traffic.sentBytes;
}
export function sumTrafficDays(
days: TrafficDay[],
- range: { start: string; endExclusive: string }
-) {
+ 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 }
+ { receivedBytes: 0, sentBytes: 0, totalBytes: 0 }
);
}
@@ -69,19 +127,46 @@ export function getExceededTrafficQuotas(
days: TrafficDay[],
date: Date
) {
- const quotaField = {
- daily: 'dailyQuota',
- weekly: 'weeklyQuota',
- monthly: 'monthlyQuota',
- } as const satisfies Record;
-
return TRAFFIC_PERIODS.filter((period) => {
- const quota = quotas[quotaField[period]];
+ const quota = getTrafficQuotaBytes(quotas, period);
if (quota === null) {
return false;
}
const usage = sumTrafficDays(days, getTrafficPeriodRange(period, date));
- return usage.receivedBytes + usage.sentBytes >= quota;
+ 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,
+ };
+}
diff --git a/src/test/unit/traffic.app.spec.ts b/src/test/unit/traffic.app.spec.ts
deleted file mode 100644
index 8b36d84a..00000000
--- a/src/test/unit/traffic.app.spec.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { describe, expect, test } from 'vitest';
-
-import {
- formatTrafficDateInput,
- getTrafficQuotaUsagePercent,
-} from '#app/utils/traffic';
-
-describe('app traffic helpers', () => {
- test('formats date inputs using the UTC date', () => {
- expect(formatTrafficDateInput(new Date('2026-06-23T23:30:00.000Z'))).toBe(
- '2026-06-23'
- );
- });
-
- test('returns null quota progress for unlimited reports', () => {
- expect(getTrafficQuotaUsagePercent(100, null)).toBeNull();
- });
-
- test('calculates quota progress below and at the limit', () => {
- expect(getTrafficQuotaUsagePercent(25, 100)).toBe(25);
- expect(getTrafficQuotaUsagePercent(100, 100)).toBe(100);
- });
-
- test('clamps quota progress above the limit', () => {
- expect(getTrafficQuotaUsagePercent(150, 100)).toBe(100);
- });
-});
diff --git a/src/test/unit/traffic.spec.ts b/src/test/unit/traffic.spec.ts
index ab43f6b6..9037035b 100644
--- a/src/test/unit/traffic.spec.ts
+++ b/src/test/unit/traffic.spec.ts
@@ -1,9 +1,13 @@
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';
@@ -52,7 +56,27 @@ describe('traffic accounting', () => {
],
{ start: '2026-06-01', endExclusive: '2026-06-04' }
)
- ).toEqual({ receivedBytes: 40, sentBytes: 60 });
+ ).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', () => {
@@ -80,4 +104,42 @@ describe('traffic accounting', () => {
)
).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,
+ },
+ ],
+ });
+ });
});