web/frpc: refactor dashboard with improved structure and API layer (#5117)

This commit is contained in:
fatedier
2026-01-09 00:40:51 +08:00
committed by GitHub
parent a4175a2595
commit 479e9f50c2
30 changed files with 2309 additions and 1694 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,9 +3,9 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>frp client admin UI</title> <title>frp client</title>
<script type="module" crossorigin src="./index-HyKZ_pht.js"></script> <script type="module" crossorigin src="./index-BAsh6RH1.js"></script>
<link rel="stylesheet" crossorigin href="./index-iuf46MlF.css"> <link rel="stylesheet" crossorigin href="./index-JCcyRUo1.css">
</head> </head>
<body> <body>

View File

@@ -168,6 +168,7 @@ func (svr *Service) apiStatus(w http.ResponseWriter, _ *http.Request) {
log.Infof("http request [/api/status]") log.Infof("http request [/api/status]")
defer func() { defer func() {
log.Infof("http response [/api/status]") log.Infof("http response [/api/status]")
w.Header().Set("Content-Type", "application/json")
buf, _ = json.Marshal(&res) buf, _ = json.Marshal(&res)
_, _ = w.Write(buf) _, _ = w.Write(buf)
}() }()

View File

@@ -123,6 +123,7 @@ func (svr *Service) apiServerInfo(w http.ResponseWriter, r *http.Request) {
}() }()
log.Infof("http request: [%s]", r.URL.Path) log.Infof("http request: [%s]", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
serverStats := mem.StatsCollector.GetServer() serverStats := mem.StatsCollector.GetServer()
svrResp := serverInfoResp{ svrResp := serverInfoResp{
Version: version.Full(), Version: version.Full(),
@@ -155,6 +156,7 @@ func (svr *Service) apiClientList(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200} res := GeneralResponse{Code: 200}
defer func() { defer func() {
log.Infof("http response [%s]: code [%d]", r.URL.RequestURI(), res.Code) log.Infof("http response [%s]: code [%d]", r.URL.RequestURI(), res.Code)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(res.Code) w.WriteHeader(res.Code)
if len(res.Msg) > 0 { if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg)) _, _ = w.Write([]byte(res.Msg))
@@ -212,6 +214,7 @@ func (svr *Service) apiClientDetail(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200} res := GeneralResponse{Code: 200}
defer func() { defer func() {
log.Infof("http response [%s]: code [%d]", r.URL.RequestURI(), res.Code) log.Infof("http response [%s]: code [%d]", r.URL.RequestURI(), res.Code)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(res.Code) w.WriteHeader(res.Code)
if len(res.Msg) > 0 { if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg)) _, _ = w.Write([]byte(res.Msg))
@@ -332,6 +335,7 @@ func (svr *Service) apiProxyByType(w http.ResponseWriter, r *http.Request) {
defer func() { defer func() {
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code) log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(res.Code) w.WriteHeader(res.Code)
if len(res.Msg) > 0 { if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg)) _, _ = w.Write([]byte(res.Msg))
@@ -404,6 +408,7 @@ func (svr *Service) apiProxyByTypeAndName(w http.ResponseWriter, r *http.Request
defer func() { defer func() {
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code) log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(res.Code) w.WriteHeader(res.Code)
if len(res.Msg) > 0 { if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg)) _, _ = w.Write([]byte(res.Msg))
@@ -472,6 +477,7 @@ func (svr *Service) apiProxyTraffic(w http.ResponseWriter, r *http.Request) {
defer func() { defer func() {
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code) log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(res.Code) w.WriteHeader(res.Code)
if len(res.Msg) > 0 { if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg)) _, _ = w.Write([]byte(res.Msg))

View File

@@ -7,18 +7,20 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ClientConfigure: typeof import('./src/components/ClientConfigure.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCol: typeof import('element-plus/es')['ElCol'] ElCard: typeof import('element-plus/es')['ElCard']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElRow: typeof import('element-plus/es')['ElRow']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
Overview: typeof import('./src/components/Overview.vue')['default'] ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
} }
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
} }

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>frp client admin UI</title> <title>frp client</title>
</head> </head>
<body> <body>

View File

@@ -11,25 +11,30 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
}, },
"dependencies": { "dependencies": {
"element-plus": "^2.5.3", "element-plus": "^2.13.0",
"vue": "^3.4.15", "vue": "^3.5.26",
"vue-router": "^4.2.5" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.7.2", "@rushstack/eslint-patch": "^1.15.0",
"@types/node": "^18.11.12", "@types/node": "24",
"@vitejs/plugin-vue": "^5.0.3", "@vitejs/plugin-vue": "^6.0.3",
"@vue/eslint-config-prettier": "^9.0.0", "@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^12.0.0", "@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.5.1", "@vue/tsconfig": "^0.8.1",
"@vueuse/core": "^14.1.0",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-plugin-vue": "^9.21.0", "eslint-plugin-vue": "^9.33.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^3.2.4", "prettier": "^3.7.4",
"typescript": "~5.3.3", "sass": "^1.97.2",
"terser": "^5.44.1",
"typescript": "^5.9.3",
"unplugin-auto-import": "^0.17.5", "unplugin-auto-import": "^0.17.5",
"unplugin-element-plus": "^0.11.2",
"unplugin-vue-components": "^0.26.0", "unplugin-vue-components": "^0.26.0",
"vite": "^5.0.12", "vite": "^7.3.0",
"vue-tsc": "^1.8.27" "vite-svg-loader": "^5.1.0",
"vue-tsc": "^3.2.2"
} }
} }

View File

@@ -1,116 +1,295 @@
<template> <template>
<div id="app"> <div id="app">
<header class="grid-content header-color"> <header class="header">
<div class="header-content"> <div class="header-top">
<div class="brand"> <div class="brand">
<a href="#">frp client</a> <a href="#" @click.prevent="router.push('/')">frpc</a>
</div> </div>
<div class="dark-switch"> <div class="header-actions">
<a
class="github-link"
href="https://github.com/fatedier/frp"
target="_blank"
aria-label="GitHub"
>
<GitHubIcon class="github-icon" />
</a>
<el-switch <el-switch
v-model="darkmodeSwitch" v-model="darkmodeSwitch"
inline-prompt inline-prompt
active-text="Dark" :active-icon="Moon"
inactive-text="Light" :inactive-icon="Sunny"
@change="toggleDark" @change="toggleDark"
style=" class="theme-switch"
--el-switch-on-color: #444452;
--el-switch-off-color: #589ef8;
"
/> />
</div> </div>
</div> </div>
<nav class="header-nav">
<el-menu
:default-active="currentRoute"
mode="horizontal"
:ellipsis="false"
@select="handleSelect"
class="nav-menu"
>
<el-menu-item index="/">Overview</el-menu-item>
<el-menu-item index="/configure">Configure</el-menu-item>
</el-menu>
</nav>
</header> </header>
<section> <main id="content">
<el-row> <router-view></router-view>
<el-col id="side-nav" :xs="24" :md="4"> </main>
<el-menu
default-active="1"
mode="vertical"
theme="light"
router="false"
@select="handleSelect"
>
<el-menu-item index="/">Overview</el-menu-item>
<el-menu-item index="/configure">Configure</el-menu-item>
<el-menu-item index="">Help</el-menu-item>
</el-menu>
</el-col>
<el-col :xs="24" :md="20">
<div id="content">
<router-view></router-view>
</div>
</el-col>
</el-row>
</section>
<footer></footer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useDark, useToggle } from '@vueuse/core' import { useDark, useToggle } from '@vueuse/core'
import { Moon, Sunny } from '@element-plus/icons-vue'
import GitHubIcon from './assets/icons/github.svg?component'
const router = useRouter()
const route = useRoute()
const isDark = useDark() const isDark = useDark()
const darkmodeSwitch = ref(isDark) const darkmodeSwitch = ref(isDark)
const toggleDark = useToggle(isDark) const toggleDark = useToggle(isDark)
const currentRoute = computed(() => {
return route.path
})
const handleSelect = (key: string) => { const handleSelect = (key: string) => {
if (key == '') { router.push(key)
window.open('https://github.com/fatedier/frp')
}
} }
</script> </script>
<style> <style>
body { body {
margin: 0px; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, sans-serif; font-family:
-apple-system,
BlinkMacSystemFont,
Helvetica Neue,
sans-serif;
} }
header { #app {
width: 100%; min-height: 100vh;
height: 60px; display: flex;
flex-direction: column;
background: #f2f2f2;
} }
.header-color { html.dark #app {
background: #58b7ff; background: #1a1a2e;
} }
html.dark .header-color { .header {
background: #395c74; position: sticky;
top: 0;
z-index: 100;
background: #fff;
} }
.header-content { html.dark .header {
background: #1e1e2d;
}
.header-top {
display: flex; display: flex;
align-items: center; align-items: center;
} justify-content: space-between;
height: 48px;
#content { padding: 0 32px;
margin-top: 20px;
padding-right: 40px;
}
.brand {
display: flex;
justify-content: flex-start;
} }
.brand a { .brand a {
color: #fff; color: #303133;
background-color: transparent; font-size: 20px;
margin-left: 20px; font-weight: 700;
line-height: 25px;
font-size: 25px;
padding: 15px 15px;
height: 30px;
text-decoration: none; text-decoration: none;
letter-spacing: -0.5px;
} }
.dark-switch { html.dark .brand a {
display: flex; color: #e5e7eb;
justify-content: flex-end;
flex-grow: 1;
padding-right: 40px;
} }
</style>
.brand a:hover {
color: #409eff;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
.github-link {
display: flex;
align-items: center;
padding: 6px;
border-radius: 6px;
transition: all 0.2s;
}
.github-link:hover {
background: #f2f3f5;
}
html.dark .github-link:hover {
background: #2a2a3c;
}
.github-icon {
width: 20px;
height: 20px;
color: #606266;
transition: color 0.2s;
}
.github-link:hover .github-icon {
color: #303133;
}
html.dark .github-icon {
color: #a0a3ad;
}
html.dark .github-link:hover .github-icon {
color: #e5e7eb;
}
.theme-switch {
--el-switch-on-color: #2c2c3a;
--el-switch-off-color: #f2f2f2;
--el-switch-border-color: #dcdfe6;
}
.theme-switch .el-switch__core .el-switch__inner .el-icon {
color: #909399 !important;
}
.header-nav {
position: relative;
padding: 0 32px;
border-bottom: 1px solid #e4e7ed;
}
html.dark .header-nav {
border-bottom-color: #3a3d5c;
}
.nav-menu {
background: transparent !important;
border-bottom: none !important;
height: 46px;
}
.nav-menu .el-menu-item,
.nav-menu .el-sub-menu__title {
position: relative;
height: 32px !important;
line-height: 32px !important;
border-bottom: none !important;
border-radius: 6px !important;
color: #666 !important;
font-weight: 400;
font-size: 14px;
padding: 0 12px !important;
margin: 7px 0;
transition:
background 0.15s ease,
color 0.15s ease;
}
.nav-menu > .el-menu-item,
.nav-menu > .el-sub-menu {
margin-right: 4px;
}
.nav-menu > .el-sub-menu {
padding: 0 !important;
}
html.dark .nav-menu .el-menu-item,
html.dark .nav-menu .el-sub-menu__title {
color: #888 !important;
}
.nav-menu .el-menu-item:hover,
.nav-menu .el-sub-menu__title:hover {
background: #f2f2f2 !important;
color: #171717 !important;
}
html.dark .nav-menu .el-menu-item:hover,
html.dark .nav-menu .el-sub-menu__title:hover {
background: #2a2a3c !important;
color: #e5e7eb !important;
}
.nav-menu .el-menu-item.is-active {
background: transparent !important;
color: #171717 !important;
font-weight: 500;
}
.nav-menu .el-menu-item.is-active::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -3px;
height: 2px;
background: #171717;
border-radius: 1px;
}
.nav-menu .el-menu-item.is-active:hover {
background: #f2f2f2 !important;
}
html.dark .nav-menu .el-menu-item.is-active {
background: transparent !important;
color: #e5e7eb !important;
font-weight: 500;
}
html.dark .nav-menu .el-menu-item.is-active::after {
background: #e5e7eb;
}
html.dark .nav-menu .el-menu-item.is-active:hover {
background: #2a2a3c !important;
}
#content {
flex: 1;
padding: 24px 40px;
max-width: 1400px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
@media (max-width: 768px) {
.header-top {
padding: 0 16px;
}
.header-nav {
padding: 0 16px;
}
#content {
padding: 16px;
}
.brand a {
font-size: 18px;
}
}
</style>

18
web/frpc/src/api/frpc.ts Normal file
View File

@@ -0,0 +1,18 @@
import { http } from './http'
import type { StatusResponse } from '../types/proxy'
export const getStatus = () => {
return http.get<StatusResponse>('/api/status')
}
export const getConfig = () => {
return http.get<string>('/api/config')
}
export const putConfig = (content: string) => {
return http.put<void>('/api/config', content)
}
export const reloadConfig = () => {
return http.get<void>('/api/reload')
}

76
web/frpc/src/api/http.ts Normal file
View File

@@ -0,0 +1,76 @@
// http.ts - Base HTTP client
class HTTPError extends Error {
status: number
statusText: string
constructor(status: number, statusText: string, message?: string) {
super(message || statusText)
this.status = status
this.statusText = statusText
}
}
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const defaultOptions: RequestInit = {
credentials: 'include',
}
const response = await fetch(url, { ...defaultOptions, ...options })
if (!response.ok) {
throw new HTTPError(response.status, response.statusText, `HTTP ${response.status}`)
}
// Handle empty response (e.g. 204 No Content)
if (response.status === 204) {
return {} as T
}
const contentType = response.headers.get('content-type')
if (contentType && contentType.includes('application/json')) {
return response.json()
}
return response.text() as unknown as T
}
export const http = {
get: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'GET' }),
post: <T>(url: string, body?: any, options?: RequestInit) => {
const headers: HeadersInit = { ...options?.headers }
let requestBody = body
if (body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof Blob)) {
if (!('Content-Type' in headers)) {
(headers as any)['Content-Type'] = 'application/json'
}
requestBody = JSON.stringify(body)
}
return request<T>(url, {
...options,
method: 'POST',
headers,
body: requestBody
})
},
put: <T>(url: string, body?: any, options?: RequestInit) => {
const headers: HeadersInit = { ...options?.headers }
let requestBody = body
if (body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof Blob)) {
if (!('Content-Type' in headers)) {
(headers as any)['Content-Type'] = 'application/json'
}
requestBody = JSON.stringify(body)
}
return request<T>(url, {
...options,
method: 'PUT',
headers,
body: requestBody
})
},
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
}

View File

@@ -0,0 +1,89 @@
.el-form-item span {
margin-left: 15px;
}
.proxy-table-expand {
font-size: 0;
}
.proxy-table-expand .el-form-item__label{
width: 90px;
color: #99a9bf;
}
.proxy-table-expand .el-form-item {
margin-right: 0;
margin-bottom: 0;
width: 50%;
}
.el-table .el-table__expanded-cell {
padding: 20px 50px;
}
/* Modern styles */
* {
box-sizing: border-box;
}
/* Smooth transitions */
.el-button,
.el-card,
.el-input,
.el-select,
.el-tag {
transition: all 0.3s ease;
}
/* Card hover effects */
.el-card:hover {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
/* Better scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Page headers */
.el-page-header {
padding: 16px 0;
}
.el-page-header__title {
font-size: 20px;
font-weight: 600;
}
/* Better form layouts */
.el-form-item {
margin-bottom: 18px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.el-row {
margin-left: 0 !important;
margin-right: 0 !important;
}
.el-col {
padding-left: 10px !important;
padding-right: 10px !important;
}
}

View File

@@ -0,0 +1,58 @@
html.dark {
--el-bg-color: #1e1e2e;
--el-fill-color-blank: #1e1e2e;
background-color: #1e1e2e;
}
html.dark body {
background-color: #1e1e2e;
color: #e5e7eb;
}
/* Dark mode scrollbar */
html.dark ::-webkit-scrollbar-track {
background: #27293d;
}
html.dark ::-webkit-scrollbar-thumb {
background: #3a3d5c;
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: #4a4d6c;
}
/* Dark mode cards */
html.dark .el-card {
background-color: #27293d;
border-color: #3a3d5c;
}
/* Dark mode inputs */
html.dark .el-input__wrapper {
background-color: #27293d;
border-color: #3a3d5c;
}
html.dark .el-input__inner {
color: #e5e7eb;
}
/* Dark mode table */
html.dark .el-table {
background-color: #27293d;
color: #e5e7eb;
}
html.dark .el-table th {
background-color: #1e1e2e;
color: #e5e7eb;
}
html.dark .el-table tr {
background-color: #27293d;
}
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td {
background-color: #1e1e2e;
}

View File

@@ -1,5 +0,0 @@
html.dark {
--el-bg-color: #343432;
--el-fill-color-blank: #343432;
background-color: #343432;
}

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>

After

Width:  |  Height:  |  Size: 671 B

View File

@@ -1,102 +0,0 @@
<template>
<div>
<el-row id="head">
<el-button type="primary" @click="fetchData">Refresh</el-button>
<el-button type="primary" @click="uploadConfig">Upload</el-button>
</el-row>
<el-input
type="textarea"
autosize
v-model="textarea"
placeholder="frpc configure file, can not be empty..."
></el-input>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
let textarea = ref('')
const fetchData = () => {
fetch('/api/config', { credentials: 'include' })
.then((res) => {
return res.text()
})
.then((text) => {
textarea.value = text
})
.catch(() => {
ElMessage({
showClose: true,
message: 'Get configure content from frpc failed!',
type: 'warning',
})
})
}
const uploadConfig = () => {
ElMessageBox.confirm(
'This operation will upload your frpc configure file content and hot reload it, do you want to continue?',
'Notice',
{
confirmButtonText: 'Yes',
cancelButtonText: 'No',
type: 'warning',
}
)
.then(() => {
if (textarea.value == '') {
ElMessage({
message: 'Configure content can not be empty!',
type: 'warning',
})
return
}
fetch('/api/config', {
credentials: 'include',
method: 'PUT',
body: textarea.value,
})
.then(() => {
fetch('/api/reload', { credentials: 'include' })
.then(() => {
ElMessage({
type: 'success',
message: 'Success',
})
})
.catch((err) => {
ElMessage({
showClose: true,
message: 'Reload frpc configure file error, ' + err,
type: 'warning',
})
})
})
.catch(() => {
ElMessage({
showClose: true,
message: 'Put config to frpc and hot reload failed!',
type: 'warning',
})
})
})
.catch(() => {
ElMessage({
message: 'Canceled',
type: 'info',
})
})
}
fetchData()
</script>
<style>
#head {
margin-bottom: 30px;
}
</style>

