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
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user