mirror of https://github.com/wg-easy/wg-easy
11 changed files with 364 additions and 42 deletions
@ -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> |
||||
@ -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); |
||||
|
} |
||||
@ -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[]; |
||||
|
}; |
||||
@ -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); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue