chore: 初始提交 Fonu 1.0.2
- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听) - 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP) - 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点) - 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 385 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 321 KiB |
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<title>Fonu</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "fonu-web",
|
||||
"private": true,
|
||||
"version": "1.0.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.4",
|
||||
"@codemirror/state": "^6.7.5",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.43.12",
|
||||
"@vicons/ionicons5": "^0.13.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"echarts": "^6.1.0",
|
||||
"naive-ui": "^2.40.1",
|
||||
"sortablejs": "^1.15.7",
|
||||
"vue": "^3.5.13",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
"vue-echarts": "^8.3.0",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sortablejs": "^1.15.9",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
|
After Width: | Height: | Size: 605 KiB |
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<n-config-provider :theme="naiveTheme" :theme-overrides="themeOverrides">
|
||||
<n-message-provider>
|
||||
<n-dialog-provider>
|
||||
<router-view />
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NConfigProvider, NDialogProvider, NMessageProvider } from 'naive-ui'
|
||||
import { useTheme } from './composables/useTheme'
|
||||
import './styles/variables.css'
|
||||
|
||||
const { naiveTheme, themeOverrides } = useTheme()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--fonu-font);
|
||||
background: var(--fonu-bg);
|
||||
color: var(--fonu-text);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--fonu-mono);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--fonu-text-muted);
|
||||
}
|
||||
|
||||
.page-shell,
|
||||
.page-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-narrow {
|
||||
width: 100%;
|
||||
max-width: var(--fonu-form-max);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.form-narrow {
|
||||
max-width: var(--fonu-form-max);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fonu-space-3);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--fonu-space-4);
|
||||
padding-top: var(--fonu-space-4);
|
||||
}
|
||||
|
||||
.n-data-table .n-data-table-th {
|
||||
background: var(--fonu-bg-muted) !important;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,372 @@
|
||||
import type {
|
||||
AccessLogEntry,
|
||||
ApiError,
|
||||
AppVersion,
|
||||
AuthStatus,
|
||||
CertificateCAOption,
|
||||
CertificateRecord,
|
||||
DashboardStatus,
|
||||
DDNSConfig,
|
||||
DDNSSavePayload,
|
||||
DDNSTestPayload,
|
||||
FRPResponse,
|
||||
FRPSavePayload,
|
||||
FRPSaveResponse,
|
||||
FRPStatus,
|
||||
FrpBinary,
|
||||
FrpDownloadTask,
|
||||
FrpProxy,
|
||||
FrpProxyPayload,
|
||||
FrpProxiesTraffic,
|
||||
FrpRelease,
|
||||
FrpRuntimeInfo,
|
||||
FrpServer,
|
||||
FrpServerDiagnostics,
|
||||
FrpServerDetail,
|
||||
FrpServerPayload,
|
||||
FrpStatus,
|
||||
DiscoveredService,
|
||||
ProxyClientConn,
|
||||
ChinaCIDRStatus,
|
||||
ProxyRule,
|
||||
ProxySavePayload,
|
||||
ProxyTraffic,
|
||||
GlobalNginxView,
|
||||
RuleNginxView,
|
||||
SettingsMap,
|
||||
SystemLogEntry,
|
||||
NotifyTestPayload,
|
||||
} from './types'
|
||||
|
||||
export function asList<T>(value: T[] | null | undefined): T[] {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const CERT_REQUEST_TIMEOUT_MS = 10 * 60_000
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
return await requestWithSignal<T>(path, init, controller.signal)
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new Error(
|
||||
timeoutMs > REQUEST_TIMEOUT_MS
|
||||
? '证书操作超时,请稍后在证书列表查看状态,或在「日志 → 实时日志」查看 Nginx 输出'
|
||||
: '请求超时,请检查 Fonu 服务或 Nginx 状态',
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
async function requestWithSignal<T>(path: string, init?: RequestInit, signal?: AbortSignal): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
credentials: 'include',
|
||||
signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
...init,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let message = '请求失败'
|
||||
try {
|
||||
const body = (await response.json()) as ApiError
|
||||
if (body.error) {
|
||||
message = body.error
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getVersion: () => request<AppVersion>('/api/version'),
|
||||
authStatus: () => request<AuthStatus>('/api/auth/status'),
|
||||
login: (username: string, password: string) =>
|
||||
request<{ message: string }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
logout: () => request<{ message: string }>('/api/auth/logout', { method: 'POST' }),
|
||||
changePassword: (old_password: string, new_password: string) =>
|
||||
request<{ message: string }>('/api/auth/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ old_password, new_password }),
|
||||
}),
|
||||
|
||||
getStatus: () => request<DashboardStatus>('/api/status'),
|
||||
listProxies: () => request<ProxyRule[]>('/api/proxies'),
|
||||
createProxy: (payload: ProxySavePayload) =>
|
||||
request<ProxyRule>('/api/proxies', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
updateProxy: (id: number, payload: Partial<ProxySavePayload>) =>
|
||||
request<ProxyRule>(`/api/proxies/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||
deleteProxy: (id: number) => request<void>(`/api/proxies/${id}`, { method: 'DELETE' }),
|
||||
reorderProxies: (ids: number[]) =>
|
||||
request<void>('/api/proxies/reorder', { method: 'PUT', body: JSON.stringify({ ids }) }),
|
||||
getProxyTraffic: () => request<ProxyTraffic[]>('/api/proxies/traffic'),
|
||||
getProxyClients: (id: number) => request<ProxyClientConn[]>(`/api/proxies/${id}/clients`),
|
||||
getProxyNginx: (id: number) => request<RuleNginxView>(`/api/proxies/${id}/nginx`),
|
||||
saveProxyNginx: (id: number, payload: { mode: 'auto' | 'custom'; content?: string }) =>
|
||||
request<RuleNginxView>(`/api/proxies/${id}/nginx`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||
rollbackProxyNginx: (id: number, backup?: string) =>
|
||||
request<RuleNginxView>(`/api/proxies/${id}/nginx/rollback`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(backup ? { backup } : {}),
|
||||
}),
|
||||
|
||||
listDDNS: () => request<DDNSConfig[]>('/api/ddns'),
|
||||
listDDNSLite: () => request<DDNSConfig[]>('/api/ddns?lite=1'),
|
||||
createDDNS: (payload: DDNSSavePayload) =>
|
||||
request<DDNSConfig>('/api/ddns', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
updateDDNS: (id: number, payload: DDNSSavePayload) =>
|
||||
request<DDNSConfig>(`/api/ddns/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||
deleteDDNS: (id: number) => request<void>(`/api/ddns/${id}`, { method: 'DELETE' }),
|
||||
testDDNS: (payload: DDNSTestPayload) =>
|
||||
request<{ message: string }>('/api/ddns/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateAllDDNS: () => request<DDNSConfig[]>('/api/ddns/update', { method: 'POST' }),
|
||||
updateDDNSOne: (id: number) => request<DDNSConfig>(`/api/ddns/${id}/update`, { method: 'POST' }),
|
||||
|
||||
listCertificates: () => request<CertificateRecord[]>('/api/certificates'),
|
||||
listCertificateCAOptions: () => request<CertificateCAOption[]>('/api/certificates/ca-options'),
|
||||
applyCertificate: (payload: {
|
||||
dns_zone?: string
|
||||
ddns_config_id?: number
|
||||
domains: string[]
|
||||
ca?: string
|
||||
email?: string
|
||||
}) =>
|
||||
request<{ job_id: string }>('/api/certificates/apply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
dns_zone: payload.dns_zone ?? '',
|
||||
ddns_config_id: payload.ddns_config_id ?? 0,
|
||||
domains: payload.domains,
|
||||
ca: payload.ca ?? '',
|
||||
email: payload.email ?? '',
|
||||
}),
|
||||
}),
|
||||
certificateApplyStreamURL: (jobId: string) => `/api/certificates/jobs/${encodeURIComponent(jobId)}/stream`,
|
||||
importCertificate: (payload: {
|
||||
certificate?: string
|
||||
private_key?: string
|
||||
cert_path?: string
|
||||
key_path?: string
|
||||
}) =>
|
||||
request<CertificateRecord>('/api/certificates/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
async downloadCertificate(domain: string, part: 'zip' | 'cert' | 'key' = 'zip') {
|
||||
const response = await fetch(
|
||||
`/api/certificates/${encodeURIComponent(domain)}/download?part=${part}`,
|
||||
{ credentials: 'include' },
|
||||
)
|
||||
if (!response.ok) {
|
||||
let message = '下载证书失败'
|
||||
try {
|
||||
const body = (await response.json()) as ApiError
|
||||
if (body.error) message = body.error
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const disposition = response.headers.get('Content-Disposition') ?? ''
|
||||
const match = disposition.match(/filename="?([^";\n]+)"?/)
|
||||
const filename = match?.[1] ?? `${domain}.zip`
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
deleteCertificate: (domain: string) =>
|
||||
request<{ message: string }>(`/api/certificates/${encodeURIComponent(domain)}`, { method: 'DELETE' }),
|
||||
renewCertificate: (domain?: string, ca?: string) =>
|
||||
request<CertificateRecord>(
|
||||
'/api/certificates/renew',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ domain: domain ?? '', ca: ca ?? '' }),
|
||||
},
|
||||
CERT_REQUEST_TIMEOUT_MS,
|
||||
),
|
||||
|
||||
getSettings: () => request<SettingsMap>('/api/settings'),
|
||||
saveSettings: (payload: SettingsMap) =>
|
||||
request<SettingsMap>('/api/settings', { method: 'PUT', body: JSON.stringify(payload) }),
|
||||
getGlobalNginx: () => request<GlobalNginxView>('/api/settings/nginx/global'),
|
||||
saveGlobalNginx: (payload: { mode: 'auto' | 'custom'; content?: string }) =>
|
||||
request<GlobalNginxView>('/api/settings/nginx/global', { method: 'PUT', body: JSON.stringify(payload) }),
|
||||
rollbackGlobalNginx: (backup?: string) =>
|
||||
request<GlobalNginxView>('/api/settings/nginx/global/rollback', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(backup ? { backup } : {}),
|
||||
}),
|
||||
getChinaCIDRStatus: () => request<ChinaCIDRStatus>('/api/settings/china-cidr'),
|
||||
refreshChinaCIDR: () =>
|
||||
request<ChinaCIDRStatus>('/api/settings/china-cidr/refresh', { method: 'POST' }),
|
||||
testNotify: (payload: NotifyTestPayload) =>
|
||||
request<{ message: string }>('/api/settings/notify/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
getFRP: () => request<FRPResponse>('/api/frp'),
|
||||
saveFRP: (payload: FRPSavePayload) =>
|
||||
request<FRPSaveResponse>('/api/frp', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
getFRPStatus: () => request<FRPStatus>('/api/frp/status'),
|
||||
syncFRPDomains: () =>
|
||||
request<FRPSaveResponse & { domains: string[] }>('/api/frp/sync-domains', {
|
||||
method: 'POST',
|
||||
}),
|
||||
getFRPLogs: (limit = 200) => request<string[]>(`/api/frp/logs?limit=${limit}`),
|
||||
|
||||
// ===== FRP 多服务端模块(/api/frpmulti)=====
|
||||
getFrpStatus: () => request<FrpStatus>('/api/frpmulti/status'),
|
||||
getFrpRuntime: () => request<FrpRuntimeInfo>('/api/frpmulti/runtime'),
|
||||
setFrpDownloadProxy: (proxy: string) => request<FrpRuntimeInfo>('/api/frpmulti/runtime/download-proxy', { method: 'PUT', body: JSON.stringify({ proxy }) }),
|
||||
listFrpReleases: () => request<FrpRelease[]>('/api/frpmulti/releases'),
|
||||
listFrpBinaries: () => request<FrpBinary[]>('/api/frpmulti/binaries'),
|
||||
getFrpDownloadStatus: () => request<FrpDownloadTask>('/api/frpmulti/download'),
|
||||
downloadFrpRelease: (version: string) => request<FrpDownloadTask>(`/api/frpmulti/releases/${encodeURIComponent(version)}/download`, { method: 'POST' }),
|
||||
activateFrpBinary: (version: string) => request<FrpStatus>(`/api/frpmulti/binaries/${encodeURIComponent(version)}/activate`, { method: 'POST' }),
|
||||
deleteFrpBinary: (version: string) => request<void>(`/api/frpmulti/binaries/${encodeURIComponent(version)}`, { method: 'DELETE' }),
|
||||
listFrpServers: () => request<FrpServer[]>('/api/frpmulti/servers'),
|
||||
getFrpServerDiagnostics: (id: number) => request<FrpServerDiagnostics>(`/api/frpmulti/servers/${id}/diagnostics`),
|
||||
getFrpServerDetail: (id: number) => request<FrpServerDetail>(`/api/frpmulti/servers/${id}/detail`),
|
||||
startFrpServer: (id: number) => request<FrpServer>(`/api/frpmulti/servers/${id}/start`, { method: 'POST' }),
|
||||
stopFrpServer: (id: number) => request<FrpServer>(`/api/frpmulti/servers/${id}/stop`, { method: 'POST' }),
|
||||
createFrpServer: (payload: FrpServerPayload) =>
|
||||
request<FrpServer>('/api/frpmulti/servers', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
updateFrpServer: (id: number, payload: FrpServerPayload) =>
|
||||
request<FrpServer>(`/api/frpmulti/servers/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||
deleteFrpServer: (id: number) => request<void>(`/api/frpmulti/servers/${id}`, { method: 'DELETE' }),
|
||||
testFrpServer: (id: number) => request<{ message: string }>(`/api/frpmulti/servers/${id}/test`, { method: 'POST' }),
|
||||
listFrpProxies: () => request<FrpProxy[]>('/api/frpmulti/proxies'),
|
||||
getFrpProxyLogs: (id: number, limit = 200) => request<string[]>(`/api/frpmulti/proxies/${id}/logs?limit=${limit}`),
|
||||
listFrpProxiesTraffic: () => request<FrpProxiesTraffic>('/api/frpmulti/proxies/traffic'),
|
||||
createFrpProxy: (payload: FrpProxyPayload) =>
|
||||
request<FrpProxy>('/api/frpmulti/proxies', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
updateFrpProxy: (id: number, payload: FrpProxyPayload) =>
|
||||
request<FrpProxy>(`/api/frpmulti/proxies/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||
deleteFrpProxy: (id: number) => request<void>(`/api/frpmulti/proxies/${id}`, { method: 'DELETE' }),
|
||||
startFrp: () => request<FrpStatus>('/api/frpmulti/start', { method: 'POST' }),
|
||||
stopFrp: () => request<FrpStatus>('/api/frpmulti/stop', { method: 'POST' }),
|
||||
reloadFrp: () => request<FrpStatus>('/api/frpmulti/reload', { method: 'POST' }),
|
||||
getFrpLogs: (limit = 200, serverID?: number) => {
|
||||
const query = new URLSearchParams({ limit: String(limit) })
|
||||
if (serverID) query.set('server_id', String(serverID))
|
||||
return request<string[]>(`/api/frpmulti/logs?${query.toString()}`)
|
||||
},
|
||||
exportFrpConfig: () => request<{ content: string }>('/api/frpmulti/config/export'),
|
||||
getFrpFRPSConfig: (id: number) => request<{ content: string }>(`/api/frpmulti/servers/${id}/frps-config`),
|
||||
testFrpAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/test`, { method: 'POST' }),
|
||||
probeFrpAgent: (id: number) => request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }),
|
||||
installFrpAgent: (id: number) => request<FrpServer>(`/api/frpmulti/servers/${id}/agent/install`, { method: 'POST' }),
|
||||
uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }),
|
||||
sshDiagnoseFrpAgent: (id: number) => request<{ output: string }>(`/api/frpmulti/servers/${id}/agent/ssh-diagnose`, { method: 'POST' }),
|
||||
getFrpAgentStatus: (id: number) => request<import('./types').FrpAgentStatus>(`/api/frpmulti/servers/${id}/agent/status`),
|
||||
getFrpAgentRoutes: (id: number) => request<import('./types').FrpAgentRouteDetail[]>(`/api/frpmulti/servers/${id}/agent/routes`),
|
||||
getFrpAgentRouteCandidates: (id: number) => request<import('./types').FrpProxy[]>(`/api/frpmulti/servers/${id}/agent/route-candidates`),
|
||||
deployFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'POST' }),
|
||||
getFrpAgentRouteConf: (serverId: number, proxyId: number) =>
|
||||
request<{ proxy_id: number; domain: string; content: string }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/conf`),
|
||||
installFrpAgentNginx: (serverId: number) =>
|
||||
request<{ ok: boolean; manager?: string; output?: string; path?: string; error?: string }>(`/api/frpmulti/servers/${serverId}/agent/nginx/install`, { method: 'POST' }),
|
||||
getFrpAgentRouteHealth: (serverId: number) =>
|
||||
request<import('./types').FrpAgentRouteHealth[]>(`/api/frpmulti/servers/${serverId}/agent/routes/health`),
|
||||
removeFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'DELETE' }),
|
||||
pushFrpConfigToAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-config`, { method: 'POST' }),
|
||||
frpsServerAction: (id: number, action: 'start' | 'stop' | 'restart') =>
|
||||
request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }),
|
||||
frpsServerActionRaw: (id: number, action: 'start' | 'stop' | 'restart') =>
|
||||
request<{ ok: boolean; error?: string }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }),
|
||||
installFrpServer: (id: number, version: string, proxy: string) =>
|
||||
request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/frps-install`, { method: 'POST', body: JSON.stringify({ version, proxy }) }),
|
||||
|
||||
getAccessLogs: (params?: { limit?: number; keyword?: string; status?: number }) => {
|
||||
const q = new URLSearchParams()
|
||||
if (params?.limit) q.set('limit', String(params.limit))
|
||||
if (params?.keyword) q.set('keyword', params.keyword)
|
||||
if (params?.status) q.set('status', String(params.status))
|
||||
return request<AccessLogEntry[]>(`/api/logs/access?${q}`)
|
||||
},
|
||||
getErrorLogs: (params?: { limit?: number; keyword?: string }) => {
|
||||
const q = new URLSearchParams()
|
||||
if (params?.limit) q.set('limit', String(params.limit))
|
||||
if (params?.keyword) q.set('keyword', params.keyword)
|
||||
return request<string[]>(`/api/logs/error?${q}`)
|
||||
},
|
||||
getSystemLogs: (params?: { limit?: number; level?: string; keyword?: string }) => {
|
||||
const q = new URLSearchParams()
|
||||
if (params?.limit) q.set('limit', String(params.limit))
|
||||
if (params?.level) q.set('level', params.level)
|
||||
if (params?.keyword) q.set('keyword', params.keyword)
|
||||
return request<SystemLogEntry[]>(`/api/logs/system?${q}`)
|
||||
},
|
||||
|
||||
scanDiscovery: (host?: string) => {
|
||||
const q = new URLSearchParams()
|
||||
if (host) q.set('host', host)
|
||||
return request<DiscoveredService[]>(`/api/discovery/scan?${q}`)
|
||||
},
|
||||
|
||||
async exportBackup() {
|
||||
const response = await fetch('/api/backup/export', { credentials: 'include' })
|
||||
if (!response.ok) {
|
||||
throw new Error('导出备份失败')
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `fonu-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
|
||||
async restoreBackup(file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const response = await fetch('/api/backup/restore', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: form,
|
||||
})
|
||||
if (!response.ok) {
|
||||
let message = '恢复备份失败'
|
||||
try {
|
||||
const body = (await response.json()) as ApiError
|
||||
if (body.error) message = body.error
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return (await response.json()) as { message: string }
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
export interface ProxyHost {
|
||||
id: number
|
||||
hostname: string
|
||||
listen_port?: number | null
|
||||
}
|
||||
|
||||
export interface BasicAuthConfig {
|
||||
enabled?: boolean
|
||||
username?: string
|
||||
has_password?: boolean
|
||||
}
|
||||
|
||||
export interface RateLimitConfig {
|
||||
enabled?: boolean
|
||||
rate?: number
|
||||
burst?: number
|
||||
}
|
||||
|
||||
export interface ConnLimitConfig {
|
||||
enabled?: boolean
|
||||
max?: number
|
||||
}
|
||||
|
||||
export interface ProxySecurityConfig {
|
||||
ip_blacklist?: string[]
|
||||
ip_whitelist?: string[]
|
||||
ip_whitelist_mode?: boolean
|
||||
china_only?: boolean
|
||||
basic_auth?: BasicAuthConfig
|
||||
rate_limit?: RateLimitConfig
|
||||
conn_limit?: ConnLimitConfig
|
||||
proxy_ssl_verify_off?: boolean
|
||||
proxy_host_upstream?: boolean
|
||||
tls_min_13_only?: boolean
|
||||
security_headers?: boolean
|
||||
}
|
||||
|
||||
export interface ProxyRule {
|
||||
id: number
|
||||
domain: string
|
||||
upstream: string
|
||||
listen_port: number
|
||||
listen_ipv4: boolean
|
||||
listen_ipv6: boolean
|
||||
hosts: ProxyHost[]
|
||||
https_enabled: boolean
|
||||
http_redirect: boolean
|
||||
enabled: boolean
|
||||
nginx_mode?: string
|
||||
name: string
|
||||
sort_order: number
|
||||
security: ProxySecurityConfig
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ChinaCIDRStatus {
|
||||
updated_at?: string
|
||||
entry_count_v4: number
|
||||
entry_count_v6: number
|
||||
last_error?: string
|
||||
updating?: boolean
|
||||
ready?: boolean
|
||||
}
|
||||
|
||||
export interface ProxyTraffic {
|
||||
rule_id: number
|
||||
upload_total: number
|
||||
download_total: number
|
||||
upload_rate: number
|
||||
download_rate: number
|
||||
connections: number
|
||||
}
|
||||
|
||||
export interface ProxyClientConn {
|
||||
ip: string
|
||||
last_seen: string
|
||||
}
|
||||
|
||||
export interface ProxySecurityPayload {
|
||||
ip_blacklist?: string[]
|
||||
ip_whitelist?: string[]
|
||||
ip_whitelist_mode?: boolean
|
||||
ip_blacklist_text?: string
|
||||
ip_whitelist_text?: string
|
||||
china_only?: boolean
|
||||
basic_auth?: {
|
||||
enabled?: boolean
|
||||
username?: string
|
||||
password?: string
|
||||
}
|
||||
rate_limit?: RateLimitConfig
|
||||
conn_limit?: ConnLimitConfig
|
||||
proxy_ssl_verify_off?: boolean
|
||||
proxy_host_upstream?: boolean
|
||||
tls_min_13_only?: boolean
|
||||
security_headers?: boolean
|
||||
}
|
||||
|
||||
export interface ProxySavePayload {
|
||||
upstream: string
|
||||
listen_port?: number
|
||||
listen_ipv4?: boolean
|
||||
listen_ipv6?: boolean
|
||||
hosts: string[]
|
||||
https_enabled?: boolean
|
||||
http_redirect?: boolean
|
||||
enabled?: boolean
|
||||
name?: string
|
||||
security?: ProxySecurityPayload
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
initialized: boolean
|
||||
authenticated: boolean
|
||||
}
|
||||
|
||||
export interface AppVersion {
|
||||
version: string
|
||||
nginx_http_port: number
|
||||
nginx_https_port: number
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface DashboardStatus {
|
||||
public_ipv4: string
|
||||
public_ipv6: string
|
||||
public_ip_source?: 'ddns' | 'detect' | 'none'
|
||||
ddns_status: string
|
||||
ddns_count: number
|
||||
ddns_last_updated?: string
|
||||
certificate_status: string
|
||||
certificate_days: number
|
||||
certificate_count: number
|
||||
certificate_summary?: string
|
||||
proxy_count: number
|
||||
nginx_status: string
|
||||
request_today: number
|
||||
request_yesterday?: number
|
||||
request_trend?: number | null
|
||||
requests_hourly: number[]
|
||||
requests_hourly_labels?: string[]
|
||||
error_today: number
|
||||
avg_response_ms: number
|
||||
started_at: string
|
||||
uptime_seconds: number
|
||||
}
|
||||
|
||||
export interface DDNSDomainRecord {
|
||||
domain: string
|
||||
ipv4?: string
|
||||
ipv6?: string
|
||||
status: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface DDNSConfig {
|
||||
id: number
|
||||
provider: string
|
||||
root_domain: string
|
||||
record_name: string
|
||||
record_names?: string[]
|
||||
ipv4_enabled: boolean
|
||||
ipv6_enabled: boolean
|
||||
enabled: boolean
|
||||
has_token: boolean
|
||||
last_ipv4?: string
|
||||
last_ipv6?: string
|
||||
last_status?: string
|
||||
last_error?: string
|
||||
custom_ipv4?: string
|
||||
custom_ipv6?: string
|
||||
domain_records?: DDNSDomainRecord[]
|
||||
last_updated_at?: string
|
||||
}
|
||||
|
||||
export interface CertificateCAOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface CertificateJobDone {
|
||||
ok: boolean
|
||||
error?: string
|
||||
domain?: string
|
||||
cert_path?: string
|
||||
key_path?: string
|
||||
cert_dir?: string
|
||||
expires_at?: string
|
||||
}
|
||||
|
||||
export interface CertificateJobEvent {
|
||||
type: 'log' | 'done'
|
||||
level?: string
|
||||
message?: string
|
||||
result?: CertificateJobDone
|
||||
}
|
||||
|
||||
export interface CertificateRecord {
|
||||
id: number
|
||||
domain: string
|
||||
domains?: string[]
|
||||
wildcard: boolean
|
||||
acme_ca?: string
|
||||
status: string
|
||||
days_left: number
|
||||
last_error?: string
|
||||
expires_at?: string
|
||||
last_renew_at?: string
|
||||
}
|
||||
|
||||
export interface AccessLogEntry {
|
||||
time: string
|
||||
domain: string
|
||||
server_port?: number
|
||||
method: string
|
||||
path: string
|
||||
status: number
|
||||
response_time: number
|
||||
client_ip: string
|
||||
upstream: string
|
||||
}
|
||||
|
||||
export interface SystemLogEntry {
|
||||
time: string
|
||||
level: string
|
||||
module: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type NotifyType = '' | 'email' | 'webhook' | 'telegram'
|
||||
export type WebhookProvider = 'bark' | 'ntfy' | 'gotify' | 'custom'
|
||||
|
||||
export interface NotifyEmailConfig {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
from: string
|
||||
to: string[]
|
||||
tls: boolean
|
||||
has_password?: boolean
|
||||
}
|
||||
|
||||
export interface NotifyWebhookConfig {
|
||||
provider: WebhookProvider
|
||||
server: string
|
||||
key: string
|
||||
topic: string
|
||||
url: string
|
||||
has_secret?: boolean
|
||||
}
|
||||
|
||||
export interface NotifyTelegramConfig {
|
||||
chat_id: string
|
||||
proxy_url: string
|
||||
has_bot_token?: boolean
|
||||
}
|
||||
|
||||
export interface NotifyTestPayload {
|
||||
type: NotifyType
|
||||
email: NotifyEmailConfig
|
||||
webhook: NotifyWebhookConfig
|
||||
telegram: NotifyTelegramConfig
|
||||
on_ddns_ip_change: boolean
|
||||
on_ddns_failure: boolean
|
||||
on_cert_expiry: boolean
|
||||
on_cert_renew_success: boolean
|
||||
on_cert_renew_failure: boolean
|
||||
on_ip_frequent_access: boolean
|
||||
on_login_failure: boolean
|
||||
on_nginx_reload_failure: boolean
|
||||
ip_frequent_threshold: number
|
||||
ip_frequent_window_sec: number
|
||||
login_failure_threshold: number
|
||||
login_failure_window_sec: number
|
||||
smtp_password?: string
|
||||
webhook_secret?: string
|
||||
telegram_token?: string
|
||||
}
|
||||
|
||||
export type SettingsMap = Record<string, string>
|
||||
|
||||
export interface DiscoveredService {
|
||||
name: string
|
||||
port: number
|
||||
host: string
|
||||
upstream: string
|
||||
detected: boolean
|
||||
platform: string
|
||||
title?: string
|
||||
suggestion?: string
|
||||
}
|
||||
|
||||
export interface DDNSSavePayload {
|
||||
provider?: string
|
||||
root_domain?: string
|
||||
record_name?: string
|
||||
record_names?: string[]
|
||||
domains?: string[]
|
||||
ipv4_enabled?: boolean
|
||||
ipv6_enabled?: boolean
|
||||
enabled?: boolean
|
||||
custom_ipv4?: string
|
||||
custom_ipv6?: string
|
||||
api_token?: string
|
||||
api_token_id?: string
|
||||
api_secret?: string
|
||||
}
|
||||
|
||||
export interface DDNSTestPayload {
|
||||
config_id?: number
|
||||
provider?: string
|
||||
api_token?: string
|
||||
api_token_id?: string
|
||||
api_secret?: string
|
||||
}
|
||||
|
||||
export interface FRPStatus {
|
||||
enabled: boolean
|
||||
connected: boolean
|
||||
message: string
|
||||
last_error?: string
|
||||
server_endpoint?: string
|
||||
connected_at?: string
|
||||
uptime_seconds?: number
|
||||
client_version?: string
|
||||
http_gateway_enabled?: boolean
|
||||
https_gateway_enabled?: boolean
|
||||
synced_domain_count?: number
|
||||
}
|
||||
|
||||
export interface FRPConfig {
|
||||
enabled: boolean
|
||||
server_addr: string
|
||||
server_port: number
|
||||
auth_token: string
|
||||
has_auth_token: boolean
|
||||
tls_enabled: boolean
|
||||
custom_domains: string[]
|
||||
}
|
||||
|
||||
export interface FRPResponse {
|
||||
enabled: boolean
|
||||
server_addr: string
|
||||
server_port: number
|
||||
auth_token: string
|
||||
has_auth_token: boolean
|
||||
tls_enabled: boolean
|
||||
custom_domains: string[]
|
||||
status: FRPStatus
|
||||
frps_config: string
|
||||
nginx_http_port: number
|
||||
nginx_https_port: number
|
||||
}
|
||||
|
||||
export interface FRPSaveResponse {
|
||||
message: string
|
||||
config: FRPConfig
|
||||
status: FRPStatus
|
||||
frps_config: string
|
||||
nginx_http_port: number
|
||||
nginx_https_port: number
|
||||
}
|
||||
|
||||
export interface FRPSavePayload {
|
||||
enabled: boolean
|
||||
server_addr: string
|
||||
server_port: number
|
||||
auth_token?: string
|
||||
tls_enabled: boolean
|
||||
custom_domains: string[]
|
||||
}
|
||||
|
||||
// ===== FRP 多服务端模块(/api/frpmulti,0.1.25 核心迁移)=====
|
||||
|
||||
export interface FrpServer {
|
||||
agent_url?: string
|
||||
agent_configured?: boolean
|
||||
ssh_configured?: boolean
|
||||
mgmt_enabled?: boolean
|
||||
ssh_host?: string
|
||||
ssh_port?: number
|
||||
ssh_user?: string
|
||||
ssh_auth?: string
|
||||
id: number
|
||||
name: string
|
||||
server_addr: string
|
||||
server_port: number
|
||||
tls_enabled: boolean
|
||||
tls_server_name?: string
|
||||
enabled: boolean
|
||||
has_token: boolean
|
||||
has_oidc_client_secret: boolean
|
||||
dashboard_has_pwd?: boolean
|
||||
options: FrpServerOptions
|
||||
boot_status: 'starting' | 'running' | 'stopped' | 'error'
|
||||
desired_state: 'starting' | 'running' | 'stopped' | 'error' | string
|
||||
process_state: 'starting' | 'running' | 'stopped' | 'error' | 'unknown' | string
|
||||
connection_state: 'connected' | 'connecting' | 'disconnected' | 'error' | 'unknown' | string
|
||||
state_source: string
|
||||
process_pid?: number
|
||||
exit_code?: number
|
||||
last_connected_at?: string
|
||||
last_disconnected_at?: string
|
||||
last_error?: string
|
||||
last_started_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface FrpServerOptions {
|
||||
user?: string
|
||||
location?: string
|
||||
/** frps 的 vhost 监听端口(http/https 反代回源端口),与 frps.toml 和 Nginx upstream 同源;留空用默认 8080/8443 */
|
||||
vhost_http_port?: number
|
||||
vhost_https_port?: number
|
||||
/** frps 进阶:子域名根(规则 subdomain 生效前提)*/
|
||||
sub_domain_host?: string
|
||||
/** HTTP vhost 404 页面路径(VPS 本地路径)*/
|
||||
custom_404_page?: string
|
||||
/** 强制 TLS:只接受 TLS 连接(frpc 必须开 transport.tls.enable)*/
|
||||
tls_force?: boolean
|
||||
/** 端口白名单:逗号分隔的单端口或范围,如 6000,6005-6010;留空不限制 */
|
||||
allow_ports?: string
|
||||
/** 每客户端可绑定端口上限;0/留空不限 */
|
||||
max_ports_per_client?: number
|
||||
/** tcpmux 规则的 HTTP CONNECT 监听端口 */
|
||||
tcpmux_http_connect_port?: number
|
||||
/** frps 写文件日志 /var/log/frps.log(journalctl 之外)*/
|
||||
frps_log_to_file?: boolean
|
||||
/** 文件日志保留天数,默认 3 */
|
||||
frps_log_max_days?: number
|
||||
/** frps 管理接口(webServer),仅用于 Fonu 拉取穿透流量等只读信息 */
|
||||
dashboard_addr?: string
|
||||
dashboard_port?: number
|
||||
dashboard_user?: string
|
||||
dashboard_has_pwd?: boolean
|
||||
auth_method: 'none' | 'token' | 'oidc'
|
||||
oidc_client_id?: string
|
||||
oidc_audience?: string
|
||||
oidc_scope?: string
|
||||
oidc_token_endpoint_url?: string
|
||||
oidc_trusted_ca_path?: string
|
||||
oidc_insecure_skip_verify?: boolean
|
||||
oidc_proxy_url?: string
|
||||
log_level: 'trace' | 'debug' | 'info' | 'warn' | 'error'
|
||||
log_max_days: number
|
||||
protocol: 'tcp' | 'kcp' | 'quic' | 'websocket' | 'wss'
|
||||
proxy_url?: string
|
||||
dial_server_timeout: number
|
||||
dial_server_keepalive: number
|
||||
connect_server_local_ip?: string
|
||||
dns_server?: string
|
||||
pool_count: number
|
||||
tcp_mux: boolean
|
||||
tcp_mux_keepalive_interval: number
|
||||
heartbeat_interval: number
|
||||
heartbeat_timeout: number
|
||||
login_fail_exit: boolean
|
||||
tls_disable_custom_first_byte: boolean
|
||||
auto_start: boolean
|
||||
remark?: string
|
||||
tls_certificate_configured?: boolean
|
||||
tls_key_configured?: boolean
|
||||
tls_trusted_ca_configured?: boolean
|
||||
}
|
||||
|
||||
export interface FrpProxy {
|
||||
id: number
|
||||
server_id: number
|
||||
name: string
|
||||
type: 'tcp' | 'udp' | 'http' | 'https' | 'tcpmux' | 'stcp' | 'sudp' | 'xtcp'
|
||||
local_ip: string
|
||||
local_port: number
|
||||
remote_port?: number | null
|
||||
custom_domains: string[]
|
||||
host_header_rewrite?: string
|
||||
options: FrpProxyOptions
|
||||
enabled: boolean
|
||||
remark?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface FrpProxyOptions {
|
||||
subdomain?: string
|
||||
/** WebSocket 开关:仅作用于公网 Nginx 反代模板(加 Upgrade/Connection 头) */
|
||||
websocket?: boolean
|
||||
/** 公网反代:HTTP 80 强制 301 跳转 HTTPS(需公网证书已部署)*/
|
||||
redirect_https?: boolean
|
||||
/** 公网反代:IP 白名单(allow/deny),空则不限制 */
|
||||
allow_ips?: string[]
|
||||
/** 公网反代:Basic Auth 用户名;密码由 agent 首次部署自动生成(见配置注释)*/
|
||||
basic_auth_user?: string
|
||||
/** 公网反代:IP 黑名单(deny),与白名单可同时使用 */
|
||||
deny_ips?: string[]
|
||||
/** 公网反代:Basic Auth 自定义密码;留空则首次部署自动生成 */
|
||||
basic_auth_password?: string
|
||||
/** 公网反代:显式关闭 HTTPS(证书已推送也不监听 443);缺省 = 启用 */
|
||||
https_disabled?: boolean
|
||||
/** 公网反代:客户端上传大小限制,如 50m */
|
||||
client_max_body_size?: string
|
||||
/** 公网反代:代理读超时,如 3600s(长轮询 / 流媒体 / 大文件必需)*/
|
||||
proxy_read_timeout?: string
|
||||
/** 公网反代:安全响应头(HSTS / X-Frame-Options / X-Content-Type-Options / Referrer-Policy)*/
|
||||
security_headers?: boolean
|
||||
/** 公网反代:仅允许 TLS 1.3 */
|
||||
tls13_only?: boolean
|
||||
/** 公网反代:请求限流(次/秒);0 = 不限制 */
|
||||
rate_limit_rate?: number
|
||||
/** 公网反代:限流突发允许量 */
|
||||
rate_limit_burst?: number
|
||||
/** 公网反代:每 IP 并发连接数上限;0 = 不限制 */
|
||||
conn_limit_max?: number
|
||||
/** 公网反代:仅允许中国大陆 IP(部署时自动下发本地维护的 IP 段)*/
|
||||
china_only?: boolean
|
||||
locations?: string[]
|
||||
route_by_http_user?: string
|
||||
http_user?: string
|
||||
http_password?: string
|
||||
multiplexer?: string
|
||||
secret_key?: string
|
||||
allow_users?: string[]
|
||||
transport: {
|
||||
bandwidth_limit?: string
|
||||
bandwidth_limit_mode?: 'client' | 'server' | string
|
||||
use_encryption?: boolean
|
||||
use_compression?: boolean
|
||||
proxy_protocol_version?: 'v1' | 'v2' | string
|
||||
}
|
||||
health_check: {
|
||||
type?: 'tcp' | 'http' | string
|
||||
path?: string
|
||||
interval_seconds?: number
|
||||
max_failed?: number
|
||||
timeout_seconds?: number
|
||||
http_headers?: Array<{ name: string; value: string }>
|
||||
}
|
||||
load_balancer: { group?: string; group_key?: string }
|
||||
metadatas?: Record<string, string>
|
||||
request_headers?: Record<string, string>
|
||||
response_headers?: Record<string, string>
|
||||
plugin?: {
|
||||
type: string
|
||||
unix_path?: string
|
||||
local_path?: string
|
||||
strip_prefix?: string
|
||||
local_addr?: string
|
||||
http_user?: string
|
||||
http_password?: string
|
||||
username?: string
|
||||
password?: string
|
||||
crt_path?: string
|
||||
key_path?: string
|
||||
host_header_rewrite?: string
|
||||
destination_ip?: string
|
||||
}
|
||||
nat_traversal: { disable_assisted_addrs?: boolean }
|
||||
}
|
||||
|
||||
/** frps 侧单条穿透规则的流量快照;速率为两次采样间隔内的均值 */
|
||||
export interface FrpProxyTraffic {
|
||||
traffic_in: number
|
||||
traffic_out: number
|
||||
cur_conns: number
|
||||
traffic_in_rate: number
|
||||
traffic_out_rate: number
|
||||
}
|
||||
|
||||
/** 按 frp 规则 ID 聚合的流量数据;errors 记录采集失败的服务端/规则错误 */
|
||||
export interface FrpProxiesTraffic {
|
||||
items: Record<string, FrpProxyTraffic>
|
||||
errors?: Record<string, string>
|
||||
sampled_at: string
|
||||
}
|
||||
|
||||
export interface FrpStatus {
|
||||
status: 'running' | 'stopped' | 'unknown'
|
||||
pid?: number
|
||||
frpc_version?: string
|
||||
last_started_at?: string
|
||||
last_reloaded_at?: string
|
||||
last_error?: string
|
||||
configured: boolean
|
||||
enabled_proxies: number
|
||||
}
|
||||
|
||||
export interface FrpRuntimeInfo {
|
||||
platform: string
|
||||
configured_bin: string
|
||||
active_bin?: string
|
||||
active_version?: string
|
||||
detected_version?: string
|
||||
download_proxy: string
|
||||
config_path: string
|
||||
config_paths?: string[]
|
||||
config_exists: boolean
|
||||
}
|
||||
|
||||
export interface FrpRelease {
|
||||
version: string
|
||||
published_at?: string
|
||||
asset_name: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface FrpBinary {
|
||||
version: string
|
||||
path: string
|
||||
size: number
|
||||
installed_at: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/** 单项诊断结果;state 为 ok/failed/unknown,unknown 表示无法确认而非失败 */
|
||||
export interface FrpDiagnosticCheck {
|
||||
name: 'tcp_reachable' | 'frpc_login' | 'proxy_reachable' | 'dns_resolution' | 'location' | string
|
||||
state: 'ok' | 'failed' | 'unknown' | string
|
||||
detail?: string
|
||||
latency_ms?: number
|
||||
}
|
||||
|
||||
export interface FrpServerDiagnostics {
|
||||
server_id: number
|
||||
available: boolean
|
||||
latency_ms?: number
|
||||
location?: string
|
||||
checks?: FrpDiagnosticCheck[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface FrpServerSample {
|
||||
checked_at: string
|
||||
available: boolean
|
||||
latency_ms?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface FrpServerStatusDetail {
|
||||
available: boolean
|
||||
availability: number
|
||||
average_latency_ms: number
|
||||
peak_latency_ms: number
|
||||
samples: FrpServerSample[]
|
||||
last_checked_at?: string
|
||||
last_error?: string
|
||||
}
|
||||
|
||||
export interface FrpServerDetail {
|
||||
server: FrpServer
|
||||
status: FrpServerStatusDetail
|
||||
proxies: FrpProxy[]
|
||||
}
|
||||
|
||||
export interface FrpDownloadTask {
|
||||
version?: string
|
||||
status: 'idle' | 'queued' | 'downloading' | 'verifying' | 'extracting' | 'completed' | 'failed'
|
||||
message: string
|
||||
downloaded: number
|
||||
total: number
|
||||
error?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface FrpServerPayload {
|
||||
agent_url?: string
|
||||
agent_token?: string
|
||||
clear_agent_token?: boolean
|
||||
ssh_host?: string
|
||||
ssh_port?: number
|
||||
ssh_user?: string
|
||||
ssh_auth?: string
|
||||
ssh_secret?: string
|
||||
clear_ssh_secret?: boolean
|
||||
mgmt_enabled?: boolean
|
||||
name: string
|
||||
server_addr: string
|
||||
server_port: number
|
||||
tls_enabled?: boolean
|
||||
tls_server_name?: string
|
||||
enabled?: boolean
|
||||
token?: string
|
||||
clear_token?: boolean
|
||||
options: FrpServerOptions
|
||||
oidc_client_secret?: string
|
||||
clear_oidc_client_secret?: boolean
|
||||
dashboard_password?: string
|
||||
clear_dashboard_password?: boolean
|
||||
tls_certificate?: string
|
||||
tls_key?: string
|
||||
tls_trusted_ca?: string
|
||||
clear_tls_certificate?: boolean
|
||||
clear_tls_key?: boolean
|
||||
clear_tls_trusted_ca?: boolean
|
||||
}
|
||||
|
||||
export interface FrpAgentRouteCandidate {
|
||||
id: number
|
||||
name: string
|
||||
type: 'http' | 'https'
|
||||
custom_domains: string[]
|
||||
local_ip: string
|
||||
local_port: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface FrpAgentRouteHealth {
|
||||
proxy_id: number
|
||||
domain: string
|
||||
dns_ok: boolean
|
||||
dns_detail?: string
|
||||
cert_ok: boolean
|
||||
cert_detail?: string
|
||||
tunnel_ok: boolean
|
||||
tunnel_name?: string
|
||||
tunnel_detail?: string
|
||||
service_ok: boolean
|
||||
}
|
||||
|
||||
export interface FrpAgentRouteDetail {
|
||||
domain: string
|
||||
file_exists: boolean
|
||||
syntax_ok: boolean
|
||||
loaded: boolean
|
||||
}
|
||||
|
||||
export interface FrpAgentStatus {
|
||||
agent_version?: string
|
||||
ports?: Record<string, boolean>
|
||||
frps_active?: boolean
|
||||
frps_enabled?: boolean
|
||||
frps_version?: string
|
||||
frps_log_tail?: string
|
||||
frps_unit_exec?: string
|
||||
dashboard_addr?: string
|
||||
service: string
|
||||
nginx_binary: string
|
||||
nginx_conf_dir: string
|
||||
/** nginx -t 是否通过 */
|
||||
nginx_ok?: boolean
|
||||
/** 未找到 nginx 可执行文件 */
|
||||
nginx_missing?: boolean
|
||||
/** 解析到的 nginx 路径 */
|
||||
nginx_path?: string
|
||||
/** 有 nginx 进程在运行 */
|
||||
nginx_running?: boolean
|
||||
/** nginx 版本,如 nginx/1.24.0 */
|
||||
nginx_version?: string
|
||||
/** 探测失败原因(缺失或配置有误) */
|
||||
nginx_error?: string
|
||||
frps_binary: string
|
||||
frps_config_path: string
|
||||
routes: string[]
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
export interface FrpProxyPayload {
|
||||
server_id: number
|
||||
name: string
|
||||
type: FrpProxy['type']
|
||||
local_ip: string
|
||||
local_port: number
|
||||
remote_port?: number | null
|
||||
custom_domains?: string[]
|
||||
host_header_rewrite?: string
|
||||
options?: FrpProxyOptions
|
||||
enabled?: boolean
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface NginxBackupEntry {
|
||||
name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface RuleNginxView {
|
||||
mode: 'auto' | 'custom'
|
||||
enabled: boolean
|
||||
active: boolean
|
||||
generated: string
|
||||
content: string
|
||||
backups: NginxBackupEntry[]
|
||||
}
|
||||
|
||||
export interface GlobalNginxView {
|
||||
mode: 'auto' | 'custom'
|
||||
generated_framework: string
|
||||
generated_snippet: string
|
||||
content: string
|
||||
backups: NginxBackupEntry[]
|
||||
}
|
||||
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 977 KiB |
|
After Width: | Height: | Size: 467 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 315 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 605 KiB |
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="settings-field">
|
||||
<div class="settings-field__label configured-secret-label">
|
||||
<span>{{ label }}</span>
|
||||
<n-tag v-if="configured" size="small" type="success" :bordered="false">{{ CONFIGURED_SECRET_TAG }}</n-tag>
|
||||
</div>
|
||||
<n-input
|
||||
:value="modelValue"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="configured ? CONFIGURED_SECRET_PLACEHOLDER : placeholder"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NInput, NTag } from 'naive-ui'
|
||||
import { CONFIGURED_SECRET_PLACEHOLDER, CONFIGURED_SECRET_TAG } from '../constants/secretField'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
modelValue: string
|
||||
configured?: boolean
|
||||
placeholder?: string
|
||||
}>(),
|
||||
{
|
||||
configured: false,
|
||||
placeholder: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.configured-secret-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__icon">{{ icon }}</div>
|
||||
<h3 class="empty-state__title">{{ title }}</h3>
|
||||
<p class="empty-state__desc">{{ description }}</p>
|
||||
<div v-if="$slots.action" class="empty-state__action">
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
icon?: string
|
||||
}>(),
|
||||
{ icon: '○' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
padding: var(--fonu-space-6) var(--fonu-space-5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto var(--fonu-space-4);
|
||||
border-radius: 50%;
|
||||
background: var(--fonu-brand-soft);
|
||||
color: var(--fonu-brand);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state__title {
|
||||
margin: 0 0 var(--fonu-space-2);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--fonu-text);
|
||||
}
|
||||
|
||||
.empty-state__desc {
|
||||
margin: 0 auto;
|
||||
max-width: 360px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.empty-state__action {
|
||||
margin-top: var(--fonu-space-5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="fonu-card" :class="{ 'fonu-card--flush': flush }">
|
||||
<div v-if="title || $slots.title || $slots.header" class="fonu-card__header">
|
||||
<div>
|
||||
<h3 v-if="title" class="fonu-card__title">{{ title }}</h3>
|
||||
<div v-else-if="$slots.title" class="fonu-card__title fonu-card__title-row">
|
||||
<slot name="title" />
|
||||
</div>
|
||||
<p v-if="subtitle" class="fonu-card__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.header" class="fonu-card__header-extra">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="fonu-card__body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title?: string
|
||||
subtitle?: string
|
||||
flush?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fonu-card {
|
||||
background: var(--fonu-surface);
|
||||
border: 1px solid var(--fonu-border);
|
||||
border-radius: var(--fonu-radius);
|
||||
box-shadow: var(--fonu-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fonu-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--fonu-space-4);
|
||||
padding: var(--fonu-space-5) var(--fonu-space-5) 0;
|
||||
}
|
||||
|
||||
.fonu-card__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--fonu-text);
|
||||
}
|
||||
|
||||
.fonu-card__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fonu-space-2);
|
||||
}
|
||||
|
||||
.fonu-card__subtitle {
|
||||
margin: var(--fonu-space-1) 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.fonu-card__body {
|
||||
padding: var(--fonu-space-5);
|
||||
}
|
||||
|
||||
.fonu-card--flush .fonu-card__body {
|
||||
padding-top: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="load-error">
|
||||
<n-result status="error" title="加载失败" :description="message">
|
||||
<template #footer>
|
||||
<n-button type="primary" @click="$emit('retry')">重新加载</n-button>
|
||||
</template>
|
||||
</n-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NButton, NResult } from 'naive-ui'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
message?: string
|
||||
}>(),
|
||||
{ message: '请检查 Fonu 服务是否正常运行' },
|
||||
)
|
||||
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.load-error {
|
||||
padding: var(--fonu-space-6) 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<VChart class="chart" :option="option" autoresize />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { use } from 'echarts/core'
|
||||
import { BarChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import VChart from 'vue-echarts'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
|
||||
use([CanvasRenderer, BarChart, GridComponent, TooltipComponent])
|
||||
|
||||
const props = defineProps<{
|
||||
values: number[]
|
||||
labels?: string[]
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const option = computed(() => {
|
||||
const labels = props.labels ?? ['00', '04', '08', '12', '16', '20']
|
||||
const textColor = isDark.value ? '#94a3b8' : '#6b7280'
|
||||
const splitColor = isDark.value ? '#334155' : '#e5e7eb'
|
||||
|
||||
return {
|
||||
grid: { left: 8, right: 8, top: 12, bottom: 28, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
backgroundColor: isDark.value ? '#1e293b' : '#fff',
|
||||
borderColor: isDark.value ? '#334155' : '#e5e7eb',
|
||||
textStyle: { color: isDark.value ? '#f1f5f9' : '#111827', fontSize: 12 },
|
||||
formatter: (params: { name: string; value: number }[]) => {
|
||||
const p = params[0]
|
||||
if (!p) return ''
|
||||
return `${p.name}<br/>请求数 ${p.value}`
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
color: textColor,
|
||||
fontSize: 10,
|
||||
interval: 0,
|
||||
hideOverlap: false,
|
||||
showMinLabel: true,
|
||||
showMaxLabel: true,
|
||||
rotate: labels.length > 8 ? 30 : 0,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: { lineStyle: { color: splitColor, type: 'dashed' } },
|
||||
axisLabel: { color: textColor, fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: props.values,
|
||||
barMaxWidth: 28,
|
||||
itemStyle: {
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#6ee7b7' },
|
||||
{ offset: 1, color: '#10b981' },
|
||||
],
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#a7f3d0' },
|
||||
{ offset: 1, color: '#059669' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<VChart class="chart" :option="option" autoresize />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { use } from 'echarts/core'
|
||||
import { PieChart } from 'echarts/charts'
|
||||
import { LegendComponent, TitleComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import VChart from 'vue-echarts'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
|
||||
use([CanvasRenderer, PieChart, LegendComponent, TitleComponent, TooltipComponent])
|
||||
|
||||
const props = defineProps<{
|
||||
segments: { label: string; value: number; color: string }[]
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const option = computed(() => {
|
||||
const textColor = isDark.value ? '#94a3b8' : '#6b7280'
|
||||
const titleColor = isDark.value ? '#f1f5f9' : '#111827'
|
||||
const total = props.segments.reduce((sum, s) => sum + s.value, 0)
|
||||
|
||||
return {
|
||||
title: [
|
||||
{
|
||||
text: String(total),
|
||||
left: '31%',
|
||||
top: '38%',
|
||||
textAlign: 'center',
|
||||
textStyle: { fontSize: 22, fontWeight: 700, color: titleColor },
|
||||
},
|
||||
{
|
||||
text: '总请求',
|
||||
left: '31%',
|
||||
top: '52%',
|
||||
textAlign: 'center',
|
||||
textStyle: { fontSize: 12, color: textColor },
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: isDark.value ? '#1e293b' : '#fff',
|
||||
borderColor: isDark.value ? '#334155' : '#e5e7eb',
|
||||
textStyle: { color: isDark.value ? '#f1f5f9' : '#111827', fontSize: 12 },
|
||||
formatter: '{b}: {c} ({d}%)',
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
right: 0,
|
||||
top: 'center',
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
itemGap: 12,
|
||||
textStyle: { color: textColor, fontSize: 13 },
|
||||
formatter: (name: string) => {
|
||||
const seg = props.segments.find((s) => s.label === name)
|
||||
const total = props.segments.reduce((sum, s) => sum + s.value, 0) || 1
|
||||
const pct = seg ? Math.round((seg.value / total) * 100) : 0
|
||||
return `${name} ${pct}% (${seg?.value ?? 0})`
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['52%', '72%'],
|
||||
center: ['32%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: { show: false },
|
||||
emphasis: {
|
||||
scale: true,
|
||||
scaleSize: 4,
|
||||
},
|
||||
data: props.segments.map((s) => ({
|
||||
name: s.label,
|
||||
value: s.value,
|
||||
itemStyle: { color: s.color },
|
||||
})),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<VChart class="chart" :option="option" autoresize />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { use } from 'echarts/core'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import VChart from 'vue-echarts'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import { formatRate } from '../utils/format'
|
||||
|
||||
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent])
|
||||
|
||||
const props = defineProps<{
|
||||
labels: string[]
|
||||
upload: number[]
|
||||
download: number[]
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
function axisRate(value: number): string {
|
||||
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(0)} MB/s`
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(0)} KB/s`
|
||||
return `${Math.round(value)} B/s`
|
||||
}
|
||||
|
||||
const option = computed(() => {
|
||||
const textColor = isDark.value ? '#94a3b8' : '#9ca3af'
|
||||
const splitColor = isDark.value ? '#334155' : '#e5e7eb'
|
||||
|
||||
return {
|
||||
grid: { left: 4, right: 8, top: 8, bottom: 4, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: isDark.value ? '#1e293b' : '#fff',
|
||||
borderColor: isDark.value ? '#334155' : '#e5e7eb',
|
||||
textStyle: { color: isDark.value ? '#f1f5f9' : '#111827', fontSize: 12 },
|
||||
formatter: (params: { seriesName: string; value: number; axisValue: string }[]) => {
|
||||
const time = params[0]?.axisValue ?? ''
|
||||
const lines = params.map((p) => `${p.seriesName} ${formatRate(p.value)}`)
|
||||
return `${time}<br/>${lines.join('<br/>')}`
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: props.labels,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: textColor, fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
splitNumber: 3,
|
||||
splitLine: { lineStyle: { color: splitColor, type: 'dashed' } },
|
||||
axisLabel: {
|
||||
color: textColor,
|
||||
fontSize: 11,
|
||||
formatter: (v: number) => axisRate(v),
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '上传',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
data: props.upload,
|
||||
lineStyle: { width: 2, color: '#10b981' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(16, 185, 129, 0.25)' },
|
||||
{ offset: 1, color: 'rgba(16, 185, 129, 0.02)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '下载',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
data: props.download,
|
||||
lineStyle: { width: 2, color: '#3b82f6' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(59, 130, 246, 0.25)' },
|
||||
{ offset: 1, color: 'rgba(59, 130, 246, 0.02)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div
|
||||
class="nginx-code-editor"
|
||||
:class="{
|
||||
'nginx-code-editor--readonly': readonly,
|
||||
'nginx-code-editor--embedded': embedded,
|
||||
}"
|
||||
>
|
||||
<Codemirror
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:style="editorStyle"
|
||||
:autofocus="false"
|
||||
:indent-with-tab="true"
|
||||
:tab-size="4"
|
||||
:extensions="extensions"
|
||||
@update:model-value="onUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Codemirror } from 'vue-codemirror'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from '@codemirror/view'
|
||||
import { StreamLanguage, syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
|
||||
import { nginx } from '@codemirror/legacy-modes/mode/nginx'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
readonly?: boolean
|
||||
embedded?: boolean
|
||||
placeholder?: string
|
||||
minHeight?: string
|
||||
}>(),
|
||||
{
|
||||
readonly: false,
|
||||
embedded: false,
|
||||
placeholder: '',
|
||||
minHeight: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const nginxLanguage = StreamLanguage.define(nginx)
|
||||
|
||||
const lightTheme = EditorView.theme(
|
||||
{
|
||||
'&': {
|
||||
backgroundColor: 'var(--fonu-surface)',
|
||||
color: 'var(--fonu-text)',
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: 'var(--fonu-brand)',
|
||||
fontFamily: 'var(--fonu-mono)',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.7',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--fonu-bg-muted)',
|
||||
color: 'var(--fonu-text-muted)',
|
||||
borderRight: '1px solid var(--fonu-border)',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'var(--fonu-bg)',
|
||||
color: 'var(--fonu-text-secondary)',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'var(--fonu-brand-soft)',
|
||||
},
|
||||
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': {
|
||||
backgroundColor: 'color-mix(in srgb, var(--fonu-brand) 22%, transparent) !important',
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: 'var(--fonu-brand)',
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: 'none',
|
||||
},
|
||||
},
|
||||
{ dark: false },
|
||||
)
|
||||
|
||||
const editorStyle = computed(() => {
|
||||
if (props.embedded) {
|
||||
return { height: '100%' }
|
||||
}
|
||||
if (props.minHeight) {
|
||||
return { height: '100%', minHeight: props.minHeight }
|
||||
}
|
||||
return { height: '100%', minHeight: '240px' }
|
||||
})
|
||||
|
||||
const extensions = computed(() => {
|
||||
const base = [
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightActiveLine(),
|
||||
nginxLanguage,
|
||||
EditorView.lineWrapping,
|
||||
]
|
||||
if (isDark.value) {
|
||||
base.push(oneDark)
|
||||
} else {
|
||||
base.push(lightTheme, syntaxHighlighting(defaultHighlightStyle, { fallback: true }))
|
||||
}
|
||||
if (props.readonly) {
|
||||
base.push(EditorState.readOnly.of(true), EditorView.editable.of(false))
|
||||
}
|
||||
return base
|
||||
})
|
||||
|
||||
function onUpdate(value: string) {
|
||||
if (!props.readonly) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nginx-code-editor {
|
||||
border: 1px solid var(--fonu-border);
|
||||
border-radius: var(--fonu-radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--fonu-surface);
|
||||
}
|
||||
|
||||
.nginx-code-editor--embedded {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.nginx-code-editor :deep(.cm-editor) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nginx-code-editor :deep(.cm-scroller) {
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--fonu-text-muted) 35%, transparent) transparent;
|
||||
}
|
||||
|
||||
.nginx-code-editor:hover :deep(.cm-scroller) {
|
||||
scrollbar-color: color-mix(in srgb, var(--fonu-text-muted) 65%, transparent) var(--fonu-bg-muted);
|
||||
}
|
||||
|
||||
.nginx-code-editor :deep(.cm-scroller)::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.nginx-code-editor :deep(.cm-scroller)::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nginx-code-editor :deep(.cm-scroller)::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--fonu-text-muted) 30%, transparent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nginx-code-editor:hover :deep(.cm-scroller)::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--fonu-text-muted) 55%, transparent);
|
||||
}
|
||||
|
||||
.nginx-code-editor--readonly :deep(.cm-cursor) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nginx-code-editor--readonly :deep(.cm-content) {
|
||||
caret-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<div class="page-header__main">
|
||||
<h1 class="page-header__title">{{ title }}</h1>
|
||||
<p v-if="description" class="page-header__desc">{{ description }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="page-header__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--fonu-space-4);
|
||||
margin-bottom: var(--fonu-space-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-header__title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: var(--fonu-text);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-header__desc {
|
||||
margin: var(--fonu-space-2) 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--fonu-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fonu-space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="proxy-log-box"
|
||||
:class="{
|
||||
'proxy-log-box--embedded': embedded,
|
||||
'proxy-log-box--fullscreen': fullscreen,
|
||||
'is-empty': parsedLines.length === 0,
|
||||
}"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<template v-for="(item, i) in parsedLines" :key="i">
|
||||
<div
|
||||
v-if="item.parsed"
|
||||
class="proxy-log-entry"
|
||||
:class="logEntryStatusClass(item.parsed.status)"
|
||||
>
|
||||
<time class="proxy-log-entry__time" :title="item.parsed.time">{{ formatLogTime(item.parsed.time) }}</time>
|
||||
<span class="proxy-log-entry__method" :class="`is-${item.parsed.method.toLowerCase()}`">
|
||||
{{ item.parsed.method }}
|
||||
</span>
|
||||
<span class="proxy-log-entry__path" :title="pathTitle(item.parsed)">{{ item.parsed.path }}</span>
|
||||
<span class="proxy-log-entry__status">{{ item.parsed.status }}</span>
|
||||
<span class="proxy-log-entry__ms">{{ item.parsed.ms }}ms</span>
|
||||
<span class="proxy-log-entry__client mono">{{ item.parsed.client }}</span>
|
||||
<span v-if="!hideUpstream" class="proxy-log-entry__upstream mono" :title="item.parsed.upstream">
|
||||
→ {{ formatUpstream(item.parsed.upstream) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="proxy-log-line proxy-log-line--raw">{{ item.raw }}</div>
|
||||
</template>
|
||||
<div v-if="parsedLines.length === 0" class="proxy-log-empty">暂无记录,通过反代域名访问后会显示在这里。</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
type AccessLogEntry = {
|
||||
time: string
|
||||
host: string
|
||||
method: string
|
||||
path: string
|
||||
status: number
|
||||
ms: string
|
||||
client: string
|
||||
upstream: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
lines: string[]
|
||||
embedded?: boolean
|
||||
fullscreen?: boolean
|
||||
hideUpstream?: boolean
|
||||
}>(),
|
||||
{
|
||||
hideUpstream: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
scroll: [el: HTMLElement]
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
const accessLineReWithPort =
|
||||
/^(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)(?:\s+(\d+)\s+(\d+))?$/
|
||||
const accessLineReLegacy =
|
||||
/^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)(?:\s+(\d+)\s+(\d+))?$/
|
||||
|
||||
function parseAccessLog(line: string): AccessLogEntry | null {
|
||||
let m = line.match(accessLineReWithPort)
|
||||
if (m) {
|
||||
const [, time, host, , method, path, status, rt, client, upstream] = m
|
||||
return buildEntry(time, host, method, path, status, rt, client, upstream)
|
||||
}
|
||||
m = line.match(accessLineReLegacy)
|
||||
if (!m) return null
|
||||
const [, time, host, method, path, status, rt, client, upstream] = m
|
||||
return buildEntry(time, host, method, path, status, rt, client, upstream)
|
||||
}
|
||||
|
||||
function buildEntry(
|
||||
time: string,
|
||||
host: string,
|
||||
method: string,
|
||||
path: string,
|
||||
status: string,
|
||||
rt: string,
|
||||
client: string,
|
||||
upstream: string,
|
||||
): AccessLogEntry {
|
||||
return {
|
||||
time: time.replace('T', ' ').replace(/([+-]\d{2}:\d{2}|Z)$/, ''),
|
||||
host,
|
||||
method,
|
||||
path,
|
||||
status: Number(status),
|
||||
ms: (parseFloat(rt) * 1000).toFixed(1),
|
||||
client,
|
||||
upstream,
|
||||
}
|
||||
}
|
||||
|
||||
const parsedLines = computed(() =>
|
||||
props.lines.map((raw) => ({ raw, parsed: parseAccessLog(raw) })),
|
||||
)
|
||||
|
||||
function formatLogTime(raw: string): string {
|
||||
const normalized = raw.replace('T', ' ')
|
||||
const space = normalized.indexOf(' ')
|
||||
if (space >= 0 && space < normalized.length - 1) {
|
||||
return normalized.slice(space + 1).replace(/([+-]\d{2}:\d{2}|Z)$/, '')
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function formatUpstream(upstream: string): string {
|
||||
if (!upstream || upstream === '-') return '-'
|
||||
return upstream
|
||||
}
|
||||
|
||||
function pathTitle(entry: AccessLogEntry): string {
|
||||
if (entry.host) return `${entry.host}${entry.path}`
|
||||
return entry.path
|
||||
}
|
||||
|
||||
function logEntryStatusClass(status: number): string {
|
||||
if (status >= 500) return 'is-error'
|
||||
if (status >= 400) return 'is-warn'
|
||||
return ''
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (root.value) emit('scroll', root.value)
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (root.value) root.value.scrollTop = root.value.scrollHeight
|
||||
}
|
||||
|
||||
defineExpose({ scrollToBottom })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.proxy-log-box {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
padding: var(--fonu-space-3);
|
||||
font-family: var(--fonu-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
border-radius: var(--fonu-radius-sm);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.2) transparent;
|
||||
}
|
||||
|
||||
.proxy-log-box:hover {
|
||||
scrollbar-color: rgba(148, 163, 184, 0.55) rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
.proxy-log-box::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.proxy-log-box::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.proxy-log-box::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.proxy-log-box:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.55);
|
||||
}
|
||||
|
||||
.proxy-log-box--embedded {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.proxy-log-box--embedded.is-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.proxy-log-box--fullscreen {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: var(--fonu-radius);
|
||||
}
|
||||
|
||||
.proxy-log-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.proxy-log-entry + .proxy-log-entry {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.proxy-log-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.proxy-log-entry__time {
|
||||
flex: 0 0 auto;
|
||||
color: #94a3b8;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.proxy-log-entry__method {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.proxy-log-entry__method.is-get { background: rgba(59, 130, 246, 0.18); color: #93c5fd; }
|
||||
.proxy-log-entry__method.is-post { background: rgba(16, 185, 129, 0.18); color: #6ee7b7; }
|
||||
.proxy-log-entry__method.is-put,
|
||||
.proxy-log-entry__method.is-patch { background: rgba(245, 158, 11, 0.18); color: #fcd34d; }
|
||||
.proxy-log-entry__method.is-delete { background: rgba(239, 68, 68, 0.18); color: #fca5a5; }
|
||||
|
||||
.proxy-log-entry__path {
|
||||
flex: 1 1 auto;
|
||||
min-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.proxy-log-entry__status {
|
||||
flex: 0 0 auto;
|
||||
min-width: 28px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.proxy-log-entry.is-warn .proxy-log-entry__status { color: #fbbf24; }
|
||||
.proxy-log-entry.is-error .proxy-log-entry__status { color: #f87171; }
|
||||
|
||||
.proxy-log-entry__ms {
|
||||
flex: 0 0 auto;
|
||||
min-width: 52px;
|
||||
color: #94a3b8;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.proxy-log-entry__client {
|
||||
flex: 0 0 auto;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.proxy-log-entry__upstream {
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.proxy-log-line {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
padding: 6px 10px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.proxy-log-line--raw + .proxy-log-entry,
|
||||
.proxy-log-entry + .proxy-log-line--raw {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.proxy-log-empty {
|
||||
padding: var(--fonu-space-5);
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.mono { font-family: var(--fonu-mono); }
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__header">
|
||||
<div class="stat-card__title-row">
|
||||
<div v-if="$slots.icon" class="stat-card__icon" :class="`stat-card__icon--${tone}`">
|
||||
<slot name="icon" />
|
||||
</div>
|
||||
<span class="stat-card__label">{{ label }}</span>
|
||||
</div>
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
<div v-if="$slots.value" class="stat-card__value-slot">
|
||||
<slot name="value" />
|
||||
</div>
|
||||
<div v-else-if="value !== undefined && value !== ''" class="stat-card__value" :class="{ 'stat-card__value--sm': valueSmall }">
|
||||
{{ value }}
|
||||
</div>
|
||||
<div v-if="sub" class="stat-card__sub">{{ sub }}</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
value?: string | number
|
||||
sub?: string
|
||||
tone?: 'brand' | 'blue' | 'green' | 'teal'
|
||||
valueSmall?: boolean
|
||||
}>(),
|
||||
{ tone: 'brand', valueSmall: false },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
background: var(--fonu-surface);
|
||||
border: 1px solid var(--fonu-border);
|
||||
border-radius: var(--fonu-radius);
|
||||
box-shadow: var(--fonu-shadow);
|
||||
padding: var(--fonu-space-5);
|
||||
min-height: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: var(--fonu-shadow-md);
|
||||
}
|
||||
|
||||
.stat-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--fonu-space-2);
|
||||
margin-bottom: var(--fonu-space-3);
|
||||
}
|
||||
|
||||
.stat-card__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fonu-space-3);
|
||||
}
|
||||
|
||||
.stat-card__icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-card__icon--brand {
|
||||
background: var(--fonu-brand-soft);
|
||||
color: var(--fonu-brand);
|
||||
}
|
||||
|
||||
.stat-card__icon--blue {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.stat-card__icon--green {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat-card__icon--teal {
|
||||
background: rgba(20, 184, 166, 0.12);
|
||||
color: #14b8a6;
|
||||
}
|
||||
|
||||
.stat-card__label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.stat-card__value,
|
||||
.stat-card__value-slot {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
color: var(--fonu-text);
|
||||
word-break: break-all;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.stat-card__value--sm {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.stat-card__sub {
|
||||
margin-top: var(--fonu-space-2);
|
||||
font-size: 13px;
|
||||
color: var(--fonu-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<span class="status-badge" :class="[kind, { pill }]">
|
||||
<span class="dot" />
|
||||
<span class="label">{{ label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { statusKind, statusLabel, type StatusKind } from '../utils/status'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value?: string
|
||||
text?: string
|
||||
kind?: StatusKind
|
||||
pill?: boolean
|
||||
}>(),
|
||||
{ pill: true },
|
||||
)
|
||||
|
||||
const kind = computed(() => props.kind ?? statusKind(props.value))
|
||||
const label = computed(() => props.text ?? statusLabel(props.value))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-badge.pill {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--fonu-bg);
|
||||
}
|
||||
|
||||
.status-badge.pill.success {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: var(--fonu-success);
|
||||
}
|
||||
|
||||
.status-badge.pill.warning {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: var(--fonu-warning);
|
||||
}
|
||||
|
||||
.status-badge.pill.error {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: var(--fonu-error);
|
||||
}
|
||||
|
||||
.status-badge.pill.disabled,
|
||||
.status-badge.pill.unknown {
|
||||
background: var(--fonu-bg-muted);
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.success .dot { background: var(--fonu-success); }
|
||||
.warning .dot { background: var(--fonu-warning); }
|
||||
.error .dot { background: var(--fonu-error); }
|
||||
.disabled .dot { background: var(--fonu-disabled); }
|
||||
.unknown .dot { background: var(--fonu-text-muted); }
|
||||
|
||||
.success .label { color: inherit; }
|
||||
.warning .label { color: inherit; }
|
||||
.error .label { color: inherit; }
|
||||
.disabled .label { color: inherit; }
|
||||
.unknown .label { color: inherit; }
|
||||
|
||||
.status-badge:not(.pill) .success .label { color: var(--fonu-success); }
|
||||
.status-badge:not(.pill) .warning .label { color: var(--fonu-warning); }
|
||||
.status-badge:not(.pill) .error .label { color: var(--fonu-error); }
|
||||
.status-badge:not(.pill) .disabled .label,
|
||||
.status-badge:not(.pill) .unknown .label { color: var(--fonu-text-secondary); }
|
||||
</style>
|
||||
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<div class="provider-form">
|
||||
<div class="provider-form__head">
|
||||
<span class="provider-form__title">{{ editing ? '编辑服务商' : '添加服务商' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="provider-form__field">
|
||||
<label class="provider-form__label">DNS 服务商</label>
|
||||
<div class="provider-select">
|
||||
<span class="provider-logo provider-logo--md">
|
||||
<img :src="providerIcon(form.provider)" :alt="providerLabel(form.provider)" />
|
||||
</span>
|
||||
<n-select
|
||||
v-model:value="form.provider"
|
||||
:options="providerOptions"
|
||||
:disabled="!!editing"
|
||||
size="small"
|
||||
class="provider-select__input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.provider === 'dnspod'" class="provider-form__field">
|
||||
<label class="provider-form__label">Token ID</label>
|
||||
<n-input v-model:value="form.api_token_id" placeholder="DNSPod ID" size="small" />
|
||||
</div>
|
||||
|
||||
<div class="provider-form__field">
|
||||
<label class="provider-form__label provider-secret-label">
|
||||
<span>{{ credentialLabel }}</span>
|
||||
<n-tag v-if="editing?.has_token" size="small" type="success" :bordered="false">
|
||||
{{ CONFIGURED_SECRET_TAG }}
|
||||
</n-tag>
|
||||
</label>
|
||||
<n-input
|
||||
v-model:value="form.api_token"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="credentialPlaceholder"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="form.provider === 'alidns' || form.provider === 'tencentcloud' || form.provider === 'volcengine'" class="provider-form__field">
|
||||
<label class="provider-form__label provider-secret-label">
|
||||
<span>{{ secretLabel }}</span>
|
||||
<n-tag v-if="editing?.has_token" size="small" type="success" :bordered="false">
|
||||
{{ CONFIGURED_SECRET_TAG }}
|
||||
</n-tag>
|
||||
</label>
|
||||
<n-input
|
||||
v-model:value="form.api_secret"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="editing?.has_token ? CONFIGURED_SECRET_PLACEHOLDER : secretPlaceholder"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="provider-form__switches">
|
||||
<div class="settings-switch">
|
||||
<span>IPv4</span>
|
||||
<n-switch v-model:value="form.ipv4_enabled" size="small" />
|
||||
</div>
|
||||
<div class="settings-switch">
|
||||
<span>IPv6</span>
|
||||
<n-switch v-model:value="form.ipv6_enabled" size="small" />
|
||||
</div>
|
||||
<div class="settings-switch">
|
||||
<span>启用</span>
|
||||
<n-switch v-model:value="form.enabled" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="provider-form__actions">
|
||||
<n-button size="small" quaternary @click="emit('cancel')">取消</n-button>
|
||||
<n-button size="small" :loading="testing" @click="emit('test')">测试连接</n-button>
|
||||
<n-button size="small" type="primary" :loading="saving" @click="emit('save')">保存</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NButton, NInput, NSelect, NSwitch, NTag } from 'naive-ui'
|
||||
import { CONFIGURED_SECRET_PLACEHOLDER, CONFIGURED_SECRET_TAG } from '../constants/secretField'
|
||||
import aliyunIcon from '../assets/brand/dns/aliyun.png'
|
||||
import cloudflareIcon from '../assets/brand/dns/cloudflare.png'
|
||||
import dnspodIcon from '../assets/brand/dns/dnspod.png'
|
||||
import tencentcloudIcon from '../assets/brand/dns/tencentcloud.png'
|
||||
import volcengineIcon from '../assets/brand/dns/volcengine.png'
|
||||
import type { DDNSConfig } from '../api/types'
|
||||
|
||||
export type ProviderForm = {
|
||||
provider: string
|
||||
api_token: string
|
||||
api_token_id: string
|
||||
api_secret: string
|
||||
ipv4_enabled: boolean
|
||||
ipv6_enabled: boolean
|
||||
enabled: boolean
|
||||
custom_ipv4: string
|
||||
custom_ipv6: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
form: ProviderForm
|
||||
editing?: DDNSConfig | null
|
||||
saving?: boolean
|
||||
testing?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: []
|
||||
test: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const providerOptions = [
|
||||
{ label: 'Cloudflare', value: 'cloudflare' },
|
||||
{ label: 'DNSPod', value: 'dnspod' },
|
||||
{ label: '阿里云 DNS', value: 'alidns' },
|
||||
{ label: '腾讯云 DNS', value: 'tencentcloud' },
|
||||
{ label: '火山引擎 DNS', value: 'volcengine' },
|
||||
]
|
||||
|
||||
const providerMap: Record<string, { label: string; icon: string }> = {
|
||||
cloudflare: { label: 'Cloudflare', icon: cloudflareIcon },
|
||||
dnspod: { label: 'DNSPod', icon: dnspodIcon },
|
||||
alidns: { label: '阿里云 DNS', icon: aliyunIcon },
|
||||
tencentcloud: { label: '腾讯云 DNS', icon: tencentcloudIcon },
|
||||
volcengine: { label: '火山引擎 DNS', icon: volcengineIcon },
|
||||
}
|
||||
|
||||
const credentialLabel = computed(() => {
|
||||
if (props.form.provider === 'alidns' || props.form.provider === 'volcengine') return 'AccessKey ID'
|
||||
if (props.form.provider === 'tencentcloud') return 'SecretId'
|
||||
if (props.form.provider === 'dnspod') return 'Token'
|
||||
return 'API Token'
|
||||
})
|
||||
|
||||
const secretLabel = computed(() => {
|
||||
if (props.form.provider === 'tencentcloud') return 'SecretKey'
|
||||
return 'AccessKey Secret'
|
||||
})
|
||||
|
||||
const secretPlaceholder = computed(() => {
|
||||
if (props.form.provider === 'tencentcloud') return '腾讯云 SecretKey'
|
||||
return 'AccessKey Secret'
|
||||
})
|
||||
|
||||
const credentialPlaceholder = computed(() => {
|
||||
if (props.editing?.has_token) return CONFIGURED_SECRET_PLACEHOLDER
|
||||
if (props.form.provider === 'alidns' || props.form.provider === 'volcengine') return 'AccessKey ID'
|
||||
if (props.form.provider === 'tencentcloud') return '腾讯云 SecretId'
|
||||
if (props.form.provider === 'dnspod') return 'DNSPod Token'
|
||||
return 'Cloudflare API Token'
|
||||
})
|
||||
|
||||
function providerLabel(v: string) {
|
||||
return providerMap[v]?.label ?? v
|
||||
}
|
||||
|
||||
function providerIcon(v: string) {
|
||||
return providerMap[v]?.icon ?? ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provider-form__head {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.provider-form__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--fonu-text);
|
||||
}
|
||||
|
||||
.provider-form__field {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.provider-form__label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.provider-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.provider-select__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.provider-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
border: 1px solid var(--fonu-border);
|
||||
}
|
||||
|
||||
.provider-logo--md {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.provider-logo img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.provider-form__switches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
|
||||
.settings-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--fonu-text);
|
||||
}
|
||||
|
||||
.provider-form__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.provider-secret-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { darkTheme, type GlobalThemeOverrides } from 'naive-ui'
|
||||
|
||||
const themeKey = 'fonu-theme'
|
||||
const themeMode = ref(localStorage.getItem(themeKey) ?? 'system')
|
||||
|
||||
function systemDark() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
const shared = {
|
||||
primaryColor: '#10B981',
|
||||
primaryColorHover: '#059669',
|
||||
primaryColorPressed: '#047857',
|
||||
primaryColorSuppl: '#34D399',
|
||||
borderRadius: '8px',
|
||||
fontFamily: 'var(--fonu-font)',
|
||||
}
|
||||
|
||||
const lightOverrides: GlobalThemeOverrides = {
|
||||
common: {
|
||||
...shared,
|
||||
bodyColor: '#F8FAFC',
|
||||
cardColor: '#FFFFFF',
|
||||
modalColor: '#FFFFFF',
|
||||
popoverColor: '#FFFFFF',
|
||||
borderColor: '#E5E7EB',
|
||||
dividerColor: '#E5E7EB',
|
||||
textColor1: '#111827',
|
||||
textColor2: '#6B7280',
|
||||
textColor3: '#9CA3AF',
|
||||
hoverColor: 'rgba(16, 185, 129, 0.08)',
|
||||
},
|
||||
Card: { borderRadius: '12px', color: '#FFFFFF' },
|
||||
Button: { borderRadiusMedium: '8px', heightMedium: '36px' },
|
||||
Input: { borderRadius: '8px' },
|
||||
DataTable: { borderRadius: '12px', thColor: '#F9FAFB', tdColor: '#FFFFFF' },
|
||||
Tabs: { tabGapMediumLine: '32px', tabPaddingMediumLine: '12px 4px' },
|
||||
Layout: { color: '#FFFFFF', siderColor: '#FFFFFF' },
|
||||
Menu: {
|
||||
itemColorActive: 'rgba(16, 185, 129, 0.12)',
|
||||
itemTextColorActive: '#047857',
|
||||
itemIconColorActive: '#10B981',
|
||||
itemColorActiveHover: 'rgba(16, 185, 129, 0.16)',
|
||||
},
|
||||
Radio: {
|
||||
buttonBorderColorActive: '#10B981',
|
||||
buttonTextColorActive: '#047857',
|
||||
buttonColorActive: 'rgba(16, 185, 129, 0.12)',
|
||||
buttonBoxShadowHover: 'none',
|
||||
buttonBoxShadowFocus: 'none',
|
||||
},
|
||||
}
|
||||
|
||||
const darkOverrides: GlobalThemeOverrides = {
|
||||
common: {
|
||||
...shared,
|
||||
bodyColor: '#0F172A',
|
||||
cardColor: '#1E293B',
|
||||
modalColor: '#1E293B',
|
||||
popoverColor: '#1E293B',
|
||||
borderColor: '#334155',
|
||||
dividerColor: '#334155',
|
||||
textColor1: '#F1F5F9',
|
||||
textColor2: '#94A3B8',
|
||||
textColor3: '#64748B',
|
||||
hoverColor: 'rgba(16, 185, 129, 0.12)',
|
||||
},
|
||||
Card: { borderRadius: '12px', color: '#1E293B', borderColor: '#334155' },
|
||||
Button: { borderRadiusMedium: '8px', heightMedium: '36px' },
|
||||
Input: { borderRadius: '8px', color: '#0F172A' },
|
||||
DataTable: { borderRadius: '12px', thColor: '#0F172A', tdColor: '#1E293B', borderColor: '#334155' },
|
||||
Tabs: { tabGapMediumLine: '32px', tabPaddingMediumLine: '12px 4px' },
|
||||
Layout: { color: '#0F172A', siderColor: '#1E293B' },
|
||||
Menu: {
|
||||
itemColorActive: 'rgba(16, 185, 129, 0.18)',
|
||||
itemTextColorActive: '#6EE7B7',
|
||||
itemIconColorActive: '#34D399',
|
||||
itemColorActiveHover: 'rgba(16, 185, 129, 0.24)',
|
||||
},
|
||||
Radio: {
|
||||
buttonBorderColorActive: '#34D399',
|
||||
buttonTextColorActive: '#6EE7B7',
|
||||
buttonColorActive: 'rgba(16, 185, 129, 0.22)',
|
||||
buttonBoxShadowHover: 'none',
|
||||
buttonBoxShadowFocus: 'none',
|
||||
},
|
||||
}
|
||||
|
||||
function resolveDark(mode: string) {
|
||||
if (mode === 'dark') return true
|
||||
if (mode === 'light') return false
|
||||
return systemDark()
|
||||
}
|
||||
|
||||
function applyDocumentTheme(dark: boolean) {
|
||||
document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const isDark = computed(() => resolveDark(themeMode.value))
|
||||
|
||||
const naiveTheme = computed(() => (isDark.value ? darkTheme : null))
|
||||
const themeOverrides = computed<GlobalThemeOverrides>(() =>
|
||||
isDark.value ? darkOverrides : lightOverrides,
|
||||
)
|
||||
|
||||
function setThemeMode(mode: string) {
|
||||
themeMode.value = mode
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
themeMode.value = isDark.value ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
watch(
|
||||
themeMode,
|
||||
(value) => {
|
||||
localStorage.setItem(themeKey, value)
|
||||
applyDocumentTheme(resolveDark(value))
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (themeMode.value === 'system') {
|
||||
applyDocumentTheme(systemDark())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { themeMode, naiveTheme, themeOverrides, isDark, toggleTheme, setThemeMode }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const GITHUB_REPO = 'O96u/Fonu'
|
||||
export const GITHUB_REPO_URL = `https://github.com/${GITHUB_REPO}`
|
||||
export const GITHUB_REPO_DISPLAY = `github.com/${GITHUB_REPO}`
|
||||
export const GITHUB_RELEASES_URL = `${GITHUB_REPO_URL}/releases`
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Mirrors internal/proxy.DefaultPrivateCIDRs — always exempt from china_only / global allow. */
|
||||
export const DEFAULT_PRIVATE_CIDRS = [
|
||||
'10.0.0.0/8',
|
||||
'127.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'::1/128',
|
||||
'fc00::/7',
|
||||
]
|
||||
|
||||
export function mergeIPList(defaults: string[], custom: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const cidr of [...defaults, ...custom]) {
|
||||
const value = cidr.trim()
|
||||
if (!value || seen.has(value)) continue
|
||||
seen.add(value)
|
||||
out.push(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function stripDefaultIPList(defaults: string[], list: string[]): string[] {
|
||||
const defaultSet = new Set(defaults)
|
||||
return list.map((item) => item.trim()).filter((item) => item && !defaultSet.has(item))
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export interface NotifyEventOption {
|
||||
key: string
|
||||
label: string
|
||||
hint?: string
|
||||
hasThreshold?: boolean
|
||||
thresholdKey?: 'ip_frequent' | 'login_failure'
|
||||
}
|
||||
|
||||
export interface NotifyEventGroup {
|
||||
title: string
|
||||
events: NotifyEventOption[]
|
||||
}
|
||||
|
||||
export const NOTIFY_EVENT_GROUPS: NotifyEventGroup[] = [
|
||||
{
|
||||
title: 'DDNS',
|
||||
events: [
|
||||
{
|
||||
key: 'on_ddns_ip_change',
|
||||
label: 'IP 变更',
|
||||
hint: '公网 IP 变更并成功同步到 DNS',
|
||||
},
|
||||
{
|
||||
key: 'on_ddns_failure',
|
||||
label: '更新失败',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '证书',
|
||||
events: [
|
||||
{
|
||||
key: 'on_cert_expiry',
|
||||
label: '即将到期',
|
||||
hint: '进入续签阈值后每天提醒一次',
|
||||
},
|
||||
{
|
||||
key: 'on_cert_renew_success',
|
||||
label: '续签成功',
|
||||
},
|
||||
{
|
||||
key: 'on_cert_renew_failure',
|
||||
label: '续签失败',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '安全',
|
||||
events: [
|
||||
{
|
||||
key: 'on_ip_frequent_access',
|
||||
label: 'IP 频繁访问',
|
||||
hint: '同一 IP 在统计窗口内超过阈值',
|
||||
hasThreshold: true,
|
||||
thresholdKey: 'ip_frequent',
|
||||
},
|
||||
{
|
||||
key: 'on_login_failure',
|
||||
label: '登录异常',
|
||||
hint: '同一 IP 在窗口内多次登录失败',
|
||||
hasThreshold: true,
|
||||
thresholdKey: 'login_failure',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统',
|
||||
events: [
|
||||
{
|
||||
key: 'on_nginx_reload_failure',
|
||||
label: 'Nginx 重载失败',
|
||||
hint: '保存反代或证书后 Nginx 未能成功重载',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_IP_FREQUENT_THRESHOLD = 100
|
||||
export const DEFAULT_IP_FREQUENT_WINDOW_SEC = 60
|
||||
export const DEFAULT_LOGIN_FAILURE_THRESHOLD = 5
|
||||
export const DEFAULT_LOGIN_FAILURE_WINDOW_SEC = 300
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { NotifyType, WebhookProvider } from '../api/types'
|
||||
|
||||
export const NOTIFY_TYPE_OPTIONS: { label: string; value: NotifyType }[] = [
|
||||
{ label: '关闭', value: '' },
|
||||
{ label: '邮件(SMTP)', value: 'email' },
|
||||
{ label: 'Webhook', value: 'webhook' },
|
||||
{ label: 'Telegram 机器人', value: 'telegram' },
|
||||
]
|
||||
|
||||
export const WEBHOOK_PROVIDER_OPTIONS: { label: string; value: WebhookProvider }[] = [
|
||||
{ label: 'Bark', value: 'bark' },
|
||||
{ label: 'ntfy', value: 'ntfy' },
|
||||
{ label: 'Gotify', value: 'gotify' },
|
||||
{ label: '自定义 Webhook', value: 'custom' },
|
||||
]
|
||||
|
||||
export const DEFAULT_BARK_SERVER = 'https://api.day.app'
|
||||
export const DEFAULT_NTFY_SERVER = 'https://ntfy.sh'
|
||||
export const MASKED_SECRET = '********'
|
||||
@@ -0,0 +1,3 @@
|
||||
/** 敏感凭据字段:标签与占位符全站统一 */
|
||||
export const CONFIGURED_SECRET_TAG = '已配置'
|
||||
export const CONFIGURED_SECRET_PLACEHOLDER = '已配置,留空表示不修改'
|
||||
@@ -0,0 +1,14 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
|
||||
declare module '*.png' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
<template>
|
||||
<n-layout has-sider class="app-layout">
|
||||
<n-layout-sider
|
||||
class="app-sider"
|
||||
bordered
|
||||
collapse-mode="width"
|
||||
:collapsed-width="72"
|
||||
:width="240"
|
||||
:collapsed="collapsed"
|
||||
show-trigger="bar"
|
||||
@collapse="collapsed = true"
|
||||
@expand="collapsed = false"
|
||||
>
|
||||
<router-link class="brand" :class="{ 'brand--collapsed': collapsed }" :to="{ name: 'dashboard' }">
|
||||
<img class="brand__logo" :src="logoImg" alt="Fonu" />
|
||||
<div v-if="!collapsed" class="brand__text">
|
||||
<div class="brand__name">Fonu</div>
|
||||
<div class="brand__tagline">让 NAS 访问更简单</div>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<n-menu
|
||||
class="app-menu"
|
||||
:value="activeKey"
|
||||
:collapsed="collapsed"
|
||||
:collapsed-width="72"
|
||||
:options="menuOptions"
|
||||
@update:value="handleMenu"
|
||||
/>
|
||||
|
||||
<div v-if="!collapsed" class="sider-footer">
|
||||
<div class="sider-footer__version-row">
|
||||
<div class="sider-footer__title">
|
||||
<span class="sider-footer__brand">Fonu</span>
|
||||
<span class="sider-footer__version">v{{ appVersion }}</span>
|
||||
</div>
|
||||
<a
|
||||
v-if="hasUpdate"
|
||||
class="sider-footer__update"
|
||||
:href="latestReleaseUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<n-icon :component="ArrowUpOutline" />
|
||||
有新版本
|
||||
<n-icon :component="OpenOutline" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sider-footer__status">
|
||||
<n-icon :component="Ellipse" class="sider-footer__status-icon" />
|
||||
<span>运行中</span>
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="sider-footer__github"
|
||||
:href="GITHUB_REPO_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<n-icon :component="LogoGithub" />
|
||||
<span>{{ GITHUB_REPO }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</n-layout-sider>
|
||||
|
||||
<n-layout class="app-main">
|
||||
<header class="topbar">
|
||||
<span class="topbar__datetime">{{ datetime }}</span>
|
||||
<div class="topbar__right">
|
||||
<n-button quaternary circle @click="toggleTheme">
|
||||
<template #icon>
|
||||
<n-icon :component="isDark ? MoonOutline : SunnyOutline" />
|
||||
</template>
|
||||
</n-button>
|
||||
<n-dropdown :options="userMenuOptions" @select="handleUserMenu">
|
||||
<n-button quaternary class="user-btn">
|
||||
<n-icon :component="PersonOutline" class="user-btn__icon" />
|
||||
<span class="user-name">管理员</span>
|
||||
<n-icon :component="ChevronDownOutline" class="user-btn__chevron" />
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<n-layout-content class="content">
|
||||
<div class="page-shell page-body">
|
||||
<router-view />
|
||||
</div>
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
NButton,
|
||||
NDropdown,
|
||||
NIcon,
|
||||
NLayout,
|
||||
NLayoutContent,
|
||||
NLayoutSider,
|
||||
NMenu,
|
||||
useMessage,
|
||||
type MenuOption,
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
ArrowUpOutline,
|
||||
ChevronDownOutline,
|
||||
CloudOutline,
|
||||
CogOutline,
|
||||
DocumentTextOutline,
|
||||
Ellipse,
|
||||
GitNetworkOutline,
|
||||
GridOutline,
|
||||
LockClosedOutline,
|
||||
LogoGithub,
|
||||
MoonOutline,
|
||||
OpenOutline,
|
||||
PersonOutline,
|
||||
SunnyOutline,
|
||||
SwapHorizontalOutline,
|
||||
HardwareChipOutline,
|
||||
ShieldCheckmarkOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import { api } from '../api/client'
|
||||
import { GITHUB_REPO, GITHUB_REPO_URL } from '../constants/app'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import { fetchLatestRelease, isNewerVersion } from '../utils/version'
|
||||
import logoImg from '../assets/brand/logo.png'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
const collapsed = ref(false)
|
||||
const datetime = ref('')
|
||||
const appVersion = ref(__APP_VERSION__)
|
||||
const latestVersion = ref('')
|
||||
const latestReleaseUrl = ref('')
|
||||
const hasUpdate = ref(false)
|
||||
let clockTimer: number | undefined
|
||||
|
||||
async function checkForUpdate() {
|
||||
const release = await fetchLatestRelease()
|
||||
if (!release) {
|
||||
return
|
||||
}
|
||||
|
||||
latestVersion.value = release.version
|
||||
latestReleaseUrl.value = release.url
|
||||
hasUpdate.value = isNewerVersion(release.version, appVersion.value)
|
||||
}
|
||||
|
||||
async function loadVersion() {
|
||||
try {
|
||||
const { version } = await api.getVersion()
|
||||
if (version) {
|
||||
appVersion.value = version
|
||||
}
|
||||
} catch {
|
||||
// keep build-time fallback
|
||||
}
|
||||
|
||||
await checkForUpdate()
|
||||
}
|
||||
|
||||
const menuDefs = [
|
||||
{ label: '仪表盘', key: 'dashboard', icon: GridOutline },
|
||||
{ label: 'DDNS', key: 'ddns', icon: CloudOutline },
|
||||
{ label: '证书管理', key: 'certificates', icon: LockClosedOutline },
|
||||
{ label: '反向代理', key: 'proxies', icon: SwapHorizontalOutline },
|
||||
{ label: '内网穿透', key: 'frp', icon: GitNetworkOutline },
|
||||
{ label: '公网 Agent', key: 'frp-agent', icon: HardwareChipOutline },
|
||||
{ label: '公网反代', key: 'public-proxy', icon: ShieldCheckmarkOutline },
|
||||
{ label: '日志中心', key: 'logs', icon: DocumentTextOutline },
|
||||
{ label: '系统设置', key: 'settings', icon: CogOutline },
|
||||
]
|
||||
|
||||
const menuOptions = computed<MenuOption[]>(() =>
|
||||
menuDefs.map((item) => ({
|
||||
label: item.label,
|
||||
key: item.key,
|
||||
icon: () => h(NIcon, null, { default: () => h(item.icon) }),
|
||||
})),
|
||||
)
|
||||
|
||||
const activeKey = computed(() => {
|
||||
const name = route.name
|
||||
return typeof name === 'string' ? name : 'dashboard'
|
||||
})
|
||||
|
||||
const userMenuOptions = [{ label: '退出登录', key: 'logout' }]
|
||||
|
||||
function updateClock() {
|
||||
const now = new Date()
|
||||
const date = now.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
const time = now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
const weekday = now.toLocaleDateString('zh-CN', { weekday: 'long' })
|
||||
datetime.value = `${date} ${time} · ${weekday}`
|
||||
}
|
||||
|
||||
function handleMenu(key: string) {
|
||||
router.push({ name: key })
|
||||
}
|
||||
|
||||
async function handleUserMenu(key: string) {
|
||||
if (key === 'logout') {
|
||||
await api.logout()
|
||||
message.success('已退出登录')
|
||||
router.push({ name: 'login' })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadVersion()
|
||||
updateClock()
|
||||
clockTimer = window.setInterval(updateClock, 30_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (clockTimer) clearInterval(clockTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
background: var(--fonu-bg);
|
||||
}
|
||||
|
||||
.app-layout :deep(.n-layout-scroll-container) {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.app-sider {
|
||||
background: var(--fonu-surface) !important;
|
||||
border-right: 1px solid var(--fonu-border) !important;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-sider :deep(.n-layout-sider-scroll-container) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fonu-space-3);
|
||||
padding: var(--fonu-space-5) var(--fonu-space-4);
|
||||
border-bottom: 1px solid var(--fonu-border);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.brand:hover {
|
||||
background: var(--fonu-bg-muted);
|
||||
}
|
||||
|
||||
.brand--collapsed {
|
||||
justify-content: center;
|
||||
padding: var(--fonu-space-4) 0;
|
||||
}
|
||||
|
||||
.brand__logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.brand__name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--fonu-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand__tagline {
|
||||
font-size: 11px;
|
||||
color: var(--fonu-text-muted);
|
||||
margin-top: 2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.app-menu {
|
||||
flex: 1;
|
||||
padding: var(--fonu-space-3);
|
||||
}
|
||||
|
||||
.app-menu :deep(.n-menu-item) {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.app-menu :deep(.n-menu-item-content) {
|
||||
border-radius: var(--fonu-radius-sm) !important;
|
||||
padding-left: 14px !important;
|
||||
}
|
||||
|
||||
.app-menu :deep(.n-menu-item-content--selected) {
|
||||
background: var(--fonu-brand-soft) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-menu :deep(.n-menu-item-content--selected .n-menu-item-content-header),
|
||||
.app-menu :deep(.n-menu-item-content--selected .n-menu-item-content-header *) {
|
||||
color: var(--fonu-brand-text) !important;
|
||||
}
|
||||
|
||||
.app-menu :deep(.n-menu-item-content--selected .n-icon),
|
||||
.app-menu :deep(.n-menu-item-content--selected .n-menu-item-content__icon) {
|
||||
color: var(--fonu-brand) !important;
|
||||
}
|
||||
|
||||
.app-menu :deep(.n-menu-item-content--selected::before) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sider-footer {
|
||||
padding: var(--fonu-space-4);
|
||||
border-top: 1px solid var(--fonu-border);
|
||||
}
|
||||
|
||||
.sider-footer__version-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--fonu-space-4);
|
||||
}
|
||||
|
||||
.sider-footer__title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sider-footer__brand {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fonu-text);
|
||||
}
|
||||
|
||||
.sider-footer__version {
|
||||
font-size: 12px;
|
||||
color: var(--fonu-text-muted);
|
||||
}
|
||||
|
||||
.sider-footer__update {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--fonu-brand);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.sider-footer__update:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.sider-footer__update :deep(.n-icon) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sider-footer__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: var(--fonu-space-4);
|
||||
font-size: 12px;
|
||||
color: var(--fonu-success);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sider-footer__status-icon {
|
||||
font-size: 8px;
|
||||
color: var(--fonu-success);
|
||||
}
|
||||
|
||||
.sider-footer__github {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--fonu-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.sider-footer__github:hover {
|
||||
color: var(--fonu-text);
|
||||
}
|
||||
|
||||
.sider-footer__github :deep(.n-icon) {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: var(--fonu-bg);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
flex-shrink: 0;
|
||||
height: var(--fonu-topbar-height);
|
||||
padding: 0 var(--fonu-space-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--fonu-space-4);
|
||||
background: var(--fonu-surface);
|
||||
border-bottom: 1px solid var(--fonu-border);
|
||||
}
|
||||
|
||||
.topbar__datetime {
|
||||
font-size: 13px;
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.topbar__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fonu-space-4);
|
||||
}
|
||||
|
||||
.user-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fonu-space-3);
|
||||
padding: 0 var(--fonu-space-2);
|
||||
}
|
||||
|
||||
.user-btn__icon,
|
||||
.user-btn__chevron {
|
||||
font-size: 18px;
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
color: var(--fonu-text);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: var(--fonu-space-5);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.page-body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.topbar,
|
||||
.content {
|
||||
padding-inline: var(--fonu-space-4);
|
||||
}
|
||||
|
||||
.user-name,
|
||||
.topbar__datetime {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import naive from 'naive-ui'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.config.errorHandler = (err, _instance, info) => {
|
||||
console.error('[fonu]', info, err)
|
||||
}
|
||||
|
||||
app.use(router).use(naive).mount('#app')
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import AppLayout from '../layouts/AppLayout.vue'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
import ProxyView from '../views/ProxyView.vue'
|
||||
import DashboardView from '../views/DashboardView.vue'
|
||||
import DdnsView from '../views/DdnsView.vue'
|
||||
import CertificatesView from '../views/CertificatesView.vue'
|
||||
import LogsView from '../views/LogsView.vue'
|
||||
import SettingsView from '../views/SettingsView.vue'
|
||||
import FrpView from '../views/FrpView.vue'
|
||||
import AgentView from '../views/AgentView.vue'
|
||||
import ProxyRemoteView from '../views/ProxyRemoteView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/setup', redirect: '/login' },
|
||||
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
|
||||
{
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: '', redirect: '/dashboard' },
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'dashboard',
|
||||
component: DashboardView,
|
||||
meta: { title: '仪表盘', description: '查看 Fonu 运行状态与关键指标' },
|
||||
},
|
||||
{
|
||||
path: 'ddns',
|
||||
name: 'ddns',
|
||||
component: DdnsView,
|
||||
meta: { title: 'DDNS', description: '自动同步公网 IP 到 DNS 解析' },
|
||||
},
|
||||
{
|
||||
path: 'certificates',
|
||||
name: 'certificates',
|
||||
component: CertificatesView,
|
||||
meta: { title: '证书管理', description: '通过 ACME 自动申请和续签证书' },
|
||||
},
|
||||
{
|
||||
path: 'proxies',
|
||||
name: 'proxies',
|
||||
component: ProxyView,
|
||||
meta: { title: '反向代理', description: '管理通过域名访问的 NAS 服务' },
|
||||
},
|
||||
{
|
||||
path: 'frp',
|
||||
name: 'frp',
|
||||
component: FrpView,
|
||||
meta: { title: '内网穿透', description: '通过 FRP 将公网流量转发到 Fonu Nginx' },
|
||||
},
|
||||
{
|
||||
path: 'frp-agent',
|
||||
name: 'frp-agent',
|
||||
component: AgentView,
|
||||
meta: { title: '公网 Agent', description: '管理部署在公网 VPS 上的 fonu-agent 与 frps' },
|
||||
},
|
||||
{
|
||||
path: 'public-proxy',
|
||||
name: 'public-proxy',
|
||||
component: ProxyRemoteView,
|
||||
meta: { title: '公网反代', description: '管理 VPS 上 Nginx 反代的部署/移除与安全选项' },
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'logs',
|
||||
component: LogsView,
|
||||
meta: { title: '日志中心', description: '查看系统、访问与 Nginx 错误日志' },
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: SettingsView,
|
||||
meta: { title: '系统设置', description: '配置系统参数与运行策略' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const status = await api.authStatus()
|
||||
if (to.meta.requiresAuth && !status.authenticated) return { name: 'login' }
|
||||
if (to.name === 'login' && status.authenticated) return { name: 'dashboard' }
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,63 @@
|
||||
:root {
|
||||
--fonu-brand: #10b981;
|
||||
--fonu-brand-hover: #059669;
|
||||
--fonu-brand-soft: rgba(16, 185, 129, 0.12);
|
||||
--fonu-brand-text: #047857;
|
||||
--fonu-brand-sidebar: #10b981;
|
||||
|
||||
--fonu-success: #10b981;
|
||||
--fonu-warning: #f59e0b;
|
||||
--fonu-error: #ef4444;
|
||||
--fonu-disabled: #94a3b8;
|
||||
--fonu-info: #3b82f6;
|
||||
--fonu-info-soft: rgba(59, 130, 246, 0.1);
|
||||
|
||||
--fonu-bg: #f8fafc;
|
||||
--fonu-bg-muted: #f3f4f6;
|
||||
--fonu-banner-bg: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 40%, #a7f3d0 100%);
|
||||
--fonu-banner-border: rgba(16, 185, 129, 0.2);
|
||||
--fonu-surface: #ffffff;
|
||||
--fonu-border: #e5e7eb;
|
||||
--fonu-text: #111827;
|
||||
--fonu-text-secondary: #6b7280;
|
||||
--fonu-text-muted: #9ca3af;
|
||||
|
||||
--fonu-radius: 12px;
|
||||
--fonu-radius-sm: 8px;
|
||||
--fonu-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
--fonu-shadow-md: 0 4px 6px -1px rgba(15, 23, 42, 0.08), 0 2px 4px -2px rgba(15, 23, 42, 0.06);
|
||||
|
||||
--fonu-sidebar-width: 240px;
|
||||
--fonu-topbar-height: 56px;
|
||||
--fonu-form-max: 720px;
|
||||
|
||||
--fonu-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--fonu-mono: ui-monospace, 'Cascadia Code', 'Segoe UI Mono', monospace;
|
||||
|
||||
--fonu-space-1: 4px;
|
||||
--fonu-space-2: 8px;
|
||||
--fonu-space-3: 12px;
|
||||
--fonu-space-4: 16px;
|
||||
--fonu-space-5: 24px;
|
||||
--fonu-space-6: 32px;
|
||||
}
|
||||
|
||||
html.dark,
|
||||
[data-theme='dark'] {
|
||||
--fonu-brand-soft: rgba(16, 185, 129, 0.18);
|
||||
--fonu-brand-text: #6ee7b7;
|
||||
--fonu-info-soft: rgba(59, 130, 246, 0.16);
|
||||
|
||||
--fonu-bg: #0f172a;
|
||||
--fonu-bg-muted: #1e293b;
|
||||
--fonu-surface: #1e293b;
|
||||
--fonu-border: #334155;
|
||||
--fonu-text: #f1f5f9;
|
||||
--fonu-text-secondary: #94a3b8;
|
||||
--fonu-text-muted: #64748b;
|
||||
--fonu-banner-bg: linear-gradient(135deg, #064e3b 0%, #065f46 45%, #047857 100%);
|
||||
--fonu-banner-border: rgba(52, 211, 153, 0.25);
|
||||
|
||||
--fonu-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--fonu-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { AccessLogEntry, ProxyHost, ProxyRule } from '../api/types'
|
||||
|
||||
export type ProxyBinding = {
|
||||
name: string
|
||||
upstream: string
|
||||
host: string
|
||||
port: number
|
||||
}
|
||||
|
||||
function bindingKey(host: string, port: number): string {
|
||||
return `${host.toLowerCase()}:${port}`
|
||||
}
|
||||
|
||||
function normalizeAccessHost(domain: string): string {
|
||||
const raw = domain.trim().toLowerCase()
|
||||
if (!raw) return ''
|
||||
if (raw.startsWith('[')) {
|
||||
const end = raw.indexOf(']')
|
||||
if (end > 0) return raw.slice(0, end + 1)
|
||||
}
|
||||
if (raw.includes(':') && !raw.includes(']')) {
|
||||
const idx = raw.lastIndexOf(':')
|
||||
const maybePort = Number.parseInt(raw.slice(idx + 1), 10)
|
||||
if (idx > 0 && maybePort > 0 && maybePort <= 65535) {
|
||||
return raw.slice(0, idx)
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
export function buildProxyBindingIndex(rules: ProxyRule[]): Map<string, ProxyBinding> {
|
||||
const index = new Map<string, ProxyBinding>()
|
||||
for (const rule of rules) {
|
||||
const fallbackName = rule.domain?.trim() || rule.upstream?.trim() || `规则 #${rule.id}`
|
||||
const name = rule.name?.trim() || fallbackName
|
||||
const hosts: ProxyHost[] = rule.hosts?.length
|
||||
? rule.hosts
|
||||
: rule.domain
|
||||
? [{ id: 0, hostname: rule.domain }]
|
||||
: []
|
||||
for (const host of hosts) {
|
||||
const hostname = host.hostname?.trim()
|
||||
if (!hostname) continue
|
||||
const port = host.listen_port ?? rule.listen_port
|
||||
if (port <= 0) continue
|
||||
index.set(bindingKey(hostname, port), {
|
||||
name,
|
||||
upstream: rule.upstream,
|
||||
host: hostname.toLowerCase(),
|
||||
port,
|
||||
})
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function formatUpstreamShort(upstream: string): string {
|
||||
return upstream.replace(/^https?:\/\//i, '')
|
||||
}
|
||||
|
||||
/** Raw host:port / domain / IP shown when no proxy rule matches. */
|
||||
export function formatAccessFallback(entry: AccessLogEntry): string {
|
||||
const rawDomain = entry.domain?.trim()
|
||||
const host = normalizeAccessHost(entry.domain)
|
||||
const port = entry.server_port ?? 0
|
||||
|
||||
if (rawDomain?.includes(':') && (rawDomain.includes(']') || rawDomain.lastIndexOf(':') > rawDomain.lastIndexOf(']'))) {
|
||||
return rawDomain
|
||||
}
|
||||
if (rawDomain?.includes(':') && !rawDomain.includes(']')) {
|
||||
const idx = rawDomain.lastIndexOf(':')
|
||||
const maybePort = Number.parseInt(rawDomain.slice(idx + 1), 10)
|
||||
if (idx > 0 && maybePort > 0 && maybePort <= 65535) {
|
||||
return rawDomain
|
||||
}
|
||||
}
|
||||
|
||||
if (host && port > 0) return `${host}:${port}`
|
||||
if (rawDomain) return rawDomain
|
||||
if (host) return host
|
||||
return entry.client_ip?.trim() || '—'
|
||||
}
|
||||
|
||||
export function resolveAccessServiceLabel(
|
||||
entry: AccessLogEntry,
|
||||
index: Map<string, ProxyBinding>,
|
||||
): string {
|
||||
const host = normalizeAccessHost(entry.domain)
|
||||
const port = entry.server_port ?? 0
|
||||
|
||||
// 域名 + 端口(同域多端口)
|
||||
if (host && port > 0) {
|
||||
const hit = index.get(bindingKey(host, port))
|
||||
if (hit) return hit.name
|
||||
}
|
||||
|
||||
// 仅域名(单规则或同域单端口)
|
||||
if (host) {
|
||||
const matches = [...index.values()].filter((binding) => binding.host === host)
|
||||
if (matches.length === 1) return matches[0].name
|
||||
if (matches.length > 1 && port > 0) {
|
||||
const byPort = matches.find((binding) => binding.port === port)
|
||||
if (byPort) return byPort.name
|
||||
}
|
||||
}
|
||||
|
||||
// 仅 IP / 域名直连、无 Host 匹配时走 upstream
|
||||
const upstream = entry.upstream?.trim()
|
||||
if (upstream && upstream !== '-') {
|
||||
return formatUpstreamShort(upstream)
|
||||
}
|
||||
|
||||
return formatAccessFallback(entry)
|
||||
}
|
||||
|
||||
export function accessServiceTooltip(entry: AccessLogEntry, index: Map<string, ProxyBinding>): string {
|
||||
const host = normalizeAccessHost(entry.domain)
|
||||
const port = entry.server_port ?? 0
|
||||
const label = resolveAccessServiceLabel(entry, index)
|
||||
const parts: string[] = [label]
|
||||
|
||||
const requestHost = formatAccessFallback(entry)
|
||||
if (requestHost !== label) {
|
||||
parts.push(`请求 ${requestHost}`)
|
||||
} else if (host) {
|
||||
parts.push(port > 0 ? `${host}:${port}` : host)
|
||||
}
|
||||
|
||||
const binding = host && port > 0 ? index.get(bindingKey(host, port)) : undefined
|
||||
if (binding?.upstream) {
|
||||
parts.push(`→ ${binding.upstream}`)
|
||||
} else if (entry.upstream && entry.upstream !== '-') {
|
||||
parts.push(`→ ${entry.upstream}`)
|
||||
}
|
||||
if (entry.client_ip) parts.push(`来自 ${entry.client_ip}`)
|
||||
if (entry.path) parts.push(entry.path)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit++
|
||||
}
|
||||
const digits = value >= 100 || unit === 0 ? 0 : value >= 10 ? 1 : 2
|
||||
return `${value.toFixed(digits)} ${units[unit]}`
|
||||
}
|
||||
|
||||
export function formatRate(bytesPerSec: number): string {
|
||||
if (!Number.isFinite(bytesPerSec) || bytesPerSec <= 0) return '0 B/s'
|
||||
return `${formatBytes(bytesPerSec)}/s`
|
||||
}
|
||||
|
||||
export function formatUptime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h > 0) return `${h} 小时 ${m} 分钟`
|
||||
return `${m} 分钟`
|
||||
}
|
||||
|
||||
export function formatRelativeTime(iso?: string): string {
|
||||
if (!iso) return '-'
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso
|
||||
const diff = Date.now() - date.getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes} 分钟前`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours} 小时前`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days} 天前`
|
||||
}
|
||||
|
||||
export function formatMs(seconds: number): string {
|
||||
const ms = Math.round(seconds * 1000)
|
||||
return `${ms} ms`
|
||||
}
|
||||
|
||||
export function formatDate(iso?: string): string {
|
||||
if (!iso) return '-'
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/** Normalize log timestamps to YYYY-MM-DD HH:mm:ss */
|
||||
export function formatLogTime(value?: string): string {
|
||||
if (!value) return '-'
|
||||
|
||||
const trimmed = value.trim()
|
||||
|
||||
const standard = trimmed.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/)
|
||||
if (standard) return standard[1]
|
||||
|
||||
const iso = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})/)
|
||||
if (iso) return `${iso[1]}-${iso[2]}-${iso[3]} ${iso[4]}:${iso[5]}:${iso[6]}`
|
||||
|
||||
const nginx = trimmed.match(/^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})/)
|
||||
if (nginx) return `${nginx[1]}-${nginx[2]}-${nginx[3]} ${nginx[4]}:${nginx[5]}:${nginx[6]}`
|
||||
|
||||
const date = new Date(trimmed)
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/** Normalize leading timestamp in a raw log line */
|
||||
export function formatLogLine(line: string): string {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return line
|
||||
|
||||
const nginx = trimmed.match(/^(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2})(.*)$/)
|
||||
if (nginx) return formatLogTime(nginx[1]) + nginx[2]
|
||||
|
||||
const iso = trimmed.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}|Z)?)(.*)$/)
|
||||
if (iso) return formatLogTime(iso[1]) + iso[2]
|
||||
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const raw = JSON.parse(trimmed) as { time?: string }
|
||||
if (raw.time) return trimmed.replace(raw.time, formatLogTime(raw.time))
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { SystemLogEntry } from '../api/types'
|
||||
import { formatLogTime } from './format'
|
||||
|
||||
export type LogTagType = 'success' | 'info' | 'warning' | 'error' | 'default'
|
||||
|
||||
export interface DisplaySystemLogEntry extends SystemLogEntry {
|
||||
displayLevel: string
|
||||
displayModule: string
|
||||
}
|
||||
|
||||
const SYSTEM_LEVEL_LABELS: Record<string, string> = {
|
||||
INFO: '信息',
|
||||
WARN: '警告',
|
||||
ERROR: '错误',
|
||||
DEBUG: '调试',
|
||||
}
|
||||
|
||||
const SYSTEM_MODULE_LABELS: Record<string, string> = {
|
||||
SYSTEM: '系统',
|
||||
NGINX: 'Nginx',
|
||||
DDNS: '域名解析',
|
||||
ACME: '证书',
|
||||
CERT: '证书',
|
||||
CHINA_CIDR: '中国 IP 段',
|
||||
API: 'API',
|
||||
HTTP: 'HTTP',
|
||||
FRP: '内网穿透',
|
||||
}
|
||||
|
||||
export function systemLevelLabel(level: string): string {
|
||||
const upper = level.trim().toUpperCase()
|
||||
return SYSTEM_LEVEL_LABELS[upper] ?? level
|
||||
}
|
||||
|
||||
export function systemModuleLabel(module: string): string {
|
||||
const trimmed = module.trim()
|
||||
if (!trimmed) return '系统'
|
||||
const upper = trimmed.toUpperCase()
|
||||
return SYSTEM_MODULE_LABELS[upper] ?? trimmed
|
||||
}
|
||||
|
||||
export function displaySystemLog(entry: SystemLogEntry): DisplaySystemLogEntry {
|
||||
const message = entry.message.trim()
|
||||
const msgLower = message.toLowerCase()
|
||||
let level = entry.level.trim().toUpperCase()
|
||||
let displayModule = systemModuleLabel(entry.module)
|
||||
|
||||
if (
|
||||
level === 'INFO' &&
|
||||
(msgLower.includes('server error') ||
|
||||
msgLower.includes(' failed') ||
|
||||
msgLower.startsWith('error') ||
|
||||
msgLower.includes('bind:'))
|
||||
) {
|
||||
level = 'ERROR'
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
displayLevel: systemLevelLabel(level),
|
||||
displayModule,
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParsedNginxError {
|
||||
time: string
|
||||
level: string
|
||||
message: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
export function parseNginxErrorLine(line: string): ParsedNginxError {
|
||||
const raw = line.trim()
|
||||
const match = raw.match(/^(\d{4}[-/]\d{2}[-/]\d{2} \d{2}:\d{2}:\d{2})\s+\[([^\]]+)\]\s+(.+)$/)
|
||||
if (!match) {
|
||||
return { time: '', level: 'unknown', message: raw, raw }
|
||||
}
|
||||
return {
|
||||
time: formatLogTime(match[1]),
|
||||
level: match[2].toLowerCase(),
|
||||
message: match[3],
|
||||
raw,
|
||||
}
|
||||
}
|
||||
|
||||
export function nginxLevelTagType(level: string): LogTagType {
|
||||
switch (level.toLowerCase()) {
|
||||
case 'emerg':
|
||||
case 'alert':
|
||||
case 'crit':
|
||||
case 'error':
|
||||
return 'error'
|
||||
case 'warn':
|
||||
case 'warning':
|
||||
return 'warning'
|
||||
case 'notice':
|
||||
case 'info':
|
||||
return 'info'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
export function systemLevelTagType(level: string): LogTagType {
|
||||
const upper = level.trim().toUpperCase()
|
||||
if (upper === 'ERROR' || level === '错误') return 'error'
|
||||
if (upper === 'WARN' || level === '警告') return 'warning'
|
||||
if (upper === 'INFO' || level === '信息') return 'info'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
export function systemModuleTagType(module: string): LogTagType {
|
||||
const upper = module.trim().toUpperCase()
|
||||
switch (upper) {
|
||||
case 'NGINX':
|
||||
case 'Nginx':
|
||||
return 'warning'
|
||||
case 'SYSTEM':
|
||||
case '系统':
|
||||
return 'success'
|
||||
case 'CHINA_CIDR':
|
||||
case '中国 IP 段':
|
||||
return 'info'
|
||||
case 'DDNS':
|
||||
case '域名解析':
|
||||
return 'info'
|
||||
case 'CERT':
|
||||
case 'ACME':
|
||||
case '证书':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
export function latencyClass(seconds: number): string {
|
||||
const ms = seconds * 1000
|
||||
if (ms >= 3000) return 'log-duration log-duration--critical'
|
||||
if (ms >= 1000) return 'log-duration log-duration--slow'
|
||||
return 'log-duration'
|
||||
}
|
||||
|
||||
export function streamLineClass(line: string): string {
|
||||
const parsed = parseNginxErrorLine(line)
|
||||
if (parsed.level !== 'unknown') {
|
||||
const type = nginxLevelTagType(parsed.level)
|
||||
if (type === 'error') return 'level-error'
|
||||
if (type === 'warning') return 'level-warn'
|
||||
if (type === 'info') return 'level-info'
|
||||
}
|
||||
|
||||
const lower = line.toLowerCase()
|
||||
if (lower.includes('[error]') || lower.includes(' emerg ') || lower.includes(' alert ') || lower.includes(' crit ')) {
|
||||
return 'level-error'
|
||||
}
|
||||
if (lower.includes('[warn]') || lower.includes(' warning ')) return 'level-warn'
|
||||
|
||||
const statusMatch = line.match(/\s(\d{3})\s+\d/)
|
||||
if (statusMatch) {
|
||||
const code = Number(statusMatch[1])
|
||||
if (code >= 500) return 'level-error'
|
||||
if (code >= 400) return 'level-warn'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export type StatusKind = 'success' | 'warning' | 'error' | 'disabled' | 'unknown'
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
ok: '正常',
|
||||
running: '运行中',
|
||||
warning: '部分异常',
|
||||
error: '异常',
|
||||
disabled: '停用',
|
||||
none: '未配置',
|
||||
stopped: '已停止',
|
||||
unknown: '未知',
|
||||
}
|
||||
|
||||
export function statusLabel(value?: string): string {
|
||||
if (!value) return '未知'
|
||||
return LABELS[value] ?? value
|
||||
}
|
||||
|
||||
export function statusKind(value?: string): StatusKind {
|
||||
switch (value) {
|
||||
case 'ok':
|
||||
case 'running':
|
||||
return 'success'
|
||||
case 'warning':
|
||||
return 'warning'
|
||||
case 'error':
|
||||
return 'error'
|
||||
case 'disabled':
|
||||
case 'stopped':
|
||||
case 'none':
|
||||
return 'disabled'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
export function httpStatusKind(code: number): StatusKind {
|
||||
if (code >= 500) return 'error'
|
||||
if (code >= 400) return 'warning'
|
||||
if (code >= 300) return 'unknown'
|
||||
return 'success'
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { h, type VNode } from 'vue'
|
||||
import { NButton, NSpace } from 'naive-ui'
|
||||
import type { ButtonProps } from 'naive-ui'
|
||||
|
||||
export interface TableRowAction {
|
||||
label: string
|
||||
onClick: () => void
|
||||
type?: ButtonProps['type']
|
||||
/** 为 false 时隐藏该操作,默认显示 */
|
||||
show?: boolean
|
||||
}
|
||||
|
||||
/** 表格操作列:所有按钮平铺展示,不使用下拉菜单 */
|
||||
export function renderTableRowActions(actions: TableRowAction[]): VNode {
|
||||
const buttons = actions
|
||||
.filter((action) => action.show !== false)
|
||||
.map((action) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
quaternary: true,
|
||||
type: action.type,
|
||||
onClick: (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
action.onClick()
|
||||
},
|
||||
},
|
||||
() => action.label,
|
||||
),
|
||||
)
|
||||
|
||||
return h(NSpace, { size: 4, wrap: false }, () => buttons)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { GITHUB_REPO } from '../constants/app'
|
||||
|
||||
export interface LatestRelease {
|
||||
version: string
|
||||
url: string
|
||||
}
|
||||
|
||||
function parseVersion(version: string): number[] {
|
||||
const clean = version.replace(/^v/i, '').split('-')[0]
|
||||
return clean.split('.').map((part) => Number.parseInt(part, 10) || 0)
|
||||
}
|
||||
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const left = parseVersion(a)
|
||||
const right = parseVersion(b)
|
||||
const length = Math.max(left.length, right.length)
|
||||
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const diff = (left[i] ?? 0) - (right[i] ?? 0)
|
||||
if (diff !== 0) {
|
||||
return diff > 0 ? 1 : -1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
export function isNewerVersion(latest: string, current: string): boolean {
|
||||
if (!latest || !current || current === 'dev') {
|
||||
return false
|
||||
}
|
||||
return compareVersions(latest, current) > 0
|
||||
}
|
||||
|
||||
export async function fetchLatestRelease(): Promise<LatestRelease | null> {
|
||||
try {
|
||||
const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
})
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { tag_name?: string; html_url?: string }
|
||||
if (!data.tag_name || !data.html_url) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
version: data.tag_name.replace(/^v/i, ''),
|
||||
url: data.html_url,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<PageHeader title="公网 Agent" description="管理部署在公网 VPS 上的 fonu-agent 与 frps 服务">
|
||||
<template #actions>
|
||||
<n-select
|
||||
v-model:value="selectedId"
|
||||
:options="serverOptions"
|
||||
placeholder="选择公网服务端"
|
||||
style="width: 220px"
|
||||
:disabled="servers.length === 0"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<LoadError v-if="loadError" :message="loadError" @retry="load" />
|
||||
|
||||
<template v-else>
|
||||
<EmptyState
|
||||
v-if="servers.length === 0"
|
||||
title="尚未配置 FRP 服务端"
|
||||
description="先在「内网穿透」页添加公网服务端并保存 SSH 信息,再回到本页管理 Agent。"
|
||||
>
|
||||
<template #action>
|
||||
<router-link to="/frp"><n-button type="primary">去内网穿透页</n-button></router-link>
|
||||
</template>
|
||||
</EmptyState>
|
||||
|
||||
<template v-else-if="server">
|
||||
<section class="status-grid">
|
||||
<FonuCard class="status-card">
|
||||
<div class="status-card__icon" :class="agentStatusOk ? 'status-card__icon--running' : 'status-card__icon--stopped'">
|
||||
<n-icon :component="CloudOutline" />
|
||||
</div>
|
||||
<div class="status-card__body">
|
||||
<div class="status-card__label">Agent 连接</div>
|
||||
<div class="status-card__value">{{ agentStatusOk ? '在线' : '未连接' }}</div>
|
||||
<div class="status-card__meta mono">{{ server.agent_url || '未配置 Agent 地址' }}</div>
|
||||
</div>
|
||||
</FonuCard>
|
||||
<FonuCard class="status-card">
|
||||
<div class="status-card__icon" :class="agentStatus?.frps_active ? 'status-card__icon--running' : 'status-card__icon--stopped'">
|
||||
<n-icon :component="GitNetworkOutline" />
|
||||
</div>
|
||||
<div class="status-card__body">
|
||||
<div class="status-card__label">frps 服务</div>
|
||||
<div class="status-card__value">{{ agentStatus?.frps_active ? '运行中' : '未运行' }}</div>
|
||||
<div class="status-card__meta">{{ agentStatus?.frps_version?.trim() || '未安装' }}</div>
|
||||
</div>
|
||||
</FonuCard>
|
||||
<FonuCard class="status-card">
|
||||
<div class="status-card__icon" :class="agentStatus?.ports?.['80'] ? 'status-card__icon--running' : 'status-card__icon--stopped'">
|
||||
<n-icon :component="SwapHorizontalOutline" />
|
||||
</div>
|
||||
<div class="status-card__body">
|
||||
<div class="status-card__label">80 端口</div>
|
||||
<div class="status-card__value">{{ agentStatus?.ports?.['80'] ? '监听中' : '未监听' }}</div>
|
||||
<div class="status-card__meta">无端口访问的入口</div>
|
||||
</div>
|
||||
</FonuCard>
|
||||
<FonuCard class="status-card">
|
||||
<div class="status-card__icon" :class="agentStatus?.ports?.['443'] ? 'status-card__icon--running' : 'status-card__icon--stopped'">
|
||||
<n-icon :component="LockClosedOutline" />
|
||||
</div>
|
||||
<div class="status-card__body">
|
||||
<div class="status-card__label">443 端口</div>
|
||||
<div class="status-card__value">{{ agentStatus?.ports?.['443'] ? '监听中' : '未监听' }}</div>
|
||||
<div class="status-card__meta">HTTPS 入口</div>
|
||||
</div>
|
||||
</FonuCard>
|
||||
</section>
|
||||
|
||||
<section class="agent-page-grid">
|
||||
<FonuCard class="section-card" title="frps 服务控制">
|
||||
<template #header>
|
||||
<n-button size="small" quaternary :loading="agentActionId === server.id" @click="refreshAll">刷新</n-button>
|
||||
</template>
|
||||
<div class="agent-section-body">
|
||||
<div class="field-grid">
|
||||
<div><label>Agent 版本</label><span class="field-tags"><n-tag size="small" :bordered="false">{{ agentStatus?.agent_version || '-' }}</n-tag></span></div>
|
||||
<div><label>frps 启动命令</label><span class="mono mono-wrap">{{ agentStatus?.frps_unit_exec || '-' }}</span></div>
|
||||
<div><label>frps 配置</label><span class="mono" :title="agentStatus?.frps_config_path">{{ agentStatus?.frps_config_path || '-' }}</span></div>
|
||||
<div><label>开机自启</label><span class="field-tags"><n-tag size="small" :bordered="false" :type="agentStatus?.frps_enabled ? 'success' : 'warning'">{{ agentStatus?.frps_enabled ? '已启用' : '未启用' }}</n-tag></span></div>
|
||||
<div><label>7000 端口</label><span class="field-tags"><n-tag size="small" :bordered="false" :type="agentStatus?.ports?.['7000'] ? 'success' : 'warning'">{{ agentStatus?.ports?.['7000'] ? '监听中' : '未监听' }}</n-tag></span></div>
|
||||
</div>
|
||||
<div class="agent-actions">
|
||||
<n-button v-if="agentStatus?.frps_active" size="small" type="warning" :loading="agentActionId === server.id" @click="frpsAction(server, 'stop')">停止 frps</n-button>
|
||||
<n-button v-else size="small" type="success" :loading="agentActionId === server.id" @click="frpsAction(server, 'start')">启动 frps</n-button>
|
||||
<n-button size="small" :loading="agentActionId === server.id" @click="frpsAction(server, 'restart')">重启 frps</n-button>
|
||||
<n-button v-if="dashboardURL" size="small" quaternary @click="openDashboard(dashboardURL)">frps 管理</n-button>
|
||||
</div>
|
||||
<p v-if="!dashboardURL" class="form-hint" style="margin-top: 8px">未配置 frps 管理接口:在「内网穿透」页服务端表单的「frps 管理接口」填写地址与端口后保存即可开启面板入口</p>
|
||||
</div>
|
||||
</FonuCard>
|
||||
|
||||
<FonuCard class="section-card" title="frps 配置与版本">
|
||||
<div class="agent-section-body">
|
||||
<div class="field-grid">
|
||||
<div><label>已安装 frps 版本</label><span class="field-tags"><n-tag size="small" :bordered="false" :type="agentStatus?.frps_version ? 'success' : 'warning'">{{ installedFrpsVersion || '未安装' }}</n-tag></span></div>
|
||||
<div><label>安装位置</label><span class="mono" :title="agentStatus?.frps_binary">{{ agentStatus?.frps_binary || '-' }}</span></div>
|
||||
</div>
|
||||
<div class="agent-actions" style="margin-top: var(--fonu-space-3)">
|
||||
<n-button size="small" :loading="agentActionId === server.id" @click="pushAgentConfig(server)">下发 frps 配置</n-button>
|
||||
</div>
|
||||
<div class="frps-install-row">
|
||||
<n-select v-model:value="frpsInstallVersion" :options="frpsVersionOptions" placeholder="选择 frps 官方版本" style="width: 200px" filterable />
|
||||
<n-button size="small" type="success" :loading="agentActionId === server.id" @click="installFrps(server)">安装/升级 frps</n-button>
|
||||
</div>
|
||||
<p class="form-hint">VPS 无 frps 二进制时使用:自动下载并注册 frps.service,完成后自动下发同源配置。</p>
|
||||
</div>
|
||||
</FonuCard>
|
||||
</section>
|
||||
|
||||
<section class="agent-page-grid">
|
||||
<FonuCard class="section-card" title="公网 Nginx 反代">
|
||||
<div class="agent-section-body">
|
||||
<p class="form-hint">反代管理已独立成「公网反代」页面:逐条部署/移除、配置查看、强制跳转、IP 白名单、Basic Auth、WebSocket 等都在那里。</p>
|
||||
<router-link to="/public-proxy"><n-button size="small" type="primary">前往公网反代</n-button></router-link>
|
||||
</div>
|
||||
</FonuCard>
|
||||
|
||||
<FonuCard class="section-card" title="frps 最近日志">
|
||||
<div class="agent-section-body">
|
||||
<pre class="code-block">{{ agentStatus?.frps_log_tail || '暂无日志' }}</pre>
|
||||
</div>
|
||||
</FonuCard>
|
||||
</section>
|
||||
|
||||
<FonuCard class="section-card" title="Agent 生命周期(SSH)">
|
||||
<template #header>
|
||||
<div class="section-actions">
|
||||
<n-button size="small" type="primary" :loading="agentActionId === server.id" @click="probeAgent(server)">探测环境</n-button>
|
||||
<n-button size="small" type="success" :loading="agentActionId === server.id" @click="installAgent(server)">{{ server.agent_configured ? '升级 Agent' : '安装 Agent' }}</n-button>
|
||||
<n-button size="small" type="warning" :loading="agentActionId === server.id" @click="uninstallAgent(server, false)">卸载(含数据)</n-button>
|
||||
<n-button size="small" quaternary :loading="agentActionId === server.id" @click="uninstallAgent(server, true)">卸载(保留路由/证书)</n-button>
|
||||
<n-button size="small" quaternary :loading="agentActionId === server.id" @click="diagnoseAgent(server)">SSH 诊断</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="agent-section-body">
|
||||
<p class="form-hint">
|
||||
首次安装、升级、卸载需要 SSH(在「内网穿透」页服务端表单的「公网 Agent 管理」区填写);安装成功后自动回填 Agent 地址与 Token。
|
||||
日常管理走 Agent API,无需 SSH。SSH 信息仅用于生命周期操作,凭据加密存储、不回显。
|
||||
</p>
|
||||
<pre v-if="agentOutput" class="code-block">{{ agentOutput }}</pre>
|
||||
</div>
|
||||
</FonuCard>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { CloudOutline, GitNetworkOutline, LockClosedOutline, SwapHorizontalOutline } from '@vicons/ionicons5'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import FonuCard from '../components/FonuCard.vue'
|
||||
import LoadError from '../components/LoadError.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import { api } from '../api/client'
|
||||
import type { FrpAgentStatus, FrpRelease, FrpServer } from '../api/types'
|
||||
|
||||
const message = useMessage()
|
||||
const servers = ref<FrpServer[]>([])
|
||||
const selectedId = ref<number | null>(null)
|
||||
const loadError = ref('')
|
||||
const agentStatus = ref<FrpAgentStatus | null>(null)
|
||||
const agentStatusOk = ref(false)
|
||||
const agentActionId = ref<number | null>(null)
|
||||
const agentOutput = ref('')
|
||||
const frpsInstallVersion = ref<string | null>(null)
|
||||
const releases = ref<FrpRelease[]>([])
|
||||
let timer: number | undefined
|
||||
|
||||
const server = computed(() => servers.value.find((item) => item.id === selectedId.value) ?? null)
|
||||
const serverOptions = computed(() => servers.value
|
||||
.filter((item) => item.agent_configured || item.ssh_configured)
|
||||
.map((item) => ({ label: item.name, value: item.id })))
|
||||
|
||||
const installedFrpsVersion = computed(() => (agentStatus.value?.frps_version || '').trim() || '')
|
||||
const frpsVersionOptions = computed(() => [
|
||||
...(releases.value.length ? [] : [{ label: '0.71.0(默认,官方版本未加载)', value: '0.71.0' }]),
|
||||
...releases.value.map((release) => ({ label: release.version, value: release.version })),
|
||||
])
|
||||
|
||||
async function load() {
|
||||
loadError.value = ''
|
||||
try {
|
||||
servers.value = (await api.listFrpServers()).filter((item) => item.agent_configured || item.ssh_configured)
|
||||
if (selectedId.value === null || !servers.value.some((item) => item.id === selectedId.value)) {
|
||||
selectedId.value = servers.value[0]?.id ?? null
|
||||
}
|
||||
if (server.value) void refreshAll()
|
||||
} catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' }
|
||||
}
|
||||
|
||||
|
||||
async function refreshAll() {
|
||||
const current = server.value
|
||||
if (!current) return
|
||||
agentActionId.value = current.id
|
||||
try {
|
||||
agentStatus.value = await api.getFrpAgentStatus(current.id)
|
||||
agentStatusOk.value = true
|
||||
} catch (error) {
|
||||
agentStatusOk.value = false
|
||||
agentOutput.value = error instanceof Error ? error.message : 'Agent 状态获取失败'
|
||||
} finally { agentActionId.value = null }
|
||||
}
|
||||
|
||||
async function probeAgent(target: FrpServer) {
|
||||
agentActionId.value = target.id
|
||||
try {
|
||||
const result = await api.probeFrpAgent(target.id)
|
||||
agentOutput.value = Object.entries(result).map(([key, value]) => `${key}: ${value}`).join('\n')
|
||||
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '探测失败' }
|
||||
finally { agentActionId.value = null }
|
||||
}
|
||||
|
||||
async function installAgent(target: FrpServer) {
|
||||
agentActionId.value = target.id
|
||||
try {
|
||||
await api.installFrpAgent(target.id)
|
||||
agentOutput.value = 'Agent 安装完成,已自动回填 Agent 地址与 Token'
|
||||
message.success('Agent 安装成功')
|
||||
await load()
|
||||
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '安装失败' }
|
||||
finally { agentActionId.value = null }
|
||||
}
|
||||
|
||||
async function uninstallAgent(target: FrpServer, keepData: boolean) {
|
||||
agentActionId.value = target.id
|
||||
try {
|
||||
const result = await api.uninstallFrpAgent(target.id, keepData)
|
||||
agentOutput.value = result.output || '已卸载'
|
||||
message.success('Agent 已卸载')
|
||||
await load()
|
||||
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '卸载失败' }
|
||||
finally { agentActionId.value = null }
|
||||
}
|
||||
|
||||
async function diagnoseAgent(target: FrpServer) {
|
||||
agentActionId.value = target.id
|
||||
try {
|
||||
const result = await api.sshDiagnoseFrpAgent(target.id)
|
||||
agentOutput.value = result.output || '无输出'
|
||||
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '诊断失败' }
|
||||
finally { agentActionId.value = null }
|
||||
}
|
||||
|
||||
// frps 管理面板地址:直接读服务端表单里已配置的「frps 管理接口」(dashboard_addr/port);
|
||||
// 监听 0.0.0.0/:: 时用服务端公网地址拼 URL(浏览器在外网访问)
|
||||
const dashboardURL = computed(() => {
|
||||
const current = server.value
|
||||
if (!current) return ''
|
||||
const addr = (current.options.dashboard_addr || '').trim()
|
||||
const port = current.options.dashboard_port || 0
|
||||
if (!addr || !port) return ''
|
||||
const host = addr === '0.0.0.0' || addr === '::' ? (current.server_addr || '127.0.0.1') : addr
|
||||
return `http://${host}:${port}`
|
||||
})
|
||||
|
||||
function openDashboard(url: string) {
|
||||
if (url) window.open(url, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
async function frpsAction(target: FrpServer, action: 'start' | 'stop' | 'restart') {
|
||||
agentActionId.value = target.id
|
||||
const actionText = action === 'stop' ? '停止' : action === 'start' ? '启动' : '重启'
|
||||
try {
|
||||
// agent 对"命令成功但服务未进入运行态"返回 200+ok:false+error(含 journal 日志)
|
||||
const result = await api.frpsServerActionRaw(target.id, action)
|
||||
if (result && result.ok === false) {
|
||||
agentOutput.value = result.error || `frps ${actionText}后未进入预期状态`
|
||||
message.error(`frps ${actionText}失败,详见输出`)
|
||||
} else {
|
||||
message.success(`frps 已${actionText}`)
|
||||
}
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
agentOutput.value = error instanceof Error ? error.message : 'frps 操作失败'
|
||||
message.error(`frps ${actionText}失败,详见输出`)
|
||||
} finally { agentActionId.value = null }
|
||||
}
|
||||
|
||||
async function pushAgentConfig(target: FrpServer) {
|
||||
agentActionId.value = target.id
|
||||
try {
|
||||
await api.pushFrpConfigToAgent(target.id)
|
||||
message.success('frps 配置已下发')
|
||||
agentOutput.value = 'frps 同源配置已下发到 VPS,点「重启 frps」后生效'
|
||||
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '下发配置失败' }
|
||||
finally { agentActionId.value = null }
|
||||
}
|
||||
|
||||
async function installFrps(target: FrpServer) {
|
||||
agentActionId.value = target.id
|
||||
try {
|
||||
const version = frpsInstallVersion.value?.trim() || '0.71.0'
|
||||
const result = await api.installFrpServer(target.id, version, '')
|
||||
const notes = Array.isArray(result?.notes) ? result.notes.join('\n') : ''
|
||||
agentOutput.value = `frps ${result?.version ?? version} 安装完成\n${notes}`
|
||||
message.success('VPS frps 已安装')
|
||||
await refreshAll()
|
||||
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '安装 frps 失败' }
|
||||
finally { agentActionId.value = null }
|
||||
}
|
||||
|
||||
async function loadReleases() {
|
||||
if (releases.value.length > 0) return
|
||||
try { releases.value = await api.listFrpReleases() } catch { /* 回退默认版本 */ }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
void loadReleases()
|
||||
timer = window.setInterval(() => { if (server.value) void refreshAll() }, 10000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 对齐 DDNS 页风格:surface 卡片、settings-grid 字段、统一代码块 */
|
||||
.status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
|
||||
.status-card { display: flex; gap: var(--fonu-space-3); align-items: center; padding: var(--fonu-space-4) var(--fonu-space-5); }
|
||||
.status-card__icon { width: 44px; height: 44px; border-radius: var(--fonu-radius-sm); display: flex; align-items: center; justify-content: center; font-size: 22px; flex-shrink: 0; }
|
||||
.status-card__icon--running { background: rgba(34, 197, 94, 0.14); color: #16a34a; }
|
||||
.status-card__icon--stopped { background: rgba(148, 163, 184, 0.16); color: #64748b; }
|
||||
.status-card__body { min-width: 0; }
|
||||
.status-card__label { color: var(--fonu-text-secondary); font-size: 12px; }
|
||||
.status-card__value { font-size: 18px; font-weight: 700; color: var(--fonu-text); }
|
||||
.status-card__meta { color: var(--fonu-text-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.agent-page-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
|
||||
.agent-section-body { padding: var(--fonu-space-2) var(--fonu-space-5) var(--fonu-space-4); }
|
||||
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-4); }
|
||||
.field-grid > div { display: grid; gap: 4px; min-width: 0; }
|
||||
.field-grid label { color: var(--fonu-text-secondary); font-size: 12px; }
|
||||
.field-grid .mono { font-family: var(--fonu-mono); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.field-grid .mono-wrap { white-space: normal; word-break: break-all; overflow: visible; text-overflow: unset; }
|
||||
.field-tags { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.agent-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: var(--fonu-space-4); }
|
||||
.route-list { display: grid; gap: 8px; margin-top: var(--fonu-space-3); }
|
||||
.route-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-surface); }
|
||||
.route-item__main { display: grid; gap: 3px; min-width: 0; }
|
||||
.route-item__domains { color: var(--fonu-text); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.route-item__upstream { color: var(--fonu-text-muted); font-size: 12px; }
|
||||
.route-item__actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; flex-shrink: 0; }
|
||||
.empty-route { margin-top: var(--fonu-space-3); padding: 18px; border: 1px dashed var(--fonu-border); border-radius: var(--fonu-radius-sm); color: var(--fonu-text-muted); text-align: center; }
|
||||
.frps-install-row { display: flex; align-items: center; gap: 10px; margin-top: var(--fonu-space-4); flex-wrap: wrap; }
|
||||
.frps-install-row .form-hint { flex-basis: 100%; margin: 0; }
|
||||
.code-block {
|
||||
margin: var(--fonu-space-3) 0 0;
|
||||
padding: var(--fonu-space-3) var(--fonu-space-4);
|
||||
background: var(--fonu-bg-muted);
|
||||
border: 1px solid var(--fonu-border);
|
||||
border-radius: var(--fonu-radius-sm);
|
||||
font-family: var(--fonu-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.65;
|
||||
color: var(--fonu-text);
|
||||
overflow: auto;
|
||||
max-height: 240px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.form-hint { color: var(--fonu-text-secondary); font-size: 12.5px; }
|
||||
.mono { font-family: var(--fonu-mono); }
|
||||
.section-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
@media (max-width: 900px) { .field-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
|
||||
<div class="auth-page">
|
||||
|
||||
<div class="auth-card">
|
||||
|
||||
<div class="auth-brand">
|
||||
|
||||
<img class="auth-brand__logo" :src="logoImg" alt="Fonu" />
|
||||
|
||||
<div>
|
||||
|
||||
<h1>Fonu</h1>
|
||||
|
||||
<p>让 NAS 访问更简单</p>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<h2 class="auth-title">登录管理后台</h2>
|
||||
|
||||
<n-form @submit.prevent="submit">
|
||||
|
||||
<n-form-item label="用户名">
|
||||
|
||||
<n-input v-model:value="username" size="large" />
|
||||
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="密码">
|
||||
|
||||
<n-input v-model:value="password" type="password" show-password-on="click" size="large" />
|
||||
|
||||
</n-form-item>
|
||||
|
||||
<n-button type="primary" block size="large" :loading="loading" attr-type="submit">登录</n-button>
|
||||
|
||||
</n-form>
|
||||
|
||||
<p class="auth-hint">首次使用请查看系统日志中的初始密码,登录后请在「设置」中修改。</p>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { NButton, NForm, NFormItem, NInput, useMessage } from 'naive-ui'
|
||||
|
||||
import { api } from '../api/client'
|
||||
import logoImg from '../assets/brand/logo.png'
|
||||
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const username = ref('admin')
|
||||
|
||||
const password = ref('')
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
|
||||
|
||||
async function submit() {
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
|
||||
await api.login(username.value, password.value)
|
||||
|
||||
message.success('登录成功')
|
||||
|
||||
router.push({ name: 'dashboard' })
|
||||
|
||||
} catch (error) {
|
||||
|
||||
message.error(error instanceof Error ? error.message : '登录失败')
|
||||
|
||||
} finally {
|
||||
|
||||
loading.value = false
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
.auth-page {
|
||||
|
||||
min-height: 100vh;
|
||||
|
||||
display: grid;
|
||||
|
||||
place-items: center;
|
||||
|
||||
padding: var(--fonu-space-5);
|
||||
|
||||
background: var(--fonu-bg);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.auth-card {
|
||||
|
||||
width: 100%;
|
||||
|
||||
max-width: 420px;
|
||||
|
||||
background: var(--fonu-surface);
|
||||
|
||||
border: 1px solid var(--fonu-border);
|
||||
|
||||
border-radius: var(--fonu-radius);
|
||||
|
||||
box-shadow: var(--fonu-shadow-md);
|
||||
|
||||
padding: var(--fonu-space-6) var(--fonu-space-5);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.auth-brand {
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: var(--fonu-space-4);
|
||||
|
||||
margin-bottom: var(--fonu-space-5);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.auth-brand__logo {
|
||||
|
||||
width: 48px;
|
||||
|
||||
height: 48px;
|
||||
|
||||
border-radius: 14px;
|
||||
|
||||
object-fit: cover;
|
||||
|
||||
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.22);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.auth-brand h1 {
|
||||
|
||||
margin: 0;
|
||||
|
||||
font-size: 22px;
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.auth-brand p {
|
||||
|
||||
margin: 4px 0 0;
|
||||
|
||||
font-size: 13px;
|
||||
|
||||
color: var(--fonu-text-muted);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.auth-title {
|
||||
|
||||
margin: 0 0 var(--fonu-space-5);
|
||||
|
||||
font-size: 16px;
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
color: var(--fonu-text);
|
||||
|
||||
}
|
||||
|
||||
.auth-hint {
|
||||
margin: var(--fonu-space-4) 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--fonu-text-muted);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
<template>
|
||||
<PageHeader
|
||||
title="日志中心"
|
||||
description="系统日志记录 Fonu 全部运行输出;访问日志与 Nginx 错误日志来自反向代理"
|
||||
/>
|
||||
|
||||
<LoadError v-if="pageError" :message="pageError" @retry="loadAll" />
|
||||
|
||||
<FonuCard v-else flush class="logs-card">
|
||||
<n-tabs v-model:value="tab" type="line" animated class="logs-tabs">
|
||||
<n-tab-pane name="system" tab="系统日志">
|
||||
<LogToolbar
|
||||
v-model:keyword="systemKeyword"
|
||||
v-model:level="systemLevel"
|
||||
:show-level="true"
|
||||
@refresh="loadSystemLogs"
|
||||
/>
|
||||
<p class="tab-hint">Fonu 全部运行日志(API、DDNS、证书、通知、Nginx 重载等),不含 Nginx 访问与错误文件。</p>
|
||||
<n-data-table
|
||||
v-if="filteredSystemLogs.length > 0"
|
||||
class="log-table"
|
||||
:columns="systemColumns"
|
||||
:data="pagedSystemLogs"
|
||||
:loading="loadingSystem"
|
||||
:bordered="false"
|
||||
:row-class-name="systemRowClassName"
|
||||
/>
|
||||
<LogPagination
|
||||
v-if="filteredSystemLogs.length > 0"
|
||||
v-model:page="systemPage"
|
||||
:item-count="filteredSystemLogs.length"
|
||||
/>
|
||||
<EmptyState
|
||||
v-if="!loadingSystem && systemLogs.length === 0"
|
||||
title="暂无系统日志"
|
||||
description="应用启动、DDNS 更新、证书操作或 API 请求会记录在这里。"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="access" tab="访问日志">
|
||||
<LogToolbar
|
||||
v-model:keyword="accessKeyword"
|
||||
v-model:status="accessStatus"
|
||||
:show-domain="true"
|
||||
:show-status="true"
|
||||
:auto-refresh="autoRefresh"
|
||||
@refresh="loadAccess"
|
||||
@toggle-auto="autoRefresh = !autoRefresh"
|
||||
/>
|
||||
<n-data-table
|
||||
v-if="filteredAccessLogs.length > 0"
|
||||
class="log-table"
|
||||
:columns="accessColumns"
|
||||
:data="pagedAccessLogs"
|
||||
:loading="loadingAccess"
|
||||
:bordered="false"
|
||||
:scroll-x="920"
|
||||
:row-class-name="accessRowClassName"
|
||||
/>
|
||||
<LogPagination
|
||||
v-if="filteredAccessLogs.length > 0"
|
||||
v-model:page="accessPage"
|
||||
:item-count="filteredAccessLogs.length"
|
||||
/>
|
||||
<EmptyState
|
||||
v-if="!loadingAccess && accessLogs.length === 0"
|
||||
title="暂无访问日志"
|
||||
description="此处记录经 Nginx 反向代理的访问(非 Fonu 管理界面本身)。请通过代理域名访问后刷新。"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="nginx" tab="Nginx 日志">
|
||||
<p class="tab-hint">Nginx 错误日志(error.log),记录 SSL 握手失败、上游连接异常、配置冲突等。</p>
|
||||
<LogToolbar v-model:keyword="errorKeyword" @refresh="loadErrorLogs" />
|
||||
<n-data-table
|
||||
v-if="filteredErrorLogs.length > 0"
|
||||
class="log-table"
|
||||
:columns="errorColumns"
|
||||
:data="pagedErrorLogs"
|
||||
:loading="loadingError"
|
||||
:bordered="false"
|
||||
:row-class-name="errorRowClassName"
|
||||
/>
|
||||
<LogPagination
|
||||
v-if="filteredErrorLogs.length > 0"
|
||||
v-model:page="errorPage"
|
||||
:item-count="filteredErrorLogs.length"
|
||||
/>
|
||||
<EmptyState
|
||||
v-if="!loadingError && errorLogs.length === 0"
|
||||
title="暂无 Nginx 错误日志"
|
||||
description="Nginx 出现 SSL、上游或配置相关错误时会记录在这里。"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</FonuCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NInput,
|
||||
NPagination,
|
||||
NSelect,
|
||||
NTabPane,
|
||||
NTabs,
|
||||
NTag,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
} from 'naive-ui'
|
||||
import { api, asList } from '../api/client'
|
||||
import type { AccessLogEntry, ProxyRule, SystemLogEntry } from '../api/types'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import FonuCard from '../components/FonuCard.vue'
|
||||
import LoadError from '../components/LoadError.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import {
|
||||
accessServiceTooltip,
|
||||
buildProxyBindingIndex,
|
||||
formatAccessFallback,
|
||||
resolveAccessServiceLabel,
|
||||
} from '../utils/accessService'
|
||||
import { formatLogTime, formatMs } from '../utils/format'
|
||||
import {
|
||||
latencyClass,
|
||||
nginxLevelTagType,
|
||||
parseNginxErrorLine,
|
||||
type ParsedNginxError,
|
||||
displaySystemLog,
|
||||
systemLevelTagType,
|
||||
systemModuleTagType,
|
||||
} from '../utils/logDisplay'
|
||||
import { httpStatusKind } from '../utils/status'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const LogPagination = defineComponent({
|
||||
name: 'LogPagination',
|
||||
props: {
|
||||
page: { type: Number, required: true },
|
||||
itemCount: { type: Number, required: true },
|
||||
},
|
||||
emits: ['update:page'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h('div', { class: 'log-pagination' }, [
|
||||
h(NPagination, {
|
||||
page: props.page,
|
||||
pageSize: PAGE_SIZE,
|
||||
itemCount: props.itemCount,
|
||||
showSizePicker: false,
|
||||
'onUpdate:page': (p: number) => emit('update:page', p),
|
||||
}),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const LogToolbar = defineComponent({
|
||||
name: 'LogToolbar',
|
||||
props: {
|
||||
keyword: { type: String, default: '' },
|
||||
status: { type: Number as () => number | null, default: null },
|
||||
level: { type: String, default: '' },
|
||||
showDomain: Boolean,
|
||||
showStatus: Boolean,
|
||||
showLevel: Boolean,
|
||||
autoRefresh: Boolean,
|
||||
},
|
||||
emits: ['update:keyword', 'update:status', 'update:level', 'refresh', 'toggle-auto'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h('div', { class: 'toolbar' }, [
|
||||
h(NInput, {
|
||||
value: props.keyword,
|
||||
placeholder: '关键词搜索',
|
||||
clearable: true,
|
||||
style: 'max-width: 220px',
|
||||
'onUpdate:value': (v: string) => emit('update:keyword', v),
|
||||
}),
|
||||
props.showStatus
|
||||
? h(NSelect, {
|
||||
value: props.status,
|
||||
placeholder: '状态码',
|
||||
clearable: true,
|
||||
style: 'width: 120px',
|
||||
options: [200, 301, 400, 403, 404, 500].map((c) => ({ label: String(c), value: c })),
|
||||
'onUpdate:value': (v: number | null) => emit('update:status', v),
|
||||
})
|
||||
: null,
|
||||
props.showLevel
|
||||
? h(NSelect, {
|
||||
value: props.level,
|
||||
placeholder: '日志级别',
|
||||
clearable: true,
|
||||
style: 'width: 120px',
|
||||
options: [
|
||||
{ label: '信息', value: 'INFO' },
|
||||
{ label: '警告', value: 'WARN' },
|
||||
{ label: '错误', value: 'ERROR' },
|
||||
],
|
||||
'onUpdate:value': (v: string) => emit('update:level', v),
|
||||
})
|
||||
: null,
|
||||
h(NButton, { onClick: () => emit('refresh') }, () => '刷新'),
|
||||
props.autoRefresh !== undefined
|
||||
? h(NButton, { quaternary: true, onClick: () => emit('toggle-auto') }, () =>
|
||||
props.autoRefresh ? '关闭自动刷新' : '自动刷新',
|
||||
)
|
||||
: null,
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
const LOG_TABS = ['system', 'access', 'nginx'] as const
|
||||
type LogTab = (typeof LOG_TABS)[number]
|
||||
|
||||
function resolveTab(queryTab: unknown): LogTab {
|
||||
if (queryTab === 'error' || queryTab === 'nginx') return 'nginx'
|
||||
if (queryTab === 'stream') return 'access'
|
||||
if (typeof queryTab === 'string' && (LOG_TABS as readonly string[]).includes(queryTab)) {
|
||||
return queryTab as LogTab
|
||||
}
|
||||
return 'system'
|
||||
}
|
||||
|
||||
const tab = ref(resolveTab(route.query.tab))
|
||||
const pageError = ref('')
|
||||
const autoRefresh = ref(false)
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const accessLogs = ref<AccessLogEntry[]>([])
|
||||
const proxyRules = ref<ProxyRule[]>([])
|
||||
const proxyBindingIndex = computed(() => buildProxyBindingIndex(proxyRules.value))
|
||||
const errorLogs = ref<ParsedNginxError[]>([])
|
||||
const systemLogs = ref<SystemLogEntry[]>([])
|
||||
const loadingAccess = ref(false)
|
||||
const loadingError = ref(false)
|
||||
const loadingSystem = ref(false)
|
||||
|
||||
const accessKeyword = ref('')
|
||||
const accessStatus = ref<number | null>(null)
|
||||
const errorKeyword = ref('')
|
||||
const systemKeyword = ref('')
|
||||
const systemLevel = ref('')
|
||||
|
||||
const accessPage = ref(1)
|
||||
const errorPage = ref(1)
|
||||
const systemPage = ref(1)
|
||||
|
||||
const filteredAccessLogs = computed(() => {
|
||||
return accessLogs.value.filter((log) => {
|
||||
if (accessKeyword.value) {
|
||||
const kw = accessKeyword.value.toLowerCase()
|
||||
const service = resolveAccessServiceLabel(log, proxyBindingIndex.value)
|
||||
const fallback = formatAccessFallback(log)
|
||||
if (!`${service} ${fallback} ${log.domain} ${log.path} ${log.client_ip}`.toLowerCase().includes(kw)) return false
|
||||
}
|
||||
if (accessStatus.value && log.status !== accessStatus.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const filteredErrorLogs = computed(() => {
|
||||
if (!errorKeyword.value) return errorLogs.value
|
||||
const kw = errorKeyword.value.toLowerCase()
|
||||
return errorLogs.value.filter((entry) => entry.raw.toLowerCase().includes(kw))
|
||||
})
|
||||
|
||||
const filteredSystemLogs = computed(() => {
|
||||
return systemLogs.value.filter((log) => {
|
||||
const display = displaySystemLog(log)
|
||||
if (systemLevel.value && log.level.toUpperCase() !== systemLevel.value) return false
|
||||
if (
|
||||
systemKeyword.value &&
|
||||
!`${display.displayModule} ${log.message}`.toLowerCase().includes(systemKeyword.value.toLowerCase())
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
function paginate<T>(items: T[], page: number) {
|
||||
const start = (page - 1) * PAGE_SIZE
|
||||
return items.slice(start, start + PAGE_SIZE)
|
||||
}
|
||||
|
||||
const pagedAccessLogs = computed(() => paginate(filteredAccessLogs.value, accessPage.value))
|
||||
const pagedErrorLogs = computed(() => paginate(filteredErrorLogs.value, errorPage.value))
|
||||
const pagedSystemLogs = computed(() => paginate(filteredSystemLogs.value, systemPage.value))
|
||||
|
||||
const methodTagType = (method: string) => {
|
||||
const map: Record<string, 'success' | 'info' | 'warning' | 'error' | 'default'> = {
|
||||
GET: 'success',
|
||||
POST: 'info',
|
||||
PUT: 'warning',
|
||||
DELETE: 'error',
|
||||
}
|
||||
return map[method] ?? 'default'
|
||||
}
|
||||
|
||||
const statusTagType = (code: number) => {
|
||||
const kind = httpStatusKind(code)
|
||||
if (kind === 'success') return 'success'
|
||||
if (kind === 'warning') return 'warning'
|
||||
if (kind === 'error') return 'error'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const logTimeCell = (time: string) =>
|
||||
h('span', { class: 'log-time' }, formatLogTime(time))
|
||||
|
||||
function accessRowClassName(row: AccessLogEntry) {
|
||||
if (row.status >= 500) return 'log-row log-row--error'
|
||||
if (row.status >= 400) return 'log-row log-row--warn'
|
||||
return 'log-row'
|
||||
}
|
||||
|
||||
function errorRowClassName(row: ParsedNginxError) {
|
||||
const type = nginxLevelTagType(row.level)
|
||||
if (type === 'error') return 'log-row log-row--error'
|
||||
if (type === 'warning') return 'log-row log-row--warn'
|
||||
return 'log-row'
|
||||
}
|
||||
|
||||
function systemRowClassName(row: SystemLogEntry) {
|
||||
const display = displaySystemLog(row)
|
||||
if (display.displayLevel === '错误') return 'log-row log-row--error'
|
||||
if (display.displayLevel === '警告') return 'log-row log-row--warn'
|
||||
return 'log-row'
|
||||
}
|
||||
|
||||
const accessColumns = computed<DataTableColumns<AccessLogEntry>>(() => [
|
||||
{ title: '时间', key: 'time', width: 168, render: (row) => logTimeCell(row.time) },
|
||||
{
|
||||
title: '服务',
|
||||
key: 'service',
|
||||
width: 128,
|
||||
render: (row) => {
|
||||
const tooltip = accessServiceTooltip(row, proxyBindingIndex.value)
|
||||
const label = resolveAccessServiceLabel(row, proxyBindingIndex.value)
|
||||
return h('span', { class: 'log-service nowrap', title: tooltip }, label)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '方法',
|
||||
key: 'method',
|
||||
width: 68,
|
||||
render: (row) => h(NTag, { size: 'small', bordered: false, type: methodTagType(row.method) }, () => row.method),
|
||||
},
|
||||
{
|
||||
title: '路径',
|
||||
key: 'path',
|
||||
width: 220,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => h('span', { class: 'log-path mono nowrap', title: row.path }, row.path),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 68,
|
||||
render: (row) => h(NTag, { size: 'small', bordered: false, type: statusTagType(row.status) }, () => String(row.status)),
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
key: 'response_time',
|
||||
width: 80,
|
||||
render: (row) => h('span', { class: latencyClass(row.response_time) }, formatMs(row.response_time)),
|
||||
},
|
||||
{
|
||||
title: '来源 IP',
|
||||
key: 'client_ip',
|
||||
width: 140,
|
||||
render: (row) => h('span', { class: 'mono nowrap', title: row.client_ip }, row.client_ip),
|
||||
},
|
||||
])
|
||||
|
||||
const errorColumns: DataTableColumns<ParsedNginxError> = [
|
||||
{ title: '时间', key: 'time', width: 170, render: (row) => logTimeCell(row.time || '-') },
|
||||
{
|
||||
title: '级别',
|
||||
key: 'level',
|
||||
width: 88,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: 'small', bordered: false, type: nginxLevelTagType(row.level) },
|
||||
() => (row.level === 'unknown' ? '未知' : row.level.toUpperCase()),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '消息',
|
||||
key: 'message',
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => h('span', { class: 'log-message mono' }, row.message),
|
||||
},
|
||||
]
|
||||
|
||||
const systemColumns: DataTableColumns<SystemLogEntry> = [
|
||||
{ title: '时间', key: 'time', width: 168, render: (row) => logTimeCell(row.time) },
|
||||
{
|
||||
title: '级别',
|
||||
key: 'level',
|
||||
width: 80,
|
||||
render: (row) => {
|
||||
const display = displaySystemLog(row)
|
||||
return h(
|
||||
NTag,
|
||||
{ size: 'small', bordered: false, type: systemLevelTagType(display.displayLevel) },
|
||||
() => display.displayLevel,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '模块',
|
||||
key: 'module',
|
||||
width: 108,
|
||||
render: (row) => {
|
||||
const display = displaySystemLog(row)
|
||||
return h(
|
||||
NTag,
|
||||
{
|
||||
size: 'small',
|
||||
bordered: false,
|
||||
type: systemModuleTagType(display.displayModule),
|
||||
class: 'log-module-tag',
|
||||
},
|
||||
() => display.displayModule,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '消息',
|
||||
key: 'message',
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => {
|
||||
const display = displaySystemLog(row)
|
||||
return h('span', { class: 'log-message' }, display.message)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async function loadProxyRules() {
|
||||
try {
|
||||
proxyRules.value = asList(await api.listProxies())
|
||||
} catch {
|
||||
proxyRules.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAccess() {
|
||||
loadingAccess.value = true
|
||||
try {
|
||||
await loadProxyRules()
|
||||
accessLogs.value = asList(await api.getAccessLogs({ limit: 100 }))
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载访问日志失败')
|
||||
} finally {
|
||||
loadingAccess.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadErrorLogs() {
|
||||
loadingError.value = true
|
||||
try {
|
||||
const errors = asList(await api.getErrorLogs({ limit: 100 }))
|
||||
errorLogs.value = errors.map((line) => parseNginxErrorLine(line))
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载错误日志失败')
|
||||
} finally {
|
||||
loadingError.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSystemLogs() {
|
||||
loadingSystem.value = true
|
||||
try {
|
||||
systemLogs.value = asList(await api.getSystemLogs({ limit: 100 }))
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载系统日志失败')
|
||||
} finally {
|
||||
loadingSystem.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
pageError.value = ''
|
||||
try {
|
||||
await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs()])
|
||||
} catch (error) {
|
||||
pageError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
|
||||
}
|
||||
}
|
||||
|
||||
watch([accessKeyword, accessStatus], () => {
|
||||
accessPage.value = 1
|
||||
})
|
||||
|
||||
watch(errorKeyword, () => {
|
||||
errorPage.value = 1
|
||||
})
|
||||
|
||||
watch([systemKeyword, systemLevel], () => {
|
||||
systemPage.value = 1
|
||||
})
|
||||
|
||||
watch(autoRefresh, (on) => {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
if (on) refreshTimer = setInterval(() => loadAccess(), 10000)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(queryTab) => {
|
||||
tab.value = resolveTab(queryTab)
|
||||
},
|
||||
)
|
||||
|
||||
watch(tab, (name) => {
|
||||
if (name === 'access') loadAccess()
|
||||
else if (name === 'nginx') loadErrorLogs()
|
||||
else if (name === 'system') loadSystemLogs()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
tab.value = resolveTab(route.query.tab)
|
||||
loadAll()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logs-card :deep(.n-tabs-nav) {
|
||||
padding: var(--fonu-space-4) var(--fonu-space-5) 0;
|
||||
}
|
||||
|
||||
.logs-card :deep(.n-tabs-tab) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.logs-card :deep(.n-tab-pane) {
|
||||
padding-top: var(--fonu-space-2);
|
||||
}
|
||||
|
||||
.tab-hint {
|
||||
margin: 0 var(--fonu-space-5) var(--fonu-space-3);
|
||||
font-size: 13px;
|
||||
color: var(--fonu-text-muted);
|
||||
}
|
||||
|
||||
.log-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--fonu-space-4) var(--fonu-space-5);
|
||||
border-top: 1px solid var(--fonu-border);
|
||||
}
|
||||
|
||||
.logs-card :deep(.log-table .n-data-table-th) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.logs-card :deep(.log-table .n-data-table-base-table) {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.logs-card :deep(.log-table .n-data-table-td) {
|
||||
font-size: 13px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logs-card :deep(.log-table .n-data-table-tr.log-row--error .n-data-table-td) {
|
||||
background: color-mix(in srgb, #ef4444 7%, transparent);
|
||||
}
|
||||
|
||||
.logs-card :deep(.log-table .n-data-table-tr.log-row--warn .n-data-table-td) {
|
||||
background: color-mix(in srgb, #f59e0b 7%, transparent);
|
||||
}
|
||||
|
||||
.logs-card :deep(.log-table .n-data-table-tr.log-row:hover .n-data-table-td) {
|
||||
background: color-mix(in srgb, var(--fonu-brand) 5%, var(--fonu-surface));
|
||||
}
|
||||
|
||||
.log-time {
|
||||
white-space: nowrap;
|
||||
color: var(--fonu-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.log-service {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.log-path {
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.log-message {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--fonu-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-duration {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.log-duration--slow {
|
||||
color: #d97706;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-duration--critical {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logs-card :deep(.log-module-tag) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,583 @@
|
||||
<template>
|
||||
<PageHeader title="公网反代" description="管理 VPS 上 Nginx 反代:部署/移除穿透规则的公网入口,支持强制跳转、IP 白名单、Basic Auth 与 WebSocket">
|
||||
<template #actions>
|
||||
<n-select
|
||||
v-model:value="selectedId"
|
||||
:options="serverOptions"
|
||||
placeholder="选择公网服务端"
|
||||
style="width: 220px"
|
||||
:disabled="servers.length === 0"
|
||||
/>
|
||||
<n-button :loading="refreshing" @click="refreshAll">刷新</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<LoadError v-if="loadError" :message="loadError" @retry="load" />
|
||||
|
||||
<template v-else>
|
||||
<EmptyState
|
||||
v-if="servers.length === 0"
|
||||
title="尚未配置 FRP 服务端"
|
||||
description="先在「内网穿透」页添加公网服务端并保存 Agent/SSH 信息,再回到本页管理公网反代。"
|
||||
>
|
||||
<template #action>
|
||||
<router-link to="/frp"><n-button type="primary">去内网穿透页</n-button></router-link>
|
||||
</template>
|
||||
</EmptyState>
|
||||
|
||||
<template v-else-if="server">
|
||||
<div v-if="agentStatus?.nginx_missing" class="nginx-alert">
|
||||
<div class="nginx-alert__text">
|
||||
<strong>VPS 未安装 Nginx</strong>
|
||||
<span>公网反代需要 Nginx 承接 80/443 并终结 HTTPS。可点右侧按钮由 agent 自动安装(识别 apt-get / dnf / yum / apk,需 root),或在 VPS 手动执行:<code>apt install -y nginx</code> / <code>yum install -y nginx</code></span>
|
||||
</div>
|
||||
<n-button size="small" type="primary" :loading="installingNginx" @click="installNginx">一键安装 Nginx</n-button>
|
||||
</div>
|
||||
<div v-else-if="agentStatus && agentStatus.nginx_ok === false" class="nginx-alert nginx-alert--warn">
|
||||
<div class="nginx-alert__text">
|
||||
<strong>Nginx 配置校验未通过</strong>
|
||||
<span class="mono">{{ agentStatus.nginx_error || 'nginx -t 执行失败' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FonuCard class="section-card" title="Nginx 状态">
|
||||
<div class="section-body">
|
||||
<div class="nginx-status-grid">
|
||||
<div class="nginx-status-item">
|
||||
<span class="nginx-status-item__label">状态</span>
|
||||
<span class="nginx-status-item__value">
|
||||
<n-tag :type="nginxState().type" size="small" :bordered="false">{{ nginxState().label }}</n-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="nginx-status-item">
|
||||
<span class="nginx-status-item__label">进程</span>
|
||||
<span class="nginx-status-item__value">{{ agentStatus?.nginx_running ? '运行中' : '未运行' }}</span>
|
||||
</div>
|
||||
<div class="nginx-status-item">
|
||||
<span class="nginx-status-item__label">版本</span>
|
||||
<span class="nginx-status-item__value mono">{{ agentStatus?.nginx_version || '—' }}</span>
|
||||
</div>
|
||||
<div class="nginx-status-item">
|
||||
<span class="nginx-status-item__label">可执行文件</span>
|
||||
<span class="nginx-status-item__value mono">{{ agentStatus?.nginx_path || agentStatus?.nginx_binary || '—' }}</span>
|
||||
</div>
|
||||
<div class="nginx-status-item">
|
||||
<span class="nginx-status-item__label">vhost 配置目录</span>
|
||||
<span class="nginx-status-item__value mono">{{ agentStatus?.nginx_conf_dir || '—' }}</span>
|
||||
</div>
|
||||
<div class="nginx-status-item">
|
||||
<span class="nginx-status-item__label">配置校验</span>
|
||||
<span class="nginx-status-item__value">{{ agentStatus?.nginx_ok ? 'nginx -t 通过' : '未通过' }}</span>
|
||||
</div>
|
||||
<div class="nginx-status-item">
|
||||
<span class="nginx-status-item__label">端口监听</span>
|
||||
<span class="nginx-status-item__value mono">80 {{ agentStatus?.ports?.['80'] ? '✓' : '✗' }} · 443 {{ agentStatus?.ports?.['443'] ? '✓' : '✗' }}</span>
|
||||
</div>
|
||||
<div class="nginx-status-item">
|
||||
<span class="nginx-status-item__label">反代配置文件</span>
|
||||
<span class="nginx-status-item__value">{{ agentStatus?.routes?.length ?? 0 }} 个</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="agentStatus?.nginx_error" class="form-hint mono nginx-status-error">{{ agentStatus.nginx_error }}</p>
|
||||
<div class="nginx-status-actions">
|
||||
<n-button v-if="agentStatus?.nginx_missing" size="small" type="primary" :loading="installingNginx" @click="installNginx">一键安装 Nginx</n-button>
|
||||
<n-button size="small" :loading="refreshing" @click="refreshAll">重新检测</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</FonuCard>
|
||||
|
||||
<FonuCard class="section-card" title="穿透规则与公网反代状态">
|
||||
<div class="section-body">
|
||||
<p class="form-hint">穿透规则保存后不会自动修改 VPS。点击「部署反代」把该规则的域名发布到公网 Nginx;部署时自动推送证书并做回源自检。</p>
|
||||
<div v-if="routeCandidates.length" class="route-list">
|
||||
<div v-for="candidate in routeCandidates" :key="candidate.id" class="route-item">
|
||||
<div class="route-item__main">
|
||||
<strong>{{ candidate.name }}</strong>
|
||||
<span class="route-item__domains mono">{{ candidate.custom_domains.join('、') }}</span>
|
||||
<span class="route-item__upstream mono">{{ candidate.local_ip }}:{{ candidate.local_port }}(回源 127.0.0.1:{{ vhostPortOf(candidate) }})</span>
|
||||
<div class="route-item__health">
|
||||
<span v-for="h in healthBadges(candidate)" :key="h.label" class="health-badge" :class="h.ok ? 'health-ok' : 'health-bad'" :title="h.detail">{{ h.label }} {{ h.ok ? '✓' : '✗' }}</span>
|
||||
</div>
|
||||
<div class="route-item__traffic">
|
||||
<span class="traffic-metric" title="frps 侧当前连接数">连接 {{ trafficOf(candidate).cur_conns }}</span>
|
||||
<span class="traffic-metric" title="当前上传速率(近两次采样均值)">当前上传 {{ formatRate(trafficOf(candidate).traffic_in_rate) }}</span>
|
||||
<span class="traffic-metric" title="当前下载速率(近两次采样均值)">当前下载 {{ formatRate(trafficOf(candidate).traffic_out_rate) }}</span>
|
||||
<span class="traffic-metric" title="frps 侧当日累计上传">总上传 {{ formatBytes(trafficOf(candidate).traffic_in) }}</span>
|
||||
<span class="traffic-metric" title="frps 侧当日累计下载">总下载 {{ formatBytes(trafficOf(candidate).traffic_out) }}</span>
|
||||
<span v-if="trafficErrors[String(candidate.id)]" class="traffic-metric traffic-metric--error" :title="trafficErrors[String(candidate.id)]">流量采集失败</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="route-item__actions">
|
||||
<n-tag :type="stateMeta(candidate).type" size="small" :bordered="false">{{ stateMeta(candidate).label }}</n-tag>
|
||||
<n-button size="small" quaternary :loading="confLoadingId === candidate.id" @click="viewConf(candidate)">查看配置</n-button>
|
||||
<n-button size="small" @click="openDeploySettings(candidate)">部署设置</n-button>
|
||||
<n-button size="small" type="primary" :loading="routeActionId === candidate.id" @click="deployRoute(candidate)">部署反代</n-button>
|
||||
<n-button v-if="stateOf(candidate) !== 'none'" size="small" type="warning" quaternary :loading="routeActionId === candidate.id" @click="removeRoute(candidate)">移除反代</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-route">暂无可部署的 HTTP/HTTPS 穿透规则——先到「内网穿透」页创建启用的 http 类型规则(填自定义域名)</div>
|
||||
<details v-if="routeDetails.length" class="raw-routes">
|
||||
<summary>VPS 上 agent 管理的路由文件({{ routeDetails.length }})</summary>
|
||||
<div v-for="detail in routeDetails" :key="detail.domain" class="raw-route-row mono">
|
||||
{{ detail.domain }} — {{ detail.loaded ? '已加载' : detail.syntax_ok ? '已写入未加载' : '语法错误' }}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</FonuCard>
|
||||
|
||||
<FonuCard class="section-card" title="使用说明">
|
||||
<div class="section-body">
|
||||
<p class="form-hint">部署选项(启用 HTTPS / 强制跳转 / WebSocket / 上传大小 / 代理超时 / IP 白名单与黑名单 / Basic Auth / 仅 TLS 1.3 / 安全响应头)在本页规则行的「部署设置」里配置,点「保存并重新部署」即生效。Basic Auth 密码留空时首次部署自动生成,写在生成的配置注释里(点「查看配置」可见)。注意:这些选项需要 VPS 上已升级到新版 agent。</p>
|
||||
</div>
|
||||
</FonuCard>
|
||||
|
||||
<n-modal
|
||||
v-model:show="settingsModal"
|
||||
:mask-closable="false"
|
||||
style="width: 640px"
|
||||
>
|
||||
<div class="proxy-modal">
|
||||
<div class="proxy-modal__header">
|
||||
<h3 class="modal-title">部署设置 — {{ settingsTarget?.name }}</h3>
|
||||
<n-button size="small" quaternary @click="settingsModal = false">取消</n-button>
|
||||
</div>
|
||||
<div class="proxy-modal__tabbar">
|
||||
<button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': settingsTab === 'basic' }" @click="settingsTab = 'basic'">基础</button>
|
||||
<button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': settingsTab === 'security' }" @click="settingsTab = 'security'">安全</button>
|
||||
<button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': settingsTab === 'advanced' }" @click="settingsTab = 'advanced'">高级</button>
|
||||
</div>
|
||||
<div class="proxy-modal__scroll">
|
||||
<n-form v-show="settingsTab === 'basic'" label-placement="top" class="proxy-modal__pane">
|
||||
<div class="form-switch-list">
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">启用 HTTPS</div>
|
||||
<div class="form-switch-row__hint">证书已部署时监听 443;关闭后仅保留 80 明文入口</div>
|
||||
</div>
|
||||
<n-switch v-model:value="settingsForm.https_enabled" size="small" />
|
||||
</div>
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">HTTP 强制跳转 HTTPS</div>
|
||||
<div class="form-switch-row__hint">80 端口访问自动 301 到 HTTPS(需启用 HTTPS)</div>
|
||||
</div>
|
||||
<n-switch v-model:value="settingsForm.redirect_https" size="small" :disabled="!settingsForm.https_enabled" />
|
||||
</div>
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">WebSocket 支持</div>
|
||||
<div class="form-switch-row__hint">附加 Upgrade / Connection 头,实时服务(家庭影院、监控等)需要</div>
|
||||
</div>
|
||||
<n-switch v-model:value="settingsForm.websocket" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<n-form-item label="上传大小限制">
|
||||
<n-input v-model:value="settingsForm.client_max_body_size" placeholder="留空用 Nginx 默认,如 50m" />
|
||||
</n-form-item>
|
||||
<n-form-item label="代理读超时">
|
||||
<n-input v-model:value="settingsForm.proxy_read_timeout" placeholder="留空用 Nginx 默认,如 3600s" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</n-form>
|
||||
<n-form v-show="settingsTab === 'security'" label-placement="top" class="proxy-modal__pane">
|
||||
<div class="panel-title">IP 访问控制</div>
|
||||
<div class="field-grid">
|
||||
<n-form-item label="IP 白名单(逗号分隔)">
|
||||
<n-input v-model:value="settingsForm.allow_ips_text" placeholder="如 1.2.3.4,10.0.0.0/24,留空不限制" />
|
||||
</n-form-item>
|
||||
<n-form-item label="IP 黑名单(逗号分隔)">
|
||||
<n-input v-model:value="settingsForm.deny_ips_text" placeholder="如 5.6.7.8,留空不限制" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
<div class="form-switch-list">
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">仅中国大陆 IP</div>
|
||||
<div class="form-switch-row__hint">部署时自动把本地维护的中国 IP 段下发到 VPS;非大陆 IP 返回 403</div>
|
||||
</div>
|
||||
<n-switch v-model:value="settingsForm.china_only" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-title">认证</div>
|
||||
<div class="field-grid">
|
||||
<n-form-item label="Basic Auth 用户名">
|
||||
<n-input v-model:value="settingsForm.basic_auth_user" placeholder="留空不启用" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Basic Auth 密码">
|
||||
<n-input v-model:value="settingsForm.basic_auth_password" type="password" show-password-on="click" placeholder="留空则首次部署自动生成" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
<div class="panel-title">流量控制</div>
|
||||
<div class="field-grid field-grid--three">
|
||||
<n-form-item label="请求限流(次/秒)">
|
||||
<n-input-number v-model:value="settingsForm.rate_limit_rate" :min="0" :max="100000" placeholder="留空不限制" style="width: 100%" />
|
||||
</n-form-item>
|
||||
<n-form-item label="突发允许量">
|
||||
<n-input-number v-model:value="settingsForm.rate_limit_burst" :min="0" :max="100000" placeholder="留空按 0" style="width: 100%" />
|
||||
</n-form-item>
|
||||
<n-form-item label="连接数上限(每 IP)">
|
||||
<n-input-number v-model:value="settingsForm.conn_limit_max" :min="0" :max="100000" placeholder="留空不限制" style="width: 100%" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</n-form>
|
||||
<n-form v-show="settingsTab === 'advanced'" label-placement="top" class="proxy-modal__pane">
|
||||
<div class="form-switch-list">
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">安全响应头</div>
|
||||
<div class="form-switch-row__hint">HSTS / X-Frame-Options / X-Content-Type-Options / Referrer-Policy</div>
|
||||
</div>
|
||||
<n-switch v-model:value="settingsForm.security_headers" size="small" />
|
||||
</div>
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">仅 TLS 1.3</div>
|
||||
<div class="form-switch-row__hint">只允许 TLS 1.3 握手(需启用 HTTPS)</div>
|
||||
</div>
|
||||
<n-switch v-model:value="settingsForm.tls13_only" size="small" :disabled="!settingsForm.https_enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</n-form>
|
||||
</div>
|
||||
<div class="proxy-modal__footer">
|
||||
<n-button size="small" @click="settingsModal = false">取消</n-button>
|
||||
<n-button size="small" type="primary" :loading="routeActionId === settingsTarget?.id" @click="saveDeploySettings">保存并重新部署</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
|
||||
<n-modal
|
||||
v-model:show="confModal"
|
||||
:mask-closable="true"
|
||||
style="width: 720px"
|
||||
>
|
||||
<div class="proxy-modal">
|
||||
<div class="proxy-modal__header">
|
||||
<h3 class="modal-title">反代配置 — {{ confDomain }}</h3>
|
||||
<n-button size="small" quaternary @click="confModal = false">关闭</n-button>
|
||||
</div>
|
||||
<pre class="code-block conf-view">{{ confContent || '加载中…' }}</pre>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import FonuCard from '../components/FonuCard.vue'
|
||||
import LoadError from '../components/LoadError.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import { api } from '../api/client'
|
||||
import type { FrpAgentRouteDetail, FrpAgentStatus, FrpProxy, FrpProxyTraffic, FrpServer } from '../api/types'
|
||||
import { formatBytes, formatRate } from '../utils/format'
|
||||
|
||||
const message = useMessage()
|
||||
const servers = ref<FrpServer[]>([])
|
||||
const selectedId = ref<number | null>(null)
|
||||
const loadError = ref('')
|
||||
const loadErrorMsg = ref('')
|
||||
const refreshing = ref(false)
|
||||
const routeCandidates = ref<FrpProxy[]>([])
|
||||
const routeDetails = ref<FrpAgentRouteDetail[]>([])
|
||||
const routeHealth = ref<import('../api/types').FrpAgentRouteHealth[]>([])
|
||||
const proxyTraffic = ref<Record<string, FrpProxyTraffic>>({})
|
||||
const trafficErrors = ref<Record<string, string>>({})
|
||||
const agentStatus = ref<FrpAgentStatus | null>(null)
|
||||
const installingNginx = ref(false)
|
||||
const routeActionId = ref<number | null>(null)
|
||||
const settingsModal = ref(false)
|
||||
const settingsTarget = ref<FrpProxy | null>(null)
|
||||
const settingsTab = ref<'basic' | 'security' | 'advanced'>('basic')
|
||||
const settingsForm = ref({
|
||||
websocket: false,
|
||||
// 存储用反向字段 https_disabled(缺省 = 启用 HTTPS,保持旧数据行为)
|
||||
https_enabled: true,
|
||||
redirect_https: false,
|
||||
client_max_body_size: '',
|
||||
proxy_read_timeout: '',
|
||||
allow_ips_text: '',
|
||||
deny_ips_text: '',
|
||||
basic_auth_user: '',
|
||||
basic_auth_password: '',
|
||||
security_headers: false,
|
||||
tls13_only: false,
|
||||
china_only: false,
|
||||
rate_limit_rate: null as number | null,
|
||||
rate_limit_burst: null as number | null,
|
||||
conn_limit_max: null as number | null,
|
||||
})
|
||||
const confLoadingId = ref<number | null>(null)
|
||||
const confModal = ref(false)
|
||||
const confDomain = ref('')
|
||||
const confContent = ref('')
|
||||
let timer: number | undefined
|
||||
|
||||
const server = computed(() => servers.value.find((item) => item.id === selectedId.value) ?? null)
|
||||
const serverOptions = computed(() => servers.value
|
||||
.filter((item) => item.agent_configured || item.ssh_configured)
|
||||
.map((item) => ({ label: item.name, value: item.id })))
|
||||
|
||||
async function load() {
|
||||
loadError.value = ''
|
||||
try {
|
||||
servers.value = (await api.listFrpServers()).filter((item) => item.agent_configured || item.ssh_configured)
|
||||
if (selectedId.value === null || !servers.value.some((item) => item.id === selectedId.value)) {
|
||||
selectedId.value = servers.value[0]?.id ?? null
|
||||
}
|
||||
await refreshAll()
|
||||
} catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' }
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
const current = server.value
|
||||
if (!current) return
|
||||
refreshing.value = true
|
||||
try {
|
||||
routeCandidates.value = await api.getFrpAgentRouteCandidates(current.id)
|
||||
routeDetails.value = await api.getFrpAgentRoutes(current.id)
|
||||
routeHealth.value = await api.getFrpAgentRouteHealth(current.id)
|
||||
// 流量按规则 ID 聚合(frps dashboard);旧版 frps 会在后端回退为逐规则查询
|
||||
const traffic = await api.listFrpProxiesTraffic()
|
||||
proxyTraffic.value = traffic.items ?? {}
|
||||
trafficErrors.value = traffic.errors ?? {}
|
||||
// agent 状态用于 Nginx 可用性提示(nginx_missing / nginx_ok)
|
||||
try { agentStatus.value = await api.getFrpAgentStatus(current.id) } catch { agentStatus.value = null }
|
||||
} catch (error) { loadErrorMsg.value = error instanceof Error ? error.message : '读取规则失败' }
|
||||
finally { refreshing.value = false }
|
||||
}
|
||||
|
||||
function vhostPortOf(candidate: FrpProxy) {
|
||||
const port = server.value?.options.vhost_http_port || 8080
|
||||
return candidate.type === 'https' ? (server.value?.options.vhost_https_port || 8443) : port
|
||||
}
|
||||
|
||||
type RouteState = 'loaded' | 'written' | 'broken' | 'none'
|
||||
|
||||
function stateOf(candidate: FrpProxy): RouteState {
|
||||
for (const domain of candidate.custom_domains) {
|
||||
const d = routeDetails.value.find((item) => item.domain === domain)
|
||||
if (d) {
|
||||
if (d.loaded) return 'loaded'
|
||||
if (d.syntax_ok) return 'written'
|
||||
return 'broken'
|
||||
}
|
||||
}
|
||||
return 'none'
|
||||
}
|
||||
|
||||
function stateMeta(candidate: FrpProxy): { label: string; type: 'success' | 'warning' | 'error' | 'default' } {
|
||||
switch (stateOf(candidate)) {
|
||||
case 'loaded': return { label: '已生效', type: 'success' }
|
||||
case 'written': return { label: '已写入未加载', type: 'warning' }
|
||||
case 'broken': return { label: '语法错误', type: 'error' }
|
||||
default: return { label: '未部署', type: 'default' }
|
||||
}
|
||||
}
|
||||
|
||||
async function deployRoute(candidate: FrpProxy) {
|
||||
const current = server.value
|
||||
if (!current) return
|
||||
routeActionId.value = candidate.id
|
||||
try {
|
||||
await api.deployFrpAgentRoute(current.id, candidate.id)
|
||||
message.success('公网反代已部署')
|
||||
await refreshAll()
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '部署反代失败') }
|
||||
finally { routeActionId.value = null }
|
||||
}
|
||||
|
||||
async function removeRoute(candidate: FrpProxy) {
|
||||
const current = server.value
|
||||
if (!current) return
|
||||
routeActionId.value = candidate.id
|
||||
try {
|
||||
await api.removeFrpAgentRoute(current.id, candidate.id)
|
||||
message.success('公网反代已移除')
|
||||
await refreshAll()
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '移除反代失败') }
|
||||
finally { routeActionId.value = null }
|
||||
}
|
||||
|
||||
function openDeploySettings(candidate: FrpProxy) {
|
||||
settingsTarget.value = candidate
|
||||
settingsTab.value = 'basic'
|
||||
settingsForm.value = {
|
||||
websocket: candidate.options?.websocket ?? false,
|
||||
https_enabled: !(candidate.options?.https_disabled ?? false),
|
||||
redirect_https: candidate.options?.redirect_https ?? false,
|
||||
client_max_body_size: candidate.options?.client_max_body_size ?? '',
|
||||
proxy_read_timeout: candidate.options?.proxy_read_timeout ?? '',
|
||||
allow_ips_text: (candidate.options?.allow_ips || []).join(','),
|
||||
deny_ips_text: (candidate.options?.deny_ips || []).join(','),
|
||||
basic_auth_user: candidate.options?.basic_auth_user ?? '',
|
||||
basic_auth_password: candidate.options?.basic_auth_password ?? '',
|
||||
security_headers: candidate.options?.security_headers ?? false,
|
||||
tls13_only: candidate.options?.tls13_only ?? false,
|
||||
china_only: candidate.options?.china_only ?? false,
|
||||
rate_limit_rate: candidate.options?.rate_limit_rate ?? null,
|
||||
rate_limit_burst: candidate.options?.rate_limit_burst ?? null,
|
||||
conn_limit_max: candidate.options?.conn_limit_max ?? null,
|
||||
}
|
||||
settingsModal.value = true
|
||||
}
|
||||
|
||||
async function saveDeploySettings() {
|
||||
const target = settingsTarget.value
|
||||
const current = server.value
|
||||
if (!target || !current) return
|
||||
routeActionId.value = target.id
|
||||
try {
|
||||
// 选项存在规则的 options 里(与穿透配置同库),更新后立即重部署生效
|
||||
const options = {
|
||||
...target.options,
|
||||
websocket: settingsForm.value.websocket,
|
||||
https_disabled: !settingsForm.value.https_enabled,
|
||||
redirect_https: settingsForm.value.redirect_https,
|
||||
client_max_body_size: settingsForm.value.client_max_body_size.trim(),
|
||||
proxy_read_timeout: settingsForm.value.proxy_read_timeout.trim(),
|
||||
allow_ips: settingsForm.value.allow_ips_text.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
deny_ips: settingsForm.value.deny_ips_text.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
basic_auth_user: settingsForm.value.basic_auth_user.trim(),
|
||||
basic_auth_password: settingsForm.value.basic_auth_password,
|
||||
security_headers: settingsForm.value.security_headers,
|
||||
tls13_only: settingsForm.value.tls13_only,
|
||||
china_only: settingsForm.value.china_only,
|
||||
rate_limit_rate: settingsForm.value.rate_limit_rate ?? 0,
|
||||
rate_limit_burst: settingsForm.value.rate_limit_burst ?? 0,
|
||||
conn_limit_max: settingsForm.value.conn_limit_max ?? 0,
|
||||
}
|
||||
await api.updateFrpProxy(target.id, { server_id: target.server_id, name: target.name, type: target.type, local_ip: target.local_ip, local_port: target.local_port, remote_port: target.remote_port ?? null, custom_domains: target.custom_domains, host_header_rewrite: target.host_header_rewrite, options, enabled: target.enabled, remark: target.remark })
|
||||
settingsModal.value = false
|
||||
message.success('部署设置已保存,正在重新部署…')
|
||||
await deployRoute(target)
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '保存部署设置失败') }
|
||||
finally { routeActionId.value = null }
|
||||
}
|
||||
|
||||
// nginxState 汇总 Nginx 状态徽标:未安装 / 配置校验未通过 / 已安装未运行 / 正常。
|
||||
function nginxState(): { label: string; type: 'success' | 'warning' | 'error' | 'default' } {
|
||||
const status = agentStatus.value
|
||||
if (!status) return { label: '未知(未连接 agent)', type: 'default' }
|
||||
if (status.nginx_missing) return { label: '未安装', type: 'error' }
|
||||
if (!status.nginx_ok) return { label: '配置校验未通过', type: 'error' }
|
||||
if (status.nginx_running === false) return { label: '已安装未运行', type: 'warning' }
|
||||
return { label: '正常', type: 'success' }
|
||||
}
|
||||
|
||||
// installNginx 触发 VPS 上一键安装 Nginx(agent 侧识别包管理器并执行,需 root)。
|
||||
async function installNginx() {
|
||||
const current = server.value
|
||||
if (!current) return
|
||||
installingNginx.value = true
|
||||
try {
|
||||
const result = await api.installFrpAgentNginx(current.id)
|
||||
if (result.ok) {
|
||||
message.success(`Nginx 安装完成${result.path ? `(${result.path})` : ''}`)
|
||||
} else {
|
||||
message.warning(`安装命令已执行,但 Nginx 仍不可用:${(result.error || '').slice(0, 300)}`)
|
||||
}
|
||||
await refreshAll()
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '一键安装 Nginx 失败') }
|
||||
finally { installingNginx.value = false }
|
||||
}
|
||||
|
||||
// trafficOf 返回该规则的 frps 流量快照;无数据时回落到全零,避免模板出现 undefined。
|
||||
function trafficOf(candidate: FrpProxy): FrpProxyTraffic {
|
||||
return proxyTraffic.value[String(candidate.id)] ?? {
|
||||
traffic_in: 0, traffic_out: 0, cur_conns: 0, traffic_in_rate: 0, traffic_out_rate: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// 四段健康徽标:DNS 指向 / 证书覆盖 / 隧道注册 / 内网服务
|
||||
function healthBadges(candidate: FrpProxy) {
|
||||
return candidate.custom_domains.map((domain) => {
|
||||
const h = routeHealth.value.find((item) => item.domain === domain)
|
||||
return [
|
||||
{ label: 'DNS', ok: h?.dns_ok ?? false, detail: h?.dns_detail || (h?.dns_ok ? '已指向服务端' : '待检测') },
|
||||
{ label: '证书', ok: h?.cert_ok ?? false, detail: h?.cert_detail || (h?.cert_ok ? '已覆盖' : '未发现证书') },
|
||||
{ label: '隧道', ok: h?.tunnel_ok ?? false, detail: h?.tunnel_detail || (h?.tunnel_ok ? '已注册到 frps' : 'frps 未发现该规则的隧道') },
|
||||
{ label: '服务', ok: h?.service_ok ?? false, detail: h?.service_ok ? '内网服务可达' : '内网服务不可达' },
|
||||
]
|
||||
}).flat()
|
||||
}
|
||||
|
||||
async function viewConf(candidate: FrpProxy) {
|
||||
const current = server.value
|
||||
if (!current) return
|
||||
confLoadingId.value = candidate.id
|
||||
try {
|
||||
const conf = await api.getFrpAgentRouteConf(current.id, candidate.id)
|
||||
confDomain.value = conf.domain
|
||||
confContent.value = conf.content
|
||||
confModal.value = true
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '读取配置失败') }
|
||||
finally { confLoadingId.value = null }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
timer = window.setInterval(() => { if (server.value && !refreshing.value) void refreshAll() }, 15000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-card { margin-bottom: var(--fonu-space-4); }
|
||||
.nginx-alert { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); padding: 12px 16px; border: 1px solid #f0c36d; border-radius: var(--fonu-radius-sm); background: rgba(240, 195, 109, 0.12); }
|
||||
.nginx-alert--warn { border-color: #e79b9b; background: rgba(231, 155, 155, 0.12); }
|
||||
.nginx-alert__text { display: grid; gap: 4px; font-size: 13px; color: var(--fonu-text); }
|
||||
.nginx-alert__text code { padding: 1px 5px; border-radius: 4px; background: var(--fonu-bg-muted); font-family: var(--fonu-mono); font-size: 12px; }
|
||||
.nginx-status-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px var(--fonu-space-4); }
|
||||
.nginx-status-item { display: grid; gap: 3px; min-width: 0; }
|
||||
.nginx-status-item__label { font-size: 12px; color: var(--fonu-text-secondary); }
|
||||
.nginx-status-item__value { font-size: 13px; color: var(--fonu-text); overflow-wrap: anywhere; }
|
||||
.nginx-status-error { margin-top: var(--fonu-space-3); color: #dc2626; }
|
||||
.nginx-status-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: var(--fonu-space-3); }
|
||||
.section-body { padding: var(--fonu-space-2) var(--fonu-space-5) var(--fonu-space-4); }
|
||||
.route-list { display: grid; gap: 8px; margin-top: var(--fonu-space-3); }
|
||||
.route-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-surface); }
|
||||
.route-item__main { display: grid; gap: 3px; min-width: 0; }
|
||||
.route-item__domains { color: var(--fonu-text); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.route-item__upstream { color: var(--fonu-text-muted); font-size: 12px; }
|
||||
.route-item__actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; flex-shrink: 0; }
|
||||
.route-item__health { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 2px; }
|
||||
.health-badge { font-size: 11px; padding: 1px 6px; border-radius: 999px; }
|
||||
.health-ok { background: rgba(34, 197, 94, 0.12); color: #16a34a; }
|
||||
.health-bad { background: rgba(239, 68, 68, 0.12); color: #dc2626; }
|
||||
.route-item__traffic { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 4px; }
|
||||
.traffic-metric { font-size: 11px; color: var(--fonu-text-secondary); }
|
||||
.traffic-metric--error { color: #dc2626; }
|
||||
.empty-route { margin-top: var(--fonu-space-3); padding: 18px; border: 1px dashed var(--fonu-border); border-radius: var(--fonu-radius-sm); color: var(--fonu-text-muted); text-align: center; }
|
||||
.raw-routes { margin-top: var(--fonu-space-3); font-size: 12px; color: var(--fonu-text-muted); }
|
||||
.raw-route-row { padding: 2px 0; }
|
||||
.conf-view { max-height: 60vh; }
|
||||
.proxy-modal { background: var(--fonu-surface); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); padding: var(--fonu-space-4) var(--fonu-space-5); box-shadow: var(--fonu-shadow-md); }
|
||||
.proxy-modal__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--fonu-space-3); }
|
||||
.proxy-modal__footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: var(--fonu-space-4); }
|
||||
.proxy-modal__tabbar { display: flex; gap: var(--fonu-space-5); padding: 0 0 var(--fonu-space-3); margin-bottom: var(--fonu-space-2); border-bottom: 1px solid var(--fonu-border); }
|
||||
.proxy-modal__tab { margin: 0 0 -1px; padding: 8px 2px 10px; border: 0; border-bottom: 2px solid transparent; background: none; color: var(--fonu-text-secondary); font: inherit; font-size: 14px; cursor: pointer; }
|
||||
.proxy-modal__tab:hover { color: var(--fonu-text); }
|
||||
.proxy-modal__tab--active { color: var(--fonu-brand-text); font-weight: 600; border-bottom-color: var(--fonu-brand); }
|
||||
.proxy-modal__scroll { max-height: 60vh; overflow-y: auto; }
|
||||
.proxy-modal__pane { width: 100%; }
|
||||
.form-switch-list { display: grid; gap: 2px; margin-bottom: var(--fonu-space-3); }
|
||||
.form-switch-row { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-4); padding: 10px 12px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); }
|
||||
.form-switch-row__label { font-size: 13px; color: var(--fonu-text); }
|
||||
.form-switch-row__hint { margin-top: 2px; font-size: 12px; color: var(--fonu-text-secondary); }
|
||||
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--fonu-space-4); }
|
||||
.field-grid--three { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.panel-title { margin: var(--fonu-space-2) 0; font-size: 13px; font-weight: 600; color: var(--fonu-text-secondary); }
|
||||
.modal-title { margin: 0; font-size: 16px; font-weight: 700; color: var(--fonu-text); }
|
||||
.code-block {
|
||||
margin: 0; padding: var(--fonu-space-3) var(--fonu-space-4); background: var(--fonu-bg-muted);
|
||||
border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm);
|
||||
font-family: var(--fonu-mono); font-size: 12.5px; line-height: 1.65; color: var(--fonu-text);
|
||||
overflow: auto; white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as { version: string }
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || pkg.version),
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
allowedHosts: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:6893',
|
||||
configure(proxy) {
|
||||
proxy.on('proxyRes', (proxyRes, req) => {
|
||||
if (req.url?.includes('/stream')) {
|
||||
proxyRes.headers['cache-control'] = 'no-cache'
|
||||
proxyRes.headers['x-accel-buffering'] = 'no'
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||