View File

@@ -1,85 +0,0 @@
<template>
<div>
<el-row>
<el-col :md="24">
<div>
<el-table
:data="status"
stripe
style="width: 100%"
:default-sort="{ prop: 'type', order: 'ascending' }"
>
<el-table-column
prop="name"
label="name"
sortable
></el-table-column>
<el-table-column
prop="type"
label="type"
width="150"
sortable
></el-table-column>
<el-table-column
prop="local_addr"
label="local address"
width="200"
sortable
></el-table-column>
<el-table-column
prop="plugin"
label="plugin"
width="200"
sortable
></el-table-column>
<el-table-column
prop="remote_addr"
label="remote address"
sortable
></el-table-column>
<el-table-column
prop="status"
label="status"
width="150"
sortable
></el-table-column>
<el-table-column prop="err" label="info"></el-table-column>
</el-table>
</div>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
let status = ref<any[]>([])
const fetchData = () => {
fetch('/api/status', { credentials: 'include' })
.then((res) => {
return res.json()
})
.then((json) => {
status.value = new Array()
for (let key in json) {
for (let ps of json[key]) {
console.log(ps)
status.value.push(ps)
}
}
})
.catch((err) => {
ElMessage({
showClose: true,
message: 'Get status info from frpc failed!' + err,
type: 'warning',
})
})
}
fetchData()
</script>
<style></style>

