server: add client registry with dashboard support (#5115)

This commit is contained in:
fatedier
2026-01-08 20:07:14 +08:00
committed by GitHub
parent bc378bcbec
commit 36718d88e4
59 changed files with 4150 additions and 1837 deletions

View File

@@ -0,0 +1,169 @@
<template>
<div class="clients-page">
<div class="filter-bar">
<el-input
v-model="searchText"
placeholder="Search by hostname, user, client ID, run ID..."
:prefix-icon="Search"
clearable
class="search-input"
/>
<el-radio-group v-model="statusFilter" class="status-filter">
<el-radio-button label="all">All ({{ stats.total }})</el-radio-button>
<el-radio-button label="online">
Online ({{ stats.online }})
</el-radio-button>
<el-radio-button label="offline">
Offline ({{ stats.offline }})
</el-radio-button>
</el-radio-group>
</div>
<div v-loading="loading" class="clients-grid">
<el-empty
v-if="filteredClients.length === 0 && !loading"
description="No clients found"
/>
<ClientCard
v-for="client in filteredClients"
:key="client.key"
:client="client"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { Client } from '../utils/client'
import ClientCard from '../components/ClientCard.vue'
import { getClients } from '../api/client'
const clients = ref<Client[]>([])
const loading = ref(false)
const searchText = ref('')
const statusFilter = ref<'all' | 'online' | 'offline'>('all')
let refreshTimer: number | null = null
const stats = computed(() => {
const total = clients.value.length
const online = clients.value.filter((c) => c.online).length
const offline = total - online
return { total, online, offline }
})
const filteredClients = computed(() => {
let result = clients.value
// Filter by status
if (statusFilter.value === 'online') {
result = result.filter((c) => c.online)
} else if (statusFilter.value === 'offline') {
result = result.filter((c) => !c.online)
}
// Filter by search text
if (searchText.value) {
result = result.filter((c) => c.matchesFilter(searchText.value))
}
// Sort: online first, then by display name
result.sort((a, b) => {
if (a.online !== b.online) {
return a.online ? -1 : 1
}
return a.displayName.localeCompare(b.displayName)
})
return result
})
const fetchData = async () => {
loading.value = true
try {
const json = await getClients()
clients.value = json.map((data) => new Client(data))
} catch (error: any) {
console.error('Failed to fetch clients:', error)
ElMessage({
showClose: true,
message: 'Failed to fetch clients: ' + error.message,
type: 'error',
})
} finally {
loading.value = false
}
}
const startAutoRefresh = () => {
// Auto refresh every 5 seconds
refreshTimer = window.setInterval(() => {
fetchData()
}, 5000)
}
const stopAutoRefresh = () => {
if (refreshTimer !== null) {
window.clearInterval(refreshTimer)
refreshTimer = null
}
}
onMounted(() => {
fetchData()
startAutoRefresh()
})
onUnmounted(() => {
stopAutoRefresh()
})
</script>
<style scoped>
.clients-page {
padding: 0 20px 20px 20px;
}
.filter-bar {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 300px;
max-width: 500px;
}
.status-filter {
flex-shrink: 0;
}
.clients-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 20px;
min-height: 200px;
}
@media (max-width: 768px) {
.clients-grid {
grid-template-columns: 1fr;
}
.filter-bar {
flex-direction: column;
align-items: stretch;
}
.search-input {
max-width: none;
}
}
</style>

View File

