add persistent proxy/visitor store with CRUD API and web UI (#5188)

This commit is contained in:
fatedier
2026-03-02 01:09:59 +08:00
committed by GitHub
parent d0347325fc
commit 01997deb98
89 changed files with 13960 additions and 3864 deletions

View File

@@ -1,30 +0,0 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier',
],
parserOptions: {
ecmaVersion: 'latest',
},
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'vue/multi-word-component-names': [
'error',
{
ignores: ['Overview'],
},
],
},
}

View File

@@ -10,13 +10,21 @@ declare module 'vue' {
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
KeyValueEditor: typeof import('./src/components/KeyValueEditor.vue')['default']
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']

36
web/frpc/eslint.config.js Normal file
View File

@@ -0,0 +1,36 @@
import pluginVue from 'eslint-plugin-vue'
import vueTsEslintConfig from '@vue/eslint-config-typescript'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default [
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
{
name: 'app/files-to-ignore',
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
},
...pluginVue.configs['flat/essential'],
...vueTsEslintConfig(),
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'vue/multi-word-component-names': [
'error',
{
ignores: ['Overview'],
},
],
},
},
skipFormatting,
]

File diff suppressed because it is too large Load Diff

View File

@@ -2,13 +2,14 @@
"name": "frpc-dashboard",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check build-only",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
"lint": "eslint --fix"
},
"dependencies": {
"element-plus": "^2.13.0",
@@ -16,14 +17,13 @@
"vue-router": "^4.6.4"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.15.0",
"@types/node": "24",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/tsconfig": "^0.8.1",
"@vueuse/core": "^14.1.0",
"eslint": "^8.56.0",
"eslint": "^9.39.0",
"eslint-plugin-vue": "^9.33.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.7.4",
@@ -37,4 +37,4 @@
"vite-svg-loader": "^5.1.0",
"vue-tsc": "^3.2.2"
}
}
}

View File

@@ -65,6 +65,12 @@ const isDark = useDark()
const currentRouteName = computed(() => {
if (route.path === '/') return 'Overview'
if (route.path === '/configure') return 'Configure'
if (route.path === '/proxies/create') return 'Create Proxy'
if (route.path.startsWith('/proxies/') && route.path.endsWith('/edit'))
return 'Edit Proxy'
if (route.path === '/visitors/create') return 'Create Visitor'
if (route.path.startsWith('/visitors/') && route.path.endsWith('/edit'))
return 'Edit Visitor'
return ''
})
</script>

View File

