web/frpc: redesign dashboard (#5145)

This commit is contained in:
fatedier
2026-01-31 12:43:31 +08:00
committed by GitHub
parent 266c492b5d
commit 886c9c2fdb
17 changed files with 1540 additions and 441 deletions

View File

@@ -1,33 +1,120 @@
<template>
<div class="configure-page">
<el-card class="main-card" shadow="never">
<div class="toolbar-header">
<h2 class="card-title">Client Configuration</h2>
<div class="toolbar-actions">
<el-tooltip content="Refresh" placement="top">
<el-button :icon="Refresh" circle @click="fetchData" />
</el-tooltip>
<el-button type="primary" :icon="Upload" @click="handleUpload">Update</el-button>
</div>
<div class="page-header">
<div class="title-section">
<h1 class="page-title">Configuration</h1>
<p class="page-subtitle">
Edit and manage your frpc configuration file
</p>
</div>
</div>
<div class="config-editor">
<el-input
type="textarea"
:autosize="{ minRows: 10, maxRows: 30 }"
v-model="configContent"
placeholder="frpc configuration file content..."
class="code-input"
></el-input>
</div>
</el-card>
<el-row :gutter="20">
<el-col :xs="24" :lg="16">
<el-card class="editor-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="header-left">
<span class="card-title">Configuration Editor</span>
<el-tag size="small" type="success">TOML</el-tag>
</div>
<div class="header-actions">
<el-tooltip content="Refresh" placement="top">
<el-button :icon="Refresh" circle @click="fetchData" />
</el-tooltip>
<el-button type="primary" :icon="Upload" @click="handleUpload">
Update & Reload
</el-button>
</div>
</div>
</template>
<div class="editor-wrapper">
<el-input
type="textarea"
:autosize="{ minRows: 20, maxRows: 40 }"
v-model="configContent"
placeholder="# frpc configuration file content...
[common]
server_addr = 127.0.0.1
server_port = 7000"
class="code-editor"
></el-input>
</div>
</el-card>
</el-col>
<el-col :xs="24" :lg="8">
<el-card class="help-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">Quick Reference</span>
</div>
</template>
<div class="help-content">
<div class="help-section">
<h4 class="help-section-title">Common Settings</h4>
<div class="help-items">
<div class="help-item">
<code>serverAddr</code>
<span>Server address</span>
</div>
<div class="help-item">
<code>serverPort</code>
<span>Server port (default: 7000)</span>
</div>
<div class="help-item">
<code>auth.token</code>
<span>Authentication token</span>
</div>
</div>
</div>
<div class="help-section">
<h4 class="help-section-title">Proxy Types</h4>
<div class="proxy-type-tags">
<el-tag type="primary" effect="plain">TCP</el-tag>
<el-tag type="success" effect="plain">UDP</el-tag>
<el-tag type="warning" effect="plain">HTTP</el-tag>
<el-tag type="danger" effect="plain">HTTPS</el-tag>
<el-tag type="info" effect="plain">STCP</el-tag>
<el-tag effect="plain">XTCP</el-tag>
</div>
</div>
<div class="help-section">
<h4 class="help-section-title">Example Proxy</h4>
<pre class="code-example">
[[proxies]]
name = "web"
type = "http"
localPort = 80
customDomains = ["example.com"]</pre
>
</div>
<div class="help-section">
<a
href="https://github.com/fatedier/frp#configuration-files"
target="_blank"
class="docs-link"
>
<el-icon><Link /></el-icon>
View Full Documentation
</a>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Upload } from '@element-plus/icons-vue'
import { Refresh, Upload, Link } from '@element-plus/icons-vue'
import { getConfig, putConfig, reloadConfig } from '../api/frpc'
const configContent = ref('')
@@ -53,7 +140,7 @@ const handleUpload = () => {
confirmButtonText: 'Update',
cancelButtonText: 'Cancel',
type: 'warning',
}
},
)
.then(async () => {
if (!configContent.value.trim()) {
@@ -80,7 +167,7 @@ const handleUpload = () => {
}
})
.catch(() => {
// cancelled
// cancelled
})
}
@@ -88,28 +175,227 @@ fetchData()
</script>
<style scoped>
.main-card {
border-radius: 12px;
border: none;
.configure-page {
display: flex;
flex-direction: column;
gap: 24px;
}
.toolbar-header {
.page-header {
display: flex;
flex-direction: column;
gap: 8px;
}
.title-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.page-title {
font-size: 28px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
line-height: 1.2;
}
.page-subtitle {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0;
}
.editor-card,
.help-card {
border-radius: 12px;
border: 1px solid #e4e7ed;
}
html.dark .editor-card,
html.dark .help-card {
border-color: #3a3d5c;
background: #27293d;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
padding-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.card-title {
margin: 0;
font-size: 18px;
font-weight: 600;
font-size: 16px;
font-weight: 500;
color: #303133;
}
.code-input {
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 14px;
html.dark .card-title {
color: #e5e7eb;
}
</style>
.editor-wrapper {
position: relative;
}
.code-editor :deep(.el-textarea__inner) {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.6;
padding: 16px;
border-radius: 8px;
background: #f8f9fa;
border: 1px solid #e4e7ed;
resize: none;
}
html.dark .code-editor :deep(.el-textarea__inner) {
background: #1e1e2d;
border-color: #3a3d5c;
color: #e5e7eb;
}
.code-editor :deep(.el-textarea__inner:focus) {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
}
/* Help Card */
.help-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.help-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.help-section-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.help-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.help-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: #f8f9fa;
border-radius: 6px;
font-size: 13px;
}
html.dark .help-item {
background: #1e1e2d;
}
.help-item code {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 12px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
}
.help-item span {
color: var(--el-text-color-secondary);
flex: 1;
}
.proxy-type-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.code-example {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.6;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e4e7ed;
margin: 0;
overflow-x: auto;
white-space: pre;
}
html.dark .code-example {
background: #1e1e2d;
border-color: #3a3d5c;
color: #e5e7eb;
}
.docs-link {
display: flex;
align-items: center;
gap: 8px;
color: var(--el-color-primary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
padding: 12px 16px;
background: var(--el-color-primary-light-9);
border-radius: 8px;
transition: all 0.2s;
}
.docs-link:hover {
background: var(--el-color-primary-light-8);
}
@media (max-width: 768px) {
.card-header {
flex-direction: column;
align-items: stretch;
}
.header-left {
justify-content: space-between;
}
.header-actions {
justify-content: flex-end;
}
}
@media (max-width: 992px) {
.help-card {
margin-top: 20px;
}
}
</style>

View File

@@ -1,92 +1,132 @@
<template>
<div class="overview-page">
<el-card class="main-card" shadow="never">
<div class="toolbar-header">
<h2 class="card-title">Proxy Status</h2>
<div class="toolbar-actions">
<el-input
v-model="searchText"
placeholder="Search..."
:prefix-icon="Search"
clearable
class="search-input"
/>
<el-tooltip content="Refresh" placement="top">
<el-button :icon="Refresh" circle @click="fetchData" />
</el-tooltip>
</div>
</div>
<el-row :gutter="20" class="stats-row">
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Total Proxies"
:value="stats.total"
type="proxies"
subtitle="Configured proxies"
/>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Running"
:value="stats.running"
type="running"
subtitle="Active connections"
/>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Error"
:value="stats.error"
type="error"
subtitle="Failed proxies"
/>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Configure"
value="Edit"
type="config"
subtitle="Manage settings"
to="/configure"
/>
</el-col>
</el-row>
<el-table
v-loading="loading"
:data="filteredStatus"
:default-sort="{ prop: 'name', order: 'ascending' }"
stripe
style="width: 100%"
class="proxy-table"
>
<el-table-column
prop="name"
label="Name"
sortable
min-width="120"
></el-table-column>
<el-table-column
prop="type"
label="Type"
width="100"
sortable
>
<template #default="scope">
<span class="type-text">{{ scope.row.type }}</span>
<el-row :gutter="20" class="content-row">
<el-col :xs="24" :lg="16">
<el-card class="proxy-list-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="header-left">
<span class="card-title">Proxy Status</span>
<el-tag size="small" type="info"
>{{ stats.total }} proxies</el-tag
>
</div>
<div class="header-actions">
<el-input
v-model="searchText"
placeholder="Search..."
:prefix-icon="Search"
clearable
class="search-input"
/>
<el-tooltip content="Refresh" placement="top">
<el-button :icon="Refresh" circle @click="fetchData" />
</el-tooltip>
</div>
</div>
</template>
</el-table-column>
<el-table-column
prop="local_addr"
label="Local Address"
min-width="150"
sortable
show-overflow-tooltip
></el-table-column>
<el-table-column
prop="plugin"
label="Plugin"
width="120"
sortable
show-overflow-tooltip
></el-table-column>
<el-table-column
prop="remote_addr"
label="Remote Address"
min-width="150"
sortable
show-overflow-tooltip
></el-table-column>
<el-table-column
prop="status"
label="Status"
width="120"
sortable
align="center"
>
<template #default="scope">
<el-tag
:type="getStatusColor(scope.row.status)"
effect="light"
round
<div v-loading="loading" class="proxy-list-content">
<div v-if="filteredStatus.length > 0" class="proxy-list">
<ProxyCard
v-for="proxy in filteredStatus"
:key="proxy.name"
:proxy="proxy"
/>
</div>
<div v-else-if="!loading" class="empty-state">
<el-empty description="No proxies found" />
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :lg="8">
<el-card class="types-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">Proxy Types</span>
<el-tag size="small" type="info">Distribution</el-tag>
</div>
</template>
<div class="proxy-types-grid">
<div
v-for="(count, type) in proxyTypeCounts"
:key="type"
class="proxy-type-item"
v-show="count > 0"
>
{{ scope.row.status }}
</el-tag>
<div class="proxy-type-name">
{{ String(type).toUpperCase() }}
</div>
<div class="proxy-type-count">{{ count }}</div>
</div>
<div v-if="!hasActiveProxies" class="no-data">No proxy data</div>
</div>
</el-card>
<el-card class="status-summary-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">Status Summary</span>
</div>
</template>
</el-table-column>
<el-table-column prop="err" label="Info" min-width="150" show-overflow-tooltip>
<template #default="scope">
<span v-if="scope.row.err" class="error-text">{{ scope.row.err }}</span>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
</el-card>
<div class="status-list">
<div class="status-item">
<div class="status-indicator running"></div>
<span class="status-name">Running</span>
<span class="status-count">{{ stats.running }}</span>
</div>
<div class="status-item">
<div class="status-indicator waiting"></div>
<span class="status-name">Waiting</span>
<span class="status-count">{{ stats.waiting }}</span>
</div>
<div class="status-item">
<div class="status-indicator error"></div>
<span class="status-name">Error</span>
<span class="status-count">{{ stats.error }}</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
@@ -96,11 +136,33 @@ import { ElMessage } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getStatus } from '../api/frpc'
import type { ProxyStatus } from '../types/proxy'
import StatCard from '../components/StatCard.vue'
import ProxyCard from '../components/ProxyCard.vue'
const status = ref<ProxyStatus[]>([])
const loading = ref(false)
const searchText = ref('')
const stats = computed(() => {
const total = status.value.length
const running = status.value.filter((p) => p.status === 'running').length
const error = status.value.filter((p) => p.status === 'error').length
const waiting = total - running - error
return { total, running, error, waiting }
})
const proxyTypeCounts = computed(() => {
const counts: Record<string, number> = {}
status.value.forEach((p) => {
counts[p.type] = (counts[p.type] || 0) + 1
})
return counts
})
const hasActiveProxies = computed(() => {
return status.value.length > 0
})
const filteredStatus = computed(() => {
if (!searchText.value) {
return status.value
@@ -111,28 +173,16 @@ const filteredStatus = computed(() => {
p.name.toLowerCase().includes(search) ||
p.type.toLowerCase().includes(search) ||
p.local_addr.toLowerCase().includes(search) ||
p.remote_addr.toLowerCase().includes(search)
p.remote_addr.toLowerCase().includes(search),
)
})
const getStatusColor = (status: string) => {
switch (status) {
case 'running':
return 'success'
case 'error':
return 'danger'
default:
return 'warning'
}
}
const fetchData = async () => {
loading.value = true
try {
const json = await getStatus()
status.value = []
for (const key in json) {
// json[key] is generic array, we assume it matches ProxyStatus
for (const ps of json[key]) {
status.value.push(ps)
}
@@ -153,63 +203,240 @@ fetchData()
<style scoped>
.overview-page {
/* No special padding needed if App.vue handles content padding */
display: flex;
flex-direction: column;
gap: 20px;
}
.main-card {
.stats-row {
margin-bottom: 0;
}
.stats-row .el-col {
margin-bottom: 20px;
}
.content-row .el-col {
margin-bottom: 20px;
}
.proxy-list-card,
.types-card,
.status-summary-card {
border-radius: 12px;
border: none;
border: 1px solid #e4e7ed;
}
.card-title {
margin: 0;
font-size: 18px;
font-weight: 600;
html.dark .proxy-list-card,
html.dark .types-card,
html.dark .status-summary-card {
border-color: #3a3d5c;
background: #27293d;
}
.toolbar-header {
.status-summary-card {
margin-top: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
padding-bottom: 16px;
gap: 12px;
}
.toolbar-actions {
.header-left {
display: flex;
gap: 12px;
align-items: center;
gap: 12px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.card-title {
font-size: 16px;
font-weight: 500;
color: #303133;
}
html.dark .card-title {
color: #e5e7eb;
}
.search-input {
width: 240px;
width: 200px;
}
.error-text {
color: var(--el-color-danger);
.proxy-list-content {
min-height: 200px;
}
.type-text {
display: inline-block;
padding: 2px 8px;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
background: var(--el-fill-color-light);
border-radius: 4px;
.proxy-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.empty-state {
padding: 40px 0;
}
/* Proxy Types Grid */
.proxy-types-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 12px;
min-height: 80px;
align-content: center;
}
.proxy-type-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 8px;
background: #f8f9fa;
border-radius: 8px;
transition: all 0.2s;
}
.proxy-type-item:hover {
background: #f0f2f5;
transform: translateY(-2px);
}
html.dark .proxy-type-item {
background: #1e1e2d;
}
html.dark .proxy-type-item:hover {
background: #2a2a3c;
}
.proxy-type-name {
font-size: 11px;
color: #909399;
font-weight: 600;
margin-bottom: 4px;
letter-spacing: 0.5px;
}
.proxy-type-count {
font-size: 20px;
font-weight: 500;
color: #303133;
}
html.dark .proxy-type-count {
color: #e5e7eb;
}
.no-data {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
height: 80px;
color: #909399;
font-size: 14px;
}
/* Status Summary */
.status-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.status-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #f8f9fa;
border-radius: 8px;
transition: all 0.2s;
}
.status-item:hover {
background: #f0f2f5;
}
html.dark .status-item {
background: #1e1e2d;
}
html.dark .status-item:hover {
background: #2a2a3c;
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-indicator.running {
background: var(--el-color-success);
box-shadow: 0 0 0 3px var(--el-color-success-light-8);
}
.status-indicator.waiting {
background: var(--el-color-warning);
box-shadow: 0 0 0 3px var(--el-color-warning-light-8);
}
.status-indicator.error {
background: var(--el-color-danger);
box-shadow: 0 0 0 3px var(--el-color-danger-light-8);
}
.status-name {
flex: 1;
font-size: 14px;
color: var(--el-text-color-regular);
font-weight: 500;
}
.status-count {
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
@media (max-width: 768px) {
.toolbar-header {
.card-header {
flex-direction: column;
align-items: stretch;
}
.header-left {
justify-content: space-between;
}
.header-actions {
justify-content: space-between;
}
.search-input {
width: 100%;
flex: 1;
width: auto;
}
.proxy-types-grid {
grid-template-columns: repeat(3, 1fr);
}
}
</style>
@media (max-width: 992px) {
.status-summary-card {
margin-top: 0;
}
}
</style>