Browse Source

add traffic report component

pull/2677/head
Bernd Storath 2 weeks ago
parent
commit
fae5b75193
  1. 246
      src/app/components/Clients/TrafficReport.vue
  2. 1
      src/app/pages/clients/[id].vue
  3. 21
      src/app/utils/traffic.ts
  4. 19
      src/i18n/locales/en.json
  5. 5
      src/server/database/repositories/traffic/service.ts
  6. 22
      src/server/database/repositories/traffic/types.ts
  7. 22
      src/server/utils/traffic.ts
  8. 19
      src/shared/utils/time.ts
  9. 22
      src/shared/utils/traffic.ts
  10. 27
      src/test/unit/traffic.app.spec.ts
  11. 2
      src/test/unit/traffic.spec.ts

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

@ -0,0 +1,246 @@
<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 v-if="pending" class="text-sm text-gray-500 dark:text-neutral-300">
{{ $t('client.trafficLoading') }}
</div>
<div v-else-if="error" class="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="overflow-x-auto">
<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="py-2 pr-4 text-left font-medium">
{{ $t('client.trafficDate') }}
</th>
<th class="px-4 py-2 text-right font-medium">
{{ $t('client.trafficReceived') }}
</th>
<th class="px-4 py-2 text-right font-medium">
{{ $t('client.trafficSent') }}
</th>
<th class="py-2 pl-4 text-right font-medium">
{{ $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="py-2 pr-4">
{{ day.date }}
</td>
<td class="px-4 py-2 text-right">
{{ bytes(day.receivedBytes, 2, true) }}
</td>
<td class="px-4 py-2 text-right">
{{ bytes(day.sentBytes, 2, true) }}
</td>
<td class="py-2 pl-4 text-right">
{{ bytes(day.totalBytes, 2, true) }}
</td>
</tr>
</tbody>
</table>
<div v-else class="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(formatTrafficDateInput());
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 [
{
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),
},
];
});
const quotaLabel = computed(() => {
if (!report.value || report.value.quotaBytes === null) {
return t('client.trafficUnlimited');
}
return bytes(report.value.quotaBytes, 2, true);
});
const quotaUsagePercent = computed(() => {
if (!report.value) {
return null;
}
return getTrafficQuotaUsagePercent(
report.value.totalBytes,
report.value.quotaBytes
);
});
const quotaUsagePercentLabel = computed(() =>
quotaUsagePercent.value === null
? ''
: `${Math.round(quotaUsagePercent.value)}%`
);
</script>

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

@ -226,6 +226,7 @@
</FormElement>
</PanelBody>
</Panel>
<ClientsTrafficReport :client-id="data.id" />
</main>
</template>

21
src/app/utils/traffic.ts

@ -0,0 +1,21 @@
import { formatUtcDate } from '#shared/utils/time';
export type {
TrafficPeriod,
TrafficReport,
TrafficReportDay,
} from '#shared/utils/traffic';
export function formatTrafficDateInput(date = new Date()) {
return formatUtcDate(date);
}
export function getTrafficQuotaUsagePercent(
totalBytes: number,
quotaBytes: number | null
) {
if (quotaBytes === null) {
return null;
}
return Math.min(100, (totalBytes / quotaBytes) * 100);
}

19
src/i18n/locales/en.json

@ -107,6 +107,25 @@
"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",

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

@ -10,14 +10,13 @@ import type { DBType } from '#db/sqlite';
import type { ID } from '#server/utils/types';
import {
calculateTrafficDelta,
formatUtcDate,
getExceededTrafficQuotas,
getTrafficPeriodRange,
parseUtcDate,
sumTrafficDays,
type TrafficDay,
type TrafficPeriod,
} from '#server/utils/traffic';
import { formatUtcDate, parseUtcDate } from '#shared/utils/time';
import type { TrafficPeriod } from '#shared/utils/traffic';
type QuotaClient = Pick<
ClientType,

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

@ -1,6 +1,9 @@
import z from 'zod';
import { TRAFFIC_PERIODS, parseUtcDate } from '#server/utils/traffic';
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),
@ -17,20 +20,3 @@ export type TrafficPeer = {
transferRx: number;
transferTx: number;
};
export type TrafficReport = {
period: TrafficQueryType['period'];
start: string;
endExclusive: string;
quotaBytes: number | null;
receivedBytes: number;
sentBytes: number;
totalBytes: number;
exceeded: boolean;
days: Array<{
date: string;
receivedBytes: number;
sentBytes: number;
totalBytes: number;
}>;
};

22
src/server/utils/traffic.ts

@ -1,6 +1,5 @@
export const TRAFFIC_PERIODS = ['daily', 'weekly', 'monthly'] as const;
export type TrafficPeriod = (typeof TRAFFIC_PERIODS)[number];
import { formatUtcDate } from '#shared/utils/time';
import { TRAFFIC_PERIODS, type TrafficPeriod } from '#shared/utils/traffic';
export type TrafficDay = {
date: string;
@ -16,23 +15,6 @@ export type TrafficQuotas = {
const DAY_MS = 24 * 60 * 60 * 1000;
export function formatUtcDate(date: Date) {
return date.toISOString().slice(0, 10);
}
export function parseUtcDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.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;
}
export function getTrafficPeriodRange(
period: TrafficPeriod,
containingDate: Date

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

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

@ -0,0 +1,27 @@
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);
});
});

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

@ -4,9 +4,9 @@ import {
calculateTrafficDelta,
getExceededTrafficQuotas,
getTrafficPeriodRange,
parseUtcDate,
sumTrafficDays,
} from '#server/utils/traffic';
import { parseUtcDate } from '#shared/utils/time';
describe('traffic accounting', () => {
test('calculates increasing and reset counter deltas', () => {

Loading…
Cancel
Save