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