Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a98cdb0cac | ||
|
|
9fda231d47 | ||
|
|
50e1661c1b |
Generated
+30
@@ -8,8 +8,10 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.38",
|
"vue": "^3.5.38",
|
||||||
|
"vue-chartjs": "^5.3.4",
|
||||||
"vue-i18n": "^11.4.6",
|
"vue-i18n": "^11.4.6",
|
||||||
"vue-router": "^5.1.0"
|
"vue-router": "^5.1.0"
|
||||||
},
|
},
|
||||||
@@ -625,6 +627,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||||
@@ -1734,6 +1742,18 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
@@ -3405,6 +3425,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-chartjs": {
|
||||||
|
"version": "5.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.4.tgz",
|
||||||
|
"integrity": "sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": "^4.1.1",
|
||||||
|
"vue": "^3.0.0-0 || ^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-i18n": {
|
"node_modules/vue-i18n": {
|
||||||
"version": "11.4.6",
|
"version": "11.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.6.tgz",
|
||||||
|
|||||||
@@ -11,8 +11,10 @@
|
|||||||
"type-check": "vue-tsc --build"
|
"type-check": "vue-tsc --build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.38",
|
"vue": "^3.5.38",
|
||||||
|
"vue-chartjs": "^5.3.4",
|
||||||
"vue-i18n": "^11.4.6",
|
"vue-i18n": "^11.4.6",
|
||||||
"vue-router": "^5.1.0"
|
"vue-router": "^5.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Bar } from 'vue-chartjs'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'chart.js'
|
||||||
|
|
||||||
|
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
interface TrafficRecord {
|
||||||
|
date: string
|
||||||
|
rx_bytes: number
|
||||||
|
tx_bytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
records: TrafficRecord[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||||
|
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = computed(() => ({
|
||||||
|
labels: props.records.map(r => r.date.slice(5)),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: t('traffic.upload'),
|
||||||
|
data: props.records.map(r => r.rx_bytes),
|
||||||
|
backgroundColor: 'rgba(14, 165, 233, 0.6)',
|
||||||
|
borderColor: 'rgba(14, 165, 233, 1)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('traffic.download'),
|
||||||
|
data: props.records.map(r => r.tx_bytes),
|
||||||
|
backgroundColor: 'rgba(34, 197, 94, 0.6)',
|
||||||
|
borderColor: 'rgba(34, 197, 94, 1)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top' as const,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (context: any) => {
|
||||||
|
const label = context.dataset.label || ''
|
||||||
|
return `${label}: ${formatBytes(context.parsed.y)}`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
callback: (value: any) => formatBytes(Number(value)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-64">
|
||||||
|
<Bar :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -95,6 +95,18 @@ export default {
|
|||||||
'Are you sure you want to delete user {username}? This action cannot be undone.',
|
'Are you sure you want to delete user {username}? This action cannot be undone.',
|
||||||
confirmDeleteButton: 'Confirm Delete',
|
confirmDeleteButton: 'Confirm Delete',
|
||||||
},
|
},
|
||||||
|
traffic: {
|
||||||
|
myTraffic: 'My Traffic',
|
||||||
|
userTrafficToday: "User Traffic Today",
|
||||||
|
todayTraffic: "Today's Traffic",
|
||||||
|
upload: 'Upload',
|
||||||
|
download: 'Download',
|
||||||
|
total: 'Total',
|
||||||
|
history: 'Traffic History',
|
||||||
|
date: 'Date',
|
||||||
|
noTrafficData: 'No traffic data',
|
||||||
|
trafficHistory7d: 'Traffic - Last 7 Days',
|
||||||
|
},
|
||||||
vpn: {
|
vpn: {
|
||||||
title: 'VPN Management',
|
title: 'VPN Management',
|
||||||
refresh: 'Refresh',
|
refresh: 'Refresh',
|
||||||
|
|||||||
@@ -94,6 +94,18 @@ export default {
|
|||||||
confirmDeleteMessage: '确定要删除用户 {username} 吗?此操作不可撤销。',
|
confirmDeleteMessage: '确定要删除用户 {username} 吗?此操作不可撤销。',
|
||||||
confirmDeleteButton: '确认删除',
|
confirmDeleteButton: '确认删除',
|
||||||
},
|
},
|
||||||
|
traffic: {
|
||||||
|
myTraffic: '我的流量统计',
|
||||||
|
userTrafficToday: '用户今日流量',
|
||||||
|
todayTraffic: '今日流量',
|
||||||
|
upload: '上行',
|
||||||
|
download: '下行',
|
||||||
|
total: '合计',
|
||||||
|
history: '流量历史',
|
||||||
|
date: '日期',
|
||||||
|
noTrafficData: '暂无流量数据',
|
||||||
|
trafficHistory7d: '近 7 天流量',
|
||||||
|
},
|
||||||
vpn: {
|
vpn: {
|
||||||
title: 'VPN 管理',
|
title: 'VPN 管理',
|
||||||
refresh: '刷新',
|
refresh: '刷新',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import TrafficChart from '@/components/TrafficChart.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -25,10 +26,35 @@ interface ClientInfo {
|
|||||||
ip: string
|
ip: string
|
||||||
ip6?: string
|
ip6?: string
|
||||||
connected_at: string
|
connected_at: string
|
||||||
|
rx_bytes: number
|
||||||
|
tx_bytes: number
|
||||||
}
|
}
|
||||||
const vpnClients = ref<ClientInfo[]>([])
|
const vpnClients = ref<ClientInfo[]>([])
|
||||||
const kickError = ref('')
|
const kickError = ref('')
|
||||||
|
|
||||||
|
interface UserTraffic {
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
rx_bytes: number
|
||||||
|
tx_bytes: number
|
||||||
|
total_bytes: number
|
||||||
|
}
|
||||||
|
const userTrafficToday = ref<UserTraffic[]>([])
|
||||||
|
|
||||||
|
interface TrafficRecord {
|
||||||
|
date: string
|
||||||
|
rx_bytes: number
|
||||||
|
tx_bytes: number
|
||||||
|
}
|
||||||
|
const siteTraffic7d = ref<TrafficRecord[]>([])
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||||
|
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
|
||||||
|
}
|
||||||
|
|
||||||
function formatUptime(seconds: number): string {
|
function formatUptime(seconds: number): string {
|
||||||
if (seconds <= 0) return '0m'
|
if (seconds <= 0) return '0m'
|
||||||
const d = Math.floor(seconds / 86400)
|
const d = Math.floor(seconds / 86400)
|
||||||
@@ -82,6 +108,28 @@ async function fetchVpnStatus() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchTrafficToday() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/traffic/today', {
|
||||||
|
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||||
|
})
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
userTrafficToday.value = (data.users || []).sort((a: UserTraffic, b: UserTraffic) => b.total_bytes - a.total_bytes)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSiteTraffic7d() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/traffic/history?days=7', {
|
||||||
|
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||||
|
})
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
siteTraffic7d.value = data.records || []
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleKick(userId: number, username: string) {
|
async function handleKick(userId: number, username: string) {
|
||||||
kickError.value = ''
|
kickError.value = ''
|
||||||
if (userId === authStore.user?.id) {
|
if (userId === authStore.user?.id) {
|
||||||
@@ -107,9 +155,12 @@ onMounted(async () => {
|
|||||||
fetchUserCount()
|
fetchUserCount()
|
||||||
fetchStats()
|
fetchStats()
|
||||||
fetchVpnStatus()
|
fetchVpnStatus()
|
||||||
|
fetchTrafficToday()
|
||||||
|
fetchSiteTraffic7d()
|
||||||
statsTimer = setInterval(() => {
|
statsTimer = setInterval(() => {
|
||||||
fetchStats()
|
fetchStats()
|
||||||
fetchVpnStatus()
|
fetchVpnStatus()
|
||||||
|
fetchTrafficToday()
|
||||||
}, 30000)
|
}, 30000)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -167,18 +218,22 @@ function handleStatClick(route: string) {
|
|||||||
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv4') }}</th>
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv4') }}</th>
|
||||||
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv6') }}</th>
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv6') }}</th>
|
||||||
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.connectTime') }}</th>
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.connectTime') }}</th>
|
||||||
|
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.upload') }}</th>
|
||||||
|
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.download') }}</th>
|
||||||
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.actions') }}</th>
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="!vpnClients.length">
|
<tr v-if="!vpnClients.length">
|
||||||
<td colspan="5" class="px-6 py-6 text-center text-gray-400">{{ t('vpn.noOnlineClients') }}</td>
|
<td colspan="7" class="px-6 py-6 text-center text-gray-400">{{ t('vpn.noOnlineClients') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="(c, i) in vpnClients" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
|
<tr v-for="(c, i) in vpnClients" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
|
||||||
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ c.username }}</td>
|
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ c.username }}</td>
|
||||||
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip }}</td>
|
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip }}</td>
|
||||||
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip6 || '-' }}</td>
|
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip6 || '-' }}</td>
|
||||||
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ c.connected_at }}</td>
|
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ c.connected_at }}</td>
|
||||||
|
<td class="px-6 py-3 text-right text-gray-700 dark:text-gray-300 tabular-nums">{{ formatBytes(c.rx_bytes) }}</td>
|
||||||
|
<td class="px-6 py-3 text-right text-gray-700 dark:text-gray-300 tabular-nums">{{ formatBytes(c.tx_bytes) }}</td>
|
||||||
<td class="px-6 py-3">
|
<td class="px-6 py-3">
|
||||||
<button
|
<button
|
||||||
class="px-3 py-1 text-xs rounded-md font-medium text-red-700 bg-red-50 hover:bg-red-100 dark:text-red-400 dark:bg-red-900/20 transition-colors"
|
class="px-3 py-1 text-xs rounded-md font-medium text-red-700 bg-red-50 hover:bg-red-100 dark:text-red-400 dark:bg-red-900/20 transition-colors"
|
||||||
@@ -192,5 +247,35 @@ function handleStatClick(route: string) {
|
|||||||
</table>
|
</table>
|
||||||
<p v-if="kickError" class="text-sm text-red-500 px-6 pb-4">{{ kickError }}</p>
|
<p v-if="kickError" class="text-sm text-red-500 px-6 pb-4">{{ kickError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('traffic.trafficHistory7d') }}</h3>
|
||||||
|
<TrafficChart :records="siteTraffic7d" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white p-6 pb-4">{{ t('traffic.userTrafficToday') }}</h3>
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.username') }}</th>
|
||||||
|
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.upload') }}</th>
|
||||||
|
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.download') }}</th>
|
||||||
|
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.total') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="!userTrafficToday.length">
|
||||||
|
<td colspan="4" class="px-6 py-6 text-center text-gray-400">{{ t('traffic.noTrafficData') }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="(u, i) in userTrafficToday" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
|
||||||
|
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ u.username }}</td>
|
||||||
|
<td class="px-6 py-3 text-right text-gray-700 dark:text-gray-300 tabular-nums">{{ formatBytes(u.rx_bytes) }}</td>
|
||||||
|
<td class="px-6 py-3 text-right text-gray-700 dark:text-gray-300 tabular-nums">{{ formatBytes(u.tx_bytes) }}</td>
|
||||||
|
<td class="px-6 py-3 text-right text-gray-900 dark:text-white font-medium tabular-nums">{{ formatBytes(u.total_bytes) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import TrafficChart from '@/components/TrafficChart.vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -16,6 +17,35 @@ interface VpnConnection {
|
|||||||
const vpnConnections = ref<VpnConnection[]>([])
|
const vpnConnections = ref<VpnConnection[]>([])
|
||||||
const maxConns = ref(30)
|
const maxConns = ref(30)
|
||||||
|
|
||||||
|
interface TrafficRecord {
|
||||||
|
date: string
|
||||||
|
rx_bytes: number
|
||||||
|
tx_bytes: number
|
||||||
|
}
|
||||||
|
const myTraffic7d = ref<TrafficRecord[]>([])
|
||||||
|
const todayRx = ref(0)
|
||||||
|
const todayTx = ref(0)
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||||
|
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMyTraffic() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/me/traffic?days=7', {
|
||||||
|
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||||
|
})
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
myTraffic7d.value = data.records || []
|
||||||
|
todayRx.value = data.today_rx_bytes || 0
|
||||||
|
todayTx.value = data.today_tx_bytes || 0
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchVpnConnections() {
|
async function fetchVpnConnections() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/me/vpn/connections', {
|
const res = await fetch('/api/me/vpn/connections', {
|
||||||
@@ -31,6 +61,7 @@ async function fetchVpnConnections() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await authStore.fetchUser()
|
await authStore.fetchUser()
|
||||||
fetchVpnConnections()
|
fetchVpnConnections()
|
||||||
|
fetchMyTraffic()
|
||||||
})
|
})
|
||||||
|
|
||||||
const showPasswordModal = ref(false)
|
const showPasswordModal = ref(false)
|
||||||
@@ -94,6 +125,26 @@ async function handleChangePassword() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('traffic.myTraffic') }}</h3>
|
||||||
|
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ t('traffic.upload') }}</p>
|
||||||
|
<p class="text-lg font-bold text-sky-600 dark:text-sky-400 tabular-nums">{{ formatBytes(todayRx) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ t('traffic.download') }}</p>
|
||||||
|
<p class="text-lg font-bold text-green-600 dark:text-green-400 tabular-nums">{{ formatBytes(todayTx) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ t('traffic.total') }}</p>
|
||||||
|
<p class="text-lg font-bold text-gray-900 dark:text-white tabular-nums">{{ formatBytes(todayRx + todayTx) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">{{ t('traffic.trafficHistory7d') }}</h4>
|
||||||
|
<TrafficChart :records="myTraffic7d" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden mb-6">
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden mb-6">
|
||||||
<div class="flex items-center justify-between p-6 pb-4">
|
<div class="flex items-center justify-between p-6 pb-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('profile.myVpnConnections') }}</h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('profile.myVpnConnections') }}</h3>
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ func Init(cfg *config.DatabaseConfig) error {
|
|||||||
return fmt.Errorf("数据库连接失败: %w", err)
|
return fmt.Errorf("数据库连接失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := DB.AutoMigrate(&model.User{}, &model.Session{}, &model.VpnSetting{}, &model.VpnReservation{}, &model.TrafficStat{}); err != nil {
|
if err := DB.AutoMigrate(&model.User{}, &model.Session{}, &model.VpnSetting{}, &model.VpnReservation{}, &model.TrafficStat{}, &model.UserTrafficStat{}); err != nil {
|
||||||
return fmt.Errorf("数据库迁移失败: %w", err)
|
return fmt.Errorf("数据库迁移失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lmvpn/internal/db"
|
||||||
|
"lmvpn/internal/model"
|
||||||
|
"lmvpn/internal/vpn"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type userTrafficItem struct {
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
RxBytes int64 `json:"rx_bytes"`
|
||||||
|
TxBytes int64 `json:"tx_bytes"`
|
||||||
|
TotalBytes int64 `json:"total_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type trafficRecord struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
RxBytes int64 `json:"rx_bytes"`
|
||||||
|
TxBytes int64 `json:"tx_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDays(c *gin.Context) int {
|
||||||
|
days := 7
|
||||||
|
if d := c.Query("days"); d != "" {
|
||||||
|
if n, err := strconv.Atoi(d); err == nil && n > 0 && n <= 365 {
|
||||||
|
days = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAdminTrafficToday(c *gin.Context) {
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
|
var stats []model.UserTrafficStat
|
||||||
|
db.DB.Where("date = ?", today).Find(&stats)
|
||||||
|
|
||||||
|
userIDs := make([]uint, 0, len(stats))
|
||||||
|
for _, s := range stats {
|
||||||
|
userIDs = append(userIDs, s.UserID)
|
||||||
|
}
|
||||||
|
nameMap := make(map[uint]string)
|
||||||
|
if len(userIDs) > 0 {
|
||||||
|
var users []model.User
|
||||||
|
db.DB.Where("id IN ?", userIDs).Find(&users)
|
||||||
|
for _, u := range users {
|
||||||
|
nameMap[u.ID] = u.Username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]userTrafficItem, 0, len(stats))
|
||||||
|
var totalRx, totalTx int64
|
||||||
|
seen := make(map[uint]bool)
|
||||||
|
for _, s := range stats {
|
||||||
|
liveRx, liveTx := int64(0), int64(0)
|
||||||
|
if vpn.VPN != nil && vpn.VPN.Running() {
|
||||||
|
liveRx, liveTx = vpn.VPN.UserLiveTraffic(s.UserID)
|
||||||
|
}
|
||||||
|
rx := s.RxBytes + liveRx
|
||||||
|
tx := s.TxBytes + liveTx
|
||||||
|
items = append(items, userTrafficItem{
|
||||||
|
UserID: s.UserID,
|
||||||
|
Username: nameMap[s.UserID],
|
||||||
|
RxBytes: rx,
|
||||||
|
TxBytes: tx,
|
||||||
|
TotalBytes: rx + tx,
|
||||||
|
})
|
||||||
|
totalRx += rx
|
||||||
|
totalTx += tx
|
||||||
|
seen[s.UserID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if vpn.VPN != nil && vpn.VPN.Running() {
|
||||||
|
for _, ci := range vpn.VPN.ClientList() {
|
||||||
|
if seen[ci.UserID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
liveRx, liveTx := vpn.VPN.UserLiveTraffic(ci.UserID)
|
||||||
|
if liveRx == 0 && liveTx == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var u model.User
|
||||||
|
if err := db.DB.First(&u, ci.UserID).Error; err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, userTrafficItem{
|
||||||
|
UserID: ci.UserID,
|
||||||
|
Username: u.Username,
|
||||||
|
RxBytes: liveRx,
|
||||||
|
TxBytes: liveTx,
|
||||||
|
TotalBytes: liveRx + liveTx,
|
||||||
|
})
|
||||||
|
totalRx += liveRx
|
||||||
|
totalTx += liveTx
|
||||||
|
seen[ci.UserID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"total_rx_bytes": totalRx,
|
||||||
|
"total_tx_bytes": totalTx,
|
||||||
|
"users": items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAdminTrafficHistory(c *gin.Context) {
|
||||||
|
days := parseDays(c)
|
||||||
|
startDate := time.Now().AddDate(0, 0, -(days - 1)).Format("2006-01-02")
|
||||||
|
var stats []model.TrafficStat
|
||||||
|
db.DB.Where("date >= ?", startDate).Order("date asc").Find(&stats)
|
||||||
|
|
||||||
|
dateMap := make(map[string]trafficRecord, len(stats))
|
||||||
|
for _, s := range stats {
|
||||||
|
dateMap[s.Date] = trafficRecord{
|
||||||
|
Date: s.Date,
|
||||||
|
RxBytes: s.RxBytes,
|
||||||
|
TxBytes: s.TxBytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]trafficRecord, 0, days)
|
||||||
|
for i := days - 1; i >= 0; i-- {
|
||||||
|
d := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
|
||||||
|
if r, ok := dateMap[d]; ok {
|
||||||
|
out = append(out, r)
|
||||||
|
} else {
|
||||||
|
out = append(out, trafficRecord{Date: d})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
var todayStat model.TrafficStat
|
||||||
|
db.DB.Where("date = ?", today).First(&todayStat)
|
||||||
|
liveRx, liveTx := int64(0), int64(0)
|
||||||
|
if vpn.VPN != nil && vpn.VPN.Running() {
|
||||||
|
liveRx, liveTx = vpn.VPN.TotalLiveTraffic()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"today_rx_bytes": todayStat.RxBytes + liveRx,
|
||||||
|
"today_tx_bytes": todayStat.TxBytes + liveTx,
|
||||||
|
"records": out,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAdminUserTraffic(c *gin.Context) { idStr := c.Param("id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := db.DB.First(&user, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
days := parseDays(c)
|
||||||
|
records := queryUserTraffic(uint(id), days)
|
||||||
|
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
var todayStat model.UserTrafficStat
|
||||||
|
db.DB.Where("user_id = ? AND date = ?", id, today).First(&todayStat)
|
||||||
|
liveRx, liveTx := int64(0), int64(0)
|
||||||
|
if vpn.VPN != nil && vpn.VPN.Running() {
|
||||||
|
liveRx, liveTx = vpn.VPN.UserLiveTraffic(uint(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"user_id": user.ID,
|
||||||
|
"username": user.Username,
|
||||||
|
"today_rx_bytes": todayStat.RxBytes + liveRx,
|
||||||
|
"today_tx_bytes": todayStat.TxBytes + liveTx,
|
||||||
|
"today_live_rx": liveRx,
|
||||||
|
"today_live_tx": liveTx,
|
||||||
|
"records": records,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetMyTrafficToday(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
uid := userID.(uint)
|
||||||
|
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
var stat model.UserTrafficStat
|
||||||
|
db.DB.Where("user_id = ? AND date = ?", uid, today).First(&stat)
|
||||||
|
|
||||||
|
liveRx, liveTx := int64(0), int64(0)
|
||||||
|
if vpn.VPN != nil && vpn.VPN.Running() {
|
||||||
|
liveRx, liveTx = vpn.VPN.UserLiveTraffic(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"rx_bytes": stat.RxBytes + liveRx,
|
||||||
|
"tx_bytes": stat.TxBytes + liveTx,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetMyTrafficHistory(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
uid := userID.(uint)
|
||||||
|
|
||||||
|
days := parseDays(c)
|
||||||
|
records := queryUserTraffic(uid, days)
|
||||||
|
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
var todayStat model.UserTrafficStat
|
||||||
|
db.DB.Where("user_id = ? AND date = ?", uid, today).First(&todayStat)
|
||||||
|
liveRx, liveTx := int64(0), int64(0)
|
||||||
|
if vpn.VPN != nil && vpn.VPN.Running() {
|
||||||
|
liveRx, liveTx = vpn.VPN.UserLiveTraffic(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"today_rx_bytes": todayStat.RxBytes + liveRx,
|
||||||
|
"today_tx_bytes": todayStat.TxBytes + liveTx,
|
||||||
|
"today_live_rx": liveRx,
|
||||||
|
"today_live_tx": liveTx,
|
||||||
|
"records": records,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryUserTraffic(userID uint, days int) []trafficRecord {
|
||||||
|
startDate := time.Now().AddDate(0, 0, -(days - 1)).Format("2006-01-02")
|
||||||
|
var stats []model.UserTrafficStat
|
||||||
|
db.DB.Where("user_id = ? AND date >= ?", userID, startDate).Order("date asc").Find(&stats)
|
||||||
|
|
||||||
|
dateMap := make(map[string]trafficRecord, len(stats))
|
||||||
|
for _, s := range stats {
|
||||||
|
dateMap[s.Date] = trafficRecord{
|
||||||
|
Date: s.Date,
|
||||||
|
RxBytes: s.RxBytes,
|
||||||
|
TxBytes: s.TxBytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]trafficRecord, 0, days)
|
||||||
|
for i := days - 1; i >= 0; i-- {
|
||||||
|
d := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
|
||||||
|
if r, ok := dateMap[d]; ok {
|
||||||
|
out = append(out, r)
|
||||||
|
} else {
|
||||||
|
out = append(out, trafficRecord{Date: d})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -144,6 +144,10 @@ func UpdateUser(c *gin.Context) {
|
|||||||
updates := map[string]interface{}{}
|
updates := map[string]interface{}{}
|
||||||
|
|
||||||
if req.Status != nil {
|
if req.Status != nil {
|
||||||
|
if user.ID == currentUserID.(uint) && *req.Status != 1 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "不能禁用自己的账号"})
|
||||||
|
return
|
||||||
|
}
|
||||||
updates["status"] = *req.Status
|
updates["status"] = *req.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -474,12 +474,11 @@ func KickUserClient(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
n := 0
|
n := 0
|
||||||
|
db.DB.Model(&user).Update("status", 0)
|
||||||
|
db.DB.Model(&model.Session{}).Where("user_id = ?", id).Update("invalid", true)
|
||||||
if vpn.VPN != nil && vpn.VPN.Running() {
|
if vpn.VPN != nil && vpn.VPN.Running() {
|
||||||
n = vpn.VPN.KickUser(uint(id))
|
n = vpn.VPN.KickUser(uint(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
db.DB.Model(&user).Update("status", 0)
|
|
||||||
db.DB.Model(&model.Session{}).Where("user_id = ?", id).Update("invalid", true)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "已断开用户连接并禁用账号", "kicked": n})
|
c.JSON(http.StatusOK, gin.H{"message": "已断开用户连接并禁用账号", "kicked": n})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,3 +45,16 @@ type TrafficStat struct {
|
|||||||
func (TrafficStat) TableName() string {
|
func (TrafficStat) TableName() string {
|
||||||
return "traffic_stats"
|
return "traffic_stats"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserTrafficStat struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||||
|
UserID uint `gorm:"uniqueIndex:idx_user_date;not null"`
|
||||||
|
Date string `gorm:"uniqueIndex:idx_user_date;size:10;not null"`
|
||||||
|
RxBytes int64 `gorm:"default:0"`
|
||||||
|
TxBytes int64 `gorm:"default:0"`
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UserTrafficStat) TableName() string {
|
||||||
|
return "user_traffic_stats"
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ func Setup(r *gin.Engine) {
|
|||||||
auth.GET("/me/sessions", handler.ListMySessions)
|
auth.GET("/me/sessions", handler.ListMySessions)
|
||||||
auth.DELETE("/me/sessions/:sessionId", handler.RevokeMySession)
|
auth.DELETE("/me/sessions/:sessionId", handler.RevokeMySession)
|
||||||
auth.GET("/me/vpn/connections", handler.GetMyVpnConnections)
|
auth.GET("/me/vpn/connections", handler.GetMyVpnConnections)
|
||||||
|
auth.GET("/me/traffic/today", handler.GetMyTrafficToday)
|
||||||
|
auth.GET("/me/traffic", handler.GetMyTrafficHistory)
|
||||||
}
|
}
|
||||||
|
|
||||||
admin := r.Group("/api/admin")
|
admin := r.Group("/api/admin")
|
||||||
@@ -48,6 +50,10 @@ func Setup(r *gin.Engine) {
|
|||||||
admin.POST("/vpn/reservations", handler.CreateVpnReservation)
|
admin.POST("/vpn/reservations", handler.CreateVpnReservation)
|
||||||
admin.DELETE("/vpn/reservations/:id", handler.DeleteVpnReservation)
|
admin.DELETE("/vpn/reservations/:id", handler.DeleteVpnReservation)
|
||||||
admin.DELETE("/vpn/clients/:id", handler.KickUserClient)
|
admin.DELETE("/vpn/clients/:id", handler.KickUserClient)
|
||||||
|
|
||||||
|
admin.GET("/traffic/today", handler.GetAdminTrafficToday)
|
||||||
|
admin.GET("/traffic/history", handler.GetAdminTrafficHistory)
|
||||||
|
admin.GET("/traffic/users/:id", handler.GetAdminUserTraffic)
|
||||||
}
|
}
|
||||||
|
|
||||||
distDir := http.Dir("./dist")
|
distDir := http.Dir("./dist")
|
||||||
|
|||||||
+61
-2
@@ -27,6 +27,7 @@ type VpnService struct {
|
|||||||
switchx *PacketSwitch
|
switchx *PacketSwitch
|
||||||
tun *TUNInterface
|
tun *TUNInterface
|
||||||
tunDone chan struct{}
|
tunDone chan struct{}
|
||||||
|
flushDone chan struct{}
|
||||||
running bool
|
running bool
|
||||||
startedAt time.Time
|
startedAt time.Time
|
||||||
clients map[*tunnelConn]struct{}
|
clients map[*tunnelConn]struct{}
|
||||||
@@ -50,16 +51,59 @@ func (s *VpnService) StartedAt() time.Time {
|
|||||||
return s.startedAt
|
return s.startedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trafficFlushPeriod = 60 * time.Second
|
||||||
|
|
||||||
func (s *VpnService) TotalLiveTraffic() (rx, tx int64) {
|
func (s *VpnService) TotalLiveTraffic() (rx, tx int64) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
for c := range s.clients {
|
for c := range s.clients {
|
||||||
rx += c.rxBytes.Load()
|
rx += c.rxBytes.Load() - c.flushedRx.Load()
|
||||||
tx += c.txBytes.Load()
|
tx += c.txBytes.Load() - c.flushedTx.Load()
|
||||||
}
|
}
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *VpnService) UserLiveTraffic(userID uint) (rx, tx int64) {
|
||||||
|
s.mu.RLock()
|
||||||
|
for c := range s.clients {
|
||||||
|
if c.user.ID == userID {
|
||||||
|
rx += c.rxBytes.Load() - c.flushedRx.Load()
|
||||||
|
tx += c.txBytes.Load() - c.flushedTx.Load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *VpnService) trafficFlusher() {
|
||||||
|
ticker := time.NewTicker(trafficFlushPeriod)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
s.flushAllTraffic()
|
||||||
|
case <-s.flushDone:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *VpnService) flushAllTraffic() {
|
||||||
|
s.mu.RLock()
|
||||||
|
conns := make([]*tunnelConn, 0, len(s.clients))
|
||||||
|
for c := range s.clients {
|
||||||
|
conns = append(conns, c)
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, c := range conns {
|
||||||
|
rx, tx := c.flushDelta()
|
||||||
|
if rx > 0 || tx > 0 {
|
||||||
|
recordTraffic(c.user.ID, rx, tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *VpnService) Settings() model.VpnSetting {
|
func (s *VpnService) Settings() model.VpnSetting {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
@@ -150,11 +194,13 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations4, res
|
|||||||
s.switchx = NewPacketSwitch(settings.AllowClientToClient)
|
s.switchx = NewPacketSwitch(settings.AllowClientToClient)
|
||||||
s.tun = tun
|
s.tun = tun
|
||||||
s.tunDone = make(chan struct{})
|
s.tunDone = make(chan struct{})
|
||||||
|
s.flushDone = make(chan struct{})
|
||||||
s.running = true
|
s.running = true
|
||||||
s.startedAt = time.Now()
|
s.startedAt = time.Now()
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
go s.serveTUN()
|
go s.serveTUN()
|
||||||
|
go s.trafficFlusher()
|
||||||
|
|
||||||
subnet4 := ipNet.String()
|
subnet4 := ipNet.String()
|
||||||
var subnet6Str string
|
var subnet6Str string
|
||||||
@@ -205,10 +251,21 @@ func (s *VpnService) Stop() error {
|
|||||||
s.running = false
|
s.running = false
|
||||||
tun := s.tun
|
tun := s.tun
|
||||||
done := s.tunDone
|
done := s.tunDone
|
||||||
|
flushDone := s.flushDone
|
||||||
|
s.flushDone = nil
|
||||||
clients := s.clients
|
clients := s.clients
|
||||||
s.clients = make(map[*tunnelConn]struct{})
|
s.clients = make(map[*tunnelConn]struct{})
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if flushDone != nil {
|
||||||
|
close(flushDone)
|
||||||
|
}
|
||||||
|
for c := range clients {
|
||||||
|
rx, tx := c.flushDelta()
|
||||||
|
if rx > 0 || tx > 0 {
|
||||||
|
recordTraffic(c.user.ID, rx, tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
for c := range clients {
|
for c := range clients {
|
||||||
c.close()
|
c.close()
|
||||||
}
|
}
|
||||||
@@ -379,6 +436,8 @@ type ClientInfo struct {
|
|||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
IP6 string `json:"ip6,omitempty"`
|
IP6 string `json:"ip6,omitempty"`
|
||||||
ConnectedAt string `json:"connected_at"`
|
ConnectedAt string `json:"connected_at"`
|
||||||
|
RxBytes int64 `json:"rx_bytes"`
|
||||||
|
TxBytes int64 `json:"tx_bytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *VpnService) KickUser(userID uint) int {
|
func (s *VpnService) KickUser(userID uint) int {
|
||||||
|
|||||||
+27
-2
@@ -40,11 +40,21 @@ type tunnelConn struct {
|
|||||||
ready atomic.Bool
|
ready atomic.Bool
|
||||||
rxBytes atomic.Int64
|
rxBytes atomic.Int64
|
||||||
txBytes atomic.Int64
|
txBytes atomic.Int64
|
||||||
|
flushedRx atomic.Int64
|
||||||
|
flushedTx atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *tunnelConn) AssignedIP() net.IP { return c.assignedIP }
|
func (c *tunnelConn) AssignedIP() net.IP { return c.assignedIP }
|
||||||
func (c *tunnelConn) AssignedIP6() net.IP { return c.assignedIP6 }
|
func (c *tunnelConn) AssignedIP6() net.IP { return c.assignedIP6 }
|
||||||
|
|
||||||
|
func (c *tunnelConn) flushDelta() (rx, tx int64) {
|
||||||
|
curRx := c.rxBytes.Load()
|
||||||
|
curTx := c.txBytes.Load()
|
||||||
|
rx = curRx - c.flushedRx.Swap(curRx)
|
||||||
|
tx = curTx - c.flushedTx.Swap(curTx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func (c *tunnelConn) WritePacket(data []byte) error {
|
func (c *tunnelConn) WritePacket(data []byte) error {
|
||||||
if !c.ready.Load() || len(data) == 0 {
|
if !c.ready.Load() || len(data) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -82,6 +92,8 @@ func (c *tunnelConn) info() ClientInfo {
|
|||||||
Username: c.user.Username,
|
Username: c.user.Username,
|
||||||
IP: c.assignedIP.String(),
|
IP: c.assignedIP.String(),
|
||||||
ConnectedAt: c.connectedAt.Format("2006-01-02 15:04:05"),
|
ConnectedAt: c.connectedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
RxBytes: c.rxBytes.Load(),
|
||||||
|
TxBytes: c.txBytes.Load(),
|
||||||
}
|
}
|
||||||
if c.assignedIP6 != nil {
|
if c.assignedIP6 != nil {
|
||||||
ci.IP6 = c.assignedIP6.String()
|
ci.IP6 = c.assignedIP6.String()
|
||||||
@@ -136,7 +148,8 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
|
|||||||
|
|
||||||
VPN.registerClient(tc)
|
VPN.registerClient(tc)
|
||||||
defer func() {
|
defer func() {
|
||||||
recordTraffic(tc.rxBytes.Load(), tc.txBytes.Load())
|
rx, tx := tc.flushDelta()
|
||||||
|
recordTraffic(tc.user.ID, rx, tx)
|
||||||
VPN.unregisterClient(tc)
|
VPN.unregisterClient(tc)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -236,11 +249,23 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func recordTraffic(rx, tx int64) {
|
func recordTraffic(userID uint, rx, tx int64) {
|
||||||
if rx == 0 && tx == 0 {
|
if rx == 0 && tx == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
today := time.Now().Format("2006-01-02")
|
today := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
|
userStat := model.UserTrafficStat{UserID: userID, Date: today, RxBytes: rx, TxBytes: tx}
|
||||||
|
if err := db.DB.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "user_id"}, {Name: "date"}},
|
||||||
|
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||||
|
"rx_bytes": gorm.Expr("rx_bytes + ?", rx),
|
||||||
|
"tx_bytes": gorm.Expr("tx_bytes + ?", tx),
|
||||||
|
}),
|
||||||
|
}).Create(&userStat).Error; err != nil {
|
||||||
|
log.Printf("记录用户流量失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
stat := model.TrafficStat{Date: today, RxBytes: rx, TxBytes: tx}
|
stat := model.TrafficStat{Date: today, RxBytes: rx, TxBytes: tx}
|
||||||
if err := db.DB.Clauses(clause.OnConflict{
|
if err := db.DB.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "date"}},
|
Columns: []clause.Column{{Name: "date"}},
|
||||||
|
|||||||
Reference in New Issue
Block a user