@@ -1,5 +1,11 @@
import { http } from './http'
import type { StatusResponse } from '../types/proxy'
import type {
StatusResponse,
StoreProxyListResp,
StoreProxyConfig,
StoreVisitorListResp,
StoreVisitorConfig,
} from '../types/proxy'
export const getStatus = () => {
return http.get<StatusResponse>('/api/status')
@@ -16,3 +22,58 @@ export const putConfig = (content: string) => {
export const reloadConfig = () => {
return http.get<void>('/api/reload')
}
// Store API - Proxies
export const listStoreProxies = () => {
return http.get<StoreProxyListResp>('/api/store/proxies')
}
export const getStoreProxy = (name: string) => {
return http.get<StoreProxyConfig>(
`/api/store/proxies/${encodeURIComponent(name)}`,
)
}
export const createStoreProxy = (config: Record<string, any>) => {
return http.post<void>('/api/store/proxies', config)
}
export const updateStoreProxy = (name: string, config: Record<string, any>) => {
return http.put<void>(
`/api/store/proxies/${encodeURIComponent(name)}`,
config,
)
}
export const deleteStoreProxy = (name: string) => {
return http.delete<void>(`/api/store/proxies/${encodeURIComponent(name)}`)
}
// Store API - Visitors
export const listStoreVisitors = () => {
return http.get<StoreVisitorListResp>('/api/store/visitors')
}
export const getStoreVisitor = (name: string) => {
return http.get<StoreVisitorConfig>(
`/api/store/visitors/${encodeURIComponent(name)}`,
)
}
export const createStoreVisitor = (config: Record<string, any>) => {
return http.post<void>('/api/store/visitors', config)
}
export const updateStoreVisitor = (
name: string,
config: Record<string, any>,
) => {
return http.put<void>(
`/api/store/visitors/${encodeURIComponent(name)}`,
config,
)
}
export const deleteStoreVisitor = (name: string) => {
return http.delete<void>(`/api/store/visitors/${encodeURIComponent(name)}`)
}

View File

@@ -0,0 +1,153 @@
<template>
<div class="kv-editor">
<div v-for="(entry, index) in modelValue" :key="index" class="kv-row">
<el-input
:model-value="entry.key"
:placeholder="keyPlaceholder"
class="kv-input"
@update:model-value="updateEntry(index, 'key', $event)"
/>
<el-input
:model-value="entry.value"
:placeholder="valuePlaceholder"
class="kv-input"
@update:model-value="updateEntry(index, 'value', $event)"
/>
<button class="kv-remove-btn" @click="removeEntry(index)">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
fill="currentColor"
/>
</svg>
</button>
</div>
<button class="kv-add-btn" @click="addEntry">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z"
fill="currentColor"
/>
</svg>
Add
</button>
</div>
</template>
<script setup lang="ts">
interface KVEntry {
key: string
value: string
}
interface Props {
modelValue: KVEntry[]
keyPlaceholder?: string
valuePlaceholder?: string
}
const props = withDefaults(defineProps<Props>(), {
keyPlaceholder: 'Key',
valuePlaceholder: 'Value',
})
const emit = defineEmits<{
'update:modelValue': [value: KVEntry[]]
}>()
const updateEntry = (index: number, field: 'key' | 'value', val: string) => {
const updated = [...props.modelValue]
updated[index] = { ...updated[index], [field]: val }
emit('update:modelValue', updated)
}
const addEntry = () => {
emit('update:modelValue', [...props.modelValue, { key: '', value: '' }])
}
const removeEntry = (index: number) => {
const updated = props.modelValue.filter((_, i) => i !== index)
emit('update:modelValue', updated)
}
</script>
<style scoped>
.kv-editor {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.kv-row {
display: flex;
gap: 8px;
align-items: center;
}
.kv-input {
flex: 1;
}
.kv-remove-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
cursor: pointer;
transition: all 0.15s ease;
flex-shrink: 0;
}
.kv-remove-btn svg {
width: 14px;
height: 14px;
}
.kv-remove-btn:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
html.dark .kv-remove-btn:hover {
background: rgba(248, 113, 113, 0.15);
color: #f87171;
}
.kv-add-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px dashed var(--el-border-color);
border-radius: 8px;
background: transparent;
color: var(--el-text-color-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
align-self: flex-start;
}
.kv-add-btn svg {
width: 14px;
height: 14px;
}
.kv-add-btn:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
</style>

View File

@@ -1,23 +1,46 @@
<template>
<div class="proxy-card" :class="{ 'has-error': proxy.err }">
<div
class="proxy-card"
:class="{ 'has-error': proxy.err, 'is-store': isStore }"
>
<div class="card-main">
<div class="card-left">
<div class="card-header">
<span class="proxy-name">{{ proxy.name }}</span>
<span class="type-tag">{{ proxy.type.toUpperCase() }}</span>
<span class="type-tag" :class="`type-${proxy.type}`">{{
proxy.type.toUpperCase()
}}</span>
<span v-if="isStore" class="source-tag">
<svg
class="store-icon"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 4.5A1.5 1.5 0 013.5 3h9A1.5 1.5 0 0114 4.5v1a.5.5 0 01-.5.5h-11a.5.5 0 01-.5-.5v-1z"
fill="currentColor"
/>
<path
d="M3 7v5.5A1.5 1.5 0 004.5 14h7a1.5 1.5 0 001.5-1.5V7H3zm4 2h2a.5.5 0 010 1H7a.5.5 0 010-1z"
fill="currentColor"
/>
</svg>
Store
</span>
</div>
<div class="card-meta">
<span v-if="proxy.local_addr" class="meta-item">
<span class="meta-label">Local:</span>
<span class="meta-label">Local</span>
<span class="meta-value code">{{ proxy.local_addr }}</span>
</span>
<span v-if="proxy.plugin" class="meta-item">
<span class="meta-label">Plugin:</span>
<span class="meta-label">Plugin</span>
<span class="meta-value code">{{ proxy.plugin }}</span>
</span>
<span v-if="proxy.remote_addr" class="meta-item">
<span class="meta-label">Remote:</span>
<span class="meta-label">Remote</span>
<span class="meta-value code">{{ proxy.remote_addr }}</span>
</span>
</div>
@@ -25,12 +48,58 @@
<div class="card-right">
<div v-if="proxy.err" class="error-info">
<el-icon class="error-icon"><Warning /></el-icon>
<span class="error-text">{{ proxy.err }}</span>
<el-tooltip :content="proxy.err" placement="top" :show-after="300">
<div class="error-badge">
<el-icon class="error-icon"><Warning /></el-icon>
<span class="error-text">Error</span>
</div>
</el-tooltip>
</div>
<div class="status-badge" :class="statusClass">
<span class="status-dot"></span>
{{ proxy.status }}
</div>
<!-- Store actions -->
<div v-if="isStore" class="card-actions">
<button
class="action-btn edit-btn"
@click.stop="$emit('edit', proxy)"
>
<svg
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.293 1.293a1 1 0 011.414 0l2 2a1 1 0 010 1.414l-9 9A1 1 0 015 14H3a1 1 0 01-1-1v-2a1 1 0 01.293-.707l9-9z"
fill="currentColor"
/>
</svg>
</button>
<button
class="action-btn delete-btn"
@click.stop="$emit('delete', proxy)"
>
<svg
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
@@ -47,6 +116,13 @@ interface Props {
const props = defineProps<Props>()
defineEmits<{
edit: [proxy: ProxyStatus]
delete: [proxy: ProxyStatus]
}>()
const isStore = computed(() => props.proxy.source === 'store')
const statusClass = computed(() => {
switch (props.proxy.status) {
case 'running':
@@ -61,17 +137,20 @@ const statusClass = computed(() => {
<style scoped>
.proxy-card {
position: relative;
display: block;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
transition: all 0.2s ease-in-out;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.proxy-card:hover {
border-color: var(--el-border-color-light);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
border-color: var(--el-border-color);
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.06),
0 1px 4px rgba(0, 0, 0, 0.04);
}
.proxy-card.has-error {
@@ -86,9 +165,9 @@ html.dark .proxy-card.has-error {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
gap: 24px;
min-height: 80px;
padding: 18px 20px;
gap: 20px;
min-height: 76px;
}
/* Left Section */
@@ -96,7 +175,7 @@ html.dark .proxy-card.has-error {
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
gap: 10px;
flex: 1;
min-width: 0;
}
@@ -104,120 +183,303 @@ html.dark .proxy-card.has-error {
.card-header {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
flex-wrap: wrap;
}
.proxy-name {
font-size: 16px;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.4;
line-height: 1.3;
letter-spacing: -0.01em;
}
.type-tag {
font-size: 11px;
font-weight: 500;
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.type-tag.type-tcp {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.type-tag.type-udp {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.type-tag.type-http {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.type-tag.type-https {
background: rgba(16, 185, 129, 0.15);
color: #059669;
}
.type-tag.type-stcp,
.type-tag.type-sudp,
.type-tag.type-xtcp {
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
}
.type-tag.type-tcpmux {
background: rgba(236, 72, 153, 0.1);
color: #ec4899;
}
html.dark .type-tag.type-tcp {
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
}
html.dark .type-tag.type-udp {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
}
html.dark .type-tag.type-http {
background: rgba(52, 211, 153, 0.15);
color: #34d399;
}
html.dark .type-tag.type-https {
background: rgba(52, 211, 153, 0.2);
color: #34d399;
}
html.dark .type-tag.type-stcp,
html.dark .type-tag.type-sudp,
html.dark .type-tag.type-xtcp {
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
}
html.dark .type-tag.type-tcpmux {
background: rgba(244, 114, 182, 0.15);
color: #f472b6;
}
.source-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
background: linear-gradient(
135deg,
rgba(102, 126, 234, 0.1) 0%,
rgba(118, 75, 162, 0.1) 100%
);
color: #764ba2;
}
html.dark .source-tag {
background: linear-gradient(
135deg,
rgba(129, 140, 248, 0.15) 0%,
rgba(167, 139, 250, 0.15) 100%
);
color: #a78bfa;
}
.store-icon {
width: 12px;
height: 12px;
}
.card-meta {
display: flex;
align-items: center;
gap: 20px;
gap: 16px;
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: baseline;
align-items: center;
gap: 6px;
line-height: 1;
}
.meta-label {
color: var(--el-text-color-placeholder);
font-size: 13px;
font-size: 12px;
font-weight: 500;
}
.meta-value {
font-size: 13px;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-regular);
}
.meta-value.code {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
'SF Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
background: var(--el-fill-color-light);
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
padding: 3px 7px;
border-radius: 5px;
font-size: 11px;
letter-spacing: -0.02em;
}
/* Right Section */
.card-right {
display: flex;
align-items: center;
gap: 16px;
gap: 12px;
flex-shrink: 0;
}
.error-info {
.error-badge {
display: flex;
align-items: center;
gap: 6px;
max-width: 200px;
gap: 4px;
padding: 4px 8px;
border-radius: 6px;
background: var(--el-color-danger-light-9);
cursor: help;
}
.error-icon {
color: var(--el-color-danger);
font-size: 16px;
flex-shrink: 0;
font-size: 14px;
}
.error-text {
font-size: 12px;
font-size: 11px;
font-weight: 500;
color: var(--el-color-danger);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-badge {
display: inline-flex;
padding: 4px 12px;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
text-transform: capitalize;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.status-badge.running {
background: var(--el-color-success-light-9);
color: var(--el-color-success);
}
.status-badge.running .status-dot {
background: var(--el-color-success);
box-shadow: 0 0 0 2px var(--el-color-success-light-7);
animation: pulse 2s infinite;
}
.status-badge.error {
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
.status-badge.error .status-dot {
background: var(--el-color-danger);
}
.status-badge.waiting {
background: var(--el-color-warning-light-9);
color: var(--el-color-warning);
}
.status-badge.waiting .status-dot {
background: var(--el-color-warning);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Action buttons */
.card-actions {
display: flex;
gap: 4px;
}
@media (hover: hover) and (pointer: fine) {
.card-actions {
display: none;
}
.proxy-card.is-store:hover .status-badge {
display: none;
}
.proxy-card:hover .card-actions {
display: flex;
}
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.action-btn svg {
width: 14px;
height: 14px;
}
.action-btn:hover {
transform: scale(1.05);
}
.edit-btn:hover {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.delete-btn:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
html.dark .edit-btn:hover {
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
}
html.dark .delete-btn:hover {
background: rgba(248, 113, 113, 0.15);
color: #f87171;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.card-main {
flex-direction: column;
align-items: stretch;
gap: 16px;
padding: 16px;
gap: 14px;
padding: 14px 16px;
}
.card-right {
@@ -225,12 +487,7 @@ html.dark .proxy-card.has-error {
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 16px;
}
.error-info {
max-width: none;
flex: 1;
padding-top: 14px;
}
}
</style>

View File

@@ -1,6 +1,10 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { ElMessage } from 'element-plus'
import Overview from '../views/Overview.vue'
import ClientConfigure from '../views/ClientConfigure.vue'
import ProxyEdit from '../views/ProxyEdit.vue'
import VisitorEdit from '../views/VisitorEdit.vue'
import { listStoreProxies } from '../api/frpc'
const router = createRouter({
history: createWebHashHistory(),
@@ -15,7 +19,59 @@ const router = createRouter({
name: 'ClientConfigure',
component: ClientConfigure,
},
{
path: '/proxies/create',
name: 'ProxyCreate',
component: ProxyEdit,
meta: { requiresStore: true },
},
{
path: '/proxies/:name/edit',
name: 'ProxyEdit',
component: ProxyEdit,
meta: { requiresStore: true },
},
{
path: '/visitors/create',
name: 'VisitorCreate',
component: VisitorEdit,
meta: { requiresStore: true },
},
{
path: '/visitors/:name/edit',
name: 'VisitorEdit',
component: VisitorEdit,
meta: { requiresStore: true },
},
],
})
const isStoreEnabled = async () => {
try {
await listStoreProxies()
return true
} catch (err: any) {
if (err?.status === 404) {
return false
}
return true
}
}
router.beforeEach(async (to) => {
if (!to.matched.some((record) => record.meta.requiresStore)) {
return true
}
const enabled = await isStoreEnabled()
if (enabled) {
return true
}
ElMessage.warning(
'Store is disabled. Enable Store in frpc config to create or edit store entries.',
)
return { name: 'Overview' }
})
export default router

View File

@@ -1,3 +1,7 @@
// ========================================
// RUNTIME STATUS TYPES (from /api/status)
// ========================================
export interface ProxyStatus {
name: string
type: string
@@ -6,7 +10,635 @@ export interface ProxyStatus {
local_addr: string
plugin: string
remote_addr: string
source?: 'store' | 'config'
[key: string]: any
}
export type StatusResponse = Record<string, ProxyStatus[]>
// ========================================
// STORE API TYPES
// ========================================
export interface StoreProxyConfig {
name: string
type: string
config: Record<string, any>
}
export interface StoreVisitorConfig {
name: string
type: string
config: Record<string, any>
}
export interface StoreProxyListResp {
proxies: StoreProxyConfig[]
}
export interface StoreVisitorListResp {
visitors: StoreVisitorConfig[]
}
// ========================================
// CONSTANTS
// ========================================
export const PROXY_TYPES = [
'tcp',
'udp',
'http',
'https',
'stcp',
'sudp',
'xtcp',
'tcpmux',
] as const
export type ProxyType = (typeof PROXY_TYPES)[number]
export const VISITOR_TYPES = ['stcp', 'sudp', 'xtcp'] as const
export type VisitorType = (typeof VISITOR_TYPES)[number]
export const PLUGIN_TYPES = [
'',
'http2https',
'http_proxy',
'https2http',
'https2https',
'http2http',
'socks5',
'static_file',
'unix_domain_socket',
'tls2raw',
'virtual_net',
] as const
export type PluginType = (typeof PLUGIN_TYPES)[number]
// ========================================
// FORM DATA INTERFACES
// ========================================
export interface ProxyFormData {
// Base fields (ProxyBaseConfig)
name: string
type: ProxyType
enabled: boolean
// Backend (ProxyBackend)
localIP: string
localPort: number | undefined
pluginType: string
pluginConfig: Record<string, any>
// Transport (ProxyTransport)
useEncryption: boolean
useCompression: boolean
bandwidthLimit: string
bandwidthLimitMode: string
proxyProtocolVersion: string
// Load Balancer (LoadBalancerConfig)
loadBalancerGroup: string
loadBalancerGroupKey: string
// Health Check (HealthCheckConfig)
healthCheckType: string
healthCheckTimeoutSeconds: number | undefined
healthCheckMaxFailed: number | undefined
healthCheckIntervalSeconds: number | undefined
healthCheckPath: string
healthCheckHTTPHeaders: Array<{ name: string; value: string }>
// Metadata & Annotations
metadatas: Array<{ key: string; value: string }>
annotations: Array<{ key: string; value: string }>
// TCP/UDP specific
remotePort: number | undefined
// Domain (HTTP/HTTPS/TCPMux) - DomainConfig
customDomains: string
subdomain: string
// HTTP specific (HTTPProxyConfig)
locations: string
httpUser: string
httpPassword: string
hostHeaderRewrite: string
requestHeaders: Array<{ key: string; value: string }>
responseHeaders: Array<{ key: string; value: string }>
routeByHTTPUser: string
// TCPMux specific
multiplexer: string
// STCP/SUDP/XTCP specific
secretKey: string
allowUsers: string
// XTCP specific (NatTraversalConfig)
natTraversalDisableAssistedAddrs: boolean
}
export interface VisitorFormData {
// Base fields (VisitorBaseConfig)
name: string
type: VisitorType
enabled: boolean
// Transport (VisitorTransport)
useEncryption: boolean
useCompression: boolean
// Connection
secretKey: string
serverUser: string
serverName: string
bindAddr: string
bindPort: number | undefined
// XTCP specific (XTCPVisitorConfig)
protocol: string
keepTunnelOpen: boolean
maxRetriesAnHour: number | undefined
minRetryInterval: number | undefined
fallbackTo: string
fallbackTimeoutMs: number | undefined
natTraversalDisableAssistedAddrs: boolean
}
// ========================================
// DEFAULT FORM CREATORS
// ========================================
export function createDefaultProxyForm(): ProxyFormData {
return {
name: '',
type: 'tcp',
enabled: true,
localIP: '127.0.0.1',
localPort: undefined,
pluginType: '',
pluginConfig: {},
useEncryption: false,
useCompression: false,
bandwidthLimit: '',
bandwidthLimitMode: 'client',
proxyProtocolVersion: '',
loadBalancerGroup: '',
loadBalancerGroupKey: '',
healthCheckType: '',
healthCheckTimeoutSeconds: undefined,
healthCheckMaxFailed: undefined,
healthCheckIntervalSeconds: undefined,
healthCheckPath: '',
healthCheckHTTPHeaders: [],
metadatas: [],
annotations: [],
remotePort: undefined,
customDomains: '',
subdomain: '',
locations: '',
httpUser: '',
httpPassword: '',
hostHeaderRewrite: '',
requestHeaders: [],
responseHeaders: [],
routeByHTTPUser: '',
multiplexer: 'httpconnect',
secretKey: '',
allowUsers: '',
natTraversalDisableAssistedAddrs: false,
}
}
export function createDefaultVisitorForm(): VisitorFormData {
return {
name: '',
type: 'stcp',
enabled: true,
useEncryption: false,
useCompression: false,
secretKey: '',
serverUser: '',
serverName: '',
bindAddr: '127.0.0.1',
bindPort: undefined,
protocol: 'quic',
keepTunnelOpen: false,
maxRetriesAnHour: undefined,
minRetryInterval: undefined,
fallbackTo: '',
fallbackTimeoutMs: undefined,
natTraversalDisableAssistedAddrs: false,
}
}
// ========================================
// CONVERTERS: Form -> Store API
// ========================================
export function formToStoreProxy(form: ProxyFormData): Record<string, any> {
const config: Record<string, any> = {
name: form.name,
type: form.type,
}
// Enabled (nil/true = enabled, false = disabled)
if (!form.enabled) {
config.enabled = false
}
// Backend - LocalIP/LocalPort
if (form.pluginType === '') {
// No plugin, use local backend
if (form.localIP && form.localIP !== '127.0.0.1') {
config.localIP = form.localIP
}
if (form.localPort != null) {
config.localPort = form.localPort
}
} else {
// Plugin backend
config.plugin = {
type: form.pluginType,
...form.pluginConfig,
}
}
// Transport
if (
form.useEncryption ||
form.useCompression ||
form.bandwidthLimit ||
(form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') ||
form.proxyProtocolVersion
) {
config.transport = {}
if (form.useEncryption) config.transport.useEncryption = true
if (form.useCompression) config.transport.useCompression = true
if (form.bandwidthLimit)
config.transport.bandwidthLimit = form.bandwidthLimit
if (form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') {
config.transport.bandwidthLimitMode = form.bandwidthLimitMode
}
if (form.proxyProtocolVersion) {
config.transport.proxyProtocolVersion = form.proxyProtocolVersion
}
}
// Load Balancer
if (form.loadBalancerGroup) {
config.loadBalancer = {
group: form.loadBalancerGroup,
}
if (form.loadBalancerGroupKey) {
config.loadBalancer.groupKey = form.loadBalancerGroupKey
}
}
// Health Check
if (form.healthCheckType) {
config.healthCheck = {
type: form.healthCheckType,
}
if (form.healthCheckTimeoutSeconds != null) {
config.healthCheck.timeoutSeconds = form.healthCheckTimeoutSeconds
}
if (form.healthCheckMaxFailed != null) {
config.healthCheck.maxFailed = form.healthCheckMaxFailed
}
if (form.healthCheckIntervalSeconds != null) {
config.healthCheck.intervalSeconds = form.healthCheckIntervalSeconds
}
if (form.healthCheckPath) {
config.healthCheck.path = form.healthCheckPath
}
if (form.healthCheckHTTPHeaders.length > 0) {
config.healthCheck.httpHeaders = form.healthCheckHTTPHeaders
}
}
// Metadata
if (form.metadatas.length > 0) {
config.metadatas = Object.fromEntries(
form.metadatas.map((m) => [m.key, m.value]),
)
}
// Annotations
if (form.annotations.length > 0) {
config.annotations = Object.fromEntries(
form.annotations.map((a) => [a.key, a.value]),
)
}
// Type-specific fields
if (form.type === 'tcp' || form.type === 'udp') {
if (form.remotePort != null) {
config.remotePort = form.remotePort
}
}
if (form.type === 'http' || form.type === 'https' || form.type === 'tcpmux') {
// Domain config
if (form.customDomains) {
config.customDomains = form.customDomains
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
if (form.subdomain) {
config.subdomain = form.subdomain
}
}
if (form.type === 'http') {
// HTTP specific
if (form.locations) {
config.locations = form.locations
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
if (form.httpUser) config.httpUser = form.httpUser
if (form.httpPassword) config.httpPassword = form.httpPassword
if (form.hostHeaderRewrite)
config.hostHeaderRewrite = form.hostHeaderRewrite
if (form.routeByHTTPUser) config.routeByHTTPUser = form.routeByHTTPUser
// Header operations
if (form.requestHeaders.length > 0) {
config.requestHeaders = {
set: Object.fromEntries(
form.requestHeaders.map((h) => [h.key, h.value]),
),
}
}
if (form.responseHeaders.length > 0) {
config.responseHeaders = {
set: Object.fromEntries(
form.responseHeaders.map((h) => [h.key, h.value]),
),
}
}
}
if (form.type === 'tcpmux') {
// TCPMux specific
if (form.httpUser) config.httpUser = form.httpUser
if (form.httpPassword) config.httpPassword = form.httpPassword
if (form.routeByHTTPUser) config.routeByHTTPUser = form.routeByHTTPUser
if (form.multiplexer && form.multiplexer !== 'httpconnect') {
config.multiplexer = form.multiplexer
}
}
if (form.type === 'stcp' || form.type === 'sudp' || form.type === 'xtcp') {
// Secure proxy types
if (form.secretKey) config.secretKey = form.secretKey
if (form.allowUsers) {
config.allowUsers = form.allowUsers
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
}
if (form.type === 'xtcp') {
// XTCP NAT traversal
if (form.natTraversalDisableAssistedAddrs) {
config.natTraversal = {
disableAssistedAddrs: true,
}
}
}
return config
}
export function formToStoreVisitor(form: VisitorFormData): Record<string, any> {
const config: Record<string, any> = {
name: form.name,
type: form.type,
}
// Enabled
if (!form.enabled) {
config.enabled = false
}
// Transport
if (form.useEncryption || form.useCompression) {
config.transport = {}
if (form.useEncryption) config.transport.useEncryption = true
if (form.useCompression) config.transport.useCompression = true
}
// Base fields
if (form.secretKey) config.secretKey = form.secretKey
if (form.serverUser) config.serverUser = form.serverUser
if (form.serverName) config.serverName = form.serverName
if (form.bindAddr && form.bindAddr !== '127.0.0.1') {
config.bindAddr = form.bindAddr
}
if (form.bindPort != null) {
config.bindPort = form.bindPort
}
// XTCP specific
if (form.type === 'xtcp') {
if (form.protocol && form.protocol !== 'quic') {
config.protocol = form.protocol
}
if (form.keepTunnelOpen) {
config.keepTunnelOpen = true
}
if (form.maxRetriesAnHour != null) {
config.maxRetriesAnHour = form.maxRetriesAnHour
}
if (form.minRetryInterval != null) {
config.minRetryInterval = form.minRetryInterval
}
if (form.fallbackTo) {
config.fallbackTo = form.fallbackTo
}
if (form.fallbackTimeoutMs != null) {
config.fallbackTimeoutMs = form.fallbackTimeoutMs
}
if (form.natTraversalDisableAssistedAddrs) {
config.natTraversal = {
disableAssistedAddrs: true,
}
}
}
return config
}
// ========================================
// CONVERTERS: Store API -> Form
// ========================================
export function storeProxyToForm(config: StoreProxyConfig): ProxyFormData {
const c = config.config || {}
const form = createDefaultProxyForm()
form.name = config.name || ''
form.type = (config.type as ProxyType) || 'tcp'
form.enabled = c.enabled !== false
// Backend
form.localIP = c.localIP || '127.0.0.1'
form.localPort = c.localPort
if (c.plugin?.type) {
form.pluginType = c.plugin.type
form.pluginConfig = { ...c.plugin }
delete form.pluginConfig.type
}
// Transport
if (c.transport) {
form.useEncryption = c.transport.useEncryption || false
form.useCompression = c.transport.useCompression || false
form.bandwidthLimit = c.transport.bandwidthLimit || ''
form.bandwidthLimitMode = c.transport.bandwidthLimitMode || 'client'
form.proxyProtocolVersion = c.transport.proxyProtocolVersion || ''
}
// Load Balancer
if (c.loadBalancer) {
form.loadBalancerGroup = c.loadBalancer.group || ''
form.loadBalancerGroupKey = c.loadBalancer.groupKey || ''
}
// Health Check
if (c.healthCheck) {
form.healthCheckType = c.healthCheck.type || ''
form.healthCheckTimeoutSeconds = c.healthCheck.timeoutSeconds
form.healthCheckMaxFailed = c.healthCheck.maxFailed
form.healthCheckIntervalSeconds = c.healthCheck.intervalSeconds
form.healthCheckPath = c.healthCheck.path || ''
form.healthCheckHTTPHeaders = c.healthCheck.httpHeaders || []
}
// Metadata
if (c.metadatas) {
form.metadatas = Object.entries(c.metadatas).map(([key, value]) => ({
key,
value: String(value),
}))
}
// Annotations
if (c.annotations) {
form.annotations = Object.entries(c.annotations).map(([key, value]) => ({
key,
value: String(value),
}))
}
// Type-specific fields
form.remotePort = c.remotePort
// Domain config
if (Array.isArray(c.customDomains)) {
form.customDomains = c.customDomains.join(', ')
} else if (c.customDomains) {
form.customDomains = c.customDomains
}
form.subdomain = c.subdomain || ''
// HTTP specific
if (Array.isArray(c.locations)) {
form.locations = c.locations.join(', ')
} else if (c.locations) {
form.locations = c.locations
}
form.httpUser = c.httpUser || ''
form.httpPassword = c.httpPassword || ''
form.hostHeaderRewrite = c.hostHeaderRewrite || ''
form.routeByHTTPUser = c.routeByHTTPUser || ''
// Header operations
if (c.requestHeaders?.set) {
form.requestHeaders = Object.entries(c.requestHeaders.set).map(
([key, value]) => ({ key, value: String(value) }),
)
}
if (c.responseHeaders?.set) {
form.responseHeaders = Object.entries(c.responseHeaders.set).map(
([key, value]) => ({ key, value: String(value) }),
)
}
// TCPMux
form.multiplexer = c.multiplexer || 'httpconnect'
// Secure types
form.secretKey = c.secretKey || ''
if (Array.isArray(c.allowUsers)) {
form.allowUsers = c.allowUsers.join(', ')
} else if (c.allowUsers) {
form.allowUsers = c.allowUsers
}
// XTCP NAT traversal
form.natTraversalDisableAssistedAddrs =
c.natTraversal?.disableAssistedAddrs || false
return form
}
export function storeVisitorToForm(
config: StoreVisitorConfig,
): VisitorFormData {
const c = config.config || {}
const form = createDefaultVisitorForm()
form.name = config.name || ''
form.type = (config.type as VisitorType) || 'stcp'
form.enabled = c.enabled !== false
// Transport
if (c.transport) {
form.useEncryption = c.transport.useEncryption || false
form.useCompression = c.transport.useCompression || false
}
// Base fields
form.secretKey = c.secretKey || ''
form.serverUser = c.serverUser || ''
form.serverName = c.serverName || ''
form.bindAddr = c.bindAddr || '127.0.0.1'
form.bindPort = c.bindPort
// XTCP specific
form.protocol = c.protocol || 'quic'
form.keepTunnelOpen = c.keepTunnelOpen || false
form.maxRetriesAnHour = c.maxRetriesAnHour
form.minRetryInterval = c.minRetryInterval
form.fallbackTo = c.fallbackTo || ''
form.fallbackTimeoutMs = c.fallbackTimeoutMs
form.natTraversalDisableAssistedAddrs =
c.natTraversal?.disableAssistedAddrs || false
return form
}

View File

@@ -48,6 +48,28 @@
>
</div>
<div class="header-actions">
<el-select
v-model="filterSource"
placeholder="Source"
clearable
class="filter-select"
>
<el-option label="Config" value="config" />
<el-option label="Store" value="store" />
</el-select>
<el-select
v-model="filterType"
placeholder="Type"
clearable
class="filter-select"
>
<el-option
v-for="type in availableTypes"
:key="type"
:label="type.toUpperCase()"
:value="type"
/>
</el-select>
<el-input
v-model="searchText"
placeholder="Search..."
@@ -58,6 +80,18 @@
<el-tooltip content="Refresh" placement="top">
<el-button :icon="Refresh" circle @click="fetchData" />
</el-tooltip>
<el-tooltip
v-if="storeEnabled"
content="Add new proxy"
placement="top"
>
<el-button
type="primary"
:icon="Plus"
circle
@click="handleCreate"
/>
</el-tooltip>
</div>
</div>
</template>
@@ -68,10 +102,46 @@
v-for="proxy in filteredStatus"
:key="proxy.name"
:proxy="proxy"
@edit="handleEdit"
@delete="handleDelete"
/>
</div>
<div v-else-if="!loading" class="empty-state">
<el-empty description="No proxies found" />
<div class="empty-content">
<div class="empty-icon">
<svg
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="8"
y="16"
width="48"
height="32"
rx="4"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="20" cy="32" r="4" fill="currentColor" />
<circle cx="32" cy="32" r="4" fill="currentColor" />
<circle cx="44" cy="32" r="4" fill="currentColor" />
</svg>
</div>
<p class="empty-text">No proxies configured</p>
<p class="empty-hint">
Add proxies in your configuration file or use Store to create
dynamic proxies
</p>
<el-button
v-if="storeEnabled"
type="primary"
:icon="Plus"
@click="handleCreate"
>
Create First Proxy
</el-button>
</div>
</div>
</div>
</el-card>
@@ -90,7 +160,9 @@
v-for="(count, type) in proxyTypeCounts"
:key="type"
class="proxy-type-item"
:class="{ active: filterType === type }"
v-show="count > 0"
@click="toggleTypeFilter(String(type))"
>
<div class="proxy-type-name">
{{ String(type).toUpperCase() }}
@@ -125,6 +197,178 @@
</div>
</div>
</el-card>
<!-- Store Status Card -->
<el-card class="store-status-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">Store</span>
<el-tag
size="small"
:type="storeEnabled ? 'success' : 'info'"
effect="plain"
>
{{ storeEnabled ? 'Enabled' : 'Disabled' }}
</el-tag>
</div>
</template>
<div class="store-info">
<template v-if="storeEnabled">
<div class="store-stat">
<span class="store-stat-label">Store Proxies</span>
<span class="store-stat-value">{{ storeProxies.length }}</span>
</div>
<div class="store-stat">
<span class="store-stat-label">Store Visitors</span>
<span class="store-stat-value">{{ storeVisitors.length }}</span>
</div>
<p class="store-hint">
Proxies from Store are marked with a purple indicator
</p>
</template>
<template v-else>
<p class="store-disabled-text">
Enable Store in your configuration to dynamically manage proxies
</p>
</template>
</div>
</el-card>
</el-col>
</el-row>
<!-- Disabled Store Proxies Section -->
<el-row v-if="storeEnabled && disabledStoreProxies.length > 0" :gutter="20">
<el-col :span="24">
<el-card class="disabled-proxies-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="header-left">
<span class="card-title">Disabled Store Proxies</span>
<el-tag size="small" type="warning">
{{ disabledStoreProxies.length }} disabled
</el-tag>
</div>
</div>
</template>
<div class="disabled-proxy-list">
<div
v-for="proxy in disabledStoreProxies"
:key="proxy.name"
class="disabled-proxy-card"
>
<div class="disabled-proxy-info">
<span class="disabled-proxy-name">{{ proxy.name }}</span>
<el-tag size="small" type="info">{{
proxy.type.toUpperCase()
}}</el-tag>
<el-tag size="small" type="warning" effect="plain"
>Disabled</el-tag
>
</div>
<div class="disabled-proxy-actions">
<el-button size="small" @click="handleEditStoreProxy(proxy)">
Edit
</el-button>
<el-button
size="small"
type="danger"
@click="handleDeleteStoreProxy(proxy)"
>
Delete
</el-button>
</div>
</div>
</div>
<p class="disabled-proxy-hint">
Edit a proxy and enable it to make it active again.
</p>
</el-card>
</el-col>
</el-row>
<!-- Store Visitors Section -->
<el-row v-if="storeEnabled" :gutter="20">
<el-col :span="24">
<el-card class="visitors-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="header-left">
<span class="card-title">Store Visitors</span>
<el-tag size="small" type="info"
>{{ storeVisitors.length }} visitors</el-tag
>
</div>
<el-tooltip content="Add new visitor" placement="top">
<el-button
type="primary"
:icon="Plus"
circle
@click="handleCreateVisitor"
/>
</el-tooltip>
</div>
</template>
<div v-if="storeVisitors.length > 0" class="visitor-list">
<div
v-for="visitor in storeVisitors"
:key="visitor.name"
class="visitor-card"
>
<div class="visitor-card-header">
<div class="visitor-info">
<span class="visitor-name">{{ visitor.name }}</span>
<el-tag size="small" type="info">{{
visitor.type.toUpperCase()
}}</el-tag>
</div>
<div class="visitor-actions">
<el-button size="small" @click="handleEditVisitor(visitor)">
Edit
</el-button>
<el-button
size="small"
type="danger"
@click="handleDeleteVisitor(visitor.name)"
>
Delete
</el-button>
</div>
</div>
<div class="visitor-card-body">
<span v-if="visitor.config?.serverName">
Server: {{ visitor.config.serverName }}
</span>
<span
v-if="
visitor.config?.bindAddr || visitor.config?.bindPort != null
"
>
Bind: {{ visitor.config.bindAddr || '127.0.0.1'
}}<template v-if="visitor.config?.bindPort != null"
>:{{ visitor.config.bindPort }}</template
>
</span>
</div>
</div>
</div>
<div v-else class="empty-state">
<div class="empty-content">
<p class="empty-text">No visitors configured</p>
<p class="empty-hint">
Create your first visitor to connect to secure proxies.
</p>
<el-button
type="primary"
:icon="Plus"
@click="handleCreateVisitor"
>
Create First Visitor
</el-button>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
@@ -132,17 +376,37 @@
<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'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
import {
getStatus,
listStoreProxies,
deleteStoreProxy,
listStoreVisitors,
deleteStoreVisitor,
} from '../api/frpc'
import type {
ProxyStatus,
StoreProxyConfig,
StoreVisitorConfig,
} from '../types/proxy'
import StatCard from '../components/StatCard.vue'
import ProxyCard from '../components/ProxyCard.vue'
const router = useRouter()
// State
const status = ref<ProxyStatus[]>([])
const storeProxies = ref<StoreProxyConfig[]>([])
const storeVisitors = ref<StoreVisitorConfig[]>([])
const storeEnabled = ref(false)
const loading = ref(false)
const searchText = ref('')
const filterSource = ref('')
const filterType = ref('')
// Computed
const stats = computed(() => {
const total = status.value.length
const running = status.value.filter((p) => p.status === 'running').length
@@ -163,41 +427,181 @@ const hasActiveProxies = computed(() => {
return status.value.length > 0
})
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 availableTypes = computed(() => {
const types = new Set<string>()
status.value.forEach((p) => types.add(p.type))
return Array.from(types).sort()
})
const filteredStatus = computed(() => {
let result = status.value
if (filterSource.value) {
if (filterSource.value === 'store') {
result = result.filter((p) => p.source === 'store')
} else {
result = result.filter((p) => !p.source || p.source !== 'store')
}
}
if (filterType.value) {
result = result.filter((p) => p.type === filterType.value)
}
if (searchText.value) {
const search = searchText.value.toLowerCase()
result = result.filter(
(p) =>
p.name.toLowerCase().includes(search) ||
p.type.toLowerCase().includes(search) ||
p.local_addr.toLowerCase().includes(search) ||
p.remote_addr.toLowerCase().includes(search),
)
}
return result
})
const disabledStoreProxies = computed(() => {
return storeProxies.value.filter((p) => p.config?.enabled === false)
})
// Methods
const toggleTypeFilter = (type: string) => {
filterType.value = filterType.value === type ? '' : type
}
const fetchStatus = async () => {
try {
const json = await getStatus()
const list: ProxyStatus[] = []
for (const key in json) {
for (const ps of json[key]) {
list.push(ps)
}
}
status.value = list
} catch (err: any) {
ElMessage.error('Failed to get status: ' + err.message)
}
}
const fetchStoreProxies = async () => {
try {
const res = await listStoreProxies()
storeProxies.value = res.proxies || []
storeEnabled.value = true
} catch (err: any) {
if (err.status === 404) {
storeEnabled.value = false
storeProxies.value = []
} else {
console.error('Failed to fetch store proxies:', err)
}
}
}
const fetchStoreVisitors = async () => {
try {
const res = await listStoreVisitors()
storeVisitors.value = res.visitors || []
} catch (err: any) {
if (err.status === 404) {
storeVisitors.value = []
} else {
console.error('Failed to fetch store visitors:', err)
}
}
}
const fetchData = async () => {
loading.value = true
try {
const json = await getStatus()
status.value = []
for (const key in json) {
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',
})
await Promise.all([
fetchStoreProxies(),
fetchStoreVisitors(),
fetchStatus(),
])
} finally {
loading.value = false
}
}
const handleCreate = () => {
router.push('/proxies/create')
}
const handleEdit = (proxy: ProxyStatus) => {
if (proxy.source !== 'store') return
router.push('/proxies/' + encodeURIComponent(proxy.name) + '/edit')
}
const confirmAndDeleteProxy = async (name: string) => {
try {
await ElMessageBox.confirm(
`Are you sure you want to delete "${name}"? This action cannot be undone.`,
'Delete Proxy',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
type: 'warning',
confirmButtonClass: 'el-button--danger',
},
)
await deleteStoreProxy(name)
ElMessage.success('Proxy deleted')
fetchData()
} catch (err: any) {
if (err !== 'cancel' && err !== 'close') {
ElMessage.error('Delete failed: ' + (err.message || 'Unknown error'))
}
}
}
const handleDelete = async (proxy: ProxyStatus) => {
if (proxy.source !== 'store') return
confirmAndDeleteProxy(proxy.name)
}
const handleEditStoreProxy = (proxy: StoreProxyConfig) => {
router.push('/proxies/' + encodeURIComponent(proxy.name) + '/edit')
}
const handleDeleteStoreProxy = async (proxy: StoreProxyConfig) => {
confirmAndDeleteProxy(proxy.name)
}
const handleCreateVisitor = () => {
router.push('/visitors/create')
}
const handleEditVisitor = (visitor: StoreVisitorConfig) => {
router.push('/visitors/' + encodeURIComponent(visitor.name) + '/edit')
}
const handleDeleteVisitor = async (name: string) => {
try {
await ElMessageBox.confirm(
`Are you sure you want to delete visitor "${name}"? This action cannot be undone.`,
'Delete Visitor',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
type: 'warning',
confirmButtonClass: 'el-button--danger',
},
)
await deleteStoreVisitor(name)
ElMessage.success('Visitor deleted')
fetchData()
} catch (err: any) {
if (err !== 'cancel' && err !== 'close') {
ElMessage.error('Delete failed: ' + (err.message || 'Unknown error'))
}
}
}
// Initial load
fetchData()
</script>
@@ -222,19 +626,22 @@ fetchData()
.proxy-list-card,
.types-card,
.status-summary-card {
.status-summary-card,
.store-status-card {
border-radius: 12px;
border: 1px solid #e4e7ed;
}
html.dark .proxy-list-card,
html.dark .types-card,
html.dark .status-summary-card {
html.dark .status-summary-card,
html.dark .store-status-card {
border-color: #3a3d5c;
background: #27293d;
}
.status-summary-card {
.status-summary-card,
.store-status-card {
margin-top: 20px;
}
@@ -255,7 +662,7 @@ html.dark .status-summary-card {
.header-actions {
display: flex;
align-items: center;
gap: 12px;
gap: 8px;
}
.card-title {
@@ -268,8 +675,12 @@ html.dark .card-title {
color: #e5e7eb;
}
.filter-select {
width: 100px;
}
.search-input {
width: 200px;
width: 180px;
}
.proxy-list-content {
@@ -282,8 +693,45 @@ html.dark .card-title {
gap: 12px;
}
/* Empty State */
.empty-state {
padding: 40px 0;
padding: 48px 24px;
}
.empty-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.empty-icon {
width: 80px;
height: 80px;
margin-bottom: 20px;
color: #c0c4cc;
}
html.dark .empty-icon {
color: #4b5563;
}
.empty-text {
font-size: 16px;
font-weight: 500;
color: #606266;
margin: 0 0 8px;
}
html.dark .empty-text {
color: #9ca3af;
}
.empty-hint {
font-size: 14px;
color: #909399;
margin: 0 0 20px;
max-width: 320px;
}
/* Proxy Types Grid */
@@ -303,6 +751,7 @@ html.dark .card-title {
background: #f8f9fa;
border-radius: 8px;
transition: all 0.2s;
cursor: pointer;
}
.proxy-type-item:hover {
@@ -310,6 +759,19 @@ html.dark .card-title {
transform: translateY(-2px);
}
.proxy-type-item.active {
background: var(--el-color-primary-light-8);
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
}
.proxy-type-item.active .proxy-type-name {
color: var(--el-color-primary);
}
.proxy-type-item.active .proxy-type-count {
color: var(--el-color-primary);
}
html.dark .proxy-type-item {
background: #1e1e2d;
}
@@ -318,6 +780,11 @@ html.dark .proxy-type-item:hover {
background: #2a2a3c;
}
html.dark .proxy-type-item.active {
background: var(--el-color-primary-dark-2);
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.proxy-type-name {
font-size: 11px;
color: #909399;
@@ -410,6 +877,213 @@ html.dark .status-item:hover {
color: var(--el-text-color-primary);
}
/* Store Status Card */
.store-info {
min-height: 60px;
}
.store-stat {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: linear-gradient(
135deg,
rgba(102, 126, 234, 0.08) 0%,
rgba(118, 75, 162, 0.08) 100%
);
border-radius: 8px;
margin-bottom: 12px;
}
html.dark .store-stat {
background: linear-gradient(
135deg,
rgba(129, 140, 248, 0.12) 0%,
rgba(167, 139, 250, 0.12) 100%
);
}
.store-stat-label {
font-size: 14px;
color: #606266;
font-weight: 500;
}
html.dark .store-stat-label {
color: #9ca3af;
}
.store-stat-value {
font-size: 24px;
font-weight: 600;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
html.dark .store-stat-value {
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.store-hint {
font-size: 12px;
color: #909399;
margin: 0;
line-height: 1.5;
}
.store-disabled-text {
font-size: 13px;
color: #909399;
margin: 0;
line-height: 1.6;
}
/* Disabled Proxies Card */
.disabled-proxies-card {
border-radius: 12px;
border: 1px solid #e4e7ed;
margin-top: 20px;
}
html.dark .disabled-proxies-card {
border-color: #3a3d5c;
background: #27293d;
}
.disabled-proxy-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.disabled-proxy-card {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 8px;
background: #faf7f0;
border: 1px solid #f1d9a6;
}
html.dark .disabled-proxy-card {
background: rgba(161, 98, 7, 0.14);
border-color: rgba(245, 158, 11, 0.45);
}
.disabled-proxy-info {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.disabled-proxy-name {
font-size: 15px;
font-weight: 600;
color: #303133;
}
html.dark .disabled-proxy-name {
color: #e5e7eb;
}
.disabled-proxy-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.disabled-proxy-hint {
margin: 12px 2px 0;
font-size: 13px;
color: #909399;
}
/* Visitors Card */
.visitors-card {
border-radius: 12px;
border: 1px solid #e4e7ed;
margin-top: 20px;
}
html.dark .visitors-card {
border-color: #3a3d5c;
background: #27293d;
}
.visitor-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.visitor-card {
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
transition: all 0.2s;
}
.visitor-card:hover {
background: #f0f2f5;
}
html.dark .visitor-card {
background: #1e1e2d;
}
html.dark .visitor-card:hover {
background: #2a2a3c;
}
.visitor-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.visitor-info {
display: flex;
align-items: center;
gap: 12px;
}
.visitor-name {
font-size: 15px;
font-weight: 600;
color: #303133;
}
html.dark .visitor-name {
color: #e5e7eb;
}
.visitor-actions {
display: flex;
gap: 8px;
}
.visitor-card-body {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: #606266;
}
html.dark .visitor-card-body {
color: #9ca3af;
}
@media (max-width: 768px) {
.card-header {
flex-direction: column;
@@ -432,10 +1106,21 @@ html.dark .status-item:hover {
.proxy-types-grid {
grid-template-columns: repeat(3, 1fr);
}
.disabled-proxy-card {
flex-direction: column;
align-items: flex-start;
}
.disabled-proxy-actions {
width: 100%;
justify-content: flex-end;
}
}
@media (max-width: 992px) {
.status-summary-card {
.status-summary-card,
.store-status-card {
margin-top: 0;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,606 @@
<template>
<div class="visitor-edit-page">
<!-- Breadcrumb -->
<nav class="breadcrumb">
<a class="breadcrumb-link" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
</a>
<router-link to="/" class="breadcrumb-item">Overview</router-link>
<span class="breadcrumb-separator">/</span>
<span class="breadcrumb-current">{{
isEditing ? 'Edit Visitor' : 'Create Visitor'
}}</span>
</nav>
<div v-loading="pageLoading" class="edit-content">
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-position="top"
@submit.prevent
>
<!-- Header Card -->
<div class="form-card header-card">
<div class="card-body">
<div class="field-row three-col">
<el-form-item label="Name" prop="name" class="field-grow">
<el-input
v-model="form.name"
:disabled="isEditing"
placeholder="my-visitor"
/>
</el-form-item>
<el-form-item label="Type" prop="type">
<el-select
v-model="form.type"
:disabled="isEditing"
:fit-input-width="false"
popper-class="visitor-type-dropdown"
class="type-select"
>
<el-option value="stcp" label="STCP">
<div class="type-option">
<span class="type-tag-inline type-stcp">STCP</span>
<span class="type-desc">Secure TCP Visitor</span>
</div>
</el-option>
<el-option value="sudp" label="SUDP">
<div class="type-option">
<span class="type-tag-inline type-sudp">SUDP</span>
<span class="type-desc">Secure UDP Visitor</span>
</div>
</el-option>
<el-option value="xtcp" label="XTCP">
<div class="type-option">
<span class="type-tag-inline type-xtcp">XTCP</span>
<span class="type-desc">P2P (NAT traversal)</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="Enabled">
<el-switch v-model="form.enabled" />
</el-form-item>
</div>
</div>
</div>
<!-- Connection -->
<div class="form-card">
<div class="card-header">
<h3 class="card-title">Connection</h3>
</div>
<div class="card-body">
<div class="field-row two-col">
<el-form-item label="Server Name" prop="serverName">
<el-input
v-model="form.serverName"
placeholder="Name of the proxy to visit"
/>
</el-form-item>
<el-form-item label="Server User">
<el-input
v-model="form.serverUser"
placeholder="Leave empty for same user"
/>
</el-form-item>
</div>
<el-form-item label="Secret Key">
<el-input
v-model="form.secretKey"
type="password"
show-password
placeholder="Shared secret"
/>
</el-form-item>
<div class="field-row two-col">
<el-form-item label="Bind Address">
<el-input v-model="form.bindAddr" placeholder="127.0.0.1" />
</el-form-item>
<el-form-item label="Bind Port" prop="bindPort">
<el-input-number
v-model="form.bindPort"
:min="bindPortMin"
:max="65535"
controls-position="right"
class="full-width"
/>
</el-form-item>
</div>
</div>
</div>
<!-- Transport Options (collapsible) -->
<div class="form-card collapsible-card">
<div
class="card-header clickable"
@click="transportExpanded = !transportExpanded"
>
<h3 class="card-title">Transport Options</h3>
<el-icon
class="collapse-icon"
:class="{ expanded: transportExpanded }"
><ArrowDown
/></el-icon>
</div>
<el-collapse-transition>
<div v-show="transportExpanded" class="card-body">
<div class="field-row two-col">
<el-form-item label="Use Encryption">
<el-switch v-model="form.useEncryption" />
</el-form-item>
<el-form-item label="Use Compression">
<el-switch v-model="form.useCompression" />
</el-form-item>
</div>
</div>
</el-collapse-transition>
</div>
<!-- XTCP Options (collapsible, xtcp only) -->
<template v-if="form.type === 'xtcp'">
<div class="form-card collapsible-card">
<div
class="card-header clickable"
@click="xtcpExpanded = !xtcpExpanded"
>
<h3 class="card-title">XTCP Options</h3>
<el-icon class="collapse-icon" :class="{ expanded: xtcpExpanded }"
><ArrowDown
/></el-icon>
</div>
<el-collapse-transition>
<div v-show="xtcpExpanded" class="card-body">
<el-form-item label="Protocol">
<el-select v-model="form.protocol" class="full-width">
<el-option value="quic" label="QUIC" />
<el-option value="kcp" label="KCP" />
</el-select>
</el-form-item>
<el-form-item label="Keep Tunnel Open">
<el-switch v-model="form.keepTunnelOpen" />
</el-form-item>
<div class="field-row two-col">
<el-form-item label="Max Retries per Hour">
<el-input-number
v-model="form.maxRetriesAnHour"
:min="0"
controls-position="right"
class="full-width"
/>
</el-form-item>
<el-form-item label="Min Retry Interval (s)">
<el-input-number
v-model="form.minRetryInterval"
:min="0"
controls-position="right"
class="full-width"
/>
</el-form-item>
</div>
<div class="field-row two-col">
<el-form-item label="Fallback To">
<el-input
v-model="form.fallbackTo"
placeholder="Fallback visitor name"
/>
</el-form-item>
<el-form-item label="Fallback Timeout (ms)">
<el-input-number
v-model="form.fallbackTimeoutMs"
:min="0"
controls-position="right"
class="full-width"
/>
</el-form-item>
</div>
</div>
</el-collapse-transition>
</div>
<!-- NAT Traversal (collapsible, xtcp only) -->
<div class="form-card collapsible-card">
<div
class="card-header clickable"
@click="natExpanded = !natExpanded"
>
<h3 class="card-title">NAT Traversal</h3>
<el-icon class="collapse-icon" :class="{ expanded: natExpanded }"
><ArrowDown
/></el-icon>
</div>
<el-collapse-transition>
<div v-show="natExpanded" class="card-body">
<el-form-item label="Disable Assisted Addresses">
<el-switch v-model="form.natTraversalDisableAssistedAddrs" />
<div class="form-tip">
Only use STUN-discovered public addresses
</div>
</el-form-item>
</div>
</el-collapse-transition>
</div>
</template>
</el-form>
</div>
<!-- Sticky Footer -->
<div class="sticky-footer">
<div class="footer-content">
<el-button @click="goBack">Cancel</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ isEditing ? 'Update' : 'Create' }}
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import {
type VisitorFormData,
createDefaultVisitorForm,
formToStoreVisitor,
storeVisitorToForm,
} from '../types/proxy'
import {
getStoreVisitor,
createStoreVisitor,
updateStoreVisitor,
} from '../api/frpc'
const route = useRoute()
const router = useRouter()
const isEditing = computed(() => !!route.params.name)
const pageLoading = ref(false)
const saving = ref(false)
const formRef = ref<FormInstance>()
const form = ref<VisitorFormData>(createDefaultVisitorForm())
const transportExpanded = ref(false)
const xtcpExpanded = ref(false)
const natExpanded = ref(false)
const bindPortMin = computed(() => (form.value.type === 'sudp' ? 1 : undefined))
const formRules: FormRules = {
name: [
{ required: true, message: 'Name is required', trigger: 'blur' },
{ min: 1, max: 50, message: 'Length should be 1 to 50', trigger: 'blur' },
],
type: [{ required: true, message: 'Type is required', trigger: 'change' }],
serverName: [
{ required: true, message: 'Server name is required', trigger: 'blur' },
],
bindPort: [
{ required: true, message: 'Bind port is required', trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (value == null) {
callback(new Error('Bind port is required'))
return
}
if (value > 65535) {
callback(new Error('Bind port must be less than or equal to 65535'))
return
}
if (form.value.type === 'sudp') {
if (value < 1) {
callback(new Error('SUDP bind port must be greater than 0'))
return
}
callback()
return
}
if (value === 0) {
callback(new Error('Bind port cannot be 0'))
return
}
callback()
},
trigger: 'blur',
},
],
}
const goBack = () => {
router.push('/')
}
const loadVisitor = async () => {
const name = route.params.name as string
if (!name) return
pageLoading.value = true
try {
const res = await getStoreVisitor(name)
form.value = storeVisitorToForm(res)
} catch (err: any) {
ElMessage.error('Failed to load visitor: ' + err.message)
router.push('/')
} finally {
pageLoading.value = false
}
}
const handleSave = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
ElMessage.warning('Please fix the form errors')
return
}
saving.value = true
try {
const data = formToStoreVisitor(form.value)
if (isEditing.value) {
await updateStoreVisitor(form.value.name, data)
ElMessage.success('Visitor updated')
} else {
await createStoreVisitor(data)
ElMessage.success('Visitor created')
}
router.push('/')
} catch (err: any) {
ElMessage.error('Operation failed: ' + (err.message || 'Unknown error'))
} finally {
saving.value = false
}
}
onMounted(() => {
if (isEditing.value) {
loadVisitor()
}
})
watch(
() => route.params.name,
(name, oldName) => {
if (name === oldName) return
if (name) {
loadVisitor()
return
}
form.value = createDefaultVisitorForm()
},
)
</script>
<style scoped>
.visitor-edit-page {
padding-bottom: 80px;
}
/* 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;
}
/* Form Cards */
.form-card {
background: var(--el-bg-color);
border: 1px solid var(--header-border);
border-radius: 12px;
margin-bottom: 16px;
overflow: hidden;
}
html.dark .form-card {
border-color: #3a3d5c;
background: #27293d;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid var(--header-border);
}
html.dark .card-header {
border-bottom-color: #3a3d5c;
}
.card-header.clickable {
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.card-header.clickable:hover {
background: var(--hover-bg);
}
.collapsible-card .card-header {
border-bottom: none;
}
.collapsible-card .card-body {
border-top: 1px solid var(--header-border);
}
html.dark .collapsible-card .card-body {
border-top-color: #3a3d5c;
}
.card-title {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
margin: 0;
}
.collapse-icon {
transition: transform 0.3s;
color: var(--text-secondary);
}
.collapse-icon.expanded {
transform: rotate(-180deg);
}
.card-body {
padding: 20px 24px;
}
/* Field Rows */
.field-row {
display: grid;
gap: 16px;
}
.field-row.two-col {
grid-template-columns: 1fr 1fr;
}
.field-row.three-col {
grid-template-columns: 1fr auto auto;
align-items: start;
}
.field-grow {
min-width: 0;
}
.full-width {
width: 100%;
}
.type-select {
width: 180px;
}
.type-option {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 0;
}
.type-tag-inline {
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
letter-spacing: 0.5px;
}
.type-tag-inline.type-stcp,
.type-tag-inline.type-sudp,
.type-tag-inline.type-xtcp {
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
}
.type-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
/* Sticky Footer */
.sticky-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 99;
background: var(--header-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid var(--header-border);
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 16px 40px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* Responsive */
@media (max-width: 768px) {
.field-row.two-col,
.field-row.three-col {
grid-template-columns: 1fr;
}
.type-select {
width: 100%;
}
.card-body {
padding: 16px;
}
.footer-content {
padding: 12px 20px;
}
}
</style>
<style>
.visitor-type-dropdown {
min-width: 300px !important;
}
.visitor-type-dropdown .el-select-dropdown__item {
height: auto;
padding: 8px 16px;
line-height: 1.4;
}
</style>

View File

@@ -1,30 +0,0 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier',
],
parserOptions: {
ecmaVersion: 'latest',
},
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'vue/multi-word-component-names': [
'error',
{
ignores: ['Traffic', 'Proxies', 'Clients'],
},
],
},
}

36
web/frps/eslint.config.js Normal file
View File

@@ -0,0 +1,36 @@
import pluginVue from 'eslint-plugin-vue'
import vueTsEslintConfig from '@vue/eslint-config-typescript'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default [
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
{
name: 'app/files-to-ignore',
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
},
...pluginVue.configs['flat/essential'],
...vueTsEslintConfig(),
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'vue/multi-word-component-names': [
'error',
{
ignores: ['Traffic', 'Proxies', 'Clients'],
},
],
},
},
skipFormatting,
]

File diff suppressed because it is too large Load Diff

View File

@@ -2,13 +2,14 @@
"name": "frps-dashboard",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check build-only",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
"lint": "eslint --fix"
},
"dependencies": {
"element-plus": "^2.13.0",
@@ -16,14 +17,13 @@
"vue-router": "^4.6.4"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.15.0",
"@types/node": "24",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/tsconfig": "^0.8.1",
"@vueuse/core": "^14.1.0",
"eslint": "^8.56.0",
"eslint": "^9.39.0",
"eslint-plugin-vue": "^9.33.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.7.4",

View File

@@ -13,6 +13,9 @@
<span v-if="client.hostname" class="hostname-badge">{{
client.hostname
}}</span>
<el-tag v-if="client.version" size="small" type="success"
>v{{ client.version }}</el-tag
>
</div>
<div class="card-meta">

View File

@@ -86,7 +86,7 @@ const processData = (trafficIn: number[], trafficOut: number[]) => {
// Calculate dates (last 7 days ending today)
const dates: string[] = []
let d = new Date()
const d = new Date()
d.setDate(d.getDate() - 6)
for (let i = 0; i < 7; i++) {

View File

@@ -3,6 +3,7 @@ export interface ClientInfoData {
user: string
clientID: string
runID: string
version?: string
hostname: string
clientIP?: string
metas?: Record<string, string>

View File

@@ -3,7 +3,6 @@ export interface ProxyStatsInfo {
conf: any
user: string
clientID: string
clientVersion: string
todayTrafficIn: number
todayTrafficOut: number
curConns: number

View File

@@ -6,6 +6,7 @@ export class Client {
user: string
clientID: string
runID: string
version: string
hostname: string
ip: string
metas: Map<string, string>
@@ -19,6 +20,7 @@ export class Client {
this.user = data.user
this.clientID = data.clientID
this.runID = data.runID
this.version = data.version || ''
this.hostname = data.hostname
this.ip = data.clientIP || ''
this.metas = new Map<string, string>()

View File

@@ -12,7 +12,6 @@ class BaseProxy {
status: string
user: string
clientID: string
clientVersion: string
addr: string
port: number
@@ -49,7 +48,6 @@ class BaseProxy {
this.status = proxyStats.status
this.user = proxyStats.user || ''
this.clientID = proxyStats.clientID || ''
this.clientVersion = proxyStats.clientVersion
this.addr = ''
this.port = 0

View File

@@ -22,7 +22,12 @@
{{ client.displayName.charAt(0).toUpperCase() }}
</div>
<div class="client-info">
<h1 class="client-name">{{ client.displayName }}</h1>
<div class="client-name-row">
<h1 class="client-name">{{ client.displayName }}</h1>
<el-tag v-if="client.version" size="small" type="success"
>v{{ client.version }}</el-tag
>
</div>
<div class="client-meta">
<span v-if="client.ip" class="meta-item">{{
client.ip
@@ -354,11 +359,18 @@ onMounted(() => {
min-width: 0;
}
.client-name-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.client-name {
font-size: 20px;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 4px 0;
margin: 0;
line-height: 1.3;
}

View File

@@ -230,7 +230,7 @@ const fetchData = async () => {
data.value.proxyCounts += count || 0
})
}
} catch (err) {
} catch {
ElMessage({
showClose: true,
message: 'Get server info from frps failed!',