Browse Source

refactor

pull/2677/head
Bernd Storath 2 weeks ago
parent
commit
578a9007f7
  1. 31
      src/app/components/Clients/TrafficReport.vue
  2. 4
      src/app/utils/math.ts
  3. 54
      src/app/utils/traffic.ts
  4. 90
      src/server/database/repositories/traffic/service.ts
  5. 115
      src/server/utils/traffic.ts
  6. 27
      src/test/unit/traffic.app.spec.ts
  7. 64
      src/test/unit/traffic.spec.ts

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

@ -173,13 +173,13 @@
{{ day.date }}
</td>
<td class="px-4 py-2 text-right">
{{ bytes(day.receivedBytes, 2, true) }}
{{ formatTrafficReportDayBytes(day, 'receivedBytes') }}
</td>
<td class="px-4 py-2 text-right">
{{ bytes(day.sentBytes, 2, true) }}
{{ formatTrafficReportDayBytes(day, 'sentBytes') }}
</td>
<td class="py-2 pl-4 text-right">
{{ bytes(day.totalBytes, 2, true) }}
{{ formatTrafficReportDayBytes(day, 'totalBytes') }}
</td>
</tr>
</tbody>
@ -203,7 +203,7 @@ const props = defineProps<{ clientId: number }>();
const { t } = useI18n();
const period = ref<TrafficPeriod>('daily');
const date = ref(formatTrafficDateInput());
const date = ref(formatUtcDate(new Date()));
const periodOptions = computed<Array<{ label: string; value: TrafficPeriod }>>(
() => [
@ -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)
);
</script>

4
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);

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

90
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<TrafficPeriod, keyof QuotaClient>;
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<DBType, 'select'>,
clientId: ID,
date: Date
range: TrafficPeriodRange
): Promise<TrafficDay[]> {
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<TrafficReport> {
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,
});
}
}

115
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<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();
@ -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<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: { 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<TrafficPeriod, keyof TrafficQuotas>;
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,
};
}

27
src/test/unit/traffic.app.spec.ts

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

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

Loading…
Cancel
Save