mirror of
https://mirror.suhoan.cn/https://github.com/EasyTier/EasyTier.git
synced 2025-12-14 13:47:24 +08:00
show traffic stats chart in web/gui (#1410)
This commit is contained in:
@@ -18,18 +18,19 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primevue/themes": "4.3.3",
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"aura": "link:@primevue\\themes\\aura",
|
||||
"axios": "^1.7.7",
|
||||
"chart.js": "^4.5.0",
|
||||
"floating-vue": "^5.2",
|
||||
"ip-num": "1.5.1",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "4.3.3",
|
||||
"primevue": "^4.3.9",
|
||||
"tailwindcss-primeui": "^0.3.4",
|
||||
"ts-md5": "^1.3.1",
|
||||
"uuid": "^11.0.2",
|
||||
"vue": "^3.5.12",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-i18n": "^10.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -170,7 +170,7 @@ const bool_flags: BoolFlag[] = [
|
||||
{ field: 'enable_private_mode', help: 'enable_private_mode_help' },
|
||||
]
|
||||
|
||||
const portForwardProtocolOptions = ref(["tcp","udp"]);
|
||||
const portForwardProtocolOptions = ref(["tcp", "udp"]);
|
||||
|
||||
</script>
|
||||
|
||||
@@ -178,7 +178,7 @@ const portForwardProtocolOptions = ref(["tcp","udp"]);
|
||||
<div class="frontend-lib">
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex flex-col">
|
||||
<div class="w-11/12 self-center ">
|
||||
<div class="w-full self-center ">
|
||||
<Panel :header="t('basic_settings')">
|
||||
<div class="flex flex-col gap-y-2">
|
||||
<div class="flex flex-row gap-x-9 flex-wrap">
|
||||
@@ -227,9 +227,8 @@ const portForwardProtocolOptions = ref(["tcp","udp"]);
|
||||
class="grow" multiple fluid :suggestions="peerSuggestions" @complete="searchPeerSuggestions" />
|
||||
|
||||
<AutoComplete v-if="curNetwork.networking_method === NetworkingMethod.PublicServer"
|
||||
v-model="curNetwork.public_server_url" :suggestions="publicServerSuggestions"
|
||||
class="grow" dropdown :complete-on-focus="false"
|
||||
@complete="searchPresetPublicServers" />
|
||||
v-model="curNetwork.public_server_url" :suggestions="publicServerSuggestions" class="grow"
|
||||
dropdown :complete-on-focus="false" @complete="searchPresetPublicServers" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -436,56 +435,36 @@ const portForwardProtocolOptions = ref(["tcp","udp"]);
|
||||
</div>
|
||||
<div v-for="(row, index) in curNetwork.port_forwards" class="form-row">
|
||||
<div style="display: flex; gap: 0.5rem; align-items: flex-end;">
|
||||
<SelectButton v-model="row.proto" :options="portForwardProtocolOptions" :allow-empty="false"/>
|
||||
<SelectButton v-model="row.proto" :options="portForwardProtocolOptions" :allow-empty="false" />
|
||||
<div style="flex-grow: 4;">
|
||||
<InputGroup>
|
||||
<InputText
|
||||
v-model="row.bind_ip"
|
||||
:placeholder="t('port_forwards_bind_addr')"
|
||||
/>
|
||||
<InputText v-model="row.bind_ip" :placeholder="t('port_forwards_bind_addr')" />
|
||||
<InputGroupAddon>
|
||||
<span style="font-weight: bold">:</span>
|
||||
</InputGroupAddon>
|
||||
<InputNumber v-model="row.bind_port" :format="false"
|
||||
inputId="horizontal-buttons" :step="1" mode="decimal" :min="1"
|
||||
:max="65535" fluid
|
||||
class="max-w-20"/>
|
||||
<InputNumber v-model="row.bind_port" :format="false" inputId="horizontal-buttons" :step="1"
|
||||
mode="decimal" :min="1" :max="65535" fluid class="max-w-20" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
<div style="flex-grow: 4;">
|
||||
<InputGroup>
|
||||
<InputText
|
||||
v-model="row.dst_ip"
|
||||
:placeholder="t('port_forwards_dst_addr')"
|
||||
/>
|
||||
<InputText v-model="row.dst_ip" :placeholder="t('port_forwards_dst_addr')" />
|
||||
<InputGroupAddon>
|
||||
<span style="font-weight: bold">:</span>
|
||||
</InputGroupAddon>
|
||||
<InputNumber v-model="row.dst_port" :format="false"
|
||||
inputId="horizontal-buttons" :step="1" mode="decimal" :min="1"
|
||||
:max="65535" fluid
|
||||
class="max-w-20"/>
|
||||
<InputNumber v-model="row.dst_port" :format="false" inputId="horizontal-buttons" :step="1"
|
||||
mode="decimal" :min="1" :max="65535" fluid class="max-w-20" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
<div style="flex-grow: 1;">
|
||||
<Button
|
||||
v-if="curNetwork.port_forwards.length > 0"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
@click="removeRow(index,curNetwork.port_forwards)"
|
||||
/>
|
||||
<Button v-if="curNetwork.port_forwards.length > 0" icon="pi pi-trash" severity="danger" text
|
||||
rounded @click="removeRow(index, curNetwork.port_forwards)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-content-end mt-4">
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
:label="t('port_forwards_add_btn')"
|
||||
severity="success"
|
||||
@click="addRow(curNetwork.port_forwards)"
|
||||
/>
|
||||
<Button icon="pi pi-plus" :label="t('port_forwards_add_btn')" severity="success"
|
||||
@click="addRow(curNetwork.port_forwards)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
279
easytier-web/frontend-lib/src/components/NetworkChart.vue
Normal file
279
easytier-web/frontend-lib/src/components/NetworkChart.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-900/20 dark:to-indigo-800/20 rounded-xl p-4 border border-blue-200 dark:border-blue-700 shadow-md hover:shadow-lg transition-all duration-300">
|
||||
<div class="flex items-center justify-center mb-3">
|
||||
<div class="flex gap-2 text-sm">
|
||||
<span class="flex items-center gap-1 w-32">
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span class="text-green-600 dark:text-green-400 truncate">{{ t('upload') }}: {{ currentUpload }}/s</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1 w-32">
|
||||
<div class="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
<span class="text-blue-600 dark:text-blue-400 truncate">{{ t('download') }}: {{ currentDownload }}/s</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-32">
|
||||
<canvas ref="chartCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
LineController,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 注册Chart.js组件
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
LineController,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
)
|
||||
|
||||
interface Props {
|
||||
uploadRate: string
|
||||
downloadRate: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const chartCanvas = ref<HTMLCanvasElement>()
|
||||
let chart: ChartJS | null = null
|
||||
let updateTimer: number | null = null
|
||||
|
||||
// 存储历史数据,最多保存30个数据点(1分钟历史)
|
||||
const maxDataPoints = 120
|
||||
const uploadHistory: number[] = []
|
||||
const downloadHistory: number[] = []
|
||||
const timeLabels: string[] = []
|
||||
|
||||
const currentUpload = ref('0')
|
||||
const currentDownload = ref('0')
|
||||
|
||||
// 将带单位的速率字符串转换为字节数
|
||||
function parseRateToBytes(rateStr: string): number {
|
||||
if (!rateStr || rateStr === '0') return 0
|
||||
|
||||
const match = rateStr.match(/([0-9.]+)\s*([KMGT]?i?B)/i)
|
||||
if (!match) return 0
|
||||
|
||||
const value = parseFloat(match[1])
|
||||
const unit = match[2].toUpperCase()
|
||||
|
||||
const multipliers: { [key: string]: number } = {
|
||||
'B': 1,
|
||||
'KB': 1000,
|
||||
'KIB': 1024,
|
||||
'MB': 1000000,
|
||||
'MIB': 1024 * 1024,
|
||||
'GB': 1000000000,
|
||||
'GIB': 1024 * 1024 * 1024,
|
||||
'TB': 1000000000000,
|
||||
'TIB': 1024 * 1024 * 1024 * 1024
|
||||
}
|
||||
|
||||
return value * (multipliers[unit] || 1)
|
||||
}
|
||||
|
||||
// 格式化字节为可读格式
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1) return bytes.toFixed(1) + ' B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// 更新数据
|
||||
function updateData() {
|
||||
const uploadBytes = parseRateToBytes(props.uploadRate)
|
||||
const downloadBytes = parseRateToBytes(props.downloadRate)
|
||||
|
||||
currentUpload.value = formatBytes(uploadBytes)
|
||||
currentDownload.value = formatBytes(downloadBytes)
|
||||
|
||||
// 添加新数据点
|
||||
uploadHistory.push(uploadBytes)
|
||||
downloadHistory.push(downloadBytes)
|
||||
|
||||
// 生成时间标签
|
||||
const now = new Date()
|
||||
const timeStr = now.toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
timeLabels.push(timeStr)
|
||||
|
||||
// 保持数据点数量不超过最大值
|
||||
if (uploadHistory.length > maxDataPoints) {
|
||||
uploadHistory.shift()
|
||||
downloadHistory.shift()
|
||||
timeLabels.shift()
|
||||
}
|
||||
|
||||
// 更新图表
|
||||
if (chart) {
|
||||
chart.data.labels = timeLabels
|
||||
chart.data.datasets[0].data = uploadHistory
|
||||
chart.data.datasets[1].data = downloadHistory
|
||||
chart.update('none')
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化图表
|
||||
function initChart() {
|
||||
if (!chartCanvas.value) return
|
||||
|
||||
const ctx = chartCanvas.value.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
chart = new ChartJS(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timeLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: t('upload'),
|
||||
data: uploadHistory,
|
||||
borderColor: 'rgb(34, 197, 94)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4
|
||||
},
|
||||
{
|
||||
label: t('download'),
|
||||
data: downloadHistory,
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context: any) {
|
||||
const value = context.parsed.y
|
||||
return `${context.dataset.label}: ${formatBytes(value)}/s`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
maxTicksLimit: 3,
|
||||
font: {
|
||||
size: 8
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
beginAtZero: true,
|
||||
min: 0,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
callback: function (value: any) {
|
||||
return formatBytes(value as number)
|
||||
},
|
||||
font: {
|
||||
size: 8
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
duration: 10
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 监听props变化
|
||||
watch([() => props.uploadRate, () => props.downloadRate], () => {
|
||||
updateData()
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
// add initial point
|
||||
const now = new Date();
|
||||
for (let i = 0; i < maxDataPoints; i++) {
|
||||
let date = new Date(now.getTime() - (maxDataPoints - i) * 2000)
|
||||
const timeStr = date.toLocaleTimeString(navigator.language, {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
uploadHistory.push(0)
|
||||
downloadHistory.push(0)
|
||||
timeLabels.push(timeStr)
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
initChart()
|
||||
updateData()
|
||||
|
||||
// 启动定时器,每2秒更新一次图表
|
||||
updateTimer = window.setInterval(() => {
|
||||
updateData()
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
}
|
||||
if (updateTimer) {
|
||||
clearInterval(updateTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -5,7 +5,8 @@ import { NetworkInstance, type TunnelInfo, type NodeInfo, type PeerRoutePair } f
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { ipv4InetToString, ipv4ToString, ipv6ToString } from '../modules/utils';
|
||||
import { DataTable, Column, Tag, Chip, Button, Dialog, ScrollPanel, Timeline, Divider, Card, } from 'primevue';
|
||||
import { Badge, DataTable, Column, Tag, Chip, Button, Dialog, ScrollPanel, Timeline, Divider, Card, } from 'primevue';
|
||||
import NetworkChart from './NetworkChart.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
curNetworkInst: NetworkInstance | null,
|
||||
@@ -285,6 +286,10 @@ let prevTxSum = 0
|
||||
let prevRxSum = 0
|
||||
const txRate = ref('0')
|
||||
const rxRate = ref('0')
|
||||
|
||||
// 控制节点详细信息chips的显示/隐藏
|
||||
const showNodeDetails = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
rateIntervalId = window.setInterval(() => {
|
||||
const curTxSum = txGlobalSum()
|
||||
@@ -365,36 +370,23 @@ function showEventLogs() {
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex w-full flex-col gap-y-5">
|
||||
<div class="m-0 flex flex-row justify-center gap-x-5">
|
||||
<div class="rounded-full w-32 h-32 flex flex-col items-center pt-6" style="border: 1px solid green">
|
||||
<div class="font-bold">
|
||||
{{ t('peer_count') }}
|
||||
</div>
|
||||
<div class="text-5xl mt-1">
|
||||
{{ peerCount }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-full w-32 h-32 flex flex-col items-center pt-6" style="border: 1px solid purple">
|
||||
<div class="font-bold">
|
||||
{{ t('upload') }}
|
||||
</div>
|
||||
<div class="text-xl mt-2">
|
||||
{{ txRate }}/s
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-full w-32 h-32 flex flex-col items-center pt-6" style="border: 1px solid fuchsia">
|
||||
<div class="font-bold">
|
||||
{{ t('download') }}
|
||||
</div>
|
||||
<div class="text-xl mt-2">
|
||||
{{ rxRate }}/s
|
||||
</div>
|
||||
<div class="gap-4">
|
||||
<!-- 网络流量图表 -->
|
||||
<div class="w-full">
|
||||
<NetworkChart :upload-rate="txRate" :download-rate="rxRate" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center flex-wrap w-full max-h-40 overflow-scroll">
|
||||
<!-- 展开/收起节点详细信息的divider按钮 -->
|
||||
<div class="w-full">
|
||||
<Button @click="showNodeDetails = !showNodeDetails"
|
||||
:icon="showNodeDetails ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
|
||||
:label="showNodeDetails ? t('hide_node_details') : t('show_node_details')" severity="secondary" outlined
|
||||
class="w-full justify-center" size="small" />
|
||||
</div>
|
||||
|
||||
<!-- 节点详细信息chips,根据showNodeDetails状态显示/隐藏 -->
|
||||
<div v-show="showNodeDetails" class="flex flex-row items-center flex-wrap w-full max-h-40 overflow-scroll">
|
||||
<Chip v-for="(chip, i) in myNodeInfoChips" :key="i" :label="chip.label" :icon="chip.icon"
|
||||
class="mr-2 mt-2 text-sm" />
|
||||
</div>
|
||||
@@ -411,7 +403,15 @@ function showEventLogs() {
|
||||
|
||||
<Card>
|
||||
<template #title>
|
||||
{{ t('peer_info') }}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ t('peer_info') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge :value="peerCount" severity="info"
|
||||
class="text-lg font-semibold px-2 py-1 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<DataTable :value="peerRouteInfos" column-resize-mode="fit" table-class="w-full">
|
||||
|
||||
@@ -2,7 +2,7 @@ import './style.css'
|
||||
|
||||
import type { App } from 'vue';
|
||||
import { Config, Status, ConfigEditDialog } from "./components";
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import PrimeVue from 'primevue/config'
|
||||
|
||||
import I18nUtils from './modules/i18n'
|
||||
|
||||
@@ -48,6 +48,8 @@ hide_dock_icon: 隐藏 Dock 图标
|
||||
show_dock_icon: 显示 Dock 图标
|
||||
exit: 退出
|
||||
chips_placeholder: 例如: {0}, 输入后在下拉框中选择生效
|
||||
show_node_details: 显示节点详细信息
|
||||
hide_node_details: 隐藏节点详细信息
|
||||
hostname_placeholder: '留空默认为主机名: {0}'
|
||||
dev_name_placeholder: 注意:当多个网络同时使用相同的TUN接口名称时,将会在设置TUN的IP时产生冲突,留空以自动生成随机名称
|
||||
off_text: 点击关闭
|
||||
|
||||
@@ -48,6 +48,8 @@ hide_dock_icon: Hide Dock Icon
|
||||
show_dock_icon: Show Dock Icon
|
||||
exit: Exit
|
||||
use_latency_first: Latency First Mode
|
||||
show_node_details: Show Node Details
|
||||
hide_node_details: Hide Node Details
|
||||
chips_placeholder: 'e.g: {0}, select from the dropdown after input'
|
||||
hostname_placeholder: 'Leave blank and default to host name: {0}'
|
||||
dev_name_placeholder: 'Note: When multiple networks use the same TUN interface name at the same time, there will be a conflict when setting the TUN''s IP. Leave blank to automatically generate a random name.'
|
||||
|
||||
Reference in New Issue
Block a user