mirror of
https://github.com/fatedier/frp.git
synced 2026-03-21 01:09:27 +08:00
web/frps: add detailed client and proxy views with enhanced tracking (#5144)
This commit is contained in:
522
web/frps/src/views/ClientDetail.vue
Normal file
522
web/frps/src/views/ClientDetail.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
985
web/frps/src/views/ProxyDetail.vue
Normal file
985
web/frps/src/views/ProxyDetail.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user