mirror of
https://github.com/fatedier/frp.git
synced 2026-07-17 17:59:18 +08:00
server: add client registry with dashboard support (#5115)
This commit is contained in:
229
web/frps/src/components/ClientCard.vue
Normal file
229
web/frps/src/components/ClientCard.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<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>
|
||||
<el-tag :type="client.statusColor" size="small">
|
||||
{{ client.online ? 'Online' : 'Offline' }}
|
||||
</el-tag>
|
||||
</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.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>
|
||||
|
||||
<div class="client-metas" v-if="client.metasArray.length > 0">
|
||||
<div class="metas-label">Metadata:</div>
|
||||
<div class="metas-tags">
|
||||
<el-tag
|
||||
v-for="meta in client.metasArray"
|
||||
:key="meta.key"
|
||||
size="small"
|
||||
type="info"
|
||||
class="meta-tag"
|
||||
>
|
||||
{{ meta.key }}: {{ meta.value }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Monitor, User, Key, Clock, CircleClose } from '@element-plus/icons-vue'
|
||||
import type { Client } from '../utils/client'
|
||||
|
||||
interface Props {
|
||||
client: Client
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const statusClass = computed(() => {
|
||||
return `status-${props.client.statusColor}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.client-card {
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.client-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background-color: #67c23a;
|
||||
box-shadow: 0 0 0 0 rgba(103, 194, 58, 0.7);
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
background-color: #e6a23c;
|
||||
box-shadow: 0 0 0 0 rgba(230, 162, 60, 0.7);
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
color: #909399;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
html.dark .info-icon {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #909399;
|
||||
font-weight: 500;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
html.dark .info-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #606266;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
html.dark .info-value {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.client-metas {
|
||||
margin-bottom: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
html.dark .client-metas {
|
||||
border-top-color: #3a3d5c;
|
||||
}
|
||||
|
||||
.metas-label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
html.dark .metas-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.metas-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.meta-tag {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.monospace {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<el-tooltip :content="content" placement="top">
|
||||
<span v-show="content.length > length"
|
||||
>{{ content.slice(0, length) }}...</span
|
||||
>
|
||||
</el-tooltip>
|
||||
<span v-show="content.length < 30">{{ content }}</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
content: string
|
||||
length: number
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<ProxyView :proxies="proxies" proxyType="http" @refresh="fetchData"/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { HTTPProxy } from '../utils/proxy.js'
|
||||
import ProxyView from './ProxyView.vue'
|
||||
|
||||
let proxies = ref<HTTPProxy[]>([])
|
||||
|
||||
const fetchData = () => {
|
||||
let vhostHTTPPort: number
|
||||
let subdomainHost: string
|
||||
fetch('../api/serverinfo', { credentials: 'include' })
|
||||
.then((res) => {
|
||||
return res.json()
|
||||
})
|
||||
.then((json) => {
|
||||
vhostHTTPPort = json.vhostHTTPPort
|
||||
subdomainHost = json.subdomainHost
|
||||
if (vhostHTTPPort == null || vhostHTTPPort == 0) {
|
||||
return
|
||||
}
|
||||
fetch('../api/proxy/http', { credentials: 'include' })
|
||||
.then((res) => {
|
||||
return res.json()
|
||||
})
|
||||
.then((json) => {
|
||||
proxies.value = []
|
||||
for (let proxyStats of json.proxies) {
|
||||
proxies.value.push(
|
||||
new HTTPProxy(proxyStats, vhostHTTPPort, subdomainHost)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
fetchData()
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<ProxyView :proxies="proxies" proxyType="https" @refresh="fetchData"/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { HTTPSProxy } from '../utils/proxy.js'
|
||||
import ProxyView from './ProxyView.vue'
|
||||
|
||||
let proxies = ref<HTTPSProxy[]>([])
|
||||
|
||||
const fetchData = () => {
|
||||
let vhostHTTPSPort: number
|
||||
let subdomainHost: string
|
||||
fetch('../api/serverinfo', { credentials: 'include' })
|
||||
.then((res) => {
|
||||
return res.json()
|
||||
})
|
||||
.then((json) => {
|
||||
vhostHTTPSPort = json.vhostHTTPSPort
|
||||
subdomainHost = json.subdomainHost
|
||||
if (vhostHTTPSPort == null || vhostHTTPSPort == 0) {
|
||||
return
|
||||
}
|
||||
fetch('../api/proxy/https', { credentials: 'include' })
|
||||
.then((res) => {
|
||||
return res.json()
|
||||
})
|
||||
.then((json) => {
|
||||
proxies.value = []
|
||||
for (let proxyStats of json.proxies) {
|
||||
proxies.value.push(
|
||||
new HTTPSProxy(proxyStats, vhostHTTPSPort, subdomainHost)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
fetchData()
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -1,27 +0,0 @@
|
||||
<template>
|
||||
<ProxyView :proxies="proxies" proxyType="stcp" @refresh="fetchData"/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { STCPProxy } from '../utils/proxy.js'
|
||||
import ProxyView from './ProxyView.vue'
|
||||
|
||||
let proxies = ref<STCPProxy[]>([])
|
||||
|
||||
const fetchData = () => {
|
||||
fetch('../api/proxy/stcp', { credentials: 'include' })
|
||||
.then((res) => {
|
||||
return res.json()
|
||||
})
|
||||
.then((json) => {
|
||||
proxies.value = []
|
||||
for (let proxyStats of json.proxies) {
|
||||
proxies.value.push(new STCPProxy(proxyStats))
|
||||
}
|
||||
})
|
||||
}
|
||||
fetchData()
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -1,27 +0,0 @@
|
||||
<template>
|
||||
<ProxyView :proxies="proxies" proxyType="sudp" @refresh="fetchData"/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { SUDPProxy } from '../utils/proxy.js'
|
||||
import ProxyView from './ProxyView.vue'
|
||||
|
||||
let proxies = ref<SUDPProxy[]>([])
|
||||
|
||||
const fetchData = () => {
|
||||
fetch('../api/proxy/sudp', { credentials: 'include' })
|
||||
.then((res) => {
|
||||
return res.json()
|
||||
})
|
||||
.then((json) => {
|
||||
proxies.value = []
|
||||
for (let proxyStats of json.proxies) {
|
||||
proxies.value.push(new SUDPProxy(proxyStats))
|
||||
}
|
||||
})
|
||||
}
|
||||
fetchData()
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -1,27 +0,0 @@
|
||||
<template>
|
||||
<ProxyView :proxies="proxies" proxyType="tcp" @refresh="fetchData" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { TCPProxy } from '../utils/proxy.js'
|
||||
import ProxyView from './ProxyView.vue'
|
||||
|
||||
let proxies = ref<TCPProxy[]>([])
|
||||
|
||||
const fetchData = () => {
|
||||
fetch('../api/proxy/tcp', { credentials: 'include' })
|
||||
.then((res) => {
|
||||
return res.json()
|
||||
})
|
||||
.then((json) => {
|
||||
proxies.value = []
|
||||
for (let proxyStats of json.proxies) {
|
||||
proxies.value.push(new TCPProxy(proxyStats))
|
||||
}
|
||||
})
|
||||
}
|
||||
fetchData()
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -1,38 +0,0 @@
|
||||
<template>
|
||||
<ProxyView :proxies="proxies" proxyType="tcpmux" @refresh="fetchData" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { TCPMuxProxy } from '../utils/proxy.js'
|
||||
import ProxyView from './ProxyView.vue'
|
||||
|
||||
let proxies = ref<TCPMuxProxy[]>([])
|
||||
|
||||
const fetchData = () => {
|
||||
let tcpmuxHTTPConnectPort: number
|
||||
let subdomainHost: string
|
||||
fetch('../api/serverinfo', { credentials: 'include' })
|
||||
.then((res) => {
|
||||
return res.json()
|
||||
})
|
||||
.then((json) => {
|
||||
tcpmuxHTTPConnectPort = json.tcpmuxHTTPConnectPort
|
||||
subdomainHost = json.subdomainHost
|
||||
|
||||
fetch('../api/proxy/tcpmux', { credentials: 'include' })
|
||||
.then((res) => {
|
||||
return res.json()
|
||||
})
|
||||
.then((json) => {
|
||||
proxies.value = []
|
||||
for (let proxyStats of json.proxies) {
|
||||
proxies.value.push(new TCPMuxProxy(proxyStats, tcpmuxHTTPConnectPort, subdomainHost))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
fetchData()
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -1,27 +0,0 @@
|
||||
<template>
|
||||
<ProxyView :proxies="proxies" proxyType="udp" @refresh="fetchData"/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { UDPProxy } from '../utils/proxy.js'
|
||||
import ProxyView from './ProxyView.vue'
|
||||
|
||||
let proxies = ref<UDPProxy[]>([])
|
||||
|
||||
const fetchData = () => {
|
||||
fetch('../api/proxy/udp', { credentials: 'include' })
|
||||
.then((res) => {
|
||||
return res.json()
|
||||
})
|
||||
.then((json) => {
|
||||
proxies.value = []
|
||||
for (let proxyStats of json.proxies) {
|
||||
proxies.value.push(new UDPProxy(proxyStats))
|
||||
}
|
||||
})
|
||||
}
|
||||
fetchData()
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -1,145 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-page-header
|
||||
:icon="null"
|
||||
style="width: 100%; margin-left: 30px; margin-bottom: 20px"
|
||||
>
|
||||
<template #title>
|
||||
<span>{{ proxyType }}</span>
|
||||
</template>
|
||||
<template #content> </template>
|
||||
<template #extra>
|
||||
<div class="flex items-center" style="margin-right: 30px">
|
||||
<el-popconfirm
|
||||
title="Are you sure to clear all data of offline proxies?"
|
||||
@confirm="clearOfflineProxies"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button>ClearOfflineProxies</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
<el-button @click="$emit('refresh')">Refresh</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-table
|
||||
:data="proxies"
|
||||
:default-sort="{ prop: 'name', order: 'ascending' }"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column type="expand">
|
||||
<template #default="props">
|
||||
<ProxyViewExpand :row="props.row" :proxyType="proxyType" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Name" prop="name" sortable> </el-table-column>
|
||||
<el-table-column label="Port" prop="port" sortable> </el-table-column>
|
||||
<el-table-column label="Connections" prop="conns" sortable>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="Traffic In"
|
||||
prop="trafficIn"
|
||||
:formatter="formatTrafficIn"
|
||||
sortable
|
||||
>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="Traffic Out"
|
||||
prop="trafficOut"
|
||||
:formatter="formatTrafficOut"
|
||||
sortable
|
||||
>
|
||||
</el-table-column>
|
||||
<el-table-column label="ClientVersion" prop="clientVersion" sortable>
|
||||
</el-table-column>
|
||||
<el-table-column label="Status" prop="status" sortable>
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.status === 'online'" type="success">{{
|
||||
scope.row.status
|
||||
}}</el-tag>
|
||||
<el-tag v-else type="danger">{{ scope.row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Operations">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
type="primary"
|
||||
:name="scope.row.name"
|
||||
style="margin-bottom: 10px"
|
||||
@click="dialogVisibleName = scope.row.name; dialogVisible = true"
|
||||
>Traffic
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
destroy-on-close="true"
|
||||
:title="dialogVisibleName"
|
||||
width="700px">
|
||||
<Traffic :proxyName="dialogVisibleName" />
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as Humanize from 'humanize-plus'
|
||||
import type { TableColumnCtx } from 'element-plus'
|
||||
import type { BaseProxy } from '../utils/proxy.js'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ProxyViewExpand from './ProxyViewExpand.vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
proxies: BaseProxy[]
|
||||
proxyType: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['refresh'])
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogVisibleName = ref("")
|
||||
|
||||
const formatTrafficIn = (row: BaseProxy, _: TableColumnCtx<BaseProxy>) => {
|
||||
return Humanize.fileSize(row.trafficIn)
|
||||
}
|
||||
|
||||
const formatTrafficOut = (row: BaseProxy, _: TableColumnCtx<BaseProxy>) => {
|
||||
return Humanize.fileSize(row.trafficOut)
|
||||
}
|
||||
|
||||
const clearOfflineProxies = () => {
|
||||
fetch('../api/proxies?status=offline', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.ok) {
|
||||
ElMessage({
|
||||
message: 'Successfully cleared offline proxies',
|
||||
type: 'success',
|
||||
})
|
||||
emit('refresh')
|
||||
} else {
|
||||
ElMessage({
|
||||
message: 'Failed to clear offline proxies: ' + res.status + ' ' + res.statusText,
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
ElMessage({
|
||||
message: 'Failed to clear offline proxies: ' + err.message,
|
||||
type: 'warning',
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.el-page-header__title {
|
||||
font-size: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -60,19 +60,18 @@
|
||||
</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()">
|
||||
<span class="annotation-key">{{ item.key }}</span>
|
||||
<span>{{ item.value }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<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
|
||||
@@ -80,13 +79,13 @@ const props = defineProps<{
|
||||
|
||||
// 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 }> = [];
|
||||
const array: Array<{ key: string; value: any }> = []
|
||||
if (props.row.annotations) {
|
||||
props.row.annotations.forEach((value: any, key: string) => {
|
||||
array.push({ key, value });
|
||||
});
|
||||
array.push({ key, value })
|
||||
})
|
||||
}
|
||||
return array;
|
||||
return array
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-row>
|
||||
<el-col :md="12">
|
||||
<div class="source">
|
||||
<el-form
|
||||
label-position="left"
|
||||
label-width="220px"
|
||||
class="server_info"
|
||||
>
|
||||
<el-form-item label="Version">
|
||||
<span>{{ data.version }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="BindPort">
|
||||
<span>{{ data.bindPort }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="KCP Bind Port" v-if="data.kcpBindPort != 0">
|
||||
<span>{{ data.kcpBindPort }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="QUIC Bind Port" v-if="data.quicBindPort != 0">
|
||||
<span>{{ data.quicBindPort }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="HTTP Port" v-if="data.vhostHTTPPort != 0">
|
||||
<span>{{ data.vhostHTTPPort }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="HTTPS Port" v-if="data.vhostHTTPSPort != 0">
|
||||
<span>{{ data.vhostHTTPSPort }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
label="TCPMux HTTPConnect Port"
|
||||
v-if="data.tcpmuxHTTPConnectPort != 0"
|
||||
>
|
||||
<span>{{ data.tcpmuxHTTPConnectPort }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
label="Subdomain Host"
|
||||
v-if="data.subdomainHost != ''"
|
||||
>
|
||||
<LongSpan :content="data.subdomainHost" :length="30"></LongSpan>
|
||||
</el-form-item>
|
||||
<el-form-item label="Max PoolCount">
|
||||
<span>{{ data.maxPoolCount }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="Max Ports Per Client">
|
||||
<span>{{ data.maxPortsPerClient }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="Allow Ports" v-if="data.allowPortsStr != ''">
|
||||
<LongSpan :content="data.allowPortsStr" :length="30"></LongSpan>
|
||||
</el-form-item>
|
||||
<el-form-item label="TLS Force" v-if="data.tlsForce === true">
|
||||
<span>{{ data.tlsForce }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="HeartBeat Timeout">
|
||||
<span>{{ data.heartbeatTimeout }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="Client Counts">
|
||||
<span>{{ data.clientCounts }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="Current Connections">
|
||||
<span>{{ data.curConns }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="Proxy Counts">
|
||||
<span>{{ data.proxyCounts }}</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :md="12">
|
||||
<div
|
||||
id="traffic"
|
||||
style="width: 400px; height: 250px; margin-bottom: 30px"
|
||||
></div>
|
||||
<div id="proxies" style="width: 400px; height: 250px"></div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { DrawTrafficChart, DrawProxyChart } from '../utils/chart'
|
||||
import LongSpan from './LongSpan.vue'
|
||||
|
||||
let data = ref({
|
||||
version: '',
|
||||
bindPort: 0,
|
||||
kcpBindPort: 0,
|
||||
quicBindPort: 0,
|
||||
vhostHTTPPort: 0,
|
||||
vhostHTTPSPort: 0,
|
||||
tcpmuxHTTPConnectPort: 0,
|
||||
subdomainHost: '',
|
||||
maxPoolCount: 0,
|
||||
maxPortsPerClient: '',
|
||||
allowPortsStr: '',
|
||||
tlsForce: false,
|
||||
heartbeatTimeout: 0,
|
||||
clientCounts: 0,
|
||||
curConns: 0,
|
||||
proxyCounts: 0,
|
||||
})
|
||||
|
||||
const fetchData = () => {
|
||||
fetch('../api/serverinfo', { credentials: 'include' })
|
||||
.then((res) => res.json())
|
||||
.then((json) => {
|
||||
data.value.version = json.version
|
||||
data.value.bindPort = json.bindPort
|
||||
data.value.kcpBindPort = json.kcpBindPort
|
||||
data.value.quicBindPort = json.quicBindPort
|
||||
data.value.vhostHTTPPort = json.vhostHTTPPort
|
||||
data.value.vhostHTTPSPort = json.vhostHTTPSPort
|
||||
data.value.tcpmuxHTTPConnectPort = json.tcpmuxHTTPConnectPort
|
||||
data.value.subdomainHost = json.subdomainHost
|
||||
data.value.maxPoolCount = json.maxPoolCount
|
||||
data.value.maxPortsPerClient = json.maxPortsPerClient
|
||||
if (data.value.maxPortsPerClient == '0') {
|
||||
data.value.maxPortsPerClient = 'no limit'
|
||||
}
|
||||
data.value.allowPortsStr = json.allowPortsStr
|
||||
data.value.tlsForce = json.tlsForce
|
||||
data.value.heartbeatTimeout = json.heartbeatTimeout
|
||||
data.value.clientCounts = json.clientCounts
|
||||
data.value.curConns = json.curConns
|
||||
data.value.proxyCounts = 0
|
||||
if (json.proxyTypeCount != null) {
|
||||
if (json.proxyTypeCount.tcp != null) {
|
||||
data.value.proxyCounts += json.proxyTypeCount.tcp
|
||||
}
|
||||
if (json.proxyTypeCount.udp != null) {
|
||||
data.value.proxyCounts += json.proxyTypeCount.udp
|
||||
}
|
||||
if (json.proxyTypeCount.http != null) {
|
||||
data.value.proxyCounts += json.proxyTypeCount.http
|
||||
}
|
||||
if (json.proxyTypeCount.https != null) {
|
||||
data.value.proxyCounts += json.proxyTypeCount.https
|
||||
}
|
||||
if (json.proxyTypeCount.stcp != null) {
|
||||
data.value.proxyCounts += json.proxyTypeCount.stcp
|
||||
}
|
||||
if (json.proxyTypeCount.sudp != null) {
|
||||
data.value.proxyCounts += json.proxyTypeCount.sudp
|
||||
}
|
||||
if (json.proxyTypeCount.xtcp != null) {
|
||||
data.value.proxyCounts += json.proxyTypeCount.xtcp
|
||||
}
|
||||
}
|
||||
|
||||
// draw chart
|
||||
DrawTrafficChart('traffic', json.totalTrafficIn, json.totalTrafficOut)
|
||||
DrawProxyChart('proxies', json)
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage({
|
||||
showClose: true,
|
||||
message: 'Get server info from frps failed!',
|
||||
type: 'warning',
|
||||
})
|
||||
})
|
||||
}
|
||||
fetchData()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.source {
|
||||
border-radius: 4px;
|
||||
transition: 0.2s;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.server_info {
|
||||
margin-left: 40px;
|
||||
font-size: 0px;
|
||||
}
|
||||
|
||||
.server_info .el-form-item__label {
|
||||
color: #99a9bf;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.server_info .el-form-item__content {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.server_info .el-form-item {
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
202
web/frps/src/components/StatCard.vue
Normal file
202
web/frps/src/components/StatCard.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<el-card
|
||||
class="stat-card"
|
||||
:class="{ clickable: !!to }"
|
||||
:body-style="{ padding: '20px' }"
|
||||
shadow="hover"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div class="stat-card-content">
|
||||
<div class="stat-icon" :class="`icon-${type}`">
|
||||
<component :is="iconComponent" class="icon" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ value }}</div>
|
||||
<div class="stat-label">{{ label }}</div>
|
||||
</div>
|
||||
<el-icon v-if="to" class="arrow-icon"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
<div v-if="subtitle" class="stat-subtitle">{{ subtitle }}</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
User,
|
||||
Connection,
|
||||
DataAnalysis,
|
||||
Promotion,
|
||||
ArrowRight,
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
value: string | number
|
||||
type?: 'clients' | 'proxies' | 'connections' | 'traffic'
|
||||
subtitle?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'clients',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'clients':
|
||||
return User
|
||||
case 'proxies':
|
||||
return Connection
|
||||
case 'connections':
|
||||
return DataAnalysis
|
||||
case 'traffic':
|
||||
return Promotion
|
||||
default:
|
||||
return User
|
||||
}
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
if (props.to) {
|
||||
router.push(props.to)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.stat-card.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stat-card.clickable:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-card.clickable:hover .arrow-icon {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
html.dark .stat-card {
|
||||
border-color: #3a3d5c;
|
||||
background: #27293d;
|
||||
}
|
||||
|
||||
.stat-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
color: #909399;
|
||||
font-size: 18px;
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
html.dark .arrow-icon {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-icon .icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.icon-clients {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-proxies {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-connections {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-traffic {
|
||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
html.dark .icon-clients {
|
||||
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
|
||||
}
|
||||
|
||||
html.dark .icon-proxies {
|
||||
background: linear-gradient(135deg, #fb7185 0%, #f43f5e 100%);
|
||||
}
|
||||
|
||||
html.dark .icon-connections {
|
||||
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
|
||||
}
|
||||
|
||||
html.dark .icon-traffic {
|
||||
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
html.dark .stat-value {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
html.dark .stat-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.stat-subtitle {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
html.dark .stat-subtitle {
|
||||
border-top-color: #3a3d5c;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +1,260 @@
|
||||
<template>
|
||||
<div :id="proxyName" style="width: 600px; height: 400px"></div>
|
||||
<div class="traffic-chart-container" v-loading="loading">
|
||||
<div v-if="!loading && chartData.length > 0" class="chart-wrapper">
|
||||
<div class="y-axis">
|
||||
<div class="y-label">{{ formatFileSize(maxVal) }}</div>
|
||||
<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>
|
||||
<div class="grid-line middle"></div>
|
||||
<div class="grid-line bottom"></div>
|
||||
|
||||
<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"
|
||||
:style="{ height: Math.max(item.inPercent, 1) + '%' }"
|
||||
></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="`Out: ${formatFileSize(item.out)}`" placement="top">
|
||||
<div
|
||||
class="bar bar-out"
|
||||
:style="{ height: Math.max(item.outPercent, 1) + '%' }"
|
||||
></div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="date-label">{{ item.date }}</div>
|
||||
</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>
|
||||
|
||||
<el-empty v-else-if="!loading" description="No traffic data" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { DrawProxyTrafficChart } from '../utils/chart.js'
|
||||
import { formatFileSize } from '../utils/format'
|
||||
import { getProxyTraffic } from '../api/proxy'
|
||||
|
||||
const props = defineProps<{
|
||||
proxyName: string
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
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)
|
||||
|
||||
// Calculate dates (last 7 days ending today)
|
||||
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)
|
||||
}
|
||||
|
||||
// Find max value for scaling
|
||||
const maxIn = Math.max(...finalIn)
|
||||
const maxOut = Math.max(...finalOut)
|
||||
maxVal.value = Math.max(maxIn, maxOut, 100) // Minimum scale 100 bytes
|
||||
|
||||
// Build chart data
|
||||
chartData.value = dates.map((date, i) => ({
|
||||
date,
|
||||
in: finalIn[i],
|
||||
out: finalOut[i],
|
||||
inPercent: (finalIn[i] / maxVal.value) * 100,
|
||||
outPercent: (finalOut[i] / maxVal.value) * 100,
|
||||
}))
|
||||
}
|
||||
|
||||
const fetchData = () => {
|
||||
let url = '../api/traffic/' + props.proxyName
|
||||
fetch(url, { credentials: 'include' })
|
||||
.then((res) => {
|
||||
return res.json()
|
||||
})
|
||||
loading.value = true
|
||||
getProxyTraffic(props.proxyName)
|
||||
.then((json) => {
|
||||
DrawProxyTrafficChart(props.proxyName, json.trafficIn, json.trafficOut)
|
||||
processData(json.trafficIn, json.trafficOut)
|
||||
})
|
||||
.catch((err) => {
|
||||
ElMessage({
|
||||
showClose: true,
|
||||
message: 'Get traffic info failed!' + err,
|
||||
message: 'Get traffic info failed! ' + err,
|
||||
type: 'warning',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
fetchData()
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
<style></style>
|
||||
|
||||
<style scoped>
|
||||
.traffic-chart-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.y-axis {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
padding-bottom: 24px; /* Align with bars area excluding date labels */
|
||||
height: calc(100% - 24px); /* Subtract date label height approx */
|
||||
}
|
||||
|
||||
.bars-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding-bottom: 24px; /* Space for date labels */
|
||||
}
|
||||
|
||||
.grid-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background-color: #e4e7ed;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
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 */
|
||||
|
||||
.day-column {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.bars-group {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 1;
|
||||
border-radius: 4px 4px 0 0;
|
||||
transition: height 0.3s ease;
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.bar-in {
|
||||
background-color: #5470c6;
|
||||
}
|
||||
|
||||
.bar-out {
|
||||
background-color: #91cc75;
|
||||
}
|
||||
|
||||
.bar:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.date-label {
|
||||
position: absolute;
|
||||
bottom: -24px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
html.dark .legend-item {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dot.in { background-color: #5470c6; }
|
||||
.dot.out { background-color: #91cc75; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user