View File

@@ -1,13 +1,13 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css' import 'element-plus/theme-chalk/dark/css-vars.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import './assets/dark.css' import './assets/css/custom.css'
import './assets/css/dark.css'
const app = createApp(App) const app = createApp(App)
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')

View File

@@ -1,6 +1,6 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import Overview from '../components/Overview.vue' import Overview from '../views/Overview.vue'
import ClientConfigure from '../components/ClientConfigure.vue' import ClientConfigure from '../views/ClientConfigure.vue'
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
@@ -18,4 +18,4 @@ const router = createRouter({
], ],
}) })
export default router export default router

5
web/frpc/src/svg.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module '*.svg?component' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}

View File

@@ -0,0 +1,12 @@
export interface ProxyStatus {
name: string
type: string
status: string
err: string
local_addr: string
plugin: string
remote_addr: string
[key: string]: any
}
export type StatusResponse = Record<string, ProxyStatus[]>

View File

@@ -0,0 +1,33 @@
export function formatDistanceToNow(date: Date): string {
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000)
let interval = seconds / 31536000
if (interval > 1) return Math.floor(interval) + ' years ago'
interval = seconds / 2592000
if (interval > 1) return Math.floor(interval) + ' months ago'
interval = seconds / 86400
if (interval > 1) return Math.floor(interval) + ' days ago'
interval = seconds / 3600
if (interval > 1) return Math.floor(interval) + ' hours ago'
interval = seconds / 60
if (interval > 1) return Math.floor(interval) + ' minutes ago'
return Math.floor(seconds) + ' seconds ago'
}
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
// Prevent index out of bounds for extremely large numbers
const unit = sizes[i] || sizes[sizes.length - 1]
const val = bytes / Math.pow(k, i)
return parseFloat(val.toFixed(2)) + ' ' + unit
}

