Files
lolia-frp/web/frps/src/views/ProxyDetail.vue

986 lines
23 KiB
Vue

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