@@ -0,0 +1,375 @@
<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="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?"
@confirm="clearOfflineProxies"
>
<template #reference>
<el-button type="danger" plain :icon="Delete"
>Clear Offline</el-button
>
</template>
</el-popconfirm>
</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>
<el-dialog
v-model="dialogVisible"
destroy-on-close
:title="`Traffic Statistics - ${dialogVisibleName}`"
width="700px"
align-center
class="traffic-dialog"
>
<Traffic :proxyName="dialogVisibleName" />
</el-dialog>
</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 {
BaseProxy,
TCPProxy,
UDPProxy,
HTTPProxy,
HTTPSProxy,
TCPMuxProxy,
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 { getServerInfo } from '../api/server'
const route = useRoute()
const router = useRouter()
const proxyTypes = [
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' },
{ label: 'HTTP', value: 'http' },
{ label: 'HTTPS', value: 'https' },
{ label: 'TCPMUX', value: 'tcpmux' },
{ label: 'STCP', value: 'stcp' },
{ label: 'SUDP', value: 'sudp' },
]
const activeType = ref((route.params.type as string) || 'tcp')
const proxies = ref<BaseProxy[]>([])
const loading = ref(false)
const searchText = ref('')
const dialogVisible = ref(false)
const dialogVisibleName = ref('')
const filteredProxies = computed(() => {
if (!searchText.value) {
return proxies.value
}
const search = searchText.value.toLowerCase()
return proxies.value.filter((p) => p.name.toLowerCase().includes(search))
})
// Server info cache
let serverInfo: {
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
} | null = null
const fetchServerInfo = async () => {
if (serverInfo) return serverInfo
const res = await getServerInfo()
serverInfo = res
return serverInfo
}
const fetchData = async () => {
loading.value = true
proxies.value = []
try {
const type = activeType.value
const json = await getProxiesByType(type)
if (type === 'tcp') {
proxies.value = json.proxies.map((p: any) => new TCPProxy(p))
} else if (type === 'udp') {
proxies.value = json.proxies.map((p: any) => new UDPProxy(p))
} else if (type === 'http') {
const info = await fetchServerInfo()
if (info && info.vhostHTTPPort) {
proxies.value = json.proxies.map(
(p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
)
}
} else if (type === 'https') {
const info = await fetchServerInfo()
if (info && info.vhostHTTPSPort) {
proxies.value = json.proxies.map(
(p: any) =>
new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
)
}
} else if (type === 'tcpmux') {
const info = await fetchServerInfo()
if (info && info.tcpmuxHTTPConnectPort) {
proxies.value = json.proxies.map(
(p: any) =>
new TCPMuxProxy(p, info.tcpmuxHTTPConnectPort, info.subdomainHost),
)
}
} else if (type === 'stcp') {
proxies.value = json.proxies.map((p: any) => new STCPProxy(p))
} else if (type === 'sudp') {
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,
type: 'error',
})
} finally {
loading.value = false
}
}
const showTraffic = (name: string) => {
dialogVisibleName.value = name
dialogVisible.value = true
}
const clearOfflineProxies = async () => {
try {
await apiClearOfflineProxies()
ElMessage({
message: 'Successfully cleared offline proxies',
type: 'success',
})
fetchData()
} catch (err: any) {
ElMessage({
message: 'Failed to clear offline proxies: ' + err.message,
type: 'warning',
})
}
}
// Watch for type changes
watch(activeType, (newType) => {
router.replace({ params: { type: newType } })
fetchData()
})
// Initial fetch
fetchData()
</script>
<style scoped>
.proxies-page {
padding: 24px;
max-width: 1600px;
margin: 0 auto;
}
/* Main Content */
.main-card {
border-radius: 12px;
border: none;
}
.toolbar-header {
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;
}
.proxy-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
}
.proxy-tabs :deep(.el-tabs__nav-wrap::after) {
height: 0;
}
.toolbar-actions {
display: flex;
gap: 12px;
align-items: center;
}
.search-input {
width: 240px;
}
/* Table Styling */
.traffic-cell {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.traffic-item {
display: flex;
align-items: center;
gap: 4px;
}
.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 {
flex-direction: column;
align-items: stretch;
}
.toolbar-actions {
justify-content: space-between;
}
.search-input {
flex: 1;
}
}
</style>

View File

@@ -0,0 +1,457 @@
<template>
<div class="server-overview">
<el-row :gutter="20" class="stats-row">
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Clients"
:value="data.clientCounts"
type="clients"
subtitle="Connected clients"
to="/clients"
/>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Proxies"
:value="data.proxyCounts"
type="proxies"
subtitle="Active proxies"
to="/proxies/tcp"
/>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Connections"
:value="data.curConns"
type="connections"
subtitle="Current connections"
/>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Traffic"
:value="formatTrafficTotal()"
type="traffic"
subtitle="Total today"
/>
</el-col>
</el-row>
<el-row :gutter="20" class="charts-row">
<el-col :xs="24" :md="12">
<el-card class="chart-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">Network Traffic</span>
<el-tag size="small" type="info">Today</el-tag>
</div>
</template>
<div class="traffic-summary">
<div class="traffic-item in">
<div class="traffic-icon">
<el-icon><Download /></el-icon>
</div>
<div class="traffic-info">
<div class="label">Inbound</div>
<div class="value">{{ formatFileSize(data.totalTrafficIn) }}</div>
</div>
</div>
<div class="traffic-divider"></div>
<div class="traffic-item out">
<div class="traffic-icon">
<el-icon><Upload /></el-icon>
</div>
<div class="traffic-info">
<div class="label">Outbound</div>
<div class="value">{{ formatFileSize(data.totalTrafficOut) }}</div>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card class="chart-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">Proxy Types</span>
<el-tag size="small" type="info">Now</el-tag>
</div>
</template>
<div class="proxy-types-grid">
<div
v-for="(count, type) in data.proxyTypeCounts"
:key="type"
class="proxy-type-item"
v-show="count > 0"
>
<div class="proxy-type-name">{{ type.toUpperCase() }}</div>
<div class="proxy-type-count">{{ count }}</div>
</div>
<div v-if="!hasActiveProxies" class="no-data">
No active proxies
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-card class="config-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">Server Configuration</span>
<el-tag size="small" type="success">v{{ data.version }}</el-tag>
</div>
</template>
<div class="config-grid">
<div class="config-item">
<span class="config-label">Bind Port</span>
<span class="config-value">{{ data.bindPort }}</span>
</div>
<div class="config-item" v-if="data.kcpBindPort != 0">
<span class="config-label">KCP Port</span>
<span class="config-value">{{ data.kcpBindPort }}</span>
</div>
<div class="config-item" v-if="data.quicBindPort != 0">
<span class="config-label">QUIC Port</span>
<span class="config-value">{{ data.quicBindPort }}</span>
</div>
<div class="config-item" v-if="data.vhostHTTPPort != 0">
<span class="config-label">HTTP Port</span>
<span class="config-value">{{ data.vhostHTTPPort }}</span>
</div>
<div class="config-item" v-if="data.vhostHTTPSPort != 0">
<span class="config-label">HTTPS Port</span>
<span class="config-value">{{ data.vhostHTTPSPort }}</span>
</div>
<div class="config-item" v-if="data.tcpmuxHTTPConnectPort != 0">
<span class="config-label">TCPMux Port</span>
<span class="config-value">{{ data.tcpmuxHTTPConnectPort }}</span>
</div>
<div class="config-item" v-if="data.subdomainHost != ''">
<span class="config-label">Subdomain Host</span>
<span class="config-value">{{ data.subdomainHost }}</span>
</div>
<div class="config-item">
<span class="config-label">Max Pool Count</span>
<span class="config-value">{{ data.maxPoolCount }}</span>
</div>
<div class="config-item">
<span class="config-label">Max Ports/Client</span>
<span class="config-value">{{ data.maxPortsPerClient }}</span>
</div>
<div class="config-item" v-if="data.allowPortsStr != ''">
<span class="config-label">Allow Ports</span>
<span class="config-value">{{ data.allowPortsStr }}</span>
</div>
<div class="config-item" v-if="data.tlsForce">
<span class="config-label">TLS Force</span>
<el-tag size="small" type="warning">Enabled</el-tag>
</div>
<div class="config-item">
<span class="config-label">Heartbeat Timeout</span>
<span class="config-value">{{ data.heartbeatTimeout }}s</span>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { formatFileSize } from '../utils/format'
import { Download, Upload } from '@element-plus/icons-vue'
import StatCard from '../components/StatCard.vue'
import { getServerInfo } from '../api/server'
const data = ref({
version: '',
bindPort: 0,
kcpBindPort: 0,
quicBindPort: 0,
vhostHTTPPort: 0,
vhostHTTPSPort: 0,
tcpmuxHTTPConnectPort: 0,
subdomainHost: '',
maxPoolCount: 0,
maxPortsPerClient: '',
allowPortsStr: '',
tlsForce: false,
heartbeatTimeout: 0,
clientCounts: 0,
curConns: 0,
proxyCounts: 0,
totalTrafficIn: 0,
totalTrafficOut: 0,
proxyTypeCounts: {} as Record<string, number>,
})
const hasActiveProxies = computed(() => {
return Object.values(data.value.proxyTypeCounts).some(c => c > 0)
})
const formatTrafficTotal = () => {
const total = data.value.totalTrafficIn + data.value.totalTrafficOut
return formatFileSize(total)
}
const fetchData = async () => {
try {
const json = await getServerInfo()
data.value.version = json.version
data.value.bindPort = json.bindPort
data.value.kcpBindPort = json.kcpBindPort
data.value.quicBindPort = json.quicBindPort
data.value.vhostHTTPPort = json.vhostHTTPPort
data.value.vhostHTTPSPort = json.vhostHTTPSPort
data.value.tcpmuxHTTPConnectPort = json.tcpmuxHTTPConnectPort
data.value.subdomainHost = json.subdomainHost
data.value.maxPoolCount = json.maxPoolCount
data.value.maxPortsPerClient = String(json.maxPortsPerClient)
if (data.value.maxPortsPerClient == '0') {
data.value.maxPortsPerClient = 'no limit'
}
data.value.allowPortsStr = json.allowPortsStr
data.value.tlsForce = json.tlsForce
data.value.heartbeatTimeout = json.heartbeatTimeout
data.value.clientCounts = json.clientCounts
data.value.curConns = json.curConns
data.value.totalTrafficIn = json.totalTrafficIn
data.value.totalTrafficOut = json.totalTrafficOut
data.value.proxyTypeCounts = json.proxyTypeCount || {}
data.value.proxyCounts = 0
if (json.proxyTypeCount != null) {
Object.values(json.proxyTypeCount).forEach((count: any) => {
data.value.proxyCounts += (count || 0)
})
}
} catch (err) {
ElMessage({
showClose: true,
message: 'Get server info from frps failed!',
type: 'error',
})
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.server-overview {
padding: 0;
}
.stats-row {
margin-bottom: 20px;
}
.charts-row {
margin-bottom: 20px;
}
.chart-card {
border-radius: 12px;
border: 1px solid #e4e7ed;
height: 100%;
}
html.dark .chart-card {
border-color: #3a3d5c;
background: #27293d;
}
.config-card {
border-radius: 12px;
border: 1px solid #e4e7ed;
margin-bottom: 20px;
}
html.dark .config-card {
border-color: #3a3d5c;
background: #27293d;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
html.dark .card-title {
color: #e5e7eb;
}
.traffic-summary {
display: flex;
align-items: center;
justify-content: space-around;
min-height: 120px;
padding: 10px 0;
}
.traffic-item {
display: flex;
align-items: center;
gap: 16px;
}
.traffic-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.traffic-item.in .traffic-icon {
background: rgba(84, 112, 198, 0.1);
color: #5470c6;
}
.traffic-item.out .traffic-icon {
background: rgba(145, 204, 117, 0.1);
color: #91cc75;
}
.traffic-info {
display: flex;
flex-direction: column;
}
.traffic-info .label {
font-size: 14px;
color: #909399;
}
.traffic-info .value {
font-size: 24px;
font-weight: 600;
color: #303133;
}
html.dark .traffic-info .value {
color: #e5e7eb;
}
.traffic-divider {
width: 1px;
height: 60px;
background: #e4e7ed;
}
html.dark .traffic-divider {
background: #3a3d5c;
}
.proxy-types-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 16px;
min-height: 120px;
align-content: center;
padding: 10px 0;
}
.proxy-type-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
}
html.dark .proxy-type-item {
background: #1e1e2d;
}
.proxy-type-name {
font-size: 12px;
color: #909399;
font-weight: 500;
margin-bottom: 4px;
}
.proxy-type-count {
font-size: 20px;
font-weight: 600;
color: #303133;
}
html.dark .proxy-type-count {
color: #e5e7eb;
}
.no-data {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #909399;
font-size: 14px;
}
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.config-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
transition: background 0.2s;
}
html.dark .config-item {
background: #1e1e2d;
}
.config-label {
font-size: 12px;
color: #909399;
font-weight: 500;
}
html.dark .config-label {
color: #9ca3af;
}
.config-value {
font-size: 14px;
color: #303133;
font-weight: 600;
word-break: break-all;
}
html.dark .config-value {
color: #e5e7eb;
}
@media (max-width: 768px) {
.chart-container {
height: 250px;
}
.config-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>