View File

@@ -0,0 +1,115 @@
<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>
<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>
</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 { getConfig, putConfig, reloadConfig } from '../api/frpc'
const configContent = ref('')
const fetchData = async () => {
try {
const text = await getConfig()
configContent.value = text
} catch (err: any) {
ElMessage({
showClose: true,
message: 'Get configuration failed: ' + err.message,
type: 'warning',
})
}
}
const handleUpload = () => {
ElMessageBox.confirm(
'This operation will update your frpc configuration and reload it. Do you want to continue?',
'Confirm Update',
{
confirmButtonText: 'Update',
cancelButtonText: 'Cancel',
type: 'warning',
}
)
.then(async () => {
if (!configContent.value.trim()) {
ElMessage({
message: 'Configuration content cannot be empty!',
type: 'warning',
})
return
}
try {
await putConfig(configContent.value)
await reloadConfig()
ElMessage({
type: 'success',
message: 'Configuration updated and reloaded successfully',
})
} catch (err: any) {
ElMessage({
showClose: true,
message: 'Update failed: ' + err.message,
type: 'error',
})
}
})
.catch(() => {
// cancelled
})
}
fetchData()
</script>
<style scoped>
.main-card {
border-radius: 12px;
border: none;
}
.toolbar-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;
}
.card-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.code-input {
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,215 @@
<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-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>
</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
>
{{ scope.row.status }}
</el-tag>
</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>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getStatus } from '../api/frpc'
import type { ProxyStatus } from '../types/proxy'
const status = ref<ProxyStatus[]>([])
const loading = ref(false)
const searchText = ref('')
const filteredStatus = computed(() => {
if (!searchText.value) {
return status.value
}
const search = searchText.value.toLowerCase()
return status.value.filter(
(p) =>
p.name.toLowerCase().includes(search) ||
p.type.toLowerCase().includes(search) ||
p.local_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)
}
}
} catch (err: any) {
ElMessage({
showClose: true,
message: 'Get status info from frpc failed! ' + err.message,
type: 'warning',
})
} finally {
loading.value = false
}
}
fetchData()
</script>
<style scoped>
.overview-page {
/* No special padding needed if App.vue handles content padding */
}
.main-card {
border-radius: 12px;
border: none;
}
.card-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.toolbar-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;
}
.toolbar-actions {
display: flex;
gap: 12px;
align-items: center;
}
.search-input {
width: 240px;
}
.error-text {
color: var(--el-color-danger);
}
.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;
color: var(--el-text-color-regular);
}
@media (max-width: 768px) {
.toolbar-header {
flex-direction: column;
align-items: stretch;
}
.search-input {
width: 100%;
}
}
</style>

View File

@@ -2,15 +2,19 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import svgLoader from 'vite-svg-loader'
import AutoImport from 'unplugin-auto-import/vite' import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite' import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import ElementPlus from 'unplugin-element-plus/vite'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
base: '', base: '',
plugins: [ plugins: [
vue(), vue(),
svgLoader(),
ElementPlus({}),
AutoImport({ AutoImport({
resolvers: [ElementPlusResolver()], resolvers: [ElementPlusResolver()],
}), }),
@@ -25,5 +29,22 @@ export default defineConfig({
}, },
build: { build: {
assetsDir: '', assetsDir: '',
chunkSizeWarningLimit: 1000,
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
},
},
}, },
}) server: {
allowedHosts: process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(',') : [],
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://127.0.0.1:7400',
changeOrigin: true,
},
},
},
})

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,7 @@ export default defineConfig({
}, },
}, },
server: { server: {
allowedHosts: process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(',') : [],
proxy: { proxy: {
'/api': { '/api': {
target: process.env.VITE_API_URL || 'http://127.0.0.1:7500', target: process.env.VITE_API_URL || 'http://127.0.0.1:7500',

File diff suppressed because it is too large Load Diff