From 578a9007f7fec6601d84980f88bb149459041c38 Mon Sep 17 00:00:00 2001 From: Bernd Storath Date: Wed, 24 Jun 2026 11:17:04 +0200 Subject: [PATCH] refactor --- src/app/components/Clients/TrafficReport.vue | 31 ++--- src/app/utils/math.ts | 4 +- src/app/utils/traffic.ts | 54 ++++++-- .../database/repositories/traffic/service.ts | 90 ++++---------- src/server/utils/traffic.ts | 115 +++++++++++++++--- src/test/unit/traffic.app.spec.ts | 27 ---- src/test/unit/traffic.spec.ts | 64 +++++++++- 7 files changed, 244 insertions(+), 141 deletions(-) delete mode 100644 src/test/unit/traffic.app.spec.ts 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, + }, + ], + }); + }); });