web/frps: add detailed client and proxy views with enhanced tracking (#5144)

This commit is contained in:
fatedier
2026-01-31 02:18:35 +08:00
committed by GitHub
parent 5dd70ace6b
commit 266c492b5d
34 changed files with 3000 additions and 923 deletions

View File

@@ -1,34 +1,26 @@
<template>
<div class="proxies-page">
<!-- Main Content -->
<el-card class="main-card" shadow="never">
<div class="toolbar-header">
<el-tabs v-model="activeType" class="proxy-tabs">
<el-tab-pane
v-for="t in proxyTypes"
:key="t.value"
:label="t.label"
:name="t.value"
/>
</el-tabs>
<div class="page-header">
<div class="header-top">
<div class="title-section">
<h1 class="page-title">Proxies</h1>
<p class="page-subtitle">View and manage all proxy configurations</p>
</div>
<div class="actions-section">
<el-button :icon="Refresh" class="action-btn" @click="fetchData"
>Refresh</el-button
>
<div class="toolbar-actions">
<el-input
v-model="searchText"
placeholder="Search by name..."
:prefix-icon="Search"
clearable
class="search-input"
/>
<el-tooltip content="Refresh" placement="top">
<el-button :icon="Refresh" circle @click="fetchData" />
</el-tooltip>
<el-popconfirm
title="Are you sure to clear all data of offline proxies?"
title="Clear all offline proxies?"
width="220"
confirm-button-text="Clear"
cancel-button-text="Cancel"
@confirm="clearOfflineProxies"
>
<template #reference>
<el-button type="danger" plain :icon="Delete"
<el-button :icon="Delete" class="action-btn" type="danger" plain
>Clear Offline</el-button
>
</template>
@@ -36,118 +28,74 @@
</div>
</div>
<el-table
v-loading="loading"
:data="filteredProxies"
:default-sort="{ prop: 'name', order: 'ascending' }"
style="width: 100%"
>
<el-table-column type="expand">
<template #default="props">
<div class="expand-wrapper">
<ProxyViewExpand :row="props.row" :proxyType="activeType" />
</div>
</template>
</el-table-column>
<el-table-column
label="Name"
prop="name"
sortable
min-width="150"
show-overflow-tooltip
/>
<el-table-column label="Port" prop="port" sortable width="100" />
<el-table-column
label="Conns"
prop="conns"
sortable
width="100"
align="center"
/>
<el-table-column label="Traffic" width="220">
<template #default="scope">
<div class="traffic-cell">
<span class="traffic-item up" title="Traffic Out">
<el-icon><Top /></el-icon>
{{ formatFileSize(scope.row.trafficOut) }}
</span>
<span class="traffic-item down" title="Traffic In">
<el-icon><Bottom /></el-icon>
{{ formatFileSize(scope.row.trafficIn) }}
</span>
</div>
</template>
</el-table-column>
<el-table-column
label="Version"
prop="clientVersion"
sortable
width="140"
show-overflow-tooltip
/>
<el-table-column
label="Status"
prop="status"
sortable
width="120"
align="center"
>
<template #default="scope">
<el-tag
:type="scope.row.status === 'online' ? 'success' : 'danger'"
effect="light"
round
>
{{ scope.row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="Action"
width="120"
align="center"
fixed="right"
>
<template #default="scope">
<el-button
type="primary"
link
:icon="DataAnalysis"
@click="showTraffic(scope.row.name)"
>
Traffic
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<div class="filter-section">
<div class="search-row">
<el-input
v-model="searchText"
placeholder="Search proxies..."
:prefix-icon="Search"
clearable
class="main-search"
/>
<el-dialog
v-model="dialogVisible"
destroy-on-close
:title="`Traffic Statistics - ${dialogVisibleName}`"
width="700px"
align-center
class="traffic-dialog"
>
<Traffic :proxyName="dialogVisibleName" />
</el-dialog>
<el-select
:model-value="selectedClientKey"
placeholder="All Clients"
clearable
filterable
class="client-select"
@change="onClientFilterChange"
>
<el-option label="All Clients" value="" />
<el-option
v-if="clientIDFilter && !selectedClientInList"
:label="`${userFilter ? userFilter + '.' : ''}${clientIDFilter} (not found)`"
:value="selectedClientKey"
style="color: var(--el-color-warning); font-style: italic"
/>
<el-option
v-for="client in clientOptions"
:key="client.key"
:label="client.label"
:value="client.key"
/>
</el-select>
</div>
<div class="type-tabs">
<button
v-for="t in proxyTypes"
:key="t.value"
class="type-tab"
:class="{ active: activeType === t.value }"
@click="activeType = t.value"
>
{{ t.label }}
</button>
</div>
</div>
</div>
<div v-loading="loading" class="proxies-content">
<div v-if="filteredProxies.length > 0" class="proxies-list">
<ProxyCard
v-for="proxy in filteredProxies"
:key="proxy.name"
:proxy="proxy"
/>
</div>
<div v-else-if="!loading" class="empty-state">
<el-empty description="No proxies found" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { formatFileSize } from '../utils/format'
import { ElMessage } from 'element-plus'
import {
Search,
Refresh,
Delete,
Top,
Bottom,
DataAnalysis,
} from '@element-plus/icons-vue'
import { Search, Refresh, Delete } from '@element-plus/icons-vue'
import {
BaseProxy,
TCPProxy,
@@ -158,10 +106,14 @@ import {
STCPProxy,
SUDPProxy,
} from '../utils/proxy'
import ProxyViewExpand from '../components/ProxyViewExpand.vue'
import Traffic from '../components/Traffic.vue'
import { getProxiesByType, clearOfflineProxies as apiClearOfflineProxies } from '../api/proxy'
import ProxyCard from '../components/ProxyCard.vue'
import {
getProxiesByType,
clearOfflineProxies as apiClearOfflineProxies,
} from '../api/proxy'
import { getServerInfo } from '../api/server'
import { getClients } from '../api/client'
import { Client } from '../utils/client'
const route = useRoute()
const router = useRouter()
@@ -178,19 +130,85 @@ const proxyTypes = [
const activeType = ref((route.params.type as string) || 'tcp')
const proxies = ref<BaseProxy[]>([])
const clients = ref<Client[]>([])
const loading = ref(false)
const searchText = ref('')
const dialogVisible = ref(false)
const dialogVisibleName = ref('')
const clientIDFilter = ref((route.query.clientID as string) || '')
const userFilter = ref((route.query.user as string) || '')
const clientOptions = computed(() => {
return clients.value
.map((c) => ({
key: c.key,
clientID: c.clientID,
user: c.user,
label: c.user ? `${c.user}.${c.clientID}` : c.clientID,
}))
.sort((a, b) => a.label.localeCompare(b.label))
})
// Compute selected client key for el-select v-model
const selectedClientKey = computed(() => {
if (!clientIDFilter.value) return ''
const client = clientOptions.value.find(
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
)
// Return a synthetic key even if not found, so the select shows the filter is active
return client?.key || `${userFilter.value}:${clientIDFilter.value}`
})
// Check if the filtered client exists in the client list
const selectedClientInList = computed(() => {
if (!clientIDFilter.value) return true
return clientOptions.value.some(
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
)
})
const filteredProxies = computed(() => {
if (!searchText.value) {
return proxies.value
let result = proxies.value
// Filter by clientID and user if specified
if (clientIDFilter.value) {
result = result.filter(
(p) => p.clientID === clientIDFilter.value && p.user === userFilter.value,
)
}
const search = searchText.value.toLowerCase()
return proxies.value.filter((p) => p.name.toLowerCase().includes(search))
// Filter by search text
if (searchText.value) {
const search = searchText.value.toLowerCase()
result = result.filter((p) => p.name.toLowerCase().includes(search))
}
return result
})
const onClientFilterChange = (key: string) => {
if (key) {
const client = clientOptions.value.find((c) => c.key === key)
if (client) {
router.replace({
query: { ...route.query, clientID: client.clientID, user: client.user },
})
}
} else {
const query = { ...route.query }
delete query.clientID
delete query.user
router.replace({ query })
}
}
const fetchClients = async () => {
try {
const json = await getClients()
clients.value = json.map((data) => new Client(data))
} catch {
// Ignore errors when fetching clients
}
}
// Server info cache
let serverInfo: {
vhostHTTPPort: number
@@ -247,7 +265,6 @@ const fetchData = async () => {
proxies.value = json.proxies.map((p: any) => new SUDPProxy(p))
}
} catch (error: any) {
console.error('Failed to fetch proxies:', error)
ElMessage({
showClose: true,
message: 'Failed to fetch proxies: ' + error.message,
@@ -258,11 +275,6 @@ const fetchData = async () => {
}
}
const showTraffic = (name: string) => {
dialogVisibleName.value = name
dialogVisible.value = true
}
const clearOfflineProxies = async () => {
try {
await apiClearOfflineProxies()
@@ -281,95 +293,177 @@ const clearOfflineProxies = async () => {
// Watch for type changes
watch(activeType, (newType) => {
router.replace({ params: { type: newType } })
// Update route but preserve query params
router.replace({ params: { type: newType }, query: route.query })
fetchData()
})
// Watch for route query changes (client filter)
watch(
() => [route.query.clientID, route.query.user],
([newClientID, newUser]) => {
clientIDFilter.value = (newClientID as string) || ''
userFilter.value = (newUser as string) || ''
},
)
// Initial fetch
fetchData()
fetchClients()
</script>
<style scoped>
.proxies-page {
padding: 24px;
max-width: 1600px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
}
/* Main Content */
.main-card {
border-radius: 12px;
border: none;
.page-header {
display: flex;
flex-direction: column;
gap: 24px;
}
.toolbar-header {
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
padding-bottom: 16px;
align-items: flex-start;
gap: 20px;
}
.proxy-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
.title-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.proxy-tabs :deep(.el-tabs__nav-wrap::after) {
height: 0;
.page-title {
font-size: 28px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
line-height: 1.2;
}
.toolbar-actions {
.page-subtitle {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0;
}
.actions-section {
display: flex;
gap: 12px;
}
.action-btn {
border-radius: 8px;
padding: 8px 16px;
height: 36px;
font-weight: 500;
}
.filter-section {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 8px;
}
.search-row {
display: flex;
gap: 16px;
width: 100%;
align-items: center;
}
.search-input {
.main-search {
flex: 1;
}
.main-search,
.client-select {
height: 44px;
}
.main-search :deep(.el-input__wrapper),
.client-select :deep(.el-input__wrapper) {
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
padding: 0 16px;
height: 100%;
border: 1px solid var(--el-border-color);
}
.main-search :deep(.el-input__wrapper) {
font-size: 15px;
}
.client-select {
width: 240px;
}
/* Table Styling */
.traffic-cell {
.client-select :deep(.el-select__wrapper) {
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
padding: 0 12px;
height: 44px;
min-height: 44px;
border: 1px solid var(--el-border-color);
}
.type-tabs {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
}
.type-tab {
padding: 6px 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
background: var(--el-bg-color);
color: var(--el-text-color-regular);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
}
.type-tab:hover {
background: var(--el-fill-color-light);
}
.type-tab.active {
background: var(--el-fill-color-darker);
color: var(--el-text-color-primary);
border-color: var(--el-fill-color-darker);
}
.proxies-content {
min-height: 200px;
}
.proxies-list {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
gap: 16px;
}
.traffic-item {
display: flex;
align-items: center;
gap: 4px;
.empty-state {
padding: 60px 0;
}
.traffic-item.up {
color: #67c23a;
}
.traffic-item.down {
color: #409eff;
}
.expand-wrapper {
padding: 16px 24px;
background-color: transparent;
}
/* Responsive */
@media (max-width: 768px) {
.toolbar-header {
.search-row {
flex-direction: column;
align-items: stretch;
}
.toolbar-actions {
justify-content: space-between;
}
.search-input {
flex: 1;
.client-select {
width: 100%;
}
}
</style>