show traffic stats chart in web/gui (#1410)

This commit is contained in:
Sijie.Sun
2025-09-25 13:43:11 +08:00
committed by GitHub
parent 7a694257d9
commit f5ba5bb146
17 changed files with 3163 additions and 10776 deletions

View File

@@ -13,18 +13,17 @@
"lint:fix": "eslint . --ignore-pattern src-tauri --fix"
},
"dependencies": {
"@primevue/themes": "4.3.3",
"@primeuix/themes": "^1.2.3",
"@tauri-apps/plugin-autostart": "2.0.0",
"@tauri-apps/plugin-clipboard-manager": "2.3.0",
"@tauri-apps/plugin-os": "2.3.0",
"@tauri-apps/plugin-process": "2.3.0",
"@tauri-apps/plugin-shell": "2.3.0",
"@vueuse/core": "^11.2.0",
"aura": "link:@primevue\\themes\\aura",
"easytier-frontend-lib": "workspace:*",
"ip-num": "1.5.1",
"pinia": "^2.2.4",
"primevue": "4.3.3",
"primevue": "^4.3.9",
"tauri-plugin-vpnservice-api": "workspace:*",
"vue": "^3.5.12",
"vue-router": "^4.4.5"
@@ -32,7 +31,7 @@
"devDependencies": {
"@antfu/eslint-config": "^3.7.3",
"@intlify/unplugin-vue-i18n": "^5.2.0",
"@primevue/auto-import-resolver": "4.3.3",
"@primevue/auto-import-resolver": "4.3.9",
"@tauri-apps/api": "2.7.0",
"@tauri-apps/cli": "2.7.1",
"@types/default-gateway": "^7.2.2",

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,6 @@ declare global {
const EffectScope: typeof import('vue')['EffectScope']
const MenuItemExit: typeof import('./composables/tray')['MenuItemExit']
const MenuItemShow: typeof import('./composables/tray')['MenuItemShow']
const ReinitTray: typeof import('./composables/tray')['ReinitTray']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const collectNetworkInfos: typeof import('./composables/network')['collectNetworkInfos']
const computed: typeof import('vue')['computed']
@@ -18,10 +17,8 @@ declare global {
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const definePage: typeof import('unplugin-vue-router/runtime')['definePage']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const event2human: typeof import('./composables/utils')['event2human']
const generateMenuItem: typeof import('./composables/tray')['generateMenuItem']
const generateNetworkConfig: typeof import('./composables/network')['generateNetworkConfig']
const getActivePinia: typeof import('pinia')['getActivePinia']
@@ -30,7 +27,6 @@ declare global {
const getEasytierVersion: typeof import('./composables/network')['getEasytierVersion']
const getOsHostname: typeof import('./composables/network')['getOsHostname']
const h: typeof import('vue')['h']
const initMobileService: typeof import('./composables/mobile_vpn')['initMobileService']
const initMobileVpnService: typeof import('./composables/mobile_vpn')['initMobileVpnService']
const inject: typeof import('vue')['inject']
const isAutostart: typeof import('./composables/network')['isAutostart']
@@ -38,7 +34,6 @@ declare global {
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const loadRunningInstanceIdsFromLocalStorage: typeof import('./stores/network')['loadRunningInstanceIdsFromLocalStorage']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
@@ -46,8 +41,6 @@ declare global {
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const num2ipv4: typeof import('./composables/utils')['num2ipv4']
const num2ipv6: typeof import('./composables/utils')['num2ipv6']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
@@ -74,7 +67,6 @@ declare global {
const retainNetworkInstance: typeof import('./composables/network')['retainNetworkInstance']
const runNetworkInstance: typeof import('./composables/network')['runNetworkInstance']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setAutoLaunchStatus: typeof import('./composables/network')['setAutoLaunchStatus']
const setLoggingLevel: typeof import('./composables/network')['setLoggingLevel']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const setTrayMenu: typeof import('./composables/tray')['setTrayMenu']
@@ -85,7 +77,6 @@ declare global {
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const timeAgoCn: typeof import('./composables/utils')['timeAgoCn']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
@@ -116,6 +107,7 @@ declare global {
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {

View File

@@ -93,7 +93,7 @@ async function registerVpnServiceListener() {
)
}
function getRoutesForVpn(routes: Route[]): string[] {
function getRoutesForVpn(routes: Route[], node_config: NetworkTypes.NetworkConfig): string[] {
if (!routes) {
return []
}
@@ -108,6 +108,10 @@ function getRoutesForVpn(routes: Route[]): string[] {
}
}
node_config.routes.forEach(r => {
ret.push(r)
})
// sort and dedup
return Array.from(new Set(ret)).sort()
}
@@ -142,7 +146,7 @@ async function onNetworkInstanceChange() {
network_length = 24
}
const routes = getRoutesForVpn(curNetworkInfo?.routes)
const routes = getRoutesForVpn(curNetworkInfo?.routes, networkStore.curNetwork)
const ipChanged = virtual_ip !== curVpnStatus.ipv4Addr
const routesChanged = JSON.stringify(routes) !== JSON.stringify(curVpnStatus.routes)

View File

@@ -1,4 +1,4 @@
import Aura from '@primevue/themes/aura'
import Aura from '@primeuix/themes/aura';
import PrimeVue from 'primevue/config'
import ToastService from 'primevue/toastservice'

View File

@@ -292,34 +292,35 @@ async function saveTomlConfig(tomlConfig: string) {
<About />
</Dialog>
<div>
<Toolbar>
<template #start>
<div class="flex items-center">
<Button icon="pi pi-plus" severity="primary" :label="t('add_new_network')" @click="addNewNetwork" />
<div class="w-full">
<div class="flex items-center gap-4 p-4 h-20">
<!-- 网络按钮 -->
<div class="flex shrink-0 items-center">
<Button icon="pi pi-plus" severity="primary" :label="t('add_new_network')" class="hidden md:inline-flex"
@click="addNewNetwork" />
<Button icon="pi pi-plus" severity="primary" class="md:hidden px-6" @click="addNewNetwork" />
</div>
</template>
<template #center>
<div class="min-w-40">
<!-- 网络选择 - 占据中间剩余空间 -->
<Select v-model="networkStore.curNetwork" :options="networkStore.networkList" :highlight-on-select="false"
:placeholder="t('select_network')" class="w-full">
:placeholder="t('select_network')" class="flex-1 h-full min-w-0">
<template #value="slotProps">
<div class="flex items-start content-center">
<div class="mr-4 flex-col">
<span>{{ slotProps.value.network_name }}</span>
<div class="flex items-center content-center min-w-0">
<div class="mr-4 flex-col min-w-0 flex-1">
<span class="truncate block"> &nbsp; {{ slotProps.value.network_name }}</span>
</div>
<Tag class="my-auto leading-3" :severity="isRunning(slotProps.value.instance_id) ? 'success' : 'info'"
<Tag class="my-auto leading-3 shrink-0"
:severity="isRunning(slotProps.value.instance_id) ? 'success' : 'info'"
:value="t(isRunning(slotProps.value.instance_id) ? 'network_running' : 'network_stopped')" />
</div>
</template>
<template #option="slotProps">
<div class="flex flex-col items-start content-center max-w-full">
<div class="flex">
<div class="mr-4">
{{ t('network_name') }}: {{ slotProps.option.network_name }}
<div class="flex items-center min-w-0 w-full">
<div class="mr-4 min-w-0 flex-1">
<span class="truncate block">{{ t('network_name') }}: {{ slotProps.option.network_name }}</span>
</div>
<Tag class="my-auto leading-3"
<Tag class="my-auto leading-3 shrink-0"
:severity="isRunning(slotProps.option.instance_id) ? 'success' : 'info'"
:value="t(isRunning(slotProps.option.instance_id) ? 'network_running' : 'network_stopped')" />
</div>
@@ -338,15 +339,16 @@ async function saveTomlConfig(tomlConfig: string) {
</div>
</template>
</Select>
</div>
</template>
<template #end>
<!-- 设置按钮 -->
<div class="flex items-center shrink-0">
<Button icon="pi pi-cog" severity="secondary" aria-haspopup="true" :label="t('settings')"
class="hidden md:inline-flex" aria-controls="overlay_setting_menu" @click="toggle_setting_menu" />
<Button icon="pi pi-cog" severity="secondary" aria-haspopup="true" class="md:hidden px-6"
aria-controls="overlay_setting_menu" @click="toggle_setting_menu" />
<TieredMenu id="overlay_setting_menu" ref="setting_menu" :model="setting_menu_items" :popup="true" />
</template>
</Toolbar>
</div>
</div>
</div>
<Panel class="h-full overflow-y-auto">

View File

@@ -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": {

View File

@@ -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>

View 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>

View File

@@ -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 class="gap-4">
<!-- 网络流量图表 -->
<div class="w-full">
<NetworkChart :upload-rate="txRate" :download-rate="rxRate" />
</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>
<!-- 展开/收起节点详细信息的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>
<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>
</div>
<div class="flex flex-row items-center flex-wrap w-full max-h-40 overflow-scroll">
<!-- 节点详细信息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">

View File

@@ -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'

View File

@@ -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: 点击关闭

View File

@@ -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.'

View File

@@ -9,18 +9,18 @@
"preview": "vite preview"
},
"dependencies": {
"@primevue/themes": "4.3.3",
"aura": "link:@primevue/themes/aura",
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@primeuix/themes": "^1.2.3",
"axios": "^1.7.7",
"easytier-frontend-lib": "workspace:*",
"primevue": "4.3.3",
"primevue": "^4.3.9",
"tailwindcss-primeui": "^0.3.4",
"vue": "^3.5.12",
"vue-router": "4",
"vue-i18n": "^9.9.1",
"@modyfi/vite-plugin-yaml": "^1.1.0"
"vue-router": "4"
},
"devDependencies": {
"@primevue/auto-import-resolver": "4.3.9",
"@types/node": "^22.8.6",
"@vitejs/plugin-vue": "^5.1.4",
"autoprefixer": "^10.4.20",

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { NetworkTypes } from 'easytier-frontend-lib';
import {computed, ref} from 'vue';
import { computed, ref } from 'vue';
import { Api } from 'easytier-frontend-lib'
import {AutoComplete, Divider, Button, Textarea} from "primevue";
import {getInitialApiHost, cleanAndLoadApiHosts, saveApiHost} from "../modules/api-host"
import { AutoComplete, Divider, Button, Textarea } from "primevue";
import { getInitialApiHost, cleanAndLoadApiHosts, saveApiHost } from "../modules/api-host"
const api = computed<Api.ApiClient>(() => new Api.ApiClient(apiHost.value));
@@ -68,7 +68,7 @@ const parseConfig = async () => {
<div class="sm:block md:flex w-full">
<div class="sm:w-full md:w-1/2 p-4">
<div class="flex flex-col">
<div class="w-11/12 self-center ">
<div class="w-full self-center ">
<label>ApiHost</label>
<AutoComplete id="api-host" v-model="apiHost" dropdown :suggestions="apiHostSuggestions"
@complete="apiHostSearch" class="w-full" />
@@ -78,13 +78,11 @@ const parseConfig = async () => {
<Config :cur-network="newNetworkConfig" @run-network="generateConfig" />
</div>
<div class="sm:w-full md:w-1/2 p-4 flex flex-col h-[calc(100vh-80px)]">
<pre v-if="errorMessage" class="mb-2 p-2 rounded text-sm overflow-auto bg-red-100 text-red-700 max-h-40">{{ errorMessage }}</pre>
<Textarea
v-model="toml_config"
spellcheck="false"
<pre v-if="errorMessage"
class="mb-2 p-2 rounded text-sm overflow-auto bg-red-100 text-red-700 max-h-40">{{ errorMessage }}</pre>
<Textarea v-model="toml_config" spellcheck="false"
class="w-full flex-grow p-2 whitespace-pre-wrap font-mono resize-none"
placeholder="Press 'Run Network' to generate TOML configuration, or paste your TOML configuration here to parse it"
></Textarea>
placeholder="Press 'Run Network' to generate TOML configuration, or paste your TOML configuration here to parse it"></Textarea>
<div class="mt-3 flex justify-center">
<Button label="Parse Config" icon="pi pi-arrow-left" icon-pos="left" @click="parseConfig" />
</div>

View File

@@ -4,7 +4,7 @@ import './style.css'
import App from './App.vue'
import EasytierFrontendLib from 'easytier-frontend-lib'
import PrimeVue from 'primevue/config'
import Aura from '@primevue/themes/aura'
import Aura from '@primeuix/themes/aura';
import ConfirmationService from 'primevue/confirmationservice';
import { I18nUtils } from 'easytier-frontend-lib'

6097
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff