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,43 +1,55 @@
<template>
<div id="app">
<header class="header">
<div class="header-top">
<div class="brand">
<a href="#" @click.prevent="router.push('/')">frp</a>
<div class="header-content">
<div class="header-top">
<div class="brand-section">
<div class="logo-wrapper">
<LogoIcon class="logo-icon" />
</div>
<span class="divider">/</span>
<span class="brand-name">frp</span>
<span class="badge" v-if="currentRouteName">{{
currentRouteName
}}</span>
</div>
<div class="header-controls">
<a
class="github-link"
href="https://github.com/fatedier/frp"
target="_blank"
aria-label="GitHub"
>
<GitHubIcon class="github-icon" />
</a>
<el-switch
v-model="isDark"
inline-prompt
:active-icon="Moon"
:inactive-icon="Sunny"
class="theme-switch"
/>
</div>
</div>
<div class="header-actions">
<a
class="github-link"
href="https://github.com/fatedier/frp"
target="_blank"
aria-label="GitHub"
<nav class="nav-bar">
<router-link to="/" class="nav-link" active-class="active"
>Overview</router-link
>
<GitHubIcon class="github-icon" />
</a>
<el-switch
v-model="darkmodeSwitch"
inline-prompt
:active-icon="Moon"
:inactive-icon="Sunny"
@change="toggleDark"
class="theme-switch"
/>
</div>
<router-link to="/clients" class="nav-link" active-class="active"
>Clients</router-link
>
<router-link
to="/proxies"
class="nav-link"
:class="{ active: route.path.startsWith('/proxies') }"
>Proxies</router-link
>
</nav>
</div>
<nav class="header-nav">
<el-menu
:default-active="currentRoute"
mode="horizontal"
:ellipsis="false"
@select="handleSelect"
class="nav-menu"
>
<el-menu-item index="/">Overview</el-menu-item>
<el-menu-item index="/clients">Clients</el-menu-item>
<el-menu-item index="/proxies">Proxies</el-menu-item>
</el-menu>
</nav>
</header>
<main id="content">
<router-view></router-view>
</main>
@@ -45,256 +57,204 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useDark, useToggle } from '@vueuse/core'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useDark } from '@vueuse/core'
import { Moon, Sunny } from '@element-plus/icons-vue'
import GitHubIcon from './assets/icons/github.svg?component'
import LogoIcon from './assets/icons/logo.svg?component'
const router = useRouter()
const route = useRoute()
const isDark = useDark()
const darkmodeSwitch = ref(isDark)
const toggleDark = useToggle(isDark)
const currentRoute = computed(() => {
// Normalize /proxies/:type to /proxies for menu highlighting
if (route.path.startsWith('/proxies')) {
return '/proxies'
}
return route.path
const currentRouteName = computed(() => {
if (route.path === '/') return 'Overview'
if (route.path.startsWith('/clients')) return 'Clients'
if (route.path.startsWith('/proxies')) return 'Proxies'
return ''
})
const handleSelect = (key: string) => {
router.push(key)
}
</script>
<style>
:root {
--header-height: 112px;
--header-bg: rgba(255, 255, 255, 0.8);
--header-border: #eaeaea;
--text-primary: #000;
--text-secondary: #666;
--hover-bg: #f5f5f5;
--active-link: #000;
}
html.dark {
--header-bg: rgba(0, 0, 0, 0.8);
--header-border: #333;
--text-primary: #fff;
--text-secondary: #888;
--hover-bg: #1a1a1a;
--active-link: #fff;
}
body {
margin: 0;
font-family:
-apple-system,
BlinkMacSystemFont,
Helvetica Neue,
sans-serif;
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, sans-serif;
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
background: #f2f2f2;
}
html.dark #app {
background: #1a1a2e;
background-color: var(--el-bg-color-page);
}
.header {
position: sticky;
top: 0;
z-index: 100;
background: #fff;
background: var(--header-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--header-border);
}
html.dark .header {
background: #1e1e2d;
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 40px;
}
.header-top {
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
height: 48px;
padding: 0 32px;
}
.brand a {
color: #303133;
font-size: 20px;
font-weight: 700;
text-decoration: none;
.brand-section {
display: flex;
align-items: center;
gap: 12px;
}
.logo-wrapper {
display: flex;
align-items: center;
}
.logo-icon {
width: 32px;
height: 32px;
}
.divider {
color: var(--header-border);
font-size: 24px;
font-weight: 200;
}
.brand-name {
font-weight: 600;
font-size: 18px;
color: var(--text-primary);
letter-spacing: -0.5px;
}
html.dark .brand a {
color: #e5e7eb;
.badge {
font-size: 12px;
color: var(--text-secondary);
background: var(--hover-bg);
padding: 2px 8px;
border-radius: 99px;
border: 1px solid var(--header-border);
}
.brand a:hover {
color: #409eff;
}
.header-actions {
.header-controls {
display: flex;
align-items: center;
gap: 16px;
}
.github-link {
width: 26px;
height: 26px;
display: flex;
align-items: center;
padding: 6px;
border-radius: 6px;
transition: all 0.2s;
}
.github-link:hover {
background: #f2f3f5;
}
html.dark .github-link:hover {
background: #2a2a3c;
justify-content: center;
border-radius: 50%;
color: var(--text-primary);
transition: background 0.2s;
background: transparent;
border: 1px solid transparent;
cursor: pointer;
}
.github-icon {
width: 20px;
height: 20px;
color: #606266;
transition: color 0.2s;
width: 18px;
height: 18px;
}
.github-link:hover .github-icon {
color: #303133;
}
html.dark .github-icon {
color: #a0a3ad;
}
html.dark .github-link:hover .github-icon {
color: #e5e7eb;
.github-link:hover {
background: var(--hover-bg);
border-color: var(--header-border);
}
.theme-switch {
--el-switch-on-color: #2c2c3a;
--el-switch-off-color: #f2f2f2;
--el-switch-border-color: #dcdfe6;
--el-switch-border-color: var(--header-border);
}
html.dark .theme-switch {
--el-switch-off-color: #333;
}
.theme-switch .el-switch__core .el-switch__inner .el-icon {
color: #909399 !important;
}
.header-nav {
position: relative;
padding: 0 32px;
border-bottom: 1px solid #e4e7ed;
.nav-bar {
height: 48px;
display: flex;
align-items: center;
gap: 24px;
}
html.dark .header-nav {
border-bottom-color: #3a3d5c;
}
.nav-menu {
background: transparent !important;
border-bottom: none !important;
height: 46px;
}
.nav-menu .el-menu-item,
.nav-menu .el-sub-menu__title {
position: relative;
height: 32px !important;
line-height: 32px !important;
border-bottom: none !important;
border-radius: 6px !important;
color: #666 !important;
font-weight: 400;
.nav-link {
text-decoration: none;
font-size: 14px;
padding: 0 12px !important;
margin: 7px 0;
transition:
background 0.15s ease,
color 0.15s ease;
color: var(--text-secondary);
padding: 8px 0;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.nav-menu > .el-menu-item,
.nav-menu > .el-sub-menu {
margin-right: 4px;
.nav-link:hover {
color: var(--text-primary);
}
.nav-menu > .el-sub-menu {
padding: 0 !important;
}
html.dark .nav-menu .el-menu-item,
html.dark .nav-menu .el-sub-menu__title {
color: #888 !important;
}
.nav-menu .el-menu-item:hover,
.nav-menu .el-sub-menu__title:hover {
background: #f2f2f2 !important;
color: #171717 !important;
}
html.dark .nav-menu .el-menu-item:hover,
html.dark .nav-menu .el-sub-menu__title:hover {
background: #2a2a3c !important;
color: #e5e7eb !important;
}
.nav-menu .el-menu-item.is-active {
background: transparent !important;
color: #171717 !important;
font-weight: 500;
}
.nav-menu .el-menu-item.is-active::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -3px;
height: 2px;
background: #171717;
border-radius: 1px;
}
.nav-menu .el-menu-item.is-active:hover {
background: #f2f2f2 !important;
}
html.dark .nav-menu .el-menu-item.is-active {
background: transparent !important;
color: #e5e7eb !important;
font-weight: 500;
}
html.dark .nav-menu .el-menu-item.is-active::after {
background: #e5e7eb;
}
html.dark .nav-menu .el-menu-item.is-active:hover {
background: #2a2a3c !important;
.nav-link.active {
color: var(--active-link);
border-bottom-color: var(--active-link);
}
#content {
flex: 1;
padding: 24px 40px;
max-width: 1400px;
margin: 0 auto;
width: 100%;
padding: 40px;
max-width: 1200px;
margin: 0 auto;
box-sizing: border-box;
}
@media (max-width: 768px) {
.header-top {
padding: 0 16px;
}
.header-nav {
padding: 0 16px;
.header-content {
padding: 0 20px;
}
#content {
padding: 16px;
}
.brand a {
font-size: 18px;
padding: 20px;
}
}
</style>

View File

@@ -19,7 +19,11 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(url, { ...defaultOptions, ...options })
if (!response.ok) {
throw new HTTPError(response.status, response.statusText, `HTTP ${response.status}`)
throw new HTTPError(
response.status,
response.statusText,
`HTTP ${response.status}`,
)
}
// Handle empty response (e.g. 204 No Content)
@@ -31,20 +35,22 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
}
export const http = {
get: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'GET' }),
post: <T>(url: string, body?: any, options?: RequestInit) =>
request<T>(url, {
...options,
method: 'POST',
get: <T>(url: string, options?: RequestInit) =>
request<T>(url, { ...options, method: 'GET' }),
post: <T>(url: string, body?: any, options?: RequestInit) =>
request<T>(url, {
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(body)
body: JSON.stringify(body),
}),
put: <T>(url: string, body?: any, options?: RequestInit) =>
request<T>(url, {
...options,
method: 'PUT',
put: <T>(url: string, body?: any, options?: RequestInit) =>
request<T>(url, {
...options,
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(body)
body: JSON.stringify(body),
}),
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
delete: <T>(url: string, options?: RequestInit) =>
request<T>(url, { ...options, method: 'DELETE' }),
}

View File

@@ -1,5 +1,9 @@
import { http } from './http'
import type { GetProxyResponse, ProxyStatsInfo, TrafficResponse } from '../types/proxy'
import type {
GetProxyResponse,
ProxyStatsInfo,
TrafficResponse,
} from '../types/proxy'
export const getProxiesByType = (type: string) => {
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
@@ -9,6 +13,10 @@ export const getProxy = (type: string, name: string) => {
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
}
export const getProxyByName = (name: string) => {
return http.get<ProxyStatsInfo>(`../api/proxies/${name}`)
}
export const getProxyTraffic = (name: string) => {
return http.get<TrafficResponse>(`../api/traffic/${name}`)
}

View File

@@ -67,7 +67,7 @@
.el-page-header__title {
font-size: 20px;
font-weight: 600;
font-weight: 500;
}
/* Better form layouts */

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 100 100" aria-label="F icon" role="img">
<circle cx="50" cy="50" r="46" fill="#477EE5"/>
<g transform="translate(50 50) skewX(-12) translate(-50 -50)">
<path
d="M37 28 V72
M37 28 H63
M37 50 H55"
fill="none"
stroke="#FFFFFF"
stroke-width="14"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 448 B

View File

@@ -1,65 +1,48 @@
<template>
<el-card class="client-card" shadow="hover" :body-style="{ padding: '20px' }">
<div class="client-header">
<div class="client-status">
<span class="status-dot" :class="statusClass"></span>
<span class="client-name">{{ client.displayName }}</span>
<div class="client-card" @click="viewDetail">
<div class="card-icon-wrapper">
<div
class="status-dot-large"
:class="client.online ? 'online' : 'offline'"
></div>
</div>
<div class="card-content">
<div class="card-header">
<span class="client-main-id">{{ client.displayName }}</span>
<span v-if="client.hostname" class="hostname-badge">{{
client.hostname
}}</span>
</div>
<el-tag :type="client.statusColor" size="small">
<div class="card-meta">
<div class="meta-group">
<span v-if="client.ip" class="meta-item">
<span class="meta-label">IP</span>
<span class="meta-value">{{ client.ip }}</span>
</span>
</div>
<span class="meta-item activity">
<el-icon class="activity-icon"><DataLine /></el-icon>
<span class="meta-value">{{
client.online ? client.lastConnectedAgo : client.disconnectedAgo
}}</span>
</span>
</div>
</div>
<div class="card-action">
<div class="status-badge" :class="client.online ? 'online' : 'offline'">
{{ client.online ? 'Online' : 'Offline' }}
</el-tag>
</div>
<el-icon class="arrow-icon"><ArrowRight /></el-icon>
</div>
<div class="client-info">
<div class="info-row">
<el-icon class="info-icon"><Monitor /></el-icon>
<span class="info-label">Hostname:</span>
<span class="info-value">{{ client.hostname || 'N/A' }}</span>
</div>
<div class="info-row" v-if="client.ip">
<el-icon class="info-icon"><Connection /></el-icon>
<span class="info-label">IP:</span>
<span class="info-value monospace">{{ client.ip }}</span>
</div>
<div class="info-row" v-if="client.user">
<el-icon class="info-icon"><User /></el-icon>
<span class="info-label">User:</span>
<span class="info-value">{{ client.user }}</span>
</div>
<div class="info-row">
<el-icon class="info-icon"><Key /></el-icon>
<span class="info-label">Run ID:</span>
<span class="info-value monospace">{{ client.runID }}</span>
</div>
<div class="info-row" v-if="client.firstConnectedAt">
<el-icon class="info-icon"><Clock /></el-icon>
<span class="info-label">First Connected:</span>
<span class="info-value">{{ client.firstConnectedAgo }}</span>
</div>
<div class="info-row" v-if="client.online">
<el-icon class="info-icon"><Clock /></el-icon>
<span class="info-label">Last Connected:</span>
<span class="info-value">{{ client.lastConnectedAgo }}</span>
</div>
<div class="info-row" v-if="!client.online && client.disconnectedAt">
<el-icon class="info-icon"><CircleClose /></el-icon>
<span class="info-label">Disconnected:</span>
<span class="info-value">{{ client.disconnectedAgo }}</span>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Monitor, User, Key, Clock, CircleClose, Connection } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { DataLine, ArrowRight } from '@element-plus/icons-vue'
import type { Client } from '../utils/client'
interface Props {
@@ -67,124 +50,209 @@ interface Props {
}
const props = defineProps<Props>()
const router = useRouter()
const statusClass = computed(() => {
return `status-${props.client.statusColor}`
})
const viewDetail = () => {
router.push({
name: 'ClientDetail',
params: { key: props.client.key },
})
}
</script>
<style scoped>
.client-card {
border-radius: 12px;
transition: all 0.3s ease;
border: 1px solid #e4e7ed;
display: flex;
align-items: center;
gap: 20px;
padding: 24px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 16px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.client-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
border-color: var(--el-border-color-light);
}
html.dark .client-card {
border-color: #3a3d5c;
background: #27293d;
}
.client-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e4e7ed;
}
html.dark .client-header {
border-bottom-color: #3a3d5c;
}
.client-status {
.card-icon-wrapper {
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--el-fill-color);
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s;
}
.status-dot {
width: 10px;
height: 10px;
.client-card:hover .card-icon-wrapper {
background: var(--el-color-success-light-9);
}
.status-dot-large {
width: 6px;
height: 6px;
border-radius: 50%;
transition: all 0.3s;
}
.status-success {
background-color: #67c23a;
box-shadow: 0 0 0 0 rgba(103, 194, 58, 0.7);
.status-dot-large.online {
background-color: var(--el-color-success);
box-shadow: 0 0 0 2px var(--el-color-success-light-8);
}
.status-warning {
background-color: #e6a23c;
box-shadow: 0 0 0 0 rgba(230, 162, 60, 0.7);
.status-dot-large.offline {
background-color: var(--el-text-color-placeholder);
}
.status-danger {
background-color: #f56c6c;
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.7);
}
.client-name {
font-size: 16px;
font-weight: 600;
color: #303133;
}
html.dark .client-name {
color: #e5e7eb;
}
.client-info {
.card-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
gap: 8px;
min-width: 0;
}
.info-row {
.card-header {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.client-main-id {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.2;
}
.hostname-badge {
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
border-radius: 6px;
background: var(--el-fill-color-dark);
color: var(--el-text-color-regular);
}
.card-meta {
display: flex;
align-items: center;
gap: 24px;
font-size: 13px;
color: var(--el-text-color-regular);
flex-wrap: wrap;
}
.meta-group {
display: flex;
align-items: center;
gap: 16px;
}
.meta-item {
display: flex;
align-items: center;
gap: 8px;
}
.meta-label {
color: var(--el-text-color-placeholder);
font-weight: 500;
font-size: 13px;
}
.info-icon {
color: #909399;
font-size: 16px;
}
html.dark .info-icon {
color: #9ca3af;
}
.info-label {
color: #909399;
.meta-value {
font-size: 13px;
font-weight: 500;
min-width: 100px;
color: var(--el-text-color-primary);
}
html.dark .info-label {
color: #9ca3af;
.activity .meta-value {
font-weight: 400;
color: var(--el-text-color-secondary);
}
.info-value {
color: #606266;
flex: 1;
.card-action {
display: flex;
align-items: center;
gap: 20px;
flex-shrink: 0;
}
html.dark .info-value {
color: #d1d5db;
.status-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
.monospace {
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
word-break: break-all;
.status-badge.online {
background: var(--el-color-success-light-9);
color: var(--el-color-success);
}
.status-badge.offline {
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
}
.arrow-icon {
font-size: 18px;
color: var(--el-text-color-placeholder);
transition: all 0.2s;
}
.client-card:hover .arrow-icon {
color: var(--el-text-color-primary);
transform: translateX(4px);
}
/* Dark mode adjustments */
html.dark .card-icon-wrapper {
background: var(--el-fill-color-light);
}
html.dark .client-card:hover .card-icon-wrapper {
background: var(--el-color-success-light-9);
}
html.dark .status-dot-large.online {
box-shadow: 0 0 0 2px rgba(var(--el-color-success-rgb), 0.2);
}
@media (max-width: 640px) {
.client-card {
flex-direction: column;
align-items: flex-start;
padding: 20px;
}
.card-icon-wrapper {
width: 48px;
height: 48px;
}
.card-content {
width: 100%;
gap: 12px;
}
.card-action {
width: 100%;
justify-content: space-between;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
}
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<router-link :to="proxyLink" class="proxy-card">
<div class="card-main">
<div class="card-left">
<div class="card-header">
<span class="proxy-name">{{ proxy.name }}</span>
<span v-if="showType" class="type-tag">{{
proxy.type.toUpperCase()
}}</span>
</div>
<div class="card-meta">
<span v-if="proxy.port" class="meta-item">
<span class="meta-label">Port:</span>
<span class="meta-value">{{ proxy.port }}</span>
</span>
<span class="meta-item">
<span class="meta-label">Connections:</span>
<span class="meta-value">{{ proxy.conns }}</span>
</span>
<span class="meta-item" v-if="proxy.clientID">
<span class="meta-label">Client:</span>
<span class="meta-value">{{
proxy.user ? `${proxy.user}.${proxy.clientID}` : proxy.clientID
}}</span>
</span>
</div>
</div>
<div class="card-right">
<div class="traffic-stats">
<div class="traffic-row">
<el-icon class="traffic-icon out"><Top /></el-icon>
<span class="traffic-value">{{
formatFileSize(proxy.trafficOut)
}}</span>
</div>
<div class="traffic-row">
<el-icon class="traffic-icon in"><Bottom /></el-icon>
<span class="traffic-value">{{
formatFileSize(proxy.trafficIn)
}}</span>
</div>
</div>
<div class="status-badge" :class="proxy.status">
{{ proxy.status }}
</div>
</div>
</div>
</router-link>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { Top, Bottom } from '@element-plus/icons-vue'
import { formatFileSize } from '../utils/format'
import type { BaseProxy } from '../utils/proxy'
interface Props {
proxy: BaseProxy
showType?: boolean
}
const props = defineProps<Props>()
const route = useRoute()
const proxyLink = computed(() => {
const base = `/proxy/${props.proxy.name}`
// If we're on a client detail page, pass client info
if (route.name === 'ClientDetail' && route.params.key) {
return `${base}?from=client&client=${route.params.key}`
}
return base
})
</script>
<style scoped>
.proxy-card {
display: block;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
transition: all 0.2s ease-in-out;
overflow: hidden;
text-decoration: none;
cursor: pointer;
}
.proxy-card:hover {
border-color: var(--el-border-color-light);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
}
.card-main {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
gap: 24px;
min-height: 80px;
}
/* Left Section */
.card-left {
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.proxy-name {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.4;
}
.type-tag {
font-size: 11px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
}
.card-meta {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: baseline;
gap: 6px;
line-height: 1;
}
.meta-label {
color: var(--el-text-color-placeholder);
font-size: 13px;
font-weight: 500;
}
.meta-value {
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-regular);
}
/* Right Section */
.card-right {
display: flex;
align-items: center;
gap: 24px;
flex-shrink: 0;
}
.traffic-stats {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-end;
}
.traffic-row {
display: flex;
align-items: center;
gap: 6px;
line-height: 1;
}
.traffic-icon {
font-size: 12px;
}
.traffic-icon.in {
color: var(--el-color-primary);
}
.traffic-icon.out {
color: var(--el-color-success);
}
.traffic-value {
font-size: 12px;
color: var(--el-text-color-secondary);
font-weight: 500;
text-align: right;
}
.status-badge {
display: inline-flex;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
text-transform: capitalize;
}
.status-badge.online {
background: var(--el-color-success-light-9);
color: var(--el-color-success);
}
.status-badge.offline {
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.card-main {
flex-direction: column;
align-items: stretch;
gap: 16px;
padding: 16px;
}
.card-right {
flex-direction: row;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 16px;
}
.traffic-stats {
align-items: flex-start;
}
}
</style>

View File

@@ -1,112 +0,0 @@
<template>
<el-form
label-position="left"
label-width="auto"
inline
class="proxy-table-expand"
>
<el-form-item label="Name">
<span>{{ row.name }}</span>
</el-form-item>
<el-form-item label="Type">
<span>{{ row.type }}</span>
</el-form-item>
<el-form-item label="Encryption">
<span>{{ row.encryption }}</span>
</el-form-item>
<el-form-item label="Compression">
<span>{{ row.compression }}</span>
</el-form-item>
<el-form-item label="Last Start">
<span>{{ row.lastStartTime }}</span>
</el-form-item>
<el-form-item label="Last Close">
<span>{{ row.lastCloseTime }}</span>
</el-form-item>
<div v-if="proxyType === 'http' || proxyType === 'https'">
<el-form-item label="Domains">
<span>{{ row.customDomains }}</span>
</el-form-item>
<el-form-item label="SubDomain">
<span>{{ row.subdomain }}</span>
</el-form-item>
<el-form-item label="locations">
<span>{{ row.locations }}</span>
</el-form-item>
<el-form-item label="HostRewrite">
<span>{{ row.hostHeaderRewrite }}</span>
</el-form-item>
</div>
<div v-else-if="proxyType === 'tcpmux'">
<el-form-item label="Multiplexer">
<span>{{ row.multiplexer }}</span>
</el-form-item>
<el-form-item label="RouteByHTTPUser">
<span>{{ row.routeByHTTPUser }}</span>
</el-form-item>
<el-form-item label="Domains">
<span>{{ row.customDomains }}</span>
</el-form-item>
<el-form-item label="SubDomain">
<span>{{ row.subdomain }}</span>
</el-form-item>
</div>
<div v-else>
<el-form-item label="Addr">
<span>{{ row.addr }}</span>
</el-form-item>
</div>
</el-form>
<div v-if="row.annotations && row.annotations.size > 0">
<el-divider />
<el-text class="title-text" size="large">Annotations</el-text>
<ul>
<li v-for="item in annotationsArray()" :key="item.key">
<span class="annotation-key">{{ item.key }}</span>
<span>{{ item.value }}</span>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
row: any
proxyType: string
}>()
// annotationsArray returns an array of key-value pairs from the annotations map.
const annotationsArray = (): Array<{ key: string; value: string }> => {
const array: Array<{ key: string; value: any }> = []
if (props.row.annotations) {
props.row.annotations.forEach((value: any, key: string) => {
array.push({ key, value })
})
}
return array
}
</script>
<style>
ul {
list-style-type: none;
padding: 5px;
}
ul li {
justify-content: space-between;
padding: 5px;
}
ul .annotation-key {
width: 300px;
display: inline-block;
vertical-align: middle;
}
.title-text {
color: #99a9bf;
}
</style>

View File

@@ -167,7 +167,7 @@ html.dark .icon-traffic {
.stat-value {
font-size: 28px;
font-weight: 600;
font-weight: 500;
line-height: 1.2;
color: #303133;
margin-bottom: 4px;

View File

@@ -6,7 +6,7 @@
<div class="y-label">{{ formatFileSize(maxVal / 2) }}</div>
<div class="y-label">0</div>
</div>
<div class="bars-area">
<!-- Grid Lines -->
<div class="grid-line top"></div>
@@ -15,15 +15,21 @@
<div v-for="(item, index) in chartData" :key="index" class="day-column">
<div class="bars-group">
<el-tooltip :content="`In: ${formatFileSize(item.in)}`" placement="top">
<div
class="bar bar-in"
<el-tooltip
:content="`In: ${formatFileSize(item.in)}`"
placement="top"
>
<div
class="bar bar-in"
:style="{ height: Math.max(item.inPercent, 1) + '%' }"
></div>
</el-tooltip>
<el-tooltip :content="`Out: ${formatFileSize(item.out)}`" placement="top">
<div
class="bar bar-out"
<el-tooltip
:content="`Out: ${formatFileSize(item.out)}`"
placement="top"
>
<div
class="bar bar-out"
:style="{ height: Math.max(item.outPercent, 1) + '%' }"
></div>
</el-tooltip>
@@ -32,15 +38,11 @@
</div>
</div>
</div>
<!-- Legend -->
<div v-if="!loading && chartData.length > 0" class="legend">
<div class="legend-item">
<span class="dot in"></span> Traffic In
</div>
<div class="legend-item">
<span class="dot out"></span> Traffic Out
</div>
<div class="legend-item"><span class="dot in"></span> Traffic In</div>
<div class="legend-item"><span class="dot out"></span> Traffic Out</div>
</div>
<el-empty v-else-if="!loading" description="No traffic data" />
@@ -58,24 +60,26 @@ const props = defineProps<{
}>()
const loading = ref(false)
const chartData = ref<Array<{
date: string
in: number
out: number
inPercent: number
outPercent: number
}>>([])
const chartData = ref<
Array<{
date: string
in: number
out: number
inPercent: number
outPercent: number
}>
>([])
const maxVal = ref(0)
const processData = (trafficIn: number[], trafficOut: number[]) => {
// Ensure we have arrays and reverse them (server returns newest first)
const inArr = [...(trafficIn || [])].reverse()
const outArr = [...(trafficOut || [])].reverse()
// Pad with zeros if less than 7 days
while (inArr.length < 7) inArr.unshift(0)
while (outArr.length < 7) outArr.unshift(0)
// Slice to last 7 entries just in case
const finalIn = inArr.slice(-7)
const finalOut = outArr.slice(-7)
@@ -84,7 +88,7 @@ const processData = (trafficIn: number[], trafficOut: number[]) => {
const dates: string[] = []
let d = new Date()
d.setDate(d.getDate() - 6)
for (let i = 0; i < 7; i++) {
dates.push(`${d.getMonth() + 1}-${d.getDate()}`)
d.setDate(d.getDate() + 1)
@@ -179,9 +183,16 @@ html.dark .grid-line {
background-color: #3a3d5c;
}
.grid-line.top { top: 0; }
.grid-line.middle { top: 50%; transform: translateY(-50%); }
.grid-line.bottom { bottom: 24px; } /* Align with bottom of bars */
.grid-line.top {
top: 0;
}
.grid-line.middle {
top: 50%;
transform: translateY(-50%);
}
.grid-line.bottom {
bottom: 24px;
} /* Align with bottom of bars */
.day-column {
flex: 1;
@@ -255,6 +266,10 @@ html.dark .legend-item {
border-radius: 50%;
}
.dot.in { background-color: #5470c6; }
.dot.out { background-color: #91cc75; }
</style>
.dot.in {
background-color: #5470c6;
}
.dot.out {
background-color: #91cc75;
}
</style>

View File

@@ -1,10 +1,15 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import ServerOverview from '../views/ServerOverview.vue'
import Clients from '../views/Clients.vue'
import ClientDetail from '../views/ClientDetail.vue'
import Proxies from '../views/Proxies.vue'
import ProxyDetail from '../views/ProxyDetail.vue'
const router = createRouter({
history: createWebHashHistory(),
scrollBehavior() {
return { top: 0 }
},
routes: [
{
path: '/',
@@ -16,11 +21,21 @@ const router = createRouter({
name: 'Clients',
component: Clients,
},
{
path: '/clients/:key',
name: 'ClientDetail',
component: ClientDetail,
},
{
path: '/proxies/:type?',
name: 'Proxies',
component: Proxies,
},
{
path: '/proxy/:name',
name: 'ProxyDetail',
component: ProxyDetail,
},
],
})

View File

@@ -1,6 +1,8 @@
export interface ProxyStatsInfo {
name: string
conf: any
user: string
clientID: string
clientVersion: string
todayTrafficIn: number
todayTrafficOut: number

View File

@@ -12,7 +12,7 @@ export interface ServerInfo {
heartbeatTimeout: number
allowPortsStr: string
tlsForce: boolean
// Stats
totalTrafficIn: number
totalTrafficOut: number

View File

@@ -28,6 +28,6 @@ export function formatFileSize(bytes: number): string {
// Prevent index out of bounds for extremely large numbers
const unit = sizes[i] || sizes[sizes.length - 1]
const val = bytes / Math.pow(k, i)
return parseFloat(val.toFixed(2)) + ' ' + unit
}

View File

@@ -10,6 +10,8 @@ class BaseProxy {
lastStartTime: string
lastCloseTime: string
status: string
user: string
clientID: string
clientVersion: string
addr: string
port: number
@@ -19,6 +21,10 @@ class BaseProxy {
locations: string
subdomain: string
// TCPMux specific
multiplexer: string
routeByHTTPUser: string
constructor(proxyStats: any) {
this.name = proxyStats.name
this.type = ''
@@ -41,6 +47,8 @@ class BaseProxy {
this.lastStartTime = proxyStats.lastStartTime
this.lastCloseTime = proxyStats.lastCloseTime
this.status = proxyStats.status
this.user = proxyStats.user || ''
this.clientID = proxyStats.clientID || ''
this.clientVersion = proxyStats.clientVersion
this.addr = ''
@@ -49,6 +57,8 @@ class BaseProxy {
this.hostHeaderRewrite = ''
this.locations = ''
this.subdomain = ''
this.multiplexer = ''
this.routeByHTTPUser = ''
}
}
@@ -111,20 +121,15 @@ class HTTPSProxy extends BaseProxy {
}
class TCPMuxProxy extends BaseProxy {
multiplexer: string
routeByHTTPUser: string
constructor(proxyStats: any, port: number, subdomainHost: string) {
super(proxyStats)
this.type = 'tcpmux'
this.port = port
this.multiplexer = ''
this.routeByHTTPUser = ''
if (proxyStats.conf) {
this.customDomains = proxyStats.conf.customDomains || this.customDomains
this.multiplexer = proxyStats.conf.multiplexer
this.routeByHTTPUser = proxyStats.conf.routeByHTTPUser
this.multiplexer = proxyStats.conf.multiplexer || ''
this.routeByHTTPUser = proxyStats.conf.routeByHTTPUser || ''
if (proxyStats.conf.subdomain) {
this.subdomain = `${proxyStats.conf.subdomain}.${subdomainHost}`
}

View File

@@ -0,0 +1,522 @@
<template>
<div class="client-detail-page">
<!-- Breadcrumb -->
<nav class="breadcrumb">
<a class="breadcrumb-link" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
</a>
<router-link to="/clients" class="breadcrumb-item">Clients</router-link>
<span class="breadcrumb-separator">/</span>
<span class="breadcrumb-current">{{
client?.displayName || route.params.key
}}</span>
</nav>
<div v-loading="loading" class="detail-content">
<template v-if="client">
<!-- Header Card -->
<div class="header-card">
<div class="header-main">
<div class="header-left">
<div class="client-avatar">
{{ client.displayName.charAt(0).toUpperCase() }}
</div>
<div class="client-info">
<h1 class="client-name">{{ client.displayName }}</h1>
<div class="client-meta">
<span v-if="client.ip" class="meta-item">{{
client.ip
}}</span>
<span v-if="client.hostname" class="meta-item">{{
client.hostname
}}</span>
</div>
</div>
</div>
<div class="header-right">
<span
class="status-badge"
:class="client.online ? 'online' : 'offline'"
>
{{ client.online ? 'Online' : 'Offline' }}
</span>
</div>
</div>
<!-- Info Section -->
<div class="info-section">
<div class="info-item">
<span class="info-label">Connections</span>
<span class="info-value">{{ totalConnections }}</span>
</div>
<div class="info-item">
<span class="info-label">Run ID</span>
<span class="info-value">{{ client.runID }}</span>
</div>
<div class="info-item">
<span class="info-label">First Connected</span>
<span class="info-value">{{ client.firstConnectedAgo }}</span>
</div>
<div class="info-item">
<span class="info-label">{{
client.online ? 'Connected' : 'Disconnected'
}}</span>
<span class="info-value">{{
client.online ? client.lastConnectedAgo : client.disconnectedAgo
}}</span>
</div>
</div>
</div>
<!-- Proxies Card -->
<div class="proxies-card">
<div class="proxies-header">
<div class="proxies-title">
<h2>Proxies</h2>
<span class="proxies-count">{{ filteredProxies.length }}</span>
</div>
<el-input
v-model="proxySearch"
placeholder="Search proxies..."
:prefix-icon="Search"
clearable
class="proxy-search"
/>
</div>
<div class="proxies-body">
<div v-if="proxiesLoading" class="loading-state">
<el-icon class="is-loading"><Loading /></el-icon>
<span>Loading...</span>
</div>
<div v-else-if="filteredProxies.length > 0" class="proxies-list">
<ProxyCard
v-for="proxy in filteredProxies"
:key="proxy.name"
:proxy="proxy"
show-type
/>
</div>
<div v-else-if="clientProxies.length > 0" class="empty-state">
<p>No proxies match "{{ proxySearch }}"</p>
</div>
<div v-else class="empty-state">
<p>No proxies found</p>
</div>
</div>
</div>
</template>
<div v-else-if="!loading" class="not-found">
<h2>Client not found</h2>
<p>The client doesn't exist or has been removed.</p>
<router-link to="/clients">
<el-button type="primary">Back to Clients</el-button>
</router-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue'
import { Client } from '../utils/client'
import { getClient } from '../api/client'
import { getProxiesByType } from '../api/proxy'
import {
BaseProxy,
TCPProxy,
UDPProxy,
HTTPProxy,
HTTPSProxy,
TCPMuxProxy,
STCPProxy,
SUDPProxy,
} from '../utils/proxy'
import { getServerInfo } from '../api/server'
import ProxyCard from '../components/ProxyCard.vue'
const route = useRoute()
const router = useRouter()
const client = ref<Client | null>(null)
const loading = ref(true)
const goBack = () => {
if (window.history.length > 1) {
router.back()
} else {
router.push('/clients')
}
}
const proxiesLoading = ref(false)
const allProxies = ref<BaseProxy[]>([])
const proxySearch = ref('')
let serverInfo: {
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
} | null = null
const clientProxies = computed(() => {
if (!client.value) return []
return allProxies.value.filter(
(p) =>
p.clientID === client.value!.clientID && p.user === client.value!.user,
)
})
const filteredProxies = computed(() => {
if (!proxySearch.value) return clientProxies.value
const search = proxySearch.value.toLowerCase()
return clientProxies.value.filter(
(p) =>
p.name.toLowerCase().includes(search) ||
p.type.toLowerCase().includes(search),
)
})
const totalConnections = computed(() => {
return clientProxies.value.reduce((sum, p) => sum + p.conns, 0)
})
const fetchServerInfo = async () => {
if (serverInfo) return serverInfo
const res = await getServerInfo()
serverInfo = res
return serverInfo
}
const fetchClient = async () => {
const key = route.params.key as string
if (!key) {
loading.value = false
return
}
try {
const data = await getClient(key)
client.value = new Client(data)
} catch (error: any) {
ElMessage.error('Failed to fetch client: ' + error.message)
} finally {
loading.value = false
}
}
const fetchProxies = async () => {
proxiesLoading.value = true
const proxyTypes = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp']
const proxies: BaseProxy[] = []
try {
const info = await fetchServerInfo()
for (const type of proxyTypes) {
try {
const json = await getProxiesByType(type)
if (!json.proxies) continue
if (type === 'tcp') {
proxies.push(...json.proxies.map((p: any) => new TCPProxy(p)))
} else if (type === 'udp') {
proxies.push(...json.proxies.map((p: any) => new UDPProxy(p)))
} else if (type === 'http' && info?.vhostHTTPPort) {
proxies.push(
...json.proxies.map(
(p: any) =>
new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
),
)
} else if (type === 'https' && info?.vhostHTTPSPort) {
proxies.push(
...json.proxies.map(
(p: any) =>
new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
),
)
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
proxies.push(
...json.proxies.map(
(p: any) =>
new TCPMuxProxy(
p,
info.tcpmuxHTTPConnectPort,
info.subdomainHost,
),
),
)
} else if (type === 'stcp') {
proxies.push(...json.proxies.map((p: any) => new STCPProxy(p)))
} else if (type === 'sudp') {
proxies.push(...json.proxies.map((p: any) => new SUDPProxy(p)))
}
} catch {
// Ignore
}
}
allProxies.value = proxies
} catch {
// Ignore
} finally {
proxiesLoading.value = false
}
}
onMounted(() => {
fetchClient()
fetchProxies()
})
</script>
<style scoped>
.client-detail-page {
}
/* Breadcrumb */
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
margin-bottom: 24px;
}
.breadcrumb-link {
display: flex;
align-items: center;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s;
margin-right: 4px;
}
.breadcrumb-link:hover {
color: var(--text-primary);
}
.breadcrumb-item {
color: var(--text-secondary);
text-decoration: none;
transition: color 0.2s;
}
.breadcrumb-item:hover {
color: var(--el-color-primary);
}
.breadcrumb-separator {
color: var(--el-border-color);
}
.breadcrumb-current {
color: var(--text-primary);
font-weight: 500;
}
/* Card Base */
.header-card,
.proxies-card {
background: var(--el-bg-color);
border: 1px solid var(--header-border);
border-radius: 12px;
margin-bottom: 16px;
}
/* Header Card */
.header-main {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 24px;
}
.header-left {
display: flex;
gap: 16px;
align-items: center;
}
.client-avatar {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 500;
flex-shrink: 0;
}
.client-info {
min-width: 0;
}
.client-name {
font-size: 20px;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 4px 0;
line-height: 1.3;
}
.client-meta {
display: flex;
gap: 12px;
font-size: 14px;
color: var(--text-secondary);
}
.status-badge {
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
}
.status-badge.online {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
.status-badge.offline {
background: var(--hover-bg);
color: var(--text-secondary);
}
html.dark .status-badge.online {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
/* Info Section */
.info-section {
display: flex;
flex-wrap: wrap;
gap: 16px 32px;
padding: 16px 24px;
}
.info-item {
display: flex;
align-items: baseline;
gap: 8px;
}
.info-label {
font-size: 13px;
color: var(--text-secondary);
}
.info-label::after {
content: ':';
}
.info-value {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
word-break: break-all;
}
/* Proxies Card */
.proxies-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
gap: 16px;
}
.proxies-title {
display: flex;
align-items: center;
gap: 8px;
}
.proxies-title h2 {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
margin: 0;
}
.proxies-count {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
background: var(--hover-bg);
padding: 4px 10px;
border-radius: 6px;
}
.proxy-search {
width: 200px;
}
.proxy-search :deep(.el-input__wrapper) {
border-radius: 6px;
}
.proxies-body {
padding: 16px;
}
.proxies-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px;
color: var(--text-secondary);
}
.empty-state {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.empty-state p {
margin: 0;
}
/* Not Found */
.not-found {
text-align: center;
padding: 60px 20px;
}
.not-found h2 {
font-size: 18px;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 8px;
}
.not-found p {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 20px;
}
/* Responsive */
@media (max-width: 640px) {
.header-main {
flex-direction: column;
gap: 16px;
}
.header-right {
align-self: flex-start;
}
}
</style>

View File

@@ -1,34 +1,48 @@
<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 class="page-header">
<div class="header-top">
<div class="title-section">
<h1 class="page-title">Clients</h1>
<p class="page-subtitle">Manage connected clients and their status</p>
</div>
<div class="status-tabs">
<button
v-for="tab in statusTabs"
:key="tab.value"
class="status-tab"
:class="{ active: statusFilter === tab.value }"
@click="statusFilter = tab.value"
>
<span class="status-dot" :class="tab.value"></span>
<span class="tab-label">{{ tab.label }}</span>
<span class="tab-count">{{ tab.count }}</span>
</button>
</div>
</div>
<div class="search-section">
<el-input
v-model="searchText"
placeholder="Search clients..."
:prefix-icon="Search"
clearable
class="search-input"
/>
</div>
</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 v-loading="loading" class="clients-content">
<div v-if="filteredClients.length > 0" class="clients-list">
<ClientCard
v-for="client in filteredClients"
:key="client.key"
:client="client"
/>
</div>
<div v-else-if="!loading" class="empty-state">
<el-empty description="No clients found" />
</div>
</div>
</div>
</template>
@@ -55,6 +69,12 @@ const stats = computed(() => {
return { total, online, offline }
})
const statusTabs = computed(() => [
{ value: 'all' as const, label: 'All', count: stats.value.total },
{ value: 'online' as const, label: 'Online', count: stats.value.online },
{ value: 'offline' as const, label: 'Offline', count: stats.value.offline },
])
const filteredClients = computed(() => {
let result = clients.value
@@ -87,7 +107,6 @@ const fetchData = async () => {
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,
@@ -99,7 +118,6 @@ const fetchData = async () => {
}
const startAutoRefresh = () => {
// Auto refresh every 5 seconds
refreshTimer = window.setInterval(() => {
fetchData()
}, 5000)
@@ -124,46 +142,161 @@ onUnmounted(() => {
<style scoped>
.clients-page {
padding: 0 20px 20px 20px;
display: flex;
flex-direction: column;
gap: 24px;
}
.filter-bar {
.page-header {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 20px;
flex-direction: column;
gap: 24px;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 20px;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 300px;
max-width: 500px;
.title-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-filter {
flex-shrink: 0;
.page-title {
font-size: 28px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
line-height: 1.2;
}
.clients-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 20px;
.page-subtitle {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0;
}
.status-tabs {
display: flex;
gap: 12px;
}
.status-tab {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: 1px solid var(--el-border-color);
border-radius: 20px;
background: var(--el-bg-color);
color: var(--el-text-color-regular);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.status-tab:hover {
border-color: var(--el-border-color-darker);
background: var(--el-fill-color-light);
}
.status-tab.active {
background: var(--el-fill-color-dark);
border-color: var(--el-text-color-primary);
color: var(--el-text-color-primary);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--el-text-color-secondary);
}
.status-dot.online {
background-color: var(--el-color-success);
}
.status-dot.offline {
background-color: var(--el-text-color-placeholder);
}
.status-dot.all {
background-color: var(--el-text-color-regular);
}
.tab-count {
font-weight: 500;
opacity: 0.8;
}
.search-section {
width: 100%;
}
.search-input :deep(.el-input__wrapper) {
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
padding: 8px 16px;
border: 1px solid var(--el-border-color);
transition: all 0.2s;
height: 48px;
font-size: 15px;
}
.search-input :deep(.el-input__wrapper:hover) {
border-color: var(--el-border-color-darker);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06);
}
.search-input :deep(.el-input__wrapper.is-focus) {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary);
}
.clients-content {
min-height: 200px;
}
@media (max-width: 768px) {
.clients-grid {
grid-template-columns: 1fr;
}
.clients-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.filter-bar {
.empty-state {
padding: 60px 0;
}
/* Dark mode adjustments */
html.dark .status-tab {
background: var(--el-bg-color-overlay);
}
html.dark .status-tab.active {
background: var(--el-fill-color);
}
@media (max-width: 640px) {
.header-top {
flex-direction: column;
align-items: stretch;
align-items: flex-start;
}
.search-input {
max-width: none;
.status-tabs {
width: 100%;
overflow-x: auto;
padding-bottom: 4px;
}
.status-tab {
flex-shrink: 0;
}
}
</style>

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>

View File

@@ -0,0 +1,985 @@
<template>
<div class="proxy-detail-page">
<!-- Breadcrumb -->
<nav class="breadcrumb">
<a class="breadcrumb-link" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
</a>
<template v-if="fromClient">
<router-link to="/clients" class="breadcrumb-item">Clients</router-link>
<span class="breadcrumb-separator">/</span>
<router-link :to="`/clients/${fromClient}`" class="breadcrumb-item">{{
fromClient
}}</router-link>
<span class="breadcrumb-separator">/</span>
</template>
<template v-else>
<router-link to="/proxies" class="breadcrumb-item">Proxies</router-link>
<span class="breadcrumb-separator">/</span>
<router-link
v-if="proxy?.clientID"
:to="clientLink"
class="breadcrumb-item"
>
{{ proxy.user ? `${proxy.user}.${proxy.clientID}` : proxy.clientID }}
</router-link>
<span v-if="proxy?.clientID" class="breadcrumb-separator">/</span>
</template>
<span class="breadcrumb-current">{{ proxyName }}</span>
</nav>
<div v-loading="loading" class="detail-content">
<template v-if="proxy">
<!-- Header Section -->
<div class="header-section">
<div class="header-main">
<div
class="proxy-icon"
:style="{ background: proxyIconConfig.gradient }"
>
<el-icon><component :is="proxyIconConfig.icon" /></el-icon>
</div>
<div class="header-info">
<div class="header-title-row">
<h1 class="proxy-name">{{ proxy.name }}</h1>
<span class="type-tag">{{ proxy.type.toUpperCase() }}</span>
<span class="status-badge" :class="proxy.status">
{{ proxy.status }}
</span>
</div>
<div class="header-meta">
<router-link
v-if="proxy.clientID"
:to="clientLink"
class="client-link"
>
<el-icon><Monitor /></el-icon>
<span
>Client:
{{
proxy.user
? `${proxy.user}.${proxy.clientID}`
: proxy.clientID
}}</span
>
</router-link>
</div>
</div>
</div>
</div>
<!-- Stats Cards -->
<div class="stats-grid">
<div v-if="proxy.port" class="stat-card">
<div class="stat-header">
<span class="stat-label">Port</span>
<div class="stat-icon port">
<el-icon><Connection /></el-icon>
</div>
</div>
<div class="stat-value">{{ proxy.port }}</div>
</div>
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">Connections</span>
<div class="stat-icon connections">
<el-icon><DataLine /></el-icon>
</div>
</div>
<div class="stat-value">{{ proxy.conns }}</div>
</div>
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">Traffic In</span>
<div class="stat-icon traffic-in">
<el-icon><Bottom /></el-icon>
</div>
</div>
<div class="stat-value">
<span class="value-number">{{
formatTrafficValue(proxy.trafficIn)
}}</span>
<span class="value-unit">{{
formatTrafficUnit(proxy.trafficIn)
}}</span>
</div>
</div>
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">Traffic Out</span>
<div class="stat-icon traffic-out">
<el-icon><Top /></el-icon>
</div>
</div>
<div class="stat-value">
<span class="value-number">{{
formatTrafficValue(proxy.trafficOut)
}}</span>
<span class="value-unit">{{
formatTrafficUnit(proxy.trafficOut)
}}</span>
</div>
</div>
</div>
<!-- Status Timeline -->
<div class="timeline-card">
<div class="timeline-header">
<el-icon><DataLine /></el-icon>
<h2>Status Timeline</h2>
</div>
<div class="timeline-body">
<div class="timeline-grid">
<div class="timeline-item">
<span class="timeline-label">Last Start Time</span>
<span class="timeline-value">{{
proxy.lastStartTime || '-'
}}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Last Close Time</span>
<span class="timeline-value">{{
proxy.lastCloseTime || '-'
}}</span>
</div>
</div>
</div>
</div>
<!-- Configuration Section -->
<div class="config-section">
<div class="config-section-header">
<el-icon><Setting /></el-icon>
<h2>Configuration</h2>
</div>
<!-- Config Cards Grid -->
<div class="config-grid">
<div class="config-item-card">
<div class="config-item-icon encryption">
<el-icon><Lock /></el-icon>
</div>
<div class="config-item-content">
<span class="config-item-label">Encryption</span>
<span class="config-item-value">{{
proxy.encryption ? 'Enabled' : 'Disabled'
}}</span>
</div>
</div>
<div class="config-item-card">
<div class="config-item-icon compression">
<el-icon><Lightning /></el-icon>
</div>
<div class="config-item-content">
<span class="config-item-label">Compression</span>
<span class="config-item-value">{{
proxy.compression ? 'Enabled' : 'Disabled'
}}</span>
</div>
</div>
<div v-if="proxy.customDomains" class="config-item-card">
<div class="config-item-icon domains">
<el-icon><Link /></el-icon>
</div>
<div class="config-item-content">
<span class="config-item-label">Custom Domains</span>
<span class="config-item-value">{{ proxy.customDomains }}</span>
</div>
</div>
<div v-if="proxy.subdomain" class="config-item-card">
<div class="config-item-icon subdomain">
<el-icon><Link /></el-icon>
</div>
<div class="config-item-content">
<span class="config-item-label">Subdomain</span>
<span class="config-item-value">{{ proxy.subdomain }}</span>
</div>
</div>
<div v-if="proxy.locations" class="config-item-card">
<div class="config-item-icon locations">
<el-icon><Location /></el-icon>
</div>
<div class="config-item-content">
<span class="config-item-label">Locations</span>
<span class="config-item-value">{{ proxy.locations }}</span>
</div>
</div>
<div v-if="proxy.hostHeaderRewrite" class="config-item-card">
<div class="config-item-icon host">
<el-icon><Tickets /></el-icon>
</div>
<div class="config-item-content">
<span class="config-item-label">Host Rewrite</span>
<span class="config-item-value">{{
proxy.hostHeaderRewrite
}}</span>
</div>
</div>
<div v-if="proxy.multiplexer" class="config-item-card">
<div class="config-item-icon multiplexer">
<el-icon><Cpu /></el-icon>
</div>
<div class="config-item-content">
<span class="config-item-label">Multiplexer</span>
<span class="config-item-value">{{ proxy.multiplexer }}</span>
</div>
</div>
<div v-if="proxy.routeByHTTPUser" class="config-item-card">
<div class="config-item-icon route">
<el-icon><Connection /></el-icon>
</div>
<div class="config-item-content">
<span class="config-item-label">Route By HTTP User</span>
<span class="config-item-value">{{
proxy.routeByHTTPUser
}}</span>
</div>
</div>
</div>
<!-- Annotations -->
<template v-if="proxy.annotations && proxy.annotations.size > 0">
<div class="annotations-section">
<div
v-for="[key, value] in proxy.annotations"
:key="key"
class="annotation-tag"
>
{{ key }}: {{ value }}
</div>
</div>
</template>
</div>
<!-- Traffic Card -->
<div class="traffic-card">
<div class="traffic-header">
<h2>Traffic Statistics</h2>
</div>
<div class="traffic-body">
<Traffic :proxy-name="proxyName" />
</div>
</div>
</template>
<div v-else-if="!loading" class="not-found">
<h2>Proxy not found</h2>
<p>The proxy doesn't exist or has been removed.</p>
<router-link to="/proxies">
<el-button type="primary">Back to Proxies</el-button>
</router-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
ArrowLeft,
Monitor,
Connection,
DataLine,
Bottom,
Top,
Link,
Lock,
Promotion,
Grid,
Setting,
Cpu,
Lightning,
Tickets,
Location,
} from '@element-plus/icons-vue'
import { getProxyByName } from '../api/proxy'
import { getServerInfo } from '../api/server'
import {
BaseProxy,
TCPProxy,
UDPProxy,
HTTPProxy,
HTTPSProxy,
TCPMuxProxy,
STCPProxy,
SUDPProxy,
} from '../utils/proxy'
import Traffic from '../components/Traffic.vue'
const route = useRoute()
const router = useRouter()
const proxyName = computed(() => route.params.name as string)
const fromClient = computed(() => {
if (route.query.from === 'client' && route.query.client) {
return route.query.client as string
}
return null
})
const proxy = ref<BaseProxy | null>(null)
const loading = ref(true)
const goBack = () => {
if (window.history.length > 1) {
router.back()
} else {
router.push('/proxies')
}
}
let serverInfo: {
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
} | null = null
const clientLink = computed(() => {
if (!proxy.value) return ''
const key = proxy.value.user
? `${proxy.value.user}.${proxy.value.clientID}`
: proxy.value.clientID
return `/clients/${key}`
})
const proxyIconConfig = computed(() => {
const type = proxy.value?.type?.toLowerCase() || ''
const configs: Record<string, { icon: any; gradient: string }> = {
tcp: {
icon: Connection,
gradient: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
},
udp: {
icon: Promotion,
gradient: 'linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%)',
},
http: {
icon: Link,
gradient: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
},
https: {
icon: Lock,
gradient: 'linear-gradient(135deg, #14b8a6 0%, #0d9488 100%)',
},
stcp: {
icon: Lock,
gradient: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)',
},
sudp: {
icon: Lock,
gradient: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)',
},
tcpmux: {
icon: Grid,
gradient: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)',
},
xtcp: {
icon: Connection,
gradient: 'linear-gradient(135deg, #ec4899 0%, #db2777 100%)',
},
}
return (
configs[type] || {
icon: Connection,
gradient: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
}
)
})
const formatTrafficValue = (bytes: number): string => {
if (bytes === 0) return '0'
const k = 1024
const i = Math.floor(Math.log(bytes) / Math.log(k))
const value = bytes / Math.pow(k, i)
return value < 10 ? value.toFixed(1) : Math.round(value).toString()
}
const formatTrafficUnit = (bytes: number): string => {
if (bytes === 0) return 'B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const k = 1024
const i = Math.floor(Math.log(bytes) / Math.log(k))
return units[i]
}
const fetchServerInfo = async () => {
if (serverInfo) return serverInfo
const res = await getServerInfo()
serverInfo = res
return serverInfo
}
const fetchProxy = async () => {
const name = proxyName.value
if (!name) {
loading.value = false
return
}
try {
const data = await getProxyByName(name)
const info = await fetchServerInfo()
const type = data.conf?.type || ''
if (type === 'tcp') {
proxy.value = new TCPProxy(data)
} else if (type === 'udp') {
proxy.value = new UDPProxy(data)
} else if (type === 'http' && info?.vhostHTTPPort) {
proxy.value = new HTTPProxy(data, info.vhostHTTPPort, info.subdomainHost)
} else if (type === 'https' && info?.vhostHTTPSPort) {
proxy.value = new HTTPSProxy(
data,
info.vhostHTTPSPort,
info.subdomainHost,
)
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
proxy.value = new TCPMuxProxy(
data,
info.tcpmuxHTTPConnectPort,
info.subdomainHost,
)
} else if (type === 'stcp') {
proxy.value = new STCPProxy(data)
} else if (type === 'sudp') {
proxy.value = new SUDPProxy(data)
} else {
proxy.value = new BaseProxy(data)
proxy.value.type = type
}
} catch (error: any) {
ElMessage.error('Failed to fetch proxy: ' + error.message)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchProxy()
})
</script>
<style scoped>
.proxy-detail-page {
}
/* Breadcrumb */
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
margin-bottom: 24px;
}
.breadcrumb-link {
display: flex;
align-items: center;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s;
margin-right: 4px;
}
.breadcrumb-link:hover {
color: var(--text-primary);
}
.breadcrumb-item {
color: var(--text-secondary);
text-decoration: none;
transition: color 0.2s;
}
.breadcrumb-item:hover {
color: var(--el-color-primary);
}
.breadcrumb-separator {
color: var(--el-border-color);
}
.breadcrumb-current {
color: var(--text-primary);
font-weight: 500;
}
/* Header Section */
.header-section {
margin-bottom: 24px;
}
.header-main {
display: flex;
align-items: flex-start;
gap: 16px;
}
.proxy-icon {
width: 56px;
height: 56px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 26px;
color: white;
}
.header-info {
flex: 1;
min-width: 0;
}
.header-title-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.proxy-name {
font-size: 20px;
font-weight: 500;
color: var(--text-primary);
margin: 0;
line-height: 1.3;
word-break: break-all;
}
.type-tag {
font-size: 12px;
font-weight: 500;
padding: 4px 12px;
border-radius: 20px;
background: var(--el-fill-color-dark);
color: var(--el-text-color-secondary);
border: 1px solid var(--el-border-color-lighter);
}
.status-badge {
padding: 4px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
text-transform: capitalize;
}
.status-badge.online {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
.status-badge.offline {
background: var(--hover-bg);
color: var(--text-secondary);
}
html.dark .status-badge.online {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
.header-meta {
display: flex;
align-items: center;
gap: 16px;
}
.client-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: var(--text-secondary);
text-decoration: none;
transition: color 0.2s;
}
.client-link:hover {
color: var(--el-color-primary);
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--el-bg-color);
border: 1px solid var(--header-border);
border-radius: 12px;
padding: 20px;
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.stat-label {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.stat-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.stat-icon.port {
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
}
.stat-icon.connections {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
.stat-icon.traffic-in {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.stat-icon.traffic-out {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
html.dark .stat-icon.port {
background: rgba(139, 92, 246, 0.15);
}
html.dark .stat-icon.connections {
background: rgba(168, 85, 247, 0.15);
}
html.dark .stat-icon.traffic-in {
background: rgba(59, 130, 246, 0.15);
}
html.dark .stat-icon.traffic-out {
background: rgba(34, 197, 94, 0.15);
}
.stat-value {
display: flex;
align-items: baseline;
gap: 6px;
}
.value-number {
font-size: 28px;
font-weight: 500;
color: var(--text-primary);
line-height: 1;
}
.stat-value:not(:has(.value-number)) {
font-size: 28px;
font-weight: 500;
color: var(--text-primary);
}
.value-unit {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
/* Timeline Card */
.timeline-card {
background: var(--el-bg-color);
border: 1px solid var(--header-border);
border-radius: 12px;
margin-bottom: 16px;
}
.timeline-header {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 20px;
color: var(--text-secondary);
}
.timeline-header h2 {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
margin: 0;
}
.timeline-body {
padding: 20px;
padding-top: 0;
}
.timeline-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
background: var(--el-fill-color-light);
border-radius: 10px;
padding: 20px 24px;
}
.timeline-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.timeline-label {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.timeline-value {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
}
/* Card Base */
.traffic-card {
background: var(--el-bg-color);
border: 1px solid var(--header-border);
border-radius: 12px;
margin-bottom: 16px;
}
/* Config Section */
.config-section {
margin-bottom: 24px;
}
.config-section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: var(--text-secondary);
}
.config-section-header h2 {
font-size: 16px;
font-weight: 500;
color: var(--text-primary);
margin: 0;
}
.config-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.config-item-card {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 20px;
background: var(--el-bg-color);
border: 1px solid var(--header-border);
border-radius: 12px;
}
.config-item-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.config-item-icon.encryption {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.config-item-icon.compression {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.config-item-icon.domains {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
.config-item-icon.subdomain {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
.config-item-icon.locations {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.config-item-icon.host {
background: rgba(249, 115, 22, 0.1);
color: #f97316;
}
.config-item-icon.multiplexer {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.config-item-icon.route {
background: rgba(236, 72, 153, 0.1);
color: #ec4899;
}
html.dark .config-item-icon.encryption,
html.dark .config-item-icon.compression {
background: rgba(34, 197, 94, 0.15);
}
html.dark .config-item-icon.domains,
html.dark .config-item-icon.subdomain {
background: rgba(168, 85, 247, 0.15);
}
html.dark .config-item-icon.locations,
html.dark .config-item-icon.multiplexer {
background: rgba(59, 130, 246, 0.15);
}
html.dark .config-item-icon.host {
background: rgba(249, 115, 22, 0.15);
}
html.dark .config-item-icon.route {
background: rgba(236, 72, 153, 0.15);
}
.config-item-content {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.config-item-label {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.config-item-value {
font-size: 15px;
color: var(--text-primary);
font-weight: 500;
word-break: break-all;
}
.annotations-section {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
.annotation-tag {
display: inline-flex;
padding: 6px 12px;
background: var(--el-fill-color);
border-radius: 6px;
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.traffic-header {
padding: 16px 20px;
border-bottom: 1px solid var(--header-border);
}
.traffic-header h2 {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
margin: 0;
}
/* Traffic Card */
.traffic-body {
padding: 20px;
}
/* Not Found */
.not-found {
text-align: center;
padding: 60px 20px;
}
.not-found h2 {
font-size: 18px;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 8px;
}
.not-found p {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 20px;
}
/* Responsive */
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.config-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.header-main {
flex-direction: column;
gap: 16px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.timeline-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -53,7 +53,9 @@
</div>
<div class="traffic-info">
<div class="label">Inbound</div>
<div class="value">{{ formatFileSize(data.totalTrafficIn) }}</div>
<div class="value">
{{ formatFileSize(data.totalTrafficIn) }}
</div>
</div>
</div>
<div class="traffic-divider"></div>
@@ -63,7 +65,9 @@
</div>
<div class="traffic-info">
<div class="label">Outbound</div>
<div class="value">{{ formatFileSize(data.totalTrafficOut) }}</div>
<div class="value">
{{ formatFileSize(data.totalTrafficOut) }}
</div>
</div>
</div>
</div>
@@ -78,9 +82,9 @@
</div>
</template>
<div class="proxy-types-grid">
<div
v-for="(count, type) in data.proxyTypeCounts"
:key="type"
<div
v-for="(count, type) in data.proxyTypeCounts"
:key="type"
class="proxy-type-item"
v-show="count > 0"
>
@@ -187,7 +191,7 @@ const data = ref({
})
const hasActiveProxies = computed(() => {
return Object.values(data.value.proxyTypeCounts).some(c => c > 0)
return Object.values(data.value.proxyTypeCounts).some((c) => c > 0)
})
const formatTrafficTotal = () => {
@@ -223,7 +227,7 @@ const fetchData = async () => {
data.value.proxyCounts = 0
if (json.proxyTypeCount != null) {
Object.values(json.proxyTypeCount).forEach((count: any) => {
data.value.proxyCounts += (count || 0)
data.value.proxyCounts += count || 0
})
}
} catch (err) {
@@ -283,7 +287,7 @@ html.dark .config-card {
.card-title {
font-size: 16px;
font-weight: 600;
font-weight: 500;
color: #303133;
}
@@ -337,7 +341,7 @@ html.dark .card-title {
.traffic-info .value {
font-size: 24px;
font-weight: 600;
font-weight: 500;
color: #303133;
}
@@ -386,7 +390,7 @@ html.dark .proxy-type-item {
.proxy-type-count {
font-size: 20px;
font-weight: 600;
font-weight: 500;
color: #303133;
}
@@ -437,7 +441,7 @@ html.dark .config-label {
.config-value {
font-size: 14px;
color: #303133;
font-weight: 600;
font-weight: 500;
word-break: break-all;
}