feat: create standalone native fnOS repository

This commit is contained in:
2026-10-02 15:19:09 +08:00
parent 69f3c30fab
commit 6725fc131d
492 changed files with 94023 additions and 3 deletions
+73
View File
@@ -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(--havline-font);
background: var(--havline-bg);
color: var(--havline-text);
}
.mono {
font-family: var(--havline-mono);
}
.text-secondary {
color: var(--havline-text-secondary);
}
.text-muted {
color: var(--havline-text-muted);
}
.page-shell,
.page-content {
width: 100%;
}
.page-narrow {
width: 100%;
max-width: var(--havline-form-max);
margin-inline: auto;
}
.form-narrow {
max-width: var(--havline-form-max);
}
.toolbar {
display: flex;
align-items: center;
gap: var(--havline-space-3);
flex-wrap: wrap;
margin-bottom: var(--havline-space-4);
padding-top: var(--havline-space-4);
}
.n-data-table .n-data-table-th {
background: var(--havline-bg-muted) !important;
font-weight: 500;
font-size: 13px;
color: var(--havline-text-secondary);
}
</style>
+6
View File
@@ -0,0 +1,6 @@
export const APP_BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
export function withBase(path: string): string {
if (!path.startsWith('/')) return path
return `${APP_BASE}${path}`
}
+559
View File
@@ -0,0 +1,559 @@
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,
UpdateStatus,
CfMirror,
CfRuntimeStatus,
CfTunnel,
CfTunnelPayload,
CfAppSettings,
CfAppSettingsInput,
CfDriftResult,
CfDiagnosticsResult,
CfAccessStatus,
CfZone,
CfTunnelRoutes,
CfPreflightResult,
CfRouteTestResult,
} from './types'
import { withBase } from './base'
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 输出'
: '请求超时,请检查 Havline 服务或 Nginx 状态',
)
}
throw error
} finally {
clearTimeout(timeout)
}
}
async function requestWithSignal<T>(path: string, init?: RequestInit, signal?: AbortSignal): Promise<T> {
const response = await fetch(withBase(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'),
getUpdateStatus: () => request<UpdateStatus>('/api/system/update/status'),
applyUpdate: () => request<UpdateStatus>('/api/system/update/apply', { method: 'POST' }),
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) => withBase(`/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(
withBase(`/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 } : {}),
}),
getNginxConfigVersions: () =>
request<import('./types').NginxConfigVersionsView>('/api/settings/nginx/config/versions'),
getNginxConfigVersion: (name: string) =>
request<import('./types').NginxConfigVersionView>(`/api/settings/nginx/config/versions/${encodeURIComponent(name)}`),
rollbackNginxConfigVersion: (name: string) =>
request<import('./types').NginxConfigVersionsView>(
`/api/settings/nginx/config/versions/${encodeURIComponent(name)}/rollback`,
{ method: 'POST' },
),
getChinaCIDRStatus: () => request<ChinaCIDRStatus>('/api/settings/china-cidr'),
listApiTokens: () => request<{ tokens: import('./types').ApiToken[] }>('/api/settings/tokens'),
createApiToken: (payload: { name: string; scope: 'read' | 'write'; expires_in_days: number }) =>
request<import('./types').CreatedApiToken>('/api/settings/tokens', {
method: 'POST',
body: JSON.stringify(payload),
}),
deleteApiToken: (id: number) =>
request<{ ok: boolean }>(`/api/settings/tokens/${id}`, { method: 'DELETE' }),
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' }),
restartFrpAgentTunnel: (id: number) =>
request<FrpServer>(`/api/frpmulti/servers/${id}/agent/tunnel/restart`, { method: 'POST' }),
setFrpAgentTransport: (id: number, transport: 'tunnel' | 'https-pin', port = 0) =>
request<FrpServer>(`/api/frpmulti/servers/${id}/agent/transport`, {
method: 'POST',
body: JSON.stringify({ transport, port }),
}),
getFrpAgentTransport: (id: number) =>
request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/transport`),
rotateFrpAgentTLSCert: (id: number) =>
request<FrpServer>(`/api/frpmulti/servers/${id}/agent/transport/rotate`, { method: 'POST' }),
getFrpAgentFirewall: (id: number, port: number) =>
request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/firewall?port=${port}`),
allowFrpAgentFirewallPort: (id: number, port: number) =>
request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/firewall/allow`, {
method: 'POST',
body: JSON.stringify({ port }),
}),
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`),
getFrpAgentCerts: (id: number) =>
request<import('./types').FrpAgentCerts>(`/api/frpmulti/servers/${id}/agent/certs`),
reloadFrpAgentNginx: (id: number) =>
request<import('./types').FrpAgentNginxReload>(`/api/frpmulti/servers/${id}/agent/nginx/reload`, {
method: 'POST',
}),
getFrpAgentMetrics: (id: number) =>
request<import('./types').FrpAgentMetrics>(`/api/frpmulti/servers/${id}/agent/metrics`),
getFrpAgentNginxLogs: (id: number, type: 'access' | 'error', lines = 200) =>
request<import('./types').FrpAgentNginxLogs>(
`/api/frpmulti/servers/${id}/agent/logs/nginx?type=${type}&lines=${lines}`,
),
getFrpAgentFRPSConfig: (id: number) =>
request<import('./types').FrpAgentFRPSConfig>(`/api/frpmulti/servers/${id}/agent/frps-config`),
getFrpAgentRouteDrift: (id: number) =>
request<import('./types').FrpRouteDriftReport>(`/api/frpmulti/servers/${id}/agent/routes/drift`),
redeployFrpAgentRoutes: (id: number) =>
request<import('./types').FrpRedeployAllResult>(`/api/frpmulti/servers/${id}/agent/routes/redeploy`, {
method: 'POST',
}),
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; basic_auth_user?: string; basic_auth_password?: string }>(`/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`),
getFrpAgentRouteVersions: (serverId: number, proxyId: number) =>
request<import('./types').FrpAgentRouteVersions>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/versions`),
getFrpAgentRouteVersion: (serverId: number, proxyId: number, name: string) =>
request<import('./types').FrpAgentRouteVersion>(
`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/versions/${encodeURIComponent(name)}`,
),
rollbackFrpAgentRouteVersion: (serverId: number, proxyId: number, name: string) =>
request<{ ok: boolean; domain: string; name: string }>(
`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/versions/${encodeURIComponent(name)}/rollback`,
{ method: 'POST' },
),
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`),
getFrpRouteHealthSummary: () =>
request<import('./types').FrpRouteHealthSummary>('/api/frpmulti/routes/summary'),
getFrpRouteUptime: (hours = 24) =>
request<{ hours: number; items: import('./types').FrpRouteUptime[] }>(`/api/frpmulti/routes/uptime?hours=${hours}`),
// 指标历史:窗口内的序列已按桶取平均,拿到就能直接画
getMetricsHistory: (hours = 24, buckets = 48) =>
request<import('./types').MetricsHistory>(`/api/metrics/history?hours=${hours}&buckets=${buckets}`),
// 匿名状态页:未开启或访问码不对时后端回 404
getStatusPage: (code?: string) =>
request<import('./types').StatusPagePayload>(
`/api/status-page${code ? `?code=${encodeURIComponent(code)}` : ''}`,
),
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(withBase('/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 = `havline-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`
link.click()
URL.revokeObjectURL(url)
},
listBackupArchives: () =>
request<{ archives: import('./types').BackupArchive[]; keep: number }>('/api/backup/archives'),
createBackupArchive: () =>
request<{ name: string; removed: number }>('/api/backup/archives', { method: 'POST' }),
restoreBackupArchive: (name: string) =>
request<{ message: string }>(`/api/backup/archives/${encodeURIComponent(name)}/restore`, { method: 'POST' }),
deleteBackupArchive: (name: string) =>
request<{ ok: boolean }>(`/api/backup/archives/${encodeURIComponent(name)}`, { method: 'DELETE' }),
async downloadBackupArchive(name: string) {
const response = await fetch(withBase(`/api/backup/archives/${encodeURIComponent(name)}`), { 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 = name
link.click()
URL.revokeObjectURL(url)
},
async restoreBackup(file: File) {
const form = new FormData()
form.append('file', file)
const response = await fetch(withBase('/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 }
},
listCfTunnels: () => request<CfTunnel[]>('/api/cloudflare/tunnels'),
getCfTunnel: (id: number) => request<CfTunnel>(`/api/cloudflare/tunnels/${id}`),
createCfTunnel: (payload: CfTunnelPayload) =>
request<CfTunnel>('/api/cloudflare/tunnels', { method: 'POST', body: JSON.stringify(payload) }),
updateCfTunnel: (id: number, payload: CfTunnelPayload) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
deleteCfTunnel: (id: number) =>
request<{ ok: boolean }>(`/api/cloudflare/tunnels/${id}`, { method: 'DELETE' }),
startCfTunnel: (id: number) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}/start`, { method: 'POST' }),
stopCfTunnel: (id: number) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}/stop`, { method: 'POST' }),
restartCfTunnel: (id: number) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}/restart`, { method: 'POST' }),
refreshCfCredentials: (id: number) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}/credentials/refresh`, { method: 'POST' }),
getCfTunnelStatus: (id: number) =>
request<CfRuntimeStatus>(`/api/cloudflare/tunnels/${id}/status`),
getCfTunnelLogs: (id: number, lines = 200) =>
request<{ output: string }>(`/api/cloudflare/tunnels/${id}/logs?lines=${lines}`),
getCfTunnelConfig: (id: number) =>
request<{ content: string }>(`/api/cloudflare/tunnels/${id}/config`),
getCfTunnelRoutes: (id: number) =>
request<CfTunnelRoutes>(`/api/cloudflare/tunnels/${id}/routes`),
replaceCfTunnelRoutes: (id: number, routes: Array<{ hostname: string; path: string; service: string }>) =>
request<CfTunnelRoutes>(`/api/cloudflare/tunnels/${id}/routes`, {
method: 'PUT',
body: JSON.stringify({ routes }),
}),
getCfTunnelDrift: (id: number) => request<CfDriftResult>(`/api/cloudflare/tunnels/${id}/drift`),
syncCfTunnelRoutes: (id: number) =>
request<{ ok: boolean }>(`/api/cloudflare/tunnels/${id}/sync`, { method: 'POST' }),
getCfTunnelDiagnostics: (id: number) =>
request<CfDiagnosticsResult>(`/api/cloudflare/tunnels/${id}/diagnostics`),
getCfBinary: () => request<{ version: string; mirrors: CfMirror[] }>('/api/cloudflare/binary'),
downloadCfBinary: (mirror: string, version = '') =>
request<{ ok: boolean; version: string }>('/api/cloudflare/binary/download', {
method: 'POST',
body: JSON.stringify({ mirror, version }),
}),
getLatestCfBinary: () => request<{ version: string }>('/api/cloudflare/binary/latest'),
rollbackCfBinary: () =>
request<{ ok: boolean; version: string }>('/api/cloudflare/binary/rollback', { method: 'POST' }),
getCfIngressCandidates: () =>
request<{ hostnames: string[] }>('/api/cloudflare/ingress-candidates'),
getCfZones: () => request<{ zones: CfZone[] }>('/api/cloudflare/zones'),
getCfAccessStatus: (hostname: string) =>
request<CfAccessStatus>(`/api/cloudflare/access-status?hostname=${encodeURIComponent(hostname)}`),
getCfPreflight: () => request<CfPreflightResult>('/api/cloudflare/preflight'),
testCfRoute: (service: string) =>
request<CfRouteTestResult>('/api/cloudflare/routes/test', {
method: 'POST',
body: JSON.stringify({ service }),
}),
getCfSettings: () => request<CfAppSettings>('/api/cloudflare/settings'),
saveCfSettings: (payload: CfAppSettingsInput) =>
request<CfAppSettings>('/api/cloudflare/settings', { method: 'PUT', body: JSON.stringify(payload) }),
testCfToken: (accountId: string, token: string, kind: 'tunnel' | 'api') =>
request<{ ok: boolean; message: string }>('/api/cloudflare/settings/test', {
method: 'POST',
body: JSON.stringify({ account_id: accountId, token, kind }),
}, 60_000),
setCfEnabled: (enabled: boolean) =>
request<CfAppSettings>('/api/cloudflare/enabled', {
method: 'POST',
body: JSON.stringify({ enabled }),
}),
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 977 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-label="Havline logo">
<defs>
<linearGradient id="havline-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0e4b68"/>
<stop offset="1" stop-color="#061827"/>
</linearGradient>
<linearGradient id="havline-line" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#2fa9ff"/>
<stop offset="1" stop-color="#63f0d0"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="88" height="88" rx="22" fill="url(#havline-bg)"/>
<rect x="4.8" y="4.8" width="86.4" height="86.4" rx="21.2" fill="none" stroke="rgba(242,255,252,.13)" stroke-width="1.6"/>
<path d="M31 25.5V70.5" stroke="#f2fffc" stroke-width="11" stroke-linecap="round"/>
<path d="M65 25.5V70.5" stroke="#f2fffc" stroke-width="11" stroke-linecap="round"/>
<path d="M31 59C39 59 42.5 49 49.5 49C56.5 49 57.5 37 65 37" stroke="url(#havline-line)" stroke-width="7" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="31" cy="59" r="5" fill="#2fa9ff" stroke="#061827" stroke-width="2.2"/>
<circle cx="65" cy="37" r="5" fill="#63f0d0" stroke="#061827" stroke-width="2.2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-label="Havline logo v2">
<defs>
<linearGradient id="havline-v2-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0f506d"/>
<stop offset="1" stop-color="#061827"/>
</linearGradient>
<linearGradient id="havline-v2-ink" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f6fffd"/>
<stop offset="1" stop-color="#c9f5ed"/>
</linearGradient>
<linearGradient id="havline-v2-line" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#299cff"/>
<stop offset="1" stop-color="#68f3d2"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="88" height="88" rx="22" fill="url(#havline-v2-bg)"/>
<rect x="4.8" y="4.8" width="86.4" height="86.4" rx="21.2" fill="none" stroke="rgba(242,255,252,.14)" stroke-width="1.6"/>
<path d="M17 62C29 52 37 68 47 58C57 48 66 52 79 43" stroke="url(#havline-v2-line)" stroke-width="6.5" fill="none" stroke-linecap="round"/>
<rect x="28" y="24" width="11" height="48" rx="5.5" fill="url(#havline-v2-ink)"/>
<rect x="57" y="24" width="11" height="48" rx="5.5" fill="url(#havline-v2-ink)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

@@ -0,0 +1,69 @@
<template>
<div class="config-diff">
<div class="config-diff__head">
<span class="config-diff__range">{{ fromLabel }} → {{ toLabel }}</span>
<span class="config-diff__stats">
<span class="config-diff__add">+{{ stats.added }}</span>
<span class="config-diff__del">−{{ stats.removed }}</span>
</span>
</div>
<div class="config-diff__body">
<div
v-for="(line, index) in lines"
:key="index"
class="config-diff__row"
:class="`config-diff__row--${line.kind}`"
>
<span class="config-diff__sign">{{ sign(line.kind) }}</span>
<span class="config-diff__text">{{ line.text || ' ' }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { diffLines, diffStats, type DiffLine } from '../utils/diff'
const props = withDefaults(
defineProps<{
fromText: string
toText: string
fromLabel?: string
toLabel?: string
}>(),
{ fromLabel: '当前', toLabel: '所选版本' },
)
const lines = computed<DiffLine[]>(() => diffLines(props.fromText, props.toText))
const stats = computed(() => diffStats(lines.value))
function sign(kind: DiffLine['kind']): string {
if (kind === 'add') return '+'
if (kind === 'del') return '−'
return ' '
}
</script>
<style scoped>
.config-diff { border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); overflow: hidden; }
.config-diff__head {
display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3);
padding: 6px var(--havline-space-3); border-bottom: 1px solid var(--havline-border);
background: var(--havline-bg-muted); font-size: 12px; color: var(--havline-text-secondary);
}
.config-diff__stats { display: flex; gap: 8px; font-family: var(--havline-mono); }
.config-diff__add { color: var(--havline-brand-text); }
.config-diff__del { color: var(--havline-error); }
.config-diff__body { max-height: 420px; overflow: auto; background: var(--havline-surface); }
.config-diff__row {
display: flex; gap: 8px; padding: 1px 10px;
font-family: var(--havline-mono); font-size: 12px; line-height: 1.6;
}
.config-diff__row--add { background: var(--havline-brand-soft); }
.config-diff__row--del { background: var(--havline-error-soft); }
.config-diff__sign { flex: none; width: 10px; color: var(--havline-text-muted); }
.config-diff__row--add .config-diff__sign { color: var(--havline-brand-text); }
.config-diff__row--del .config-diff__sign { color: var(--havline-error); }
.config-diff__text { white-space: pre-wrap; overflow-wrap: anywhere; }
</style>
@@ -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>
+61
View File
@@ -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(--havline-space-6) var(--havline-space-5);
text-align: center;
}
.empty-state__icon {
width: 48px;
height: 48px;
margin: 0 auto var(--havline-space-4);
border-radius: 50%;
background: var(--havline-brand-soft);
color: var(--havline-brand);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 600;
}
.empty-state__title {
margin: 0 0 var(--havline-space-2);
font-size: 16px;
font-weight: 600;
color: var(--havline-text);
}
.empty-state__desc {
margin: 0 auto;
max-width: 360px;
font-size: 14px;
line-height: 1.6;
color: var(--havline-text-secondary);
}
.empty-state__action {
margin-top: var(--havline-space-5);
}
</style>
@@ -0,0 +1,72 @@
<template>
<div class="havline-card" :class="{ 'havline-card--flush': flush }">
<div v-if="title || $slots.title || $slots.header" class="havline-card__header">
<div>
<h3 v-if="title" class="havline-card__title">{{ title }}</h3>
<div v-else-if="$slots.title" class="havline-card__title havline-card__title-row">
<slot name="title" />
</div>
<p v-if="subtitle" class="havline-card__subtitle">{{ subtitle }}</p>
</div>
<div v-if="$slots.header" class="havline-card__header-extra">
<slot name="header" />
</div>
</div>
<div class="havline-card__body">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
title?: string
subtitle?: string
flush?: boolean
}>()
</script>
<style scoped>
.havline-card {
background: var(--havline-surface);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius);
box-shadow: var(--havline-shadow);
overflow: hidden;
}
.havline-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--havline-space-4);
padding: var(--havline-space-5) var(--havline-space-5) 0;
}
.havline-card__title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--havline-text);
}
.havline-card__title-row {
display: flex;
align-items: center;
gap: var(--havline-space-2);
}
.havline-card__subtitle {
margin: var(--havline-space-1) 0 0;
font-size: 13px;
color: var(--havline-text-secondary);
}
.havline-card__body {
padding: var(--havline-space-5);
}
.havline-card--flush .havline-card__body {
padding-top: 0;
}
</style>
+28
View File
@@ -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: '请检查 Havline 服务是否正常运行' },
)
defineEmits<{ retry: [] }>()
</script>
<style scoped>
.load-error {
padding: var(--havline-space-6) 0;
}
</style>
@@ -0,0 +1,93 @@
<template>
<VChart class="trend-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, LegendComponent, 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, LegendComponent, TooltipComponent])
const props = defineProps<{
labels: string[]
series: { name: string; values: (number | null)[] }[]
unit: 'percent' | 'rate'
}>()
const { isDark } = useTheme()
// 与其它图表保持同一套配色(ECharts 是色值的既有例外)
const SERIES_COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#8b5cf6']
function axisValue(value: number): string {
if (props.unit === 'percent') return `${Math.round(value)}%`
return formatRate(value)
}
const option = computed(() => {
const textColor = isDark.value ? '#94a3b8' : '#9ca3af'
const splitColor = isDark.value ? '#334155' : '#e5e7eb'
return {
grid: { left: 4, right: 12, top: 30, bottom: 6, containLabel: true },
legend: {
top: 0,
left: 0,
icon: 'roundRect',
itemWidth: 8,
itemHeight: 8,
itemGap: 14,
textStyle: { color: textColor, fontSize: 11 },
},
tooltip: {
trigger: 'axis',
backgroundColor: isDark.value ? '#1e293b' : '#fff',
borderColor: isDark.value ? '#334155' : '#e5e7eb',
textStyle: { color: isDark.value ? '#f1f5f9' : '#111827', fontSize: 12 },
valueFormatter: (value: number | null | undefined) =>
value === null || value === undefined ? '—' : axisValue(value),
},
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) => axisValue(v) },
},
series: props.series.map((item, index) => ({
name: item.name,
type: 'line',
smooth: true,
// 刚开始采样时只有一两个点:必须画点,否则「无线可画」看起来像没有数据
showSymbol: props.labels.length <= 12,
symbolSize: 5,
// 空桶在数据里是 null:图上断开而不是补 0,避免凭空出现谷底
connectNulls: false,
data: item.values,
lineStyle: { width: 2, color: SERIES_COLORS[index % SERIES_COLORS.length] },
itemStyle: { color: SERIES_COLORS[index % SERIES_COLORS.length] },
})),
}
})
</script>
<style scoped>
.trend-chart {
width: 100%;
height: 200px;
}
</style>
@@ -0,0 +1,141 @@
<template>
<div class="trend-panel" :class="{ 'trend-panel--loading': loading }">
<p v-if="error" class="trend-panel__hint trend-panel__hint--error">{{ error }}</p>
<template v-else>
<section class="trend-panel__section">
<span class="trend-panel__label">资源占用</span>
<MetricsTrendChart :labels="resource.labels" :series="resource.series" unit="percent" />
<p v-if="resource.labels.length === 0" class="trend-panel__hint">
还没有资源数据:从这一版起每 2 分钟采样一次,等一两分钟就会出现。
</p>
</section>
<section class="trend-panel__section">
<span class="trend-panel__label">隧道流量</span>
<MetricsTrendChart :labels="traffic.labels" :series="traffic.series" unit="rate" />
<p v-if="traffic.labels.length === 0" class="trend-panel__hint">
还没有流量数据(需要反代规则产生过访问)。
</p>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import MetricsTrendChart from './MetricsTrendChart.vue'
import { api } from '../api/client'
import { METRICS_RANGES } from '../constants/metrics'
import type { MetricsSeries } from '../api/types'
// 时间档与切换按钮都由父级(卡片表头)给,本组件只负责取数与绘制
const props = defineProps<{ hours: number }>()
const seriesRaw = ref<MetricsSeries[]>([])
const error = ref('')
const loading = ref(false)
async function load() {
loading.value = true
try {
const range = METRICS_RANGES.find((item) => item.hours === props.hours) ?? METRICS_RANGES[1]
const result = await api.getMetricsHistory(range.hours, range.buckets)
seriesRaw.value = result.series ?? []
error.value = ''
} catch (err) {
error.value = err instanceof Error ? err.message : '读取指标历史失败'
} finally {
loading.value = false
}
}
function formatLabel(at: string): string {
const date = new Date(at)
if (Number.isNaN(date.getTime())) return at
const pad = (value: number) => String(value).padStart(2, '0')
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`
return props.hours <= 24 ? time : `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${time}`
}
function pick(items: MetricsSeries[], metric: string) {
return items.find((item) => item.metric === metric)
}
// 各序列的点数不一致(空桶不产点),所以按时间戳对齐、并集做横轴
function unionLabels(items: MetricsSeries[]): string[] {
const set = new Set<string>()
for (const item of items) {
for (const point of item.points) set.add(point.at)
}
return [...set].sort()
}
function valueByTime(series?: MetricsSeries) {
const map = new Map<string, number>()
for (const point of series?.points ?? []) map.set(point.at, point.value)
return map
}
const resource = computed(() => {
const host = seriesRaw.value.filter((item) => item.scope === 'host')
const labels = unionLabels(host)
const cpu = valueByTime(pick(host, 'cpu_percent'))
const memUsed = valueByTime(pick(host, 'mem_used_bytes'))
const memTotal = valueByTime(pick(host, 'mem_total_bytes'))
const diskUsed = valueByTime(pick(host, 'disk_used_bytes'))
const diskTotal = valueByTime(pick(host, 'disk_total_bytes'))
const percentOf = (used: Map<string, number>, total: Map<string, number>, at: string) => {
const u = used.get(at)
const t = total.get(at)
if (u === undefined || t === undefined || t <= 0) return null
return (u / t) * 100
}
return {
labels: labels.map(formatLabel),
series: [
{ name: 'CPU', values: labels.map((at) => cpu.get(at) ?? null) },
{ name: '内存', values: labels.map((at) => percentOf(memUsed, memTotal, at)) },
{ name: '磁盘', values: labels.map((at) => percentOf(diskUsed, diskTotal, at)) },
],
}
})
const traffic = computed(() => {
const routes = seriesRaw.value.filter((item) => item.scope.startsWith('route:'))
const labels = unionLabels(routes)
const sumByTime = (metric: string) => {
const totals = new Map<string, number>()
for (const item of routes) {
if (item.metric !== metric) continue
for (const point of item.points) {
totals.set(point.at, (totals.get(point.at) ?? 0) + point.value)
}
}
return totals
}
const upload = sumByTime('traffic_in_rate')
const download = sumByTime('traffic_out_rate')
return {
labels: labels.map(formatLabel),
series: [
{ name: '上传', values: labels.map((at) => upload.get(at) ?? null) },
{ name: '下载', values: labels.map((at) => download.get(at) ?? null) },
],
}
})
onMounted(load)
watch(() => props.hours, load)
</script>
<style scoped>
.trend-panel { display: flex; flex-direction: column; gap: var(--havline-space-4); }
.trend-panel--loading { opacity: 0.6; }
/* 每块自带小标题,卡片被同行撑高时也不会显得内容散掉 */
.trend-panel__section { display: flex; flex-direction: column; gap: 6px; }
.trend-panel__label { font-size: 12px; font-weight: 600; color: var(--havline-text-secondary); }
.trend-panel__hint { margin: 0; font-size: 12.5px; color: var(--havline-text-muted); }
.trend-panel__hint--error { color: var(--havline-error); }
</style>
+107
View File
@@ -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(--havline-surface)',
color: 'var(--havline-text)',
},
'.cm-content': {
caretColor: 'var(--havline-brand)',
fontFamily: 'var(--havline-mono)',
fontSize: '12px',
lineHeight: '1.7',
},
'.cm-gutters': {
backgroundColor: 'var(--havline-bg-muted)',
color: 'var(--havline-text-muted)',
borderRight: '1px solid var(--havline-border)',
},
'.cm-activeLineGutter': {
backgroundColor: 'var(--havline-bg)',
color: 'var(--havline-text-secondary)',
},
'.cm-activeLine': {
backgroundColor: 'var(--havline-brand-soft)',
},
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': {
backgroundColor: 'color-mix(in srgb, var(--havline-brand) 22%, transparent) !important',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--havline-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(--havline-border);
border-radius: var(--havline-radius-sm);
overflow: hidden;
background: var(--havline-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(--havline-text-muted) 35%, transparent) transparent;
}
.nginx-code-editor:hover :deep(.cm-scroller) {
scrollbar-color: color-mix(in srgb, var(--havline-text-muted) 65%, transparent) var(--havline-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(--havline-text-muted) 30%, transparent);
border-radius: 4px;
}
.nginx-code-editor:hover :deep(.cm-scroller)::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--havline-text-muted) 55%, transparent);
}
.nginx-code-editor--readonly :deep(.cm-cursor) {
display: none;
}
.nginx-code-editor--readonly :deep(.cm-content) {
caret-color: transparent;
}
</style>
+52
View File
@@ -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(--havline-space-4);
margin-bottom: var(--havline-space-5);
flex-wrap: wrap;
}
.page-header__title {
margin: 0;
font-size: 24px;
font-weight: 700;
line-height: 1.3;
color: var(--havline-text);
letter-spacing: -0.02em;
}
.page-header__desc {
margin: var(--havline-space-2) 0 0;
font-size: 14px;
color: var(--havline-text-secondary);
line-height: 1.5;
}
.page-header__actions {
display: flex;
align-items: center;
gap: var(--havline-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(--havline-space-3);
font-family: var(--havline-mono);
font-size: 12px;
line-height: 1.7;
border-radius: var(--havline-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(--havline-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(--havline-space-5);
text-align: center;
color: #94a3b8;
white-space: normal;
}
.mono { font-family: var(--havline-mono); }
</style>
+124
View File
@@ -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(--havline-surface);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius);
box-shadow: var(--havline-shadow);
padding: var(--havline-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(--havline-shadow-md);
}
.stat-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--havline-space-2);
margin-bottom: var(--havline-space-3);
}
.stat-card__title-row {
display: flex;
align-items: center;
gap: var(--havline-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(--havline-brand-soft);
color: var(--havline-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(--havline-text-secondary);
}
.stat-card__value,
.stat-card__value-slot {
font-size: 26px;
font-weight: 700;
line-height: 1.25;
color: var(--havline-text);
word-break: break-all;
letter-spacing: -0.02em;
}
.stat-card__value--sm {
font-size: 18px;
}
.stat-card__sub {
margin-top: var(--havline-space-2);
font-size: 13px;
color: var(--havline-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(--havline-bg);
}
.status-badge.pill.success {
background: rgba(16, 185, 129, 0.12);
color: var(--havline-success);
}
.status-badge.pill.warning {
background: rgba(245, 158, 11, 0.12);
color: var(--havline-warning);
}
.status-badge.pill.error {
background: rgba(239, 68, 68, 0.12);
color: var(--havline-error);
}
.status-badge.pill.disabled,
.status-badge.pill.unknown {
background: var(--havline-bg-muted);
color: var(--havline-text-secondary);
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.success .dot { background: var(--havline-success); }
.warning .dot { background: var(--havline-warning); }
.error .dot { background: var(--havline-error); }
.disabled .dot { background: var(--havline-disabled); }
.unknown .dot { background: var(--havline-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(--havline-success); }
.status-badge:not(.pill) .warning .label { color: var(--havline-warning); }
.status-badge:not(.pill) .error .label { color: var(--havline-error); }
.status-badge:not(.pill) .disabled .label,
.status-badge:not(.pill) .unknown .label { color: var(--havline-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(--havline-text);
}
.provider-form__field {
margin-bottom: 10px;
}
.provider-form__label {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: var(--havline-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(--havline-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(--havline-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>
+243
View File
@@ -0,0 +1,243 @@
<template>
<n-modal v-model:show="show" :mask-closable="false">
<div class="update-modal">
<div class="update-modal__header">
<div>
<h3 class="update-modal__title">升级 Havline</h3>
<p class="update-modal__subtitle">升级完成后容器会重启,数据目录不会被删除</p>
</div>
<n-button quaternary size="small" :disabled="applying" @click="show = false">关闭</n-button>
</div>
<div class="update-modal__body">
<n-alert :type="alertType" :bordered="false" class="update-modal__alert">
{{ displayMessage }}
</n-alert>
<div class="update-modal__grid">
<div>
<span class="update-modal__label">当前版本</span>
<strong class="mono">{{ status?.current_version || appVersion || '未知' }}</strong>
</div>
<div>
<span class="update-modal__label">目标版本</span>
<strong class="mono">{{ status?.latest_version || '检查中…' }}</strong>
</div>
<div>
<span class="update-modal__label">升级方式</span>
<strong>{{ modeLabel }}</strong>
</div>
<div>
<span class="update-modal__label">服务状态</span>
<strong>{{ enabledLabel }}</strong>
</div>
</div>
<p class="update-modal__hint">
升级会重新构建应用镜像并只重建 <span class="mono">havline</span> 容器。构建期间管理页面可能短暂无法访问,
浏览器会自动等待服务恢复。
</p>
<pre v-if="logText" class="update-modal__log">{{ logText }}</pre>
</div>
<div class="update-modal__footer">
<n-button :disabled="applying" @click="show = false">取消</n-button>
<n-button type="primary" :loading="applying || loading" :disabled="!canApply" @click="startUpdate">
{{ applying ? '正在升级…' : '确认升级' }}
</n-button>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { NAlert, NButton, NModal, useMessage } from 'naive-ui'
import { api } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import type { UpdateStatus } from '../api/types'
const show = defineModel<boolean>('show', { required: true })
const emit = defineEmits<{ updated: [] }>()
const message = useMessage()
const status = ref<UpdateStatus | null>(null)
const appVersion = ref(__APP_VERSION__)
const loading = ref(false)
const applying = ref(false)
const localMessage = ref('')
const modeLabel = computed(() => {
if (status.value?.mode === 'git') return 'Git 仓库构建'
if (status.value?.mode === 'local') return '本机源码构建'
return '未启用'
})
const enabledLabel = computed(() => (status.value?.enabled ? '侧车可用' : '不可用'))
const displayMessage = computed(() => {
if (localMessage.value) return localMessage.value
if (!status.value) return loading.value ? '正在检查更新状态…' : '尚未检查更新状态'
return status.value.message || '未知状态'
})
const alertType = computed(() => {
if (status.value?.phase === 'failed') return 'error'
if (status.value?.phase === 'success') return 'success'
if (applying.value || status.value?.busy) return 'warning'
return status.value?.update_available ? 'info' : 'default'
})
const logText = computed(() => (status.value?.log ?? []).slice(-20).join('\n'))
const canApply = computed(() => Boolean(
status.value?.enabled &&
status.value.update_available &&
!applying.value &&
!status.value.busy
))
async function loadStatus() {
loading.value = true
localMessage.value = ''
try {
status.value = await api.getUpdateStatus()
} catch (error) {
localMessage.value = error instanceof Error ? error.message : '读取升级状态失败'
} finally {
loading.value = false
}
}
async function startUpdate() {
applying.value = true
localMessage.value = '升级任务已启动,正在等待容器重建…'
try {
status.value = await api.applyUpdate()
} catch (error) {
applying.value = false
localMessage.value = error instanceof Error ? error.message : '启动升级失败'
}
}
async function pollStatus() {
try {
status.value = await api.getUpdateStatus()
localMessage.value = ''
if (status.value.phase === 'success') {
applying.value = false
message.success('Havline 已升级完成')
emit('updated')
window.setTimeout(() => window.location.reload(), 1500)
return
}
if (status.value.phase === 'failed') {
applying.value = false
message.error(status.value.message || '升级失败')
}
} catch {
localMessage.value = '服务正在重启,等待恢复…'
}
}
watch(show, (visible) => {
if (!visible) return
void loadStatus()
})
useVisibilityPolling(pollStatus, 2000, { enabled: applying })
</script>
<style scoped>
.update-modal {
width: 560px;
max-width: 94vw;
background: var(--havline-surface);
border-radius: var(--havline-radius);
box-shadow: var(--havline-shadow-md);
overflow: hidden;
}
.update-modal__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--havline-space-3);
padding: var(--havline-space-5) var(--havline-space-5) var(--havline-space-3);
}
.update-modal__title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--havline-text);
}
.update-modal__subtitle {
margin: 4px 0 0;
font-size: 12px;
color: var(--havline-text-muted);
}
.update-modal__body {
display: grid;
gap: var(--havline-space-4);
padding: 0 var(--havline-space-5) var(--havline-space-4);
}
.update-modal__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--havline-space-3);
}
.update-modal__grid > div {
display: grid;
gap: 3px;
padding: 10px 12px;
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-bg);
}
.update-modal__label {
font-size: 12px;
color: var(--havline-text-muted);
}
.update-modal__hint {
margin: 0;
font-size: 12px;
line-height: 1.6;
color: var(--havline-text-secondary);
}
.update-modal__log {
max-height: 220px;
margin: 0;
padding: 12px;
overflow: auto;
border-radius: var(--havline-radius-sm);
background: var(--havline-bg-muted);
color: var(--havline-text-secondary);
font-family: var(--havline-mono);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
}
.update-modal__footer {
display: flex;
justify-content: flex-end;
gap: var(--havline-space-3);
padding: var(--havline-space-4) var(--havline-space-5);
border-top: 1px solid var(--havline-border);
}
@media (max-width: 640px) {
.update-modal__grid {
grid-template-columns: 1fr;
}
}
</style>
+136
View File
@@ -0,0 +1,136 @@
import { computed, ref, watch } from 'vue'
import { darkTheme, type GlobalThemeOverrides } from 'naive-ui'
const themeKey = 'havline-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(--havline-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,116 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import { createVisibilityPoller, type PollingHost } from './useVisibilityPolling'
function fakeHost() {
let hidden = false
let intervalFn: (() => void) | null = null
let intervalId = 0
const listeners: Array<() => void> = []
const host: PollingHost = {
setInterval: (fn) => {
intervalFn = fn
return ++intervalId
},
clearInterval: () => {
intervalFn = null
},
isHidden: () => hidden,
addVisibilityListener: (fn) => {
listeners.push(fn)
},
removeVisibilityListener: (fn) => {
const index = listeners.indexOf(fn)
if (index >= 0) listeners.splice(index, 1)
},
}
return {
host,
setHidden: (value: boolean) => {
hidden = value
},
fireInterval: () => intervalFn?.(),
fireVisibility: () => listeners.forEach((fn) => fn()),
hasTimer: () => intervalFn !== null,
listenerCount: () => listeners.length,
}
}
describe('createVisibilityPoller', () => {
it('页面隐藏时不执行', async () => {
const h = fakeHost()
let calls = 0
const poller = createVisibilityPoller(() => { calls++ }, 1000, {}, h.host)
h.setHidden(true)
await poller.tick()
expect(calls).toBe(0)
})
it('回到前台立即补一次', async () => {
const h = fakeHost()
let calls = 0
const poller = createVisibilityPoller(() => { calls++ }, 1000, {}, h.host)
poller.attach()
h.fireVisibility()
await Promise.resolve()
expect(calls).toBe(1)
})
it('上一轮未结束时跳过下一轮', async () => {
const h = fakeHost()
let calls = 0
let release: () => void = () => {}
const pending = new Promise<void>((resolve) => { release = resolve })
const poller = createVisibilityPoller(() => {
calls++
return calls === 1 ? pending : Promise.resolve()
}, 1000, {}, h.host)
const first = poller.tick()
await Promise.resolve()
await poller.tick()
expect(calls).toBe(1)
release()
await first
await poller.tick()
expect(calls).toBe(2)
})
it('enabled=false 时不执行', async () => {
const h = fakeHost()
const enabled = ref(false)
let calls = 0
const poller = createVisibilityPoller(() => { calls++ }, 1000, { enabled }, h.host)
await poller.tick()
expect(calls).toBe(0)
enabled.value = true
await poller.tick()
expect(calls).toBe(1)
})
it('卸载后不再保留计时器与监听', () => {
const h = fakeHost()
const poller = createVisibilityPoller(() => {}, 1000, {}, h.host)
poller.attach()
expect(h.hasTimer()).toBe(true)
expect(h.listenerCount()).toBe(1)
poller.detach()
expect(h.hasTimer()).toBe(false)
expect(h.listenerCount()).toBe(0)
})
it('onError 被调用且不影响后续轮询', async () => {
const h = fakeHost()
const errors: unknown[] = []
let calls = 0
const poller = createVisibilityPoller(() => {
calls++
if (calls === 1) throw new Error('boom')
}, 1000, { onError: (error) => errors.push(error) }, h.host)
await poller.tick()
expect(errors).toHaveLength(1)
expect(poller.lastError.value).toBeInstanceOf(Error)
await poller.tick()
expect(calls).toBe(2)
expect(poller.lastError.value).toBeNull()
})
})
@@ -0,0 +1,115 @@
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
export interface VisibilityPollingOptions {
/** 为 false 时整轮跳过(例如未选中服务端、弹窗未打开)。 */
enabled?: Ref<boolean>
/** 挂载后是否立即执行一次。 */
immediate?: boolean
/** 轮询回调抛错时调用;不传则只记录到 lastError。 */
onError?: (error: unknown) => void
}
/** 注入的宿主接口,便于在无 DOM 环境下测试轮询核心。 */
export interface PollingHost {
setInterval: (fn: () => void, ms: number) => number
clearInterval: (id: number) => void
isHidden: () => boolean
addVisibilityListener: (fn: () => void) => void
removeVisibilityListener: (fn: () => void) => void
}
export interface VisibilityPoller {
start: () => void
stop: () => void
tick: () => Promise<void>
attach: () => void
detach: () => void
busy: Ref<boolean>
lastError: Ref<unknown>
}
function browserPollingHost(): PollingHost {
return {
setInterval: (fn, ms) => window.setInterval(fn, ms),
clearInterval: (id) => window.clearInterval(id),
isHidden: () => document.visibilityState === 'hidden',
addVisibilityListener: (fn) => document.addEventListener('visibilitychange', fn),
removeVisibilityListener: (fn) => document.removeEventListener('visibilitychange', fn),
}
}
/**
* 可测试的轮询核心:隐藏时跳过、上一轮未结束时跳过、回到前台立即补一次。
* 不依赖 Vue 生命周期,宿主能力全部由 host 注入。
*/
export function createVisibilityPoller(
fn: () => void | Promise<void>,
intervalMs: number,
options: VisibilityPollingOptions = {},
host: PollingHost = browserPollingHost(),
): VisibilityPoller {
const busy = ref(false)
const lastError = ref<unknown>(null)
let timer: number | undefined
const tick = async () => {
if (host.isHidden()) return
if (options.enabled && !options.enabled.value) return
if (busy.value) return
busy.value = true
try {
await fn()
lastError.value = null
} catch (error) {
lastError.value = error
options.onError?.(error)
} finally {
busy.value = false
}
}
const start = () => {
if (timer !== undefined) return
timer = host.setInterval(() => void tick(), intervalMs)
}
const stop = () => {
if (timer === undefined) return
host.clearInterval(timer)
timer = undefined
}
const onVisibilityChange = () => {
if (host.isHidden()) return
void tick()
}
const attach = () => {
host.addVisibilityListener(onVisibilityChange)
start()
}
const detach = () => {
host.removeVisibilityListener(onVisibilityChange)
stop()
}
return { start, stop, tick, attach, detach, busy, lastError }
}
/**
* 页面可见时才轮询:隐藏期间跳过,回到前台立即补一次,上一轮未结束时跳过下一轮。
*/
export function useVisibilityPolling(
fn: () => void | Promise<void>,
intervalMs: number,
options: VisibilityPollingOptions = {},
): VisibilityPoller {
const poller = createVisibilityPoller(fn, intervalMs, options)
onMounted(() => {
poller.attach()
if (options.immediate) void poller.tick()
})
onBeforeUnmount(() => poller.detach())
return poller
}
+4
View File
@@ -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`
+26
View File
@@ -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))
}
+12
View File
@@ -0,0 +1,12 @@
// 趋势图的时间档:后端按 hours 取窗口、按 buckets 取桶数(点位在该数量级最清楚)
export interface MetricsRange {
label: string
hours: number
buckets: number
}
export const METRICS_RANGES: MetricsRange[] = [
{ label: '1 小时', hours: 1, buckets: 30 },
{ label: '24 小时', hours: 24, buckets: 48 },
{ label: '7 天', hours: 24 * 7, buckets: 84 },
]
+128
View File
@@ -0,0 +1,128 @@
export interface NotifyEventOption {
key: string
label: string
hint?: string
hasThreshold?: boolean
thresholdKey?: 'ip_frequent' | 'login_failure' | 'resource' | 'tunnel_fail' | 'frps_fail' | 'agent_fail' | 'drift_cooldown'
}
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 未能成功重载',
},
{
key: 'on_backup_failure',
label: '自动备份失败',
hint: '定时备份导出或校验失败——没有备份这件事,往往到需要它的那天才发现',
},
{
key: 'on_resource_threshold',
label: '资源占用过高',
hint: 'CPU / 内存 / 磁盘持续超过阈值,同一项每 30 分钟最多提醒一次',
hasThreshold: true,
thresholdKey: 'resource',
},
{
key: 'on_tunnel_down',
label: '隧道连续不通',
hint: '连续多次巡检未在 frps 上注册,恢复时再通知一次',
hasThreshold: true,
thresholdKey: 'tunnel_fail',
},
{
key: 'on_frps_down',
label: '公网服务端掉线',
hint: '服务端进程没在跑或隧道连不上 frps(手动停止的不报),恢复时再通知一次',
hasThreshold: true,
thresholdKey: 'frps_fail',
},
{
key: 'on_agent_unreachable',
label: '公网 agent 不可达',
hint: 'VPS 上的 havline-agent 连续无响应(重启后没起来最常见),恢复时再通知一次',
hasThreshold: true,
thresholdKey: 'agent_fail',
},
{
key: 'on_route_drift',
label: '配置漂移',
hint: 'VPS 上的 vhost 与 Havline 记录不一致(被手工改过或重装后未重部署),同一台按提醒间隔重复提醒',
hasThreshold: true,
thresholdKey: 'drift_cooldown',
},
],
},
]
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
export const DEFAULT_CPU_THRESHOLD = 90
export const DEFAULT_MEMORY_THRESHOLD = 90
export const DEFAULT_DISK_THRESHOLD = 85
export const DEFAULT_TUNNEL_FAIL_THRESHOLD = 3
export const DEFAULT_FRPS_FAIL_THRESHOLD = 2
export const DEFAULT_AGENT_FAIL_THRESHOLD = 3
export const DEFAULT_DRIFT_COOLDOWN_HOURS = 24
@@ -0,0 +1,26 @@
import type { NotifyType, WebhookProvider } from '../api/types'
export const NOTIFY_TYPE_OPTIONS: { label: string; value: NotifyType }[] = [
{ label: '关闭', value: '' },
{ label: '邮件(SMTP)', value: 'email' },
// 企业微信 / 钉钉 / 飞书机器人都是「POST 一个 URL + JSON」,归在 Webhook 预设里,标签要说明白
{ label: 'Webhook / IM 机器人', 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: '企业微信机器人', value: 'wecom' },
{ label: '钉钉机器人', value: 'dingtalk' },
{ label: '飞书机器人', value: 'feishu' },
{ label: '自定义 Webhook', value: 'custom' },
]
export const DEFAULT_BARK_SERVER = 'https://api.day.app'
export const DEFAULT_NTFY_SERVER = 'https://ntfy.sh'
export const DEFAULT_WECOM_SERVER = 'https://qyapi.weixin.qq.com'
export const DEFAULT_DINGTALK_SERVER = 'https://oapi.dingtalk.com'
export const DEFAULT_FEISHU_SERVER = 'https://open.feishu.cn'
export const MASKED_SECRET = '********'
+3
View File
@@ -0,0 +1,3 @@
/** 敏感凭据字段:标签与占位符全站统一 */
export const CONFIGURED_SECRET_TAG = '已配置'
export const CONFIGURED_SECRET_PLACEHOLDER = '已配置,留空表示不修改'
+14
View File
@@ -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
}
+515
View File
@@ -0,0 +1,515 @@
<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="brandLogo" alt="Havline" />
<div v-if="!collapsed" class="brand__text">
<div class="brand__name">Havline</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">Havline</span>
<span class="sider-footer__version">v{{ appVersion }}</span>
</div>
<a
v-if="canOneClickUpdate"
class="sider-footer__update"
href="#"
@click.prevent="showUpdateModal = true"
>
<n-icon :component="ArrowUpOutline" />
一键升级
</a>
<a
v-else-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>
<UpdateModal v-model:show="showUpdateModal" @updated="checkForUpdate" />
</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,
InformationCircleOutline,
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 type { UpdateStatus } from '../api/types'
import brandLogo from '../assets/brand/havline-logo.svg'
import UpdateModal from '../components/UpdateModal.vue'
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)
const showUpdateModal = ref(false)
const updateStatus = ref<UpdateStatus | null>(null)
let clockTimer: number | undefined
const canOneClickUpdate = computed(() => Boolean(
updateStatus.value?.enabled && updateStatus.value.update_available
))
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 Promise.all([checkForUpdate(), loadUpdateStatus()])
}
async function loadUpdateStatus() {
try {
updateStatus.value = await api.getUpdateStatus()
} catch {
updateStatus.value = null
}
}
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: '公网服务端', key: 'frp-agent', icon: HardwareChipOutline },
{ label: '公网反代', key: 'public-proxy', icon: ShieldCheckmarkOutline },
{ label: 'Cloudflare 隧道', key: 'cloudflare', icon: CloudOutline },
{ label: '日志中心', key: 'logs', icon: DocumentTextOutline },
{ label: '系统设置', key: 'settings', icon: CogOutline },
{ label: '关于项目', key: 'about', icon: InformationCircleOutline },
]
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(--havline-bg);
}
.app-layout :deep(.n-layout-scroll-container) {
min-height: 100%;
}
.app-sider {
background: var(--havline-surface) !important;
border-right: 1px solid var(--havline-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(--havline-space-3);
padding: var(--havline-space-5) var(--havline-space-4);
border-bottom: 1px solid var(--havline-border);
color: inherit;
text-decoration: none;
cursor: pointer;
transition: background 0.15s ease;
}
.brand:hover {
background: var(--havline-bg-muted);
}
.brand--collapsed {
justify-content: center;
padding: var(--havline-space-4) 0;
}
.brand__logo {
width: 40px;
height: 40px;
flex-shrink: 0;
}
.brand__name {
font-size: 18px;
font-weight: 700;
color: var(--havline-text);
line-height: 1.2;
}
.brand__tagline {
font-size: 11px;
color: var(--havline-text-muted);
margin-top: 2px;
line-height: 1.3;
}
.app-menu {
flex: 1;
padding: var(--havline-space-3);
}
.app-menu :deep(.n-menu-item) {
margin-bottom: 2px;
}
.app-menu :deep(.n-menu-item-content) {
border-radius: var(--havline-radius-sm) !important;
padding-left: 14px !important;
}
.app-menu :deep(.n-menu-item-content--selected) {
background: var(--havline-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(--havline-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(--havline-brand) !important;
}
.app-menu :deep(.n-menu-item-content--selected::before) {
display: none !important;
}
.sider-footer {
padding: var(--havline-space-4);
border-top: 1px solid var(--havline-border);
}
.sider-footer__version-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: var(--havline-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(--havline-text);
}
.sider-footer__version {
font-size: 12px;
color: var(--havline-text-muted);
}
.sider-footer__update {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 0;
border: 0;
background: none;
font-size: 11px;
font-weight: 500;
font-family: inherit;
color: var(--havline-brand);
text-decoration: none;
white-space: nowrap;
cursor: pointer;
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(--havline-space-4);
font-size: 12px;
color: var(--havline-success);
font-weight: 500;
}
.sider-footer__status-icon {
font-size: 8px;
color: var(--havline-success);
}
.sider-footer__github {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--havline-text-secondary);
text-decoration: none;
transition: color 0.15s;
}
.sider-footer__github:hover {
color: var(--havline-text);
}
.sider-footer__github :deep(.n-icon) {
font-size: 15px;
}
.app-main {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--havline-bg);
}
.topbar {
flex-shrink: 0;
height: var(--havline-topbar-height);
padding: 0 var(--havline-space-5);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--havline-space-4);
background: var(--havline-surface);
border-bottom: 1px solid var(--havline-border);
}
.topbar__datetime {
font-size: 13px;
color: var(--havline-text-secondary);
}
.topbar__right {
display: flex;
align-items: center;
gap: var(--havline-space-4);
}
.user-btn {
display: flex;
align-items: center;
gap: var(--havline-space-3);
padding: 0 var(--havline-space-2);
}
.user-btn__icon,
.user-btn__chevron {
font-size: 18px;
color: var(--havline-text-secondary);
}
.user-name {
font-size: 14px;
color: var(--havline-text);
}
.content {
flex: 1;
padding: var(--havline-space-5);
overflow: auto;
}
.page-body {
min-height: 100%;
}
@media (max-width: 768px) {
.topbar,
.content {
padding-inline: var(--havline-space-4);
}
.user-name,
.topbar__datetime {
display: none;
}
}
</style>
+12
View File
@@ -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('[havline]', info, err)
}
app.use(router).use(naive).mount('#app')
+118
View File
@@ -0,0 +1,118 @@
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 CloudflareView from '../views/CloudflareView.vue'
import CloudflareTunnelConfigView from '../views/CloudflareTunnelConfigView.vue'
import AgentView from '../views/AgentView.vue'
import ProxyRemoteView from '../views/ProxyRemoteView.vue'
import StatusPageView from '../views/StatusPageView.vue'
import AboutView from '../views/AboutView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/setup', redirect: '/login' },
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
// 公开状态页:挂到公网给别人看的,不经过登录守卫
{ path: '/status', name: 'status', component: StatusPageView, meta: { public: true } },
{
path: '/',
component: AppLayout,
meta: { requiresAuth: true },
children: [
{ path: '', redirect: '/dashboard' },
{
path: 'dashboard',
name: 'dashboard',
component: DashboardView,
meta: { title: '仪表盘', description: '查看 Havline 运行状态与关键指标' },
},
{
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 将公网流量转发到 Havline Nginx' },
},
{
path: 'frp-agent',
name: 'frp-agent',
component: AgentView,
meta: { title: '公网 Agent', description: '管理部署在公网 VPS 上的 havline-agent 与 frps' },
},
{
path: 'cloudflare',
name: 'cloudflare',
component: CloudflareView,
meta: { title: 'Cloudflare 隧道', description: '通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘' },
},
{
path: 'cloudflare/:id/config',
name: 'cloudflare-tunnel-config',
component: CloudflareTunnelConfigView,
meta: { title: 'Cloudflare 隧道配置', description: '配置隧道、查看副本指标与已发布路由' },
},
{
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: '配置系统参数与运行策略' },
},
{
path: 'about',
name: 'about',
component: AboutView,
meta: { title: '关于项目', description: '查看 Havline 版本、源开源项目和技术信息' },
},
],
},
],
})
router.beforeEach(async (to) => {
// 公开页(状态页)不登录也能打开,也不必为它去问一次登录状态
if (to.meta.public && to.name !== 'login') return true
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
+65
View File
@@ -0,0 +1,65 @@
:root {
--havline-brand: #10b981;
--havline-brand-hover: #059669;
--havline-brand-soft: rgba(16, 185, 129, 0.12);
--havline-brand-text: #047857;
--havline-brand-sidebar: #10b981;
--havline-success: #10b981;
--havline-warning: #f59e0b;
--havline-error: #ef4444;
--havline-disabled: #94a3b8;
--havline-info: #3b82f6;
--havline-info-soft: rgba(59, 130, 246, 0.1);
--havline-error-soft: rgba(239, 68, 68, 0.1);
--havline-bg: #f8fafc;
--havline-bg-muted: #f3f4f6;
--havline-banner-bg: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 40%, #a7f3d0 100%);
--havline-banner-border: rgba(16, 185, 129, 0.2);
--havline-surface: #ffffff;
--havline-border: #e5e7eb;
--havline-text: #111827;
--havline-text-secondary: #6b7280;
--havline-text-muted: #9ca3af;
--havline-radius: 12px;
--havline-radius-sm: 8px;
--havline-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
--havline-shadow-md: 0 4px 6px -1px rgba(15, 23, 42, 0.08), 0 2px 4px -2px rgba(15, 23, 42, 0.06);
--havline-sidebar-width: 240px;
--havline-topbar-height: 56px;
--havline-form-max: 720px;
--havline-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--havline-mono: ui-monospace, 'Cascadia Code', 'Segoe UI Mono', monospace;
--havline-space-1: 4px;
--havline-space-2: 8px;
--havline-space-3: 12px;
--havline-space-4: 16px;
--havline-space-5: 24px;
--havline-space-6: 32px;
}
html.dark,
[data-theme='dark'] {
--havline-brand-soft: rgba(16, 185, 129, 0.18);
--havline-brand-text: #6ee7b7;
--havline-info-soft: rgba(59, 130, 246, 0.16);
--havline-error-soft: rgba(239, 68, 68, 0.16);
--havline-bg: #0f172a;
--havline-bg-muted: #1e293b;
--havline-surface: #1e293b;
--havline-border: #334155;
--havline-text: #f1f5f9;
--havline-text-secondary: #94a3b8;
--havline-text-muted: #64748b;
--havline-banner-bg: linear-gradient(135deg, #064e3b 0%, #065f46 45%, #047857 100%);
--havline-banner-border: rgba(52, 211, 153, 0.25);
--havline-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--havline-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
}
+138
View File
@@ -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(' · ')
}
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { agentVersionText } from './agent'
describe('agentVersionText', () => {
it('有版本号时原样展示', () => {
expect(agentVersionText('0.14.1')).toBe('0.14.1')
})
it('旧 Agent 缺少版本字段时降级为占位符', () => {
expect(agentVersionText()).toBe('—')
expect(agentVersionText('')).toBe('—')
expect(agentVersionText(' ')).toBe('—')
})
})
+4
View File
@@ -0,0 +1,4 @@
/** Agent 版本展示:旧 Agent 未返回 agent_version 时降级为占位符,避免页面出现空白。 */
export function agentVersionText(version?: string): string {
return version?.trim() || '—'
}
+31
View File
@@ -0,0 +1,31 @@
// 剪贴板复制。
//
// Havline 常以 http://内网IP:6893 访问,属于**非安全上下文**,此时 navigator.clipboard 为 undefined,
// 直接调用会导致复制必然失败。这里统一收口:优先用异步剪贴板 API,不可用时回退到 execCommand。
// 返回是否复制成功,由调用方决定提示文案。
export async function copyText(text: string): Promise<boolean> {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// 权限被拒或浏览器限制:继续尝试回退方案
}
}
try {
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.top = '-1000px'
el.style.opacity = '0'
document.body.appendChild(el)
el.select()
el.setSelectionRange(0, el.value.length)
const ok = document.execCommand('copy')
document.body.removeChild(el)
return ok
} catch {
return false
}
}
+73
View File
@@ -0,0 +1,73 @@
// 极简行级差异:Nginx 配置通常只有几十行,用 LCS 就够,不为此引入第三方 diff 依赖。
export interface DiffLine {
kind: 'same' | 'add' | 'del'
text: string
oldLine?: number
newLine?: number
}
// maxDiffCells 限制 LCS 表大小:超长文本(例如整份日志误粘进来)退化为「整体替换」,
// 避免 O(n*m) 的表把页面卡死。
const maxDiffCells = 250_000
export function diffLines(fromText: string, toText: string): DiffLine[] {
const from = splitLines(fromText)
const to = splitLines(toText)
if (from.length * to.length > maxDiffCells) {
return [
...from.map((text, index) => ({ kind: 'del' as const, text, oldLine: index + 1 })),
...to.map((text, index) => ({ kind: 'add' as const, text, newLine: index + 1 })),
]
}
// dp[i][j] = from[i..] 与 to[j..] 的最长公共子序列长度
const dp: number[][] = Array.from({ length: from.length + 1 }, () => new Array<number>(to.length + 1).fill(0))
for (let i = from.length - 1; i >= 0; i -= 1) {
for (let j = to.length - 1; j >= 0; j -= 1) {
dp[i][j] = from[i] === to[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
}
}
const out: DiffLine[] = []
let i = 0
let j = 0
while (i < from.length && j < to.length) {
if (from[i] === to[j]) {
out.push({ kind: 'same', text: from[i], oldLine: i + 1, newLine: j + 1 })
i += 1
j += 1
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
out.push({ kind: 'del', text: from[i], oldLine: i + 1 })
i += 1
} else {
out.push({ kind: 'add', text: to[j], newLine: j + 1 })
j += 1
}
}
while (i < from.length) {
out.push({ kind: 'del', text: from[i], oldLine: i + 1 })
i += 1
}
while (j < to.length) {
out.push({ kind: 'add', text: to[j], newLine: j + 1 })
j += 1
}
return out
}
export function diffStats(lines: DiffLine[]): { added: number; removed: number } {
let added = 0
let removed = 0
for (const line of lines) {
if (line.kind === 'add') added += 1
else if (line.kind === 'del') removed += 1
}
return { added, removed }
}
function splitLines(text: string): string[] {
if (text === '') return []
return text.replace(/\r\n/g, '\n').replace(/\n+$/, '').split('\n')
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { formatBytes, formatLogLine, formatLogTime, formatRate, formatRelativeTime, formatUptime } from './format'
describe('formatBytes', () => {
it('按 1024 进制换算并按量级保留有效位', () => {
expect(formatBytes(0)).toBe('0 B')
expect(formatBytes(512)).toBe('512 B')
expect(formatBytes(1024)).toBe('1.00 KB')
expect(formatBytes(1536)).toBe('1.50 KB')
expect(formatBytes(20 * 1024)).toBe('20.0 KB')
expect(formatBytes(200 * 1024)).toBe('200 KB')
expect(formatBytes(3 * 1024 ** 3)).toBe('3.00 GB')
})
it('非法输入回落 0 B,不产生 NaN', () => {
expect(formatBytes(Number.NaN)).toBe('0 B')
expect(formatBytes(Number.POSITIVE_INFINITY)).toBe('0 B')
expect(formatBytes(-1)).toBe('0 B')
})
})
describe('formatRate', () => {
it('复用字节格式化并加 /s', () => {
expect(formatRate(0)).toBe('0 B/s')
expect(formatRate(2048)).toBe('2.00 KB/s')
})
})
describe('formatUptime', () => {
it('小时为 0 时只显示分钟', () => {
expect(formatUptime(90)).toBe('1 分钟')
expect(formatUptime(3661)).toBe('1 小时 1 分钟')
})
})
describe('formatRelativeTime', () => {
it('按分钟 / 小时 / 天分档', () => {
const ago = (minutes: number) => new Date(Date.now() - minutes * 60_000).toISOString()
expect(formatRelativeTime(undefined)).toBe('-')
expect(formatRelativeTime(ago(0))).toBe('刚刚')
expect(formatRelativeTime(ago(5))).toBe('5 分钟前')
expect(formatRelativeTime(ago(3 * 60))).toBe('3 小时前')
expect(formatRelativeTime(ago(2 * 24 * 60))).toBe('2 天前')
})
it('无法解析的字符串原样返回,不显示 NaN', () => {
expect(formatRelativeTime('not-a-date')).toBe('not-a-date')
})
})
describe('formatLogTime', () => {
it('三种日志时间格式统一成 YYYY-MM-DD HH:mm:ss', () => {
expect(formatLogTime('2026-09-24 18:30:05')).toBe('2026-09-24 18:30:05')
expect(formatLogTime('2026-09-24T18:30:05Z')).toBe('2026-09-24 18:30:05')
expect(formatLogTime('2026/09/24 18:30:05')).toBe('2026-09-24 18:30:05')
expect(formatLogTime('')).toBe('-')
})
})
describe('formatLogLine', () => {
it('只改写行首时间戳,保留其余内容', () => {
expect(formatLogLine('2026/09/24 18:30:05 [error] boom')).toBe('2026-09-24 18:30:05 [error] boom')
expect(formatLogLine('2026-09-24T18:30:05Z upstream timeout')).toBe('2026-09-24 18:30:05 upstream timeout')
})
it('JSON 日志只替换 time 字段', () => {
expect(formatLogLine('{"time":"2026-09-24T18:30:05Z","level":"info"}')).toBe('{"time":"2026-09-24 18:30:05","level":"info"}')
})
})
+103
View File
@@ -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
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { shouldShowFrpHint } from './frp'
describe('shouldShowFrpHint', () => {
it('配置了服务端且存在启用规则时显示提示', () => {
expect(shouldShowFrpHint({ configured: true, enabled_proxies: 1 })).toBe(true)
})
it('未配置服务端时不显示提示', () => {
expect(shouldShowFrpHint({ configured: false, enabled_proxies: 1 })).toBe(false)
})
it('配置了服务端但没有启用规则时不显示提示', () => {
expect(shouldShowFrpHint({ configured: true, enabled_proxies: 0 })).toBe(false)
})
it('状态为空时不显示提示', () => {
expect(shouldShowFrpHint(undefined)).toBe(false)
})
})
+6
View File
@@ -0,0 +1,6 @@
import type { FrpStatus } from '../api/types'
// 只有配置了 FRP 服务端且存在启用规则时,DDNS 页才提示域名应指向 VPS。
export function shouldShowFrpHint(status: Pick<FrpStatus, 'configured' | 'enabled_proxies'> | null | undefined): boolean {
return Boolean(status?.configured && status.enabled_proxies > 0)
}
+165
View File
@@ -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 ''
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { httpStatusKind, statusKind, statusLabel } from './status'
describe('statusKind', () => {
it('把后端状态值映射成 StatusKind', () => {
expect(statusKind('ok')).toBe('success')
expect(statusKind('running')).toBe('success')
expect(statusKind('warning')).toBe('warning')
expect(statusKind('error')).toBe('error')
expect(statusKind('stopped')).toBe('disabled')
expect(statusKind('none')).toBe('disabled')
expect(statusKind(undefined)).toBe('unknown')
expect(statusKind('')).toBe('unknown')
})
it('回归:n-tag 的 type 值不是状态值,必须落到 unknown', () => {
// 曾把 serverStartupType() 返回的 'success'/'default' 传给 StatusBadge :value,
// 导致服务端详情弹窗「运行中」也显示灰点。
expect(statusKind('success')).toBe('unknown')
expect(statusKind('default')).toBe('unknown')
})
})
describe('statusLabel', () => {
it('已知值给中文,未知值原样返回', () => {
expect(statusLabel('ok')).toBe('正常')
expect(statusLabel('stopped')).toBe('已停止')
expect(statusLabel('weird')).toBe('weird')
expect(statusLabel()).toBe('未知')
})
})
describe('httpStatusKind', () => {
it('按状态码分段', () => {
expect(httpStatusKind(200)).toBe('success')
expect(httpStatusKind(301)).toBe('unknown')
expect(httpStatusKind(404)).toBe('warning')
expect(httpStatusKind(503)).toBe('error')
})
})
+42
View File
@@ -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'
}
+34
View File
@@ -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)
}
+56
View File
@@ -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
}
}
+576
View File
@@ -0,0 +1,576 @@
<template>
<PageHeader title="关于项目" description="了解 Havline 的项目定位、来源、能力与开源信息">
<template #actions>
<n-button :loading="refreshing" @click="refreshVersion">
<template #icon><n-icon :component="RefreshOutline" /></template>
刷新版本信息
</n-button>
</template>
</PageHeader>
<section class="about-hero">
<div class="about-hero__main">
<img class="about-hero__logo" :src="brandLogo" alt="Havline" />
<div class="about-hero__copy">
<p class="about-kicker">ABOUT HAVLINE</p>
<h2>让每一台 NAS,都能安全地连接世界。</h2>
<p>
Havline 是一款面向飞牛 fnOS 等 NAS 环境的轻量公网访问管理工具。
它把反向代理、DDNS、HTTPS 证书、内网穿透、公网 Agent 与 Cloudflare Tunnel
收进一个 Web 控制台,让家庭与小型办公环境中的服务访问更简单、更可控。
</p>
<div class="about-actions">
<a :href="GITHUB_REPO_URL" target="_blank" rel="noopener noreferrer">
<n-icon :component="LogoGithub" />
查看源项目
</a>
<a :href="GITHUB_RELEASES_URL" target="_blank" rel="noopener noreferrer">
<n-icon :component="CloudDownloadOutline" />
Release
</a>
<a :href="GPL_URL" target="_blank" rel="noopener noreferrer">
<n-icon :component="DocumentTextOutline" />
GPL-3.0
</a>
</div>
</div>
</div>
<div class="about-hero__meta">
<div class="about-meta-item">
<span>当前版本</span>
<strong class="mono">v{{ version }}</strong>
</div>
<div class="about-meta-item">
<span>源项目</span>
<strong>{{ GITHUB_REPO }}</strong>
</div>
<div class="about-meta-item">
<span>开源协议</span>
<strong>GPL-3.0</strong>
</div>
<div class="about-meta-item">
<span>最近检查</span>
<strong>{{ checkedAt || '刚刚' }}</strong>
</div>
</div>
</section>
<HavlineCard title="项目定位" subtitle="Havline 解决什么问题">
<div class="about-lead">
<p>
NAS 的价值不只在存储,更在于把文件、媒体、自动化工具和家庭服务安全地带到需要它们的地方。
Havline 不试图替代专业网关或企业级运维平台,而是把 NAS 用户最常用、最容易出错的公网访问能力
做成一个可理解、可维护、可回滚的 Web 控制台。
</p>
</div>
<div class="about-points">
<article class="about-point">
<h3>减少手工配置</h3>
<p>自动生成 Nginx 配置、管理证书续期,并统一处理 DDNS 与访问入口。</p>
</article>
<article class="about-point">
<h3>覆盖多种出口</h3>
<p>支持本机反向代理、FRP 内网穿透、公网 VPS Agent 和 Cloudflare Tunnel。</p>
</article>
<article class="about-point">
<h3>保留可控性</h3>
<p>所有配置、凭据和运行数据都保存在自己的数据目录中,不依赖外部托管平台。</p>
</article>
</div>
</HavlineCard>
<HavlineCard title="来源与致谢" subtitle="站在巨人的肩膀上继续前行">
<div class="about-origin">
<blockquote>开源不是重新发明轮子,而是站在巨人的肩膀上,把一件事继续做得更好。</blockquote>
<p>
Havline 是基于开源项目
<a :href="GITHUB_REPO_URL" target="_blank" rel="noopener noreferrer">{{ GITHUB_REPO }}</a>
<strong>1.0.2 版本</strong>进行二次开发的项目。原始项目已经完成了反向代理、DDNS、证书、
内网穿透等核心能力,为 Havline 提供了坚实的工程基础。
</p>
<p>
我们在原项目的基础上进行了品牌重塑、界面调整、功能迭代、部署流程整理和文档重构。
无论项目名称如何变化,Havline 都保留对源项目和其贡献者的明确致谢,并继续遵循
<a :href="GPL_URL" target="_blank" rel="noopener noreferrer">GPL-3.0</a>
开源协议。
</p>
</div>
</HavlineCard>
<HavlineCard title="核心能力" subtitle="从入口到证书,从内网到公网">
<div class="about-features">
<article v-for="feature in features" :key="feature.title" class="about-feature">
<span class="about-feature__icon">
<n-icon :component="feature.icon" />
</span>
<div>
<h3>{{ feature.title }}</h3>
<p>{{ feature.description }}</p>
</div>
</article>
</div>
</HavlineCard>
<HavlineCard title="技术栈" subtitle="保持简单、透明、易部署">
<div class="about-tech">
<section v-for="group in techGroups" :key="group.title" class="about-tech__group">
<h3>{{ group.title }}</h3>
<div class="about-tags">
<span v-for="item in group.items" :key="item">{{ item }}</span>
</div>
</section>
</div>
</HavlineCard>
<HavlineCard title="开源与许可" subtitle="尊重版权,也尊重每一位使用者">
<div class="about-license">
<div class="about-license__item">
<n-icon :component="DocumentTextOutline" />
<div>
<h3>GPL-3.0</h3>
<p>项目继续采用 GNU General Public License v3.0 开源。</p>
</div>
</div>
<div class="about-license__item">
<n-icon :component="LogoGithub" />
<div>
<h3>源项目保留</h3>
<p>源项目地址固定为 {{ GITHUB_REPO }},用于追溯上游来源和许可证义务。</p>
</div>
</div>
<div class="about-license__item">
<n-icon :component="ShieldCheckmarkOutline" />
<div>
<h3>无担保声明</h3>
<p>软件按“原样”提供,请在部署前做好备份、权限和网络访问控制。</p>
</div>
</div>
</div>
</HavlineCard>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { NButton, NIcon, useMessage } from 'naive-ui'
import {
CloudDownloadOutline,
CloudOutline,
DocumentTextOutline,
GitNetworkOutline,
GridOutline,
HardwareChipOutline,
LockClosedOutline,
LogoGithub,
RefreshOutline,
ShieldCheckmarkOutline,
SwapHorizontalOutline,
} from '@vicons/ionicons5'
import { api } from '../api/client'
import brandLogo from '../assets/brand/havline-logo.svg'
import HavlineCard from '../components/HavlineCard.vue'
import PageHeader from '../components/PageHeader.vue'
import {
GITHUB_RELEASES_URL,
GITHUB_REPO,
GITHUB_REPO_URL,
} from '../constants/app'
import { formatDate } from '../utils/format'
const GPL_URL = 'https://www.gnu.org/licenses/gpl-3.0.html'
const message = useMessage()
const version = ref(__APP_VERSION__)
const checkedAt = ref('')
const refreshing = ref(false)
const features = [
{
title: '反向代理',
description: '通过 Web 界面管理 Nginx 域名与端口规则,自动生成配置并热重载。',
icon: SwapHorizontalOutline,
},
{
title: 'DDNS',
description: '对接 Cloudflare、DNSPod、阿里云、腾讯云和火山引擎,自动同步公网 IP。',
icon: CloudOutline,
},
{
title: 'HTTPS 证书',
description: '使用 ACME DNS-01 自动申请与续期证书,支持通配符和手动导入。',
icon: LockClosedOutline,
},
{
title: '内网穿透',
description: '内置 FRP 多服务端管理,在没有公网 IP 时把服务安全暴露到 VPS。',
icon: GitNetworkOutline,
},
{
title: '公网 Agent',
description: '通过 SSH 安装和维护 VPS 上的 havline-agent,远程编排 frps 与 Nginx。',
icon: HardwareChipOutline,
},
{
title: 'Cloudflare Tunnel',
description: '管理 cloudflared 隧道、路由与 DNS,适合不愿意开放公网端口的场景。',
icon: CloudOutline,
},
{
title: '日志与监控',
description: '查看系统、访问和错误日志,观察流量趋势、服务状态与运行健康度。',
icon: GridOutline,
},
{
title: '安全与备份',
description: '提供访问控制、凭据加密、配置备份和恢复,降低误操作与数据丢失风险。',
icon: ShieldCheckmarkOutline,
},
]
const techGroups = [
{
title: '后端',
items: ['Go 1.23', 'SQLite', 'Nginx', 'FRP', 'ACME / lego'],
},
{
title: '前端',
items: ['Vue 3', 'TypeScript', 'Vite', 'Naive UI', 'ECharts'],
},
{
title: '部署',
items: ['Docker', 'Docker Compose', 'Linux systemd', 'amd64 / arm64'],
},
]
async function refreshVersion() {
refreshing.value = true
try {
const result = await api.getVersion()
version.value = result.version || version.value
checkedAt.value = formatDate(new Date().toISOString())
} catch (error) {
message.error(error instanceof Error ? error.message : '读取版本失败')
} finally {
refreshing.value = false
}
}
onMounted(() => {
void refreshVersion()
})
</script>
<style scoped>
.about-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: var(--havline-space-6);
align-items: center;
margin-bottom: var(--havline-space-5);
padding: var(--havline-space-6);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius);
background:
linear-gradient(135deg, var(--havline-brand-soft), transparent 58%),
var(--havline-surface);
}
.about-hero__main {
display: flex;
align-items: flex-start;
gap: var(--havline-space-5);
min-width: 0;
}
.about-hero__logo {
width: 76px;
height: 76px;
flex: 0 0 auto;
}
.about-hero__copy {
min-width: 0;
}
.about-kicker {
margin: 0 0 8px;
color: var(--havline-brand-text);
font-family: var(--havline-mono);
font-size: 11px;
letter-spacing: 0.14em;
}
.about-hero h2 {
margin: 0 0 12px;
color: var(--havline-text);
font-size: clamp(24px, 3vw, 34px);
line-height: 1.22;
}
.about-hero__copy > p:not(.about-kicker) {
max-width: 720px;
margin: 0;
color: var(--havline-text-secondary);
font-size: 14px;
line-height: 1.8;
}
.about-actions {
display: flex;
flex-wrap: wrap;
gap: var(--havline-space-3);
margin-top: var(--havline-space-5);
}
.about-actions a {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
color: var(--havline-brand-text);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-surface);
text-decoration: none;
}
.about-hero__meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: color-mix(in srgb, var(--havline-surface) 92%, transparent);
overflow: hidden;
}
.about-meta-item {
min-width: 0;
padding: 16px;
border-right: 1px solid var(--havline-border);
border-bottom: 1px solid var(--havline-border);
}
.about-meta-item:nth-child(2n) {
border-right: 0;
}
.about-meta-item:nth-last-child(-n + 2) {
border-bottom: 0;
}
.about-meta-item span,
.about-meta-item strong {
display: block;
}
.about-meta-item span {
margin-bottom: 6px;
color: var(--havline-text-muted);
font-size: 12px;
}
.about-meta-item strong {
overflow: hidden;
color: var(--havline-text);
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.about-lead {
max-width: 860px;
color: var(--havline-text-secondary);
font-size: 14px;
line-height: 1.9;
}
.about-lead p {
margin: 0;
}
.about-points {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--havline-space-4);
margin-top: var(--havline-space-5);
}
.about-point {
padding-left: var(--havline-space-4);
border-left: 2px solid var(--havline-brand);
}
.about-point h3,
.about-feature h3,
.about-tech__group h3,
.about-license__item h3 {
margin: 0 0 6px;
color: var(--havline-text);
font-size: 15px;
}
.about-point p,
.about-feature p,
.about-license__item p {
margin: 0;
color: var(--havline-text-secondary);
font-size: 13px;
line-height: 1.7;
}
.about-origin {
display: grid;
gap: var(--havline-space-4);
color: var(--havline-text-secondary);
font-size: 14px;
line-height: 1.85;
}
.about-origin blockquote {
margin: 0;
padding: var(--havline-space-4) var(--havline-space-5);
color: var(--havline-brand-text);
border-left: 3px solid var(--havline-brand);
background: var(--havline-brand-soft);
border-radius: 0 var(--havline-radius-sm) var(--havline-radius-sm) 0;
font-size: 16px;
font-weight: 600;
}
.about-origin p {
margin: 0;
}
.about-origin a {
color: var(--havline-brand-text);
text-decoration: none;
}
.about-features {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--havline-space-4);
}
.about-feature {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
gap: var(--havline-space-3);
min-width: 0;
padding: var(--havline-space-4);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-bg);
}
.about-feature__icon {
display: grid;
place-items: center;
width: 36px;
height: 36px;
color: var(--havline-brand-text);
border-radius: 10px;
background: var(--havline-brand-soft);
font-size: 19px;
}
.about-tech {
display: grid;
gap: var(--havline-space-5);
}
.about-tech__group {
display: grid;
grid-template-columns: 100px minmax(0, 1fr);
gap: var(--havline-space-4);
align-items: start;
}
.about-tech__group h3 {
margin: 0;
padding-top: 5px;
}
.about-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.about-tags span {
padding: 5px 10px;
color: var(--havline-text-secondary);
border: 1px solid var(--havline-border);
border-radius: 999px;
background: var(--havline-bg);
font-size: 12px;
}
.about-license {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--havline-space-4);
}
.about-license__item {
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
gap: var(--havline-space-3);
min-width: 0;
}
.about-license__item > .n-icon {
margin-top: 2px;
color: var(--havline-brand-text);
font-size: 19px;
}
@media (max-width: 1100px) {
.about-hero,
.about-features {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.about-hero__main {
grid-column: 1 / -1;
}
.about-hero__meta {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
.about-hero {
grid-template-columns: 1fr;
padding: var(--havline-space-5);
}
.about-hero__main {
display: grid;
}
.about-hero__logo {
width: 64px;
height: 64px;
}
.about-points,
.about-features,
.about-license {
grid-template-columns: 1fr;
}
.about-tech__group {
grid-template-columns: 1fr;
gap: var(--havline-space-2);
}
.about-meta-item {
border-right: 0;
}
.about-meta-item:nth-child(odd) {
border-right: 1px solid var(--havline-border);
}
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,713 @@
<template>
<PageHeader :title="`配置 · ${tunnel?.name || 'Cloudflare 隧道'}`" description="配置隧道、查看副本指标与已发布路由">
<template #actions>
<n-button @click="router.push('/cloudflare')">
<template #icon><n-icon :component="ArrowBackOutline" /></template>
返回列表
</n-button>
</template>
</PageHeader>
<LoadError v-if="loadError" :message="loadError" @retry="load" />
<template v-else-if="tunnel">
<div class="stats-row">
<StatCard label="活动副本" :value="status?.connections ?? 0" sub="cloudflared HA 连接" tone="blue">
<template #icon><n-icon :component="GitNetworkOutline" /></template>
</StatCard>
<StatCard label="路由" :value="routeData?.routes.length ?? 0" sub="已发布应用" tone="green">
<template #icon><n-icon :component="LayersOutline" /></template>
</StatCard>
<StatCard label="状态" :sub="status?.transport || tunnel.mode" tone="teal">
<template #icon><n-icon :component="PulseOutline" /></template>
<template #value><StatusBadge :kind="statusKind(runtimeState)" :text="statusText(runtimeState)" /></template>
</StatCard>
<StatCard label="运行时间" :value="status?.running ? '运行中' : '未运行'" :sub="status?.metrics_port ? `metrics :${status.metrics_port}` : '无 metrics 端口'" tone="brand" value-small>
<template #icon><n-icon :component="TimeOutline" /></template>
</StatCard>
</div>
<HavlineCard class="section-card" title="基础配置" subtitle="手工隧道可在这里修改;托管隧道由反向代理规则派生">
<div class="section-body">
<n-alert v-if="tunnel.managed" type="info" :bordered="false">
这是自动托管隧道。暴露域名与回源请到
<router-link to="/proxies">反向代理</router-link>
的 Cloudflare 出口中维护。
</n-alert>
<div v-else class="config-basic-action">
<n-button type="primary" @click="basicVisible = true">编辑基础配置</n-button>
</div>
</div>
</HavlineCard>
<HavlineCard class="section-card" title="路由" subtitle="流量通过以下路由到应用程序和服务">
<div class="section-body">
<div class="topology-canvas" :style="{ height: `${topologyHeight}px` }">
<div class="topology-graph">
<div
v-for="(route, index) in routeData?.routes || []"
:key="'domain-' + route.hostname"
class="topology-domain"
:style="{ top: `${routeY(index)}px` }"
>
<div class="topology-node-copy">
<span class="topology-domain__name mono">{{ route.hostname }}</span>
</div>
<n-tag size="small" type="info" :bordered="false" round>已发布的应用程序</n-tag>
</div>
<n-button
v-if="!tunnel.managed"
class="topology-add"
size="small"
:style="{ top: `${addRouteY}px` }"
@click="openAddRoute"
>
<template #icon><n-icon :component="AddOutline" /></template>
添加路由
</n-button>
<div class="topology-exit" :style="{ top: `${centerY - 22}px` }">
<n-icon :component="CloudOutline" />
<div class="topology-node-copy">
<strong>Cloudflare</strong>
</div>
</div>
<div class="topology-tunnel" :style="{ top: `${centerY - 22}px` }">
<n-icon :component="GitNetworkOutline" />
<div class="topology-node-copy">
<strong>{{ tunnel.name }}</strong>
</div>
</div>
<div
v-for="(service, index) in topologyServices"
:key="'service-' + service"
class="topology-service"
:style="{ top: `${serviceY(index)}px` }"
>
<n-icon :component="ServerOutline" />
<div class="topology-node-copy">
<span class="mono">{{ service }}</span>
</div>
</div>
<svg class="topology-lines" :viewBox="`0 0 ${TOPOLOGY_WIDTH} ${topologyHeight}`">
<template v-for="(route, index) in routeData?.routes || []" :key="'line-' + route.hostname">
<path :d="domainToExitPath(routeY(index) + 22)" />
<path :d="exitToTunnelPath" />
<path :d="tunnelToServicePath(serviceY(topologyServices.indexOf(route.service)) + 22)" />
</template>
<path v-if="!tunnel.managed" :d="domainToExitPath(addRouteY + 17)" />
</svg>
</div>
</div>
<div v-if="routeData?.routes.length" class="routes-table">
<div class="routes-table__head">
<span>顺序</span>
<span>目标</span>
<span>路径</span>
<span>服务</span>
<span>来源</span>
<span>操作</span>
</div>
<div v-for="(route, index) in routeData.routes" :key="route.hostname + route.service" class="routes-table__row">
<span>{{ index + 1 }}</span>
<span class="mono">{{ route.hostname }}</span>
<span class="mono">{{ route.path }}</span>
<span class="mono">{{ route.service }}</span>
<n-tag size="small" :bordered="false" round>{{ route.source === 'rule' ? '反向代理规则' : 'config.yml' }}</n-tag>
<div class="routes-table__actions">
<n-button size="tiny" quaternary :disabled="tunnel.managed || index === 0" @click="moveRoute(index, -1)">上移</n-button>
<n-button size="tiny" quaternary :disabled="tunnel.managed || index === (routeData?.routes.length ?? 1) - 1" @click="moveRoute(index, 1)">下移</n-button>
<n-button size="tiny" quaternary :disabled="tunnel.managed" @click="copyRoute(route)">复制</n-button>
<n-button size="tiny" quaternary @click="testRoute(route)">测试</n-button>
<n-button size="tiny" :disabled="tunnel.managed" @click="openEditRoute(route, index)">编辑</n-button>
<n-popconfirm :disabled="tunnel.managed" @positive-click="deleteRoute(index)">
<template #trigger>
<n-button size="tiny" type="error" quaternary :disabled="tunnel.managed">删除</n-button>
</template>
删除路由 {{ route.hostname }}?保存后会同步到 Cloudflare。
</n-popconfirm>
</div>
</div>
</div>
<EmptyState
v-else
title="还没有已发布路由"
description="手工隧道可在基础配置中添加域名和源服务,托管隧道请到反向代理规则维护。"
>
<template v-if="!tunnel.managed" #action>
<n-button type="primary" @click="openAddRoute">添加路由</n-button>
</template>
</EmptyState>
<div class="routes-catchall">
<span>全部捕获规则:</span>
<strong class="mono">{{ routeData?.catch_all || 'http_status:404' }}</strong>
</div>
</div>
</HavlineCard>
<HavlineCard class="section-card" title="运行控制" subtitle="进程操作会立即作用在本机 cloudflared">
<div class="section-body">
<n-space>
<n-button type="success" :disabled="runtimeState === 'connected'" @click="runAction('启动')">启动</n-button>
<n-button type="warning" :disabled="runtimeState !== 'connected'" @click="runAction('停止')">停止</n-button>
<n-button @click="runAction('重启')">重启</n-button>
</n-space>
</div>
</HavlineCard>
</template>
<n-modal v-model:show="basicVisible" preset="card" :style="{ width: 'min(720px, 96vw)' }" title="基础配置">
<n-form label-placement="top" class="config-form">
<n-form-item label="名称">
<n-input v-model:value="form.name" />
</n-form-item>
<div class="config-form__grid">
<n-form-item label="子域名(可选)">
<n-input v-model:value="form.subdomain" placeholder="例如 999" />
</n-form-item>
<n-form-item label="域">
<n-select
v-model:value="form.domain"
:options="zoneOptions"
filterable
tag
placeholder="从 Cloudflare Zone 选择"
/>
</n-form-item>
</div>
<div class="hostname-preview">
<span>完整主机名:</span>
<strong class="mono">{{ basicHostname() || '未选择域' }}</strong>
</div>
<n-form-item label="服务">
<div class="config-origin">
<n-select v-model:value="form.type" :options="originTypeOptions" class="config-origin__type" />
<span class="config-origin__scheme">://</span>
<n-input v-model:value="form.target" placeholder="192.168.11.30:8001" />
</div>
</n-form-item>
<div class="config-form__footer">
<n-switch v-model:value="form.autoStart" />
<span>随 Havline 自动启动</span>
</div>
</n-form>
<template #footer>
<div class="config-modal__footer">
<n-button @click="basicVisible = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="save">保存配置</n-button>
</div>
</template>
</n-modal>
<n-modal v-model:show="routeVisible" preset="card" :style="{ width: 'min(620px, 96vw)' }" :title="routeEditingIndex === null ? '添加路由' : '编辑路由'">
<n-form label-placement="top" class="config-form">
<div class="config-form__grid">
<n-form-item label="子域名(可选)">
<n-input v-model:value="routeForm.subdomain" placeholder="例如 999" />
</n-form-item>
<n-form-item label="域">
<n-select
v-model:value="routeForm.domain"
:options="zoneOptions"
filterable
tag
placeholder="从 Cloudflare Zone 选择"
/>
</n-form-item>
</div>
<div class="hostname-preview">
<span>完整主机名:</span>
<strong class="mono">{{ routeHostname() || '未选择域' }}</strong>
</div>
<n-form-item label="服务">
<div class="config-origin">
<n-select v-model:value="routeForm.type" :options="originTypeOptions" class="config-origin__type" />
<span class="config-origin__scheme">://</span>
<n-input v-model:value="routeForm.target" placeholder="192.168.11.30:8001" />
</div>
</n-form-item>
</n-form>
<template #footer>
<div class="config-modal__footer">
<n-button @click="routeVisible = false">取消</n-button>
<n-button type="primary" :loading="routeSaving" @click="saveRoute">保存路由</n-button>
</div>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NAlert, NButton, NForm, NFormItem, NIcon, NInput, NModal, NPopconfirm, NSelect, NSpace, NSwitch, NTag, useMessage } from 'naive-ui'
import { AddOutline, ArrowBackOutline, CloudOutline, GitNetworkOutline, LayersOutline, PulseOutline, ServerOutline, TimeOutline } from '@vicons/ionicons5'
import { api, asList } from '../api/client'
import type { CfRouteView, CfRuntimeStatus, CfTunnel, CfTunnelPayload, CfTunnelRoutes, CfZone } from '../api/types'
import HavlineCard from '../components/HavlineCard.vue'
import EmptyState from '../components/EmptyState.vue'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
import StatCard from '../components/StatCard.vue'
import StatusBadge from '../components/StatusBadge.vue'
import type { StatusKind } from '../utils/status'
const route = useRoute()
const router = useRouter()
const message = useMessage()
const tunnel = ref<CfTunnel | null>(null)
const status = ref<CfRuntimeStatus | null>(null)
const routeData = ref<CfTunnelRoutes | null>(null)
const zones = ref<CfZone[]>([])
const loading = ref(false)
const saving = ref(false)
const basicVisible = ref(false)
const routeVisible = ref(false)
const routeSaving = ref(false)
const routeEditingIndex = ref<number | null>(null)
const routeForm = ref({ subdomain: '', domain: '', type: 'http', target: '' })
const loadError = ref('')
const form = ref({ name: '', subdomain: '', domain: '', type: 'http', target: '', autoStart: false })
const TOPOLOGY_WIDTH = 880
const TOPOLOGY_ROW_HEIGHT = 54
const TOPOLOGY_START_Y = 38
const tunnelID = computed(() => Number(route.params.id))
const runtimeState = computed(() => status.value?.status || tunnel.value?.status || 'stopped')
const zoneOptions = computed(() => zones.value.map((zone) => ({ label: zone.name, value: zone.name })))
const originTypeOptions = [
{ label: 'HTTP', value: 'http' },
{ label: 'HTTPS', value: 'https' },
{ label: 'TCP', value: 'tcp' },
{ label: 'SSH', value: 'ssh' },
]
const topologyServices = computed(() => {
const values = new Set((routeData.value?.routes || []).map((route) => route.service))
return [...values]
})
const addRouteY = computed(() => TOPOLOGY_START_Y + (routeData.value?.routes.length ?? 0) * TOPOLOGY_ROW_HEIGHT)
const topologyHeight = computed(() => Math.max(240, Math.max(addRouteY.value + 70, topologyServices.value.length * TOPOLOGY_ROW_HEIGHT + TOPOLOGY_START_Y + 30)))
const centerY = computed(() => topologyHeight.value / 2)
function routeY(index: number) {
return TOPOLOGY_START_Y + index * TOPOLOGY_ROW_HEIGHT
}
function serviceY(index: number) {
const count = Math.max(1, topologyServices.value.length)
const total = (count - 1) * TOPOLOGY_ROW_HEIGHT
return topologyHeight.value / 2 - total / 2 + index * TOPOLOGY_ROW_HEIGHT - 22
}
function domainToExitPath(sourceY: number) {
return `M 280 ${sourceY} C 295 ${sourceY}, 305 ${centerY.value}, 320 ${centerY.value}`
}
function exitToTunnelPath() {
return `M 420 ${centerY.value} L 460 ${centerY.value}`
}
function tunnelToServicePath(targetY: number) {
return `M 570 ${centerY.value} C 585 ${centerY.value}, 598 ${targetY}, 610 ${targetY}`
}
async function load() {
if (!Number.isFinite(tunnelID.value) || tunnelID.value <= 0) {
loadError.value = '隧道 ID 无效'
return
}
loading.value = true
loadError.value = ''
try {
const [item, runtime, routes, zoneResult] = await Promise.all([
api.getCfTunnel(tunnelID.value),
api.getCfTunnelStatus(tunnelID.value),
api.getCfTunnelRoutes(tunnelID.value),
api.getCfZones().catch(() => ({ zones: [] as CfZone[] })),
])
tunnel.value = item
status.value = runtime
routeData.value = routes
zones.value = asList(zoneResult.zones)
loadForm(item)
} catch (error) {
loadError.value = error instanceof Error ? error.message : '加载隧道配置失败'
} finally {
loading.value = false
}
}
function loadForm(item: CfTunnel) {
const hostname = item.hostnames?.[0] || ''
const origin = item.origin_service || 'http://127.0.0.1:80'
const [type, target = ''] = origin.split('://')
const zone = zones.value
.filter((entry) => hostname === entry.name || hostname.endsWith('.' + entry.name))
.sort((a, b) => b.name.length - a.name.length)[0]
form.value = {
name: item.name,
subdomain: zone ? hostname.slice(0, -(zone.name.length + 1)) : '',
domain: zone?.name || hostname,
type: type || 'http',
target: target || '127.0.0.1:80',
autoStart: item.auto_start,
}
}
async function save() {
if (!tunnel.value || tunnel.value.managed) return
const hostname = manualHostname()
if (!hostname || !form.value.target.trim()) {
message.warning('请填写域名和源服务')
return
}
const payload: CfTunnelPayload = {
name: form.value.name.trim(),
mode: tunnel.value.mode,
token: '',
hostnames: [hostname],
origin_service: `${form.value.type}://${form.value.target.trim().replace(/^[a-z]+:\/\//i, '')}`,
network: tunnel.value.network,
auto_start: form.value.autoStart,
managed: false,
}
saving.value = true
try {
const result = await api.updateCfTunnel(tunnel.value.id, payload)
tunnel.value = result
if (result.dns_warning) message.warning(result.dns_warning)
else message.success('配置已保存')
basicVisible.value = false
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败')
} finally {
saving.value = false
}
}
function openAddRoute() {
routeEditingIndex.value = null
routeForm.value = { subdomain: '', domain: zones.value[0]?.name || '', type: 'http', target: '127.0.0.1:80' }
routeVisible.value = true
}
function openEditRoute(route: CfRouteView, index: number) {
const [type, target = ''] = route.service.split('://')
const zone = zones.value
.filter((entry) => route.hostname === entry.name || route.hostname.endsWith('.' + entry.name))
.sort((a, b) => b.name.length - a.name.length)[0]
routeEditingIndex.value = index
routeForm.value = {
subdomain: zone ? route.hostname.slice(0, -(zone.name.length + 1)) : '',
domain: zone?.name || route.hostname,
type: type || 'http',
target,
}
routeVisible.value = true
}
async function saveRoute() {
if (!tunnel.value || !routeData.value) return
const hostname = routeHostname()
const target = routeForm.value.target.trim().replace(/^[a-z]+:\/\//i, '')
if (!hostname || !target) {
message.warning('请填写目标域名和源服务')
return
}
const next = routeData.value.routes.map((route) => ({
hostname: route.hostname,
path: route.path || '*',
service: route.service,
}))
const item = { hostname, path: '*', service: `${routeForm.value.type}://${target}` }
if (routeEditingIndex.value === null) next.push(item)
else next[routeEditingIndex.value] = item
routeSaving.value = true
try {
routeData.value = await api.replaceCfTunnelRoutes(tunnel.value.id, next)
routeVisible.value = false
message.success('路由已保存')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存路由失败')
} finally {
routeSaving.value = false
}
}
async function deleteRoute(index: number) {
if (!tunnel.value || !routeData.value || tunnel.value.managed) return
const next = routeData.value.routes
.filter((_, routeIndex) => routeIndex !== index)
.map((route) => ({
hostname: route.hostname,
path: route.path || '*',
service: route.service,
}))
try {
routeData.value = await api.replaceCfTunnelRoutes(tunnel.value.id, next)
message.success('路由已删除')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '删除路由失败')
}
}
async function moveRoute(index: number, delta: number) {
if (!tunnel.value || !routeData.value || tunnel.value.managed) return
const target = index + delta
if (target < 0 || target >= routeData.value.routes.length) return
const routes = routeData.value.routes.map((route) => ({
hostname: route.hostname,
path: route.path || '*',
service: route.service,
}))
;[routes[index], routes[target]] = [routes[target], routes[index]]
try {
routeData.value = await api.replaceCfTunnelRoutes(tunnel.value.id, routes)
message.success('路由顺序已更新')
} catch (error) {
message.error(error instanceof Error ? error.message : '调整顺序失败')
}
}
function copyRoute(route: CfRouteView) {
openAddRoute()
const [type, target = ''] = route.service.split('://')
routeForm.value = {
subdomain: '',
domain: zones.value.find((zone) => route.hostname.endsWith('.' + zone.name))?.name || route.hostname,
type: type || 'http',
target,
}
}
async function testRoute(route: CfRouteView) {
try {
const result = await api.testCfRoute(route.service)
if (result.ok) message.success(`${route.hostname}:${result.message},${result.latency_ms} ms`)
else message.error(`${route.hostname}:${result.message}`)
} catch (error) {
message.error(error instanceof Error ? error.message : '测试失败')
}
}
function routeHostname() {
const subdomain = routeForm.value.subdomain.trim().replace(/^\.+|\.+$/g, '')
const domain = routeForm.value.domain.trim().replace(/^\.+|\.+$/g, '')
if (!domain) return ''
return (subdomain ? `${subdomain}.${domain}` : domain).toLowerCase()
}
function basicHostname() {
const subdomain = form.value.subdomain.trim().replace(/^\.+|\.+$/g, '')
const domain = form.value.domain.trim().replace(/^\.+|\.+$/g, '')
if (!domain) return ''
return (subdomain ? `${subdomain}.${domain}` : domain).toLowerCase()
}
function manualHostname() {
const subdomain = form.value.subdomain.trim().replace(/^\.+|\.+$/g, '')
const domain = form.value.domain.trim().replace(/^\.+|\.+$/g, '')
if (!domain) return ''
return subdomain ? `${subdomain}.${domain}` : domain
}
async function runAction(action: '启动' | '停止' | '重启') {
if (!tunnel.value) return
try {
if (action === '启动') await api.startCfTunnel(tunnel.value.id)
if (action === '停止') await api.stopCfTunnel(tunnel.value.id)
if (action === '重启') await api.restartCfTunnel(tunnel.value.id)
message.success(`${action}指令已下发`)
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : `${action}失败`)
}
}
function statusText(value: string) {
return ({
connected: '已连接',
starting: '启动中',
reconnecting: '重连中',
failed: '失败',
stopped: '已停止',
} as Record<string, string>)[value] || value || '未知'
}
function statusKind(value: string): StatusKind {
if (value === 'connected') return 'success'
if (value === 'starting' || value === 'reconnecting') return 'warning'
if (value === 'failed') return 'error'
return 'unknown'
}
onMounted(load)
</script>
<style scoped>
.stats-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--havline-space-4);
margin-bottom: var(--havline-space-4);
}
.section-card + .section-card { margin-top: var(--havline-space-4); }
.config-basic-action { display: flex; justify-content: flex-end; }
.topology-canvas {
position: relative;
width: 100%;
margin-bottom: var(--havline-space-4);
overflow-x: auto;
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background-color: var(--havline-bg);
background-image: radial-gradient(var(--havline-border) 0.8px, transparent 0.8px);
background-size: 10px 10px;
}
.topology-graph {
position: relative;
width: 880px;
height: 100%;
margin: 0 auto;
}
.topology-domain {
position: absolute;
left: 0;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
width: 280px;
height: 44px;
}
.topology-domain__name {
white-space: nowrap;
color: var(--havline-brand-text);
text-decoration: underline;
}
.topology-node-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.topology-lines {
position: absolute;
inset: 0;
z-index: 1;
width: 880px;
height: 100%;
pointer-events: none;
}
.topology-lines path {
fill: none;
stroke: var(--havline-border);
stroke-width: 1.5;
}
.topology-add {
position: absolute;
left: 0;
z-index: 2;
width: 150px;
}
.topology-exit,
.topology-tunnel {
position: absolute;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
border: 1px solid var(--havline-border);
border-radius: 999px;
background: var(--havline-surface);
box-shadow: var(--havline-shadow);
white-space: nowrap;
}
.topology-exit { left: 320px; min-width: 100px; }
.topology-tunnel { left: 460px; min-width: 110px; }
.topology-service {
position: absolute;
left: 610px;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
width: max-content;
min-width: 260px;
height: 44px;
padding: 7px 8px;
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-surface);
color: var(--havline-text-secondary);
font-size: 12px;
white-space: nowrap;
}
.topology-service .mono { white-space: nowrap; }
.topology-service :deep(.n-icon) { flex: none; color: var(--havline-brand); }
.config-form { max-width: 780px; }
.config-form__grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
.config-origin {
display: grid;
grid-template-columns: 128px auto minmax(0, 1fr);
align-items: center;
gap: 8px;
width: 100%;
}
.config-origin__type { width: 128px; }
.config-origin__scheme {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
color: var(--havline-text-muted);
white-space: nowrap;
}
.config-form__footer { display: flex; align-items: center; gap: 10px; font-size: 13px; }
.config-form__spacer { flex: 1; }
.config-modal__footer { display: flex; justify-content: flex-end; gap: 8px; }
.hostname-preview {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 var(--havline-space-4);
padding: 10px 12px;
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-bg);
}
.hostname-preview span { color: var(--havline-text-muted); font-size: 12px; }
.hostname-preview strong { color: var(--havline-text); }
.routes-table { overflow-x: auto; }
.routes-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--havline-space-3);
margin-bottom: var(--havline-space-3);
}
.routes-table__head,
.routes-table__row {
display: grid;
grid-template-columns: 64px minmax(180px, 1fr) 90px minmax(220px, 1.2fr) 130px 360px;
gap: 12px;
align-items: center;
min-width: 1080px;
padding: 10px 0;
}
.routes-table__actions { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.routes-table__head { color: var(--havline-text-muted); font-size: 12px; border-bottom: 1px solid var(--havline-border); }
.routes-table__row { border-bottom: 1px solid var(--havline-border); }
.routes-table__row:last-child { border-bottom: none; }
.routes-empty { padding: 20px 0; color: var(--havline-text-muted); }
.routes-catchall { margin-top: 12px; padding: 10px 12px; background: var(--havline-bg); border-radius: var(--havline-radius-sm); font-size: 13px; }
@media (max-width: 900px) {
.stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.config-form__grid { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
.stats-row { grid-template-columns: 1fr; }
}
</style>
+965
View File
@@ -0,0 +1,965 @@
<template>
<PageHeader title="Cloudflare 隧道" description="通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘,无需公网 IP">
<template #actions>
<n-button :loading="preflightLoading" @click="openPreflight">接入向导</n-button>
<n-button :loading="settingsLoading" @click="openSettings">应用配置</n-button>
<n-button type="primary" @click="openCreate">新建隧道</n-button>
</template>
</PageHeader>
<div class="cf-global" :class="{ 'cf-global--off': !appSettings.enabled }">
<div class="cf-global__text">
<strong>全局托管</strong>
<span>{{ appSettings.enabled ? '已启用:开启时和下次启动 Havline 时都会启动全部隧道' : '已停用:所有隧道已停止,应用重启后也不会自动拉起' }}</span>
</div>
<n-switch :value="appSettings.enabled" :loading="globalLoading" @update:value="toggleGlobal" />
</div>
<n-alert v-if="binaryVersion" type="info" :bordered="false" class="cf-alert">
已安装:<span class="mono">{{ displayBinaryVersion(binaryVersion) }}</span>
</n-alert>
<n-alert v-else type="warning" :bordered="false" class="cf-alert">
尚未安装 cloudflared。请先点右上角「cloudflared 二进制」下载,再创建隧道。
</n-alert>
<n-alert type="info" :bordered="false" class="cf-alert">
Cloudflare Tunnel 适合 NAS 面板、相册和小型 Web 服务。免费套餐不适合持续大文件或视频分发,请不要把它当作大流量媒体出口。
</n-alert>
<HavlineCard title="隧道列表" subtitle="托管隧道从「反向代理」规则的 Cloudflare 出口自动派生;手工隧道仍在 Cloudflare 面板管理">
<div v-if="!tunnels.length" class="cf-empty">还没有隧道,点「新建隧道」开始。</div>
<div v-else class="cf-table">
<div class="cf-table__head">
<span>隧道</span>
<span>状态</span>
<span>副本</span>
<span>版本</span>
<span>路由</span>
<span>调和</span>
<span>操作</span>
</div>
<div v-for="item in tunnels" :key="item.id" class="cf-table__row">
<div class="cf-table__tunnel">
<strong>{{ item.name }}</strong>
<div class="cf-table__meta">
<n-tag v-if="item.managed" size="small" type="info" :bordered="false" round>自动托管</n-tag>
<span class="cf-meta mono">{{ modeText(item.mode) }}</span>
<span class="cf-meta mono">{{ item.tunnel_id || '—' }}</span>
<span v-if="item.metrics_port" class="cf-meta mono">metrics :{{ item.metrics_port }}</span>
</div>
</div>
<div>
<StatusBadge
:kind="statusKind(runtimeStatus(item).status || item.status)"
:text="statusText(runtimeStatus(item).status || item.status)"
/>
</div>
<div class="cf-table__number">{{ replicaCount(item) }}</div>
<div class="cf-meta mono">{{ displayBinaryVersion(binaryVersion) || '—' }}</div>
<div><n-tag size="small" :bordered="false" round>{{ routeCount(item) }} 条</n-tag></div>
<div>
<n-tag size="small" :type="driftTagType(item)" :bordered="false" round>{{ driftText(item) }}</n-tag>
</div>
<div class="cf-row__actions">
<n-button size="small" @click="openDetail(item)">详情</n-button>
<n-button size="small" @click="openConfig(item)">配置</n-button>
<n-button v-if="runtimeStatus(item).status !== 'connected' && runtimeStatus(item).status !== 'starting'" size="small" type="success" :disabled="!appSettings.enabled" @click="startTunnel(item)">启动</n-button>
<n-button v-else size="small" type="warning" @click="stopTunnel(item)">停止</n-button>
<n-button size="small" @click="restartTunnel(item)">重启</n-button>
<n-button v-if="driftByTunnel[item.id]?.has_drift" size="small" type="warning" @click="repairTunnel(item)">修复</n-button>
<n-popconfirm @positive-click="removeTunnel(item)">
<template #trigger><n-button size="small" type="error" quaternary>删除</n-button></template>
将停止该隧道并删除本地配置、凭据与日志。继续?
</n-popconfirm>
</div>
</div>
</div>
</HavlineCard>
<n-modal v-model:show="editorVisible" preset="card" :style="{ width: 'min(720px, 96vw)' }" title="新建隧道">
<div class="cf-modal__body">
<n-alert type="info" :bordered="false" class="cf-mode-alert">
隧道由 Cloudflare API 创建;Havline 负责本地配置、进程、日志与健康。
</n-alert>
<n-alert v-if="!appSettings.account_id || !appSettings.api_token_configured" type="warning" :bordered="false" class="cf-mode-alert">
请先在「应用配置」中填写 Account ID 与 API Token,再创建隧道。
</n-alert>
<n-divider title-placement="left">基本信息</n-divider>
<n-form label-placement="top">
<n-form-item label="名称"><n-input v-model:value="form.name" placeholder="例如 home-nas" /></n-form-item>
<n-form-item label="自动托管">
<div class="cf-managed-field">
<n-switch v-model:value="form.managed" />
<span>启用后,暴露域名与回源由「反向代理」规则中的 Cloudflare 出口自动生成。</span>
</div>
</n-form-item>
</n-form>
<template v-if="!form.managed">
<n-divider title-placement="left">暴露规则</n-divider>
<n-form label-placement="top">
<div class="cf-rule-row">
<n-form-item label="子域名(可选)" class="cf-rule-row__subdomain">
<n-input v-model:value="manualRule.subdomain" placeholder="例如 999" />
</n-form-item>
<n-form-item label="域" class="cf-rule-row__domain">
<n-select
v-model:value="manualRule.domain"
:options="domainOptions"
filterable
tag
placeholder="从 Cloudflare Zone 选择"
/>
</n-form-item>
</div>
<div class="cf-hostname-preview">
<span>完整主机名:</span>
<strong class="mono">{{ manualHostname() || '未选择域' }}</strong>
</div>
<n-form-item label="服务">
<div class="cf-origin-row">
<n-select v-model:value="manualRule.type" :options="originTypeOptions" class="cf-origin-row__type" />
<span class="cf-origin-row__scheme">://</span>
<n-input v-model:value="manualRule.target" placeholder="192.168.11.30:8001" />
</div>
<p class="cf-hint">路由流量的源服务,例如:192.168.11.30:8001、localhost:8080、192.168.1.2:3306</p>
</n-form-item>
</n-form>
</template>
<n-alert v-else type="info" :bordered="false" class="cf-mode-alert">
保存托管隧道后,请到「反向代理」编辑服务规则,在出口中选择 Cloudflare 并指定该隧道。
</n-alert>
<n-divider title-placement="left">运行</n-divider>
<n-form label-placement="top">
<n-form-item label="随 Havline 自动启动"><n-switch v-model:value="form.auto_start" /></n-form-item>
</n-form>
<p class="cf-hint">网络调优统一在「应用配置」中管理,保存后会应用到新建隧道。</p>
</div>
<template #footer>
<div class="cf-modal__footer">
<n-button @click="editorVisible = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="saveTunnel">保存</n-button>
</div>
</template>
</n-modal>
<n-modal v-model:show="detailVisible" preset="card" :style="{ width: 'min(760px, 96vw)' }" :title="detailTunnel?.name || '隧道详情'">
<div v-if="detailTunnel" class="cf-detail">
<div class="cf-detail__stats">
<div class="cf-stat"><label>状态</label><StatusBadge :kind="statusKind(detailStatus?.status || detailTunnel.status)" :text="statusText(detailStatus?.status || detailTunnel.status)" /></div>
<div class="cf-stat"><label>连接数</label><strong>{{ detailStatus?.connections ?? 0 }}</strong></div>
<div class="cf-stat"><label>延迟</label><strong>{{ detailStatus?.transport === 'http2' ? '—' : `${(detailStatus?.latency_ms ?? 0).toFixed(0)} ms` }}</strong></div>
<div class="cf-stat"><label>传输协议</label><strong>{{ detailStatus?.transport || '—' }}</strong></div>
<div class="cf-stat"><label>metrics 端口</label><strong>{{ detailStatus?.metrics_port || '—' }}</strong></div>
</div>
<p v-if="detailStatus?.bandwidth_notice" class="cf-hint">{{ detailStatus.bandwidth_notice }}</p>
<n-tabs type="line" animated>
<n-tab-pane name="config" tab="config.yml">
<div class="cf-code-toolbar"><n-button size="tiny" @click="copyConfig">复制</n-button></div>
<pre class="cf-pre">{{ detailConfig || '暂无配置' }}</pre>
</n-tab-pane>
<n-tab-pane name="logs" tab="日志">
<div class="cf-code-toolbar"><n-button size="tiny" :loading="detailLoading" @click="refreshDetail">刷新</n-button></div>
<pre class="cf-pre">{{ detailLogs || '暂无日志' }}</pre>
</n-tab-pane>
<n-tab-pane name="diagnostics" tab="漂移与诊断">
<div class="cf-code-toolbar">
<n-button size="tiny" :loading="driftLoading" @click="loadDrift">检测漂移</n-button>
<n-button size="tiny" :disabled="!detailDrift?.has_drift" :loading="driftSyncing" @click="syncDrift">以本地覆盖</n-button>
<n-button size="tiny" :loading="diagnosticsLoading" @click="loadDiagnostics">运行诊断</n-button>
<n-button size="tiny" :loading="accessLoading" @click="loadAccessStatus">检查 Access</n-button>
<n-button size="tiny" :loading="credentialLoading" @click="refreshCredentials">刷新凭据</n-button>
</div>
<n-alert v-if="detailDrift" :type="detailDrift.has_drift ? 'warning' : 'success'" :bordered="false" class="cf-mode-alert">
<template v-if="detailDrift.has_drift">
发现 {{ detailDrift.items.length }} 项云端漂移:
<div v-for="item in detailDrift.items" :key="item" class="mono">{{ item }}</div>
</template>
<template v-else>本地规则与 Cloudflare 云端配置一致。</template>
</n-alert>
<div v-if="detailDiagnostics" class="cf-diagnostics">
<div v-for="check in detailDiagnostics.checks" :key="check.name" class="cf-diagnostic-row">
<n-tag size="small" :type="diagnosticTag(check.status)" :bordered="false">{{ check.name }}</n-tag>
<span>{{ check.message }}</span>
</div>
</div>
<n-alert v-if="detailAccess" type="info" :bordered="false" class="cf-mode-alert">
{{ detailAccess.enabled ? `已启用 Cloudflare Access${detailAccess.name ? ':' + detailAccess.name : ''}` : '未检测到 Cloudflare Access 保护。Havline 只做只读检测,不代配策略。' }}
</n-alert>
</n-tab-pane>
</n-tabs>
</div>
<template #footer>
<div class="cf-modal__footer">
<n-button @click="refreshDetail">刷新全部</n-button>
<n-button type="primary" @click="detailVisible = false">关闭</n-button>
</div>
</template>
</n-modal>
<n-modal v-model:show="settingsVisible" preset="card" :style="{ width: 'min(640px, 96vw)' }" title="Cloudflare 应用配置">
<div class="cf-modal__body">
<n-divider title-placement="left">程序安装</n-divider>
<div class="cf-binary">
<div><label>当前版本</label><strong class="mono">{{ displayBinaryVersion(binaryVersion) || '未安装' }}</strong></div>
<div><label>检测到的最新版本</label><strong class="mono">{{ latestBinaryVersion || '未检查' }}</strong></div>
<div><label>目标文件</label><strong class="mono">cloudflared</strong></div>
</div>
<n-form label-placement="top">
<n-form-item label="下载镜像">
<n-select v-model:value="settingsForm.mirror" :options="mirrorOptions" />
</n-form-item>
</n-form>
<div class="cf-code-toolbar">
<n-button size="small" :loading="binaryChecking" @click="checkLatestBinary">检查更新</n-button>
<n-button size="small" :loading="binaryLoading" @click="downloadBinary">下载并安装</n-button>
<n-button size="small" :loading="binaryRollbackLoading" @click="rollbackBinary">回滚上一版</n-button>
</div>
<p class="cf-hint">下载后会校验文件大小与可执行性,并原子替换现有二进制。国内环境推荐 gh-proxy 镜像。</p>
<n-divider title-placement="left">Cloudflare 账号</n-divider>
<n-form label-placement="top">
<n-form-item label="Account ID">
<n-input v-model:value="settingsForm.account_id" :placeholder="appSettings.account_id || '例如 b590ee0f16701d36f2819b0e3c938e81'" />
</n-form-item>
<n-form-item label="API Token">
<n-input
v-model:value="settingsForm.api_token"
type="password"
show-password-on="click"
:placeholder="appSettings.api_token_configured ? `已配置 ${appSettings.api_token_masked},留空保留` : 'cfat_...'"
/>
</n-form-item>
</n-form>
<div class="cf-code-toolbar">
<n-button size="small" :loading="tokenTesting" @click="testToken">测试连接</n-button>
<n-button size="small" tag="a" href="https://dash.cloudflare.com/profile/api-tokens" target="_blank" rel="noopener">获取 API Token</n-button>
</div>
<n-alert v-if="tokenTestResult" :type="tokenTestOk ? 'success' : 'error'" :bordered="false" class="cf-mode-alert">
{{ tokenTestResult }}
</n-alert>
<p class="cf-hint">Havline 使用 Account ID + API Token 调用 Cloudflare API 创建和管理隧道与 DNS。API Token 需要 Account: Cloudflare Tunnel: Edit、Zone: Zone: Read、Zone: DNS: Edit 权限,且 Zone Resources 要包含所有要发布的根域名。</p>
<n-divider title-placement="left">默认网络设置</n-divider>
<n-form label-placement="top">
<n-form-item label="传输协议"><n-select v-model:value="settingsForm.network.transport_protocol" :options="protocolOptions" /></n-form-item>
<n-form-item label="边缘地址族"><n-select v-model:value="settingsForm.network.edge_ip_version" :options="edgeOptions" /></n-form-item>
<n-form-item label="HA 连接数"><n-input-number v-model:value="settingsForm.network.ha_connections" :min="1" :max="4" /></n-form-item>
<n-form-item label="代理模式"><n-select v-model:value="settingsForm.network.proxy_mode" :options="proxyOptions" /></n-form-item>
</n-form>
</div>
<template #footer>
<div class="cf-modal__footer">
<n-button @click="settingsVisible = false">取消</n-button>
<n-button type="primary" :loading="settingsSaving" @click="saveSettings">保存配置</n-button>
</div>
</template>
</n-modal>
<n-modal v-model:show="preflightVisible" preset="card" :style="{ width: 'min(620px, 96vw)' }" title="Cloudflare 接入向导">
<div class="cf-preflight">
<div v-for="check in preflight?.checks || []" :key="check.name" class="cf-preflight__row">
<n-tag size="small" :type="check.status === 'ok' ? 'success' : 'error'" :bordered="false">{{ check.name }}</n-tag>
<span>{{ check.message }}</span>
<n-button v-if="check.action === 'settings' && check.status !== 'ok'" size="tiny" @click="openSettings">去配置</n-button>
</div>
<n-alert v-if="preflight" :type="preflight.ready ? 'success' : 'warning'" :bordered="false" class="cf-mode-alert">
{{ preflight.ready ? '预检通过,可以创建隧道或发布服务。' : '还有项目未完成,请按上面的提示处理。' }}
</n-alert>
</div>
<template #footer>
<div class="cf-modal__footer">
<n-button :loading="preflightLoading" @click="loadPreflight">重新检查</n-button>
<n-button type="primary" @click="preflightVisible = false">关闭</n-button>
</div>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NAlert, NButton, NDivider, NForm, NFormItem, NInput, NInputNumber, NModal, NPopconfirm, NSelect, NSwitch, NTabPane, NTabs, useMessage } from 'naive-ui'
import { api, asList } from '../api/client'
import type { CfAccessStatus, CfAppSettings, CfAppSettingsInput, CfDiagnosticsResult, CfDriftResult, CfMirror, CfPreflightResult, CfRuntimeStatus, CfTunnel, CfTunnelPayload, CfZone } from '../api/types'
import HavlineCard from '../components/HavlineCard.vue'
import PageHeader from '../components/PageHeader.vue'
import StatusBadge from '../components/StatusBadge.vue'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import { copyText } from '../utils/clipboard'
import type { StatusKind } from '../utils/status'
const message = useMessage()
const router = useRouter()
const tunnels = ref<CfTunnel[]>([])
const runtimeStatuses = ref<Record<number, CfRuntimeStatus>>({})
const driftByTunnel = ref<Record<number, CfDriftResult>>({})
const binaryVersion = ref('')
const latestBinaryVersion = ref('')
const mirrors = ref<CfMirror[]>([])
const zones = ref<CfZone[]>([])
const loading = ref(false)
const saving = ref(false)
const binaryLoading = ref(false)
const binaryChecking = ref(false)
const binaryRollbackLoading = ref(false)
const globalLoading = ref(false)
const settingsVisible = ref(false)
const settingsLoading = ref(false)
const settingsSaving = ref(false)
const tokenTesting = ref(false)
const tokenTestResult = ref('')
const tokenTestOk = ref(false)
const appSettings = ref<CfAppSettings>({
account_id: '',
enabled: true,
default_token_configured: false,
default_token_masked: '',
api_token_configured: false,
api_token_masked: '',
mirror: 'official',
network: emptyNetwork(),
})
const settingsForm = ref<CfAppSettingsInput>({ account_id: '', token: '', api_token: '', mirror: 'official', network: emptyNetwork() })
const editorVisible = ref(false)
const detailVisible = ref(false)
const detailTunnel = ref<CfTunnel | null>(null)
const detailStatus = ref<CfRuntimeStatus | null>(null)
const detailConfig = ref('')
const detailLogs = ref('')
const detailLoading = ref(false)
const detailDrift = ref<CfDriftResult | null>(null)
const detailDiagnostics = ref<CfDiagnosticsResult | null>(null)
const driftLoading = ref(false)
const driftSyncing = ref(false)
const diagnosticsLoading = ref(false)
const detailAccess = ref<CfAccessStatus | null>(null)
const accessLoading = ref(false)
const credentialLoading = ref(false)
const preflightVisible = ref(false)
const preflightLoading = ref(false)
const preflight = ref<CfPreflightResult | null>(null)
const form = ref<CfTunnelPayload>(emptyForm())
const manualRule = ref({ subdomain: '', domain: '', type: 'http', target: '127.0.0.1:80' })
const protocolOptions = [
{ label: 'HTTP/2(国内推荐)', value: 'http2' },
{ label: 'QUIC', value: 'quic' },
{ label: '自动', value: 'auto' },
]
const edgeOptions = [
{ label: 'IPv4(推荐)', value: '4' },
{ label: 'IPv6', value: '6' },
{ label: '自动', value: 'auto' },
]
const proxyOptions = [
{ label: '跟随系统环境变量', value: 'system' },
{ label: '禁用代理', value: 'disabled' },
]
const originTypeOptions = [
{ label: 'HTTP', value: 'http' },
{ label: 'HTTPS', value: 'https' },
{ label: 'TCP', value: 'tcp' },
{ label: 'SSH', value: 'ssh' },
]
const domainOptions = computed(() => zones.value.map((zone) => ({ label: zone.name, value: zone.name })))
const mirrorOptions = computed(() => {
const list = mirrors.value.length ? mirrors.value : [{ id: 'official', name: 'GitHub 官方源', base: '' }]
return list.map((item) => ({ label: item.name, value: item.id }))
})
function emptyForm(): CfTunnelPayload {
return {
name: '',
mode: 'account-local',
token: '',
hostnames: [],
origin_service: 'http://127.0.0.1:80',
network: emptyNetwork(),
auto_start: false,
managed: true,
}
}
function emptyNetwork() {
return { transport_protocol: 'http2', edge_ip_version: '4', ha_connections: 2, proxy_mode: 'system' }
}
async function load() {
loading.value = true
try {
tunnels.value = asList(await api.listCfTunnels())
const pairs = await Promise.all(
tunnels.value.map(async (tunnel) => {
try {
return [tunnel.id, await api.getCfTunnelStatus(tunnel.id)] as const
} catch {
return [tunnel.id, { running: false, status: tunnel.status, connections: 0, latency_ms: 0, bandwidth_in: 0, bandwidth_out: 0, transport: '' }] as const
}
}),
)
runtimeStatuses.value = Object.fromEntries(pairs)
const driftPairs = await Promise.all(
tunnels.value.filter((tunnel) => tunnel.managed).map(async (tunnel) => {
try {
return [tunnel.id, await api.getCfTunnelDrift(tunnel.id)] as const
} catch {
return [tunnel.id, { tunnel_id: tunnel.id, managed: true, has_drift: false, items: [] }] as const
}
}),
)
driftByTunnel.value = Object.fromEntries(driftPairs)
} catch (error) {
message.error(error instanceof Error ? error.message : '读取隧道列表失败')
} finally {
loading.value = false
}
}
function runtimeStatus(item: CfTunnel): CfRuntimeStatus {
return runtimeStatuses.value[item.id] ?? {
running: false,
status: item.status,
connections: 0,
latency_ms: 0,
bandwidth_in: 0,
bandwidth_out: 0,
transport: '',
}
}
function replicaCount(item: CfTunnel): number {
return runtimeStatus(item).connections || 0
}
function routeCount(item: CfTunnel): number {
return item.hostnames?.length || 0
}
function driftText(item: CfTunnel): string {
if (!item.managed) return '手工'
const drift = driftByTunnel.value[item.id]
if (!drift) return '未知'
return drift.has_drift ? '有漂移' : '已同步'
}
function driftTagType(item: CfTunnel): 'success' | 'warning' | 'default' {
if (!item.managed) return 'default'
return driftByTunnel.value[item.id]?.has_drift ? 'warning' : 'success'
}
async function repairTunnel(item: CfTunnel) {
try {
await api.syncCfTunnelRoutes(item.id)
message.success('已按本地规则修复云端配置')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '修复失败')
}
}
async function toggleGlobal(value: boolean) {
globalLoading.value = true
try {
const result = await api.setCfEnabled(value)
appSettings.value = result
message.success(value ? '全局托管已启用' : '全局托管已停用,所有隧道已停止')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '切换全局托管失败')
} finally {
globalLoading.value = false
}
}
async function openPreflight() {
preflightVisible.value = true
await loadPreflight()
}
async function loadPreflight() {
preflightLoading.value = true
try {
preflight.value = await api.getCfPreflight()
} catch (error) {
message.error(error instanceof Error ? error.message : '预检失败')
} finally {
preflightLoading.value = false
}
}
async function loadBinary() {
try {
const result = await api.getCfBinary()
binaryVersion.value = result.version || ''
mirrors.value = asList(result.mirrors)
} catch {
// 二进制状态不影响列表
}
}
async function loadZones() {
try {
const result = await api.getCfZones()
zones.value = asList(result.zones)
} catch {
zones.value = []
}
}
function resetManualRule() {
manualRule.value = { subdomain: '', domain: zones.value[0]?.name || '', type: 'http', target: '127.0.0.1:80' }
}
function manualHostname() {
const subdomain = manualRule.value.subdomain.trim().replace(/^\.+|\.+$/g, '')
const domain = manualRule.value.domain.trim().replace(/^\.+|\.+$/g, '')
if (!domain) return ''
return subdomain ? `${subdomain}.${domain}` : domain
}
function manualOriginService() {
const type = manualRule.value.type || 'http'
const target = manualRule.value.target.trim().replace(/^[a-z]+:\/\//i, '')
return `${type}://${target}`
}
async function openCreate() {
if (!zones.value.length) await loadZones()
form.value = emptyForm()
resetManualRule()
editorVisible.value = true
}
function openConfig(item: CfTunnel) {
void router.push(`/cloudflare/${item.id}/config`)
}
async function saveTunnel() {
if (!form.value.name.trim()) {
message.warning('请填写隧道名称')
return
}
if (!appSettings.value.account_id || !appSettings.value.api_token_configured) {
message.warning('请先在「应用配置」中填写 Account ID 与 API Token')
return
}
if (!form.value.managed) {
const hostname = manualHostname()
if (!hostname) {
message.warning('请选择或填写域')
return
}
if (!manualRule.value.target.trim()) {
message.warning('请填写源服务 URL')
return
}
form.value.hostnames = [hostname]
form.value.origin_service = manualOriginService()
}
const payload: CfTunnelPayload = {
...form.value,
mode: 'account-local',
token: '',
network: { ...appSettings.value.network },
}
saving.value = true
try {
const result = await api.createCfTunnel(payload)
editorVisible.value = false
if (result.dns_warning) {
message.warning(result.dns_warning)
} else {
message.success('隧道已保存')
}
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存隧道失败')
} finally {
saving.value = false
}
}
async function startTunnel(item: CfTunnel) {
await runAction('启动', () => api.startCfTunnel(item.id))
}
async function stopTunnel(item: CfTunnel) {
await runAction('停止', () => api.stopCfTunnel(item.id))
}
async function restartTunnel(item: CfTunnel) {
await runAction('重启', () => api.restartCfTunnel(item.id))
}
async function removeTunnel(item: CfTunnel) {
try {
await api.deleteCfTunnel(item.id)
message.success('隧道已删除')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '删除隧道失败')
}
}
async function runAction(label: string, fn: () => Promise<unknown>) {
try {
await fn()
message.success(`${label}指令已下发`)
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : `${label}失败`)
}
}
async function openDetail(item: CfTunnel) {
detailTunnel.value = item
detailDrift.value = null
detailDiagnostics.value = null
detailAccess.value = null
detailVisible.value = true
await refreshDetail()
}
async function refreshDetail() {
const item = detailTunnel.value
if (!item) return
detailLoading.value = true
try {
const [status, config, logs] = await Promise.all([
api.getCfTunnelStatus(item.id),
api.getCfTunnelConfig(item.id),
api.getCfTunnelLogs(item.id, 200),
])
detailStatus.value = status
detailConfig.value = config.content || ''
detailLogs.value = logs.output || ''
} catch (error) {
message.error(error instanceof Error ? error.message : '读取隧道详情失败')
} finally {
detailLoading.value = false
}
}
async function loadDrift() {
const item = detailTunnel.value
if (!item) return
driftLoading.value = true
try {
detailDrift.value = await api.getCfTunnelDrift(item.id)
} catch (error) {
message.error(error instanceof Error ? error.message : '漂移检测失败')
} finally {
driftLoading.value = false
}
}
async function syncDrift() {
const item = detailTunnel.value
if (!item) return
driftSyncing.value = true
try {
await api.syncCfTunnelRoutes(item.id)
message.success('已按本地规则覆盖云端配置')
await Promise.all([loadDrift(), refreshDetail(), load()])
} catch (error) {
message.error(error instanceof Error ? error.message : '同步失败')
} finally {
driftSyncing.value = false
}
}
async function loadDiagnostics() {
const item = detailTunnel.value
if (!item) return
diagnosticsLoading.value = true
try {
detailDiagnostics.value = await api.getCfTunnelDiagnostics(item.id)
} catch (error) {
message.error(error instanceof Error ? error.message : '诊断失败')
} finally {
diagnosticsLoading.value = false
}
}
async function loadAccessStatus() {
const item = detailTunnel.value
const hostname = item?.hostnames?.[0]
if (!hostname) {
message.warning('该隧道暂无暴露域名')
return
}
accessLoading.value = true
try {
detailAccess.value = await api.getCfAccessStatus(hostname)
} catch (error) {
message.error(error instanceof Error ? error.message : 'Access 检测失败,请确认 Token 具备 Access: Apps: Read 权限')
} finally {
accessLoading.value = false
}
}
async function refreshCredentials() {
const item = detailTunnel.value
if (!item) return
credentialLoading.value = true
try {
detailTunnel.value = await api.refreshCfCredentials(item.id)
message.success('隧道凭据已刷新')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '刷新凭据失败')
} finally {
credentialLoading.value = false
}
}
function diagnosticTag(status: string) {
if (status === 'ok') return 'success'
if (status === 'warning') return 'warning'
return 'error'
}
async function copyConfig() {
if (!detailConfig.value) return
try {
await copyText(detailConfig.value)
message.success('已复制 config.yml')
} catch {
message.error('复制失败')
}
}
async function openSettings() {
preflightVisible.value = false
settingsVisible.value = true
await loadAppSettings()
}
async function loadAppSettings() {
settingsLoading.value = true
tokenTestResult.value = ''
try {
const result = await api.getCfSettings()
appSettings.value = result
settingsForm.value = { account_id: result.account_id || '', token: '', api_token: '', mirror: result.mirror || 'official', network: { ...result.network } }
} catch (error) {
message.error(error instanceof Error ? error.message : '读取应用配置失败')
} finally {
settingsLoading.value = false
}
}
async function testToken() {
tokenTesting.value = true
tokenTestResult.value = ''
try {
const result = await api.testCfToken(
(settingsForm.value.account_id || '').trim(),
(settingsForm.value.api_token || '').trim(),
'api',
)
tokenTestOk.value = true
tokenTestResult.value = result.message || '连接成功'
} catch (error) {
tokenTestOk.value = false
tokenTestResult.value = error instanceof Error ? error.message : '连接失败'
} finally {
tokenTesting.value = false
}
}
async function saveSettings() {
settingsSaving.value = true
try {
const result = await api.saveCfSettings(settingsForm.value)
appSettings.value = result
settingsForm.value = { account_id: result.account_id || '', token: '', api_token: '', mirror: result.mirror, network: { ...result.network } }
await loadZones()
message.success('应用配置已保存')
} catch (error) {
message.error(error instanceof Error ? error.message : '保存应用配置失败')
} finally {
settingsSaving.value = false
}
}
async function downloadBinary() {
binaryLoading.value = true
try {
const result = await api.downloadCfBinary(settingsForm.value.mirror || 'official', latestBinaryVersion.value)
binaryVersion.value = result.version || ''
message.success('cloudflared 已下载并安装')
} catch (error) {
message.error(error instanceof Error ? error.message : '下载 cloudflared 失败')
} finally {
binaryLoading.value = false
}
}
async function checkLatestBinary() {
binaryChecking.value = true
try {
const result = await api.getLatestCfBinary()
latestBinaryVersion.value = result.version || ''
if (binaryVersion.value.includes(latestBinaryVersion.value)) {
message.success('当前已是最新版本')
} else {
message.info(`发现新版本 ${latestBinaryVersion.value}`)
}
} catch (error) {
message.error(error instanceof Error ? error.message : '检查更新失败')
} finally {
binaryChecking.value = false
}
}
async function rollbackBinary() {
binaryRollbackLoading.value = true
try {
const result = await api.rollbackCfBinary()
binaryVersion.value = result.version || ''
latestBinaryVersion.value = ''
message.success('已回滚 cloudflared')
} catch (error) {
message.error(error instanceof Error ? error.message : '回滚失败')
} finally {
binaryRollbackLoading.value = false
}
}
function displayBinaryVersion(version: string) {
return version.match(/\d{4}\.\d+\.\d+/)?.[0] || version
}
function modeText(mode: string) {
if (mode === 'account-local') return 'API 管理'
if (mode === 'token-remote') return '云端规则'
if (mode === 'token-local') return '本地规则'
return mode
}
function statusText(status: string) {
return ({
connected: '已连接',
starting: '启动中',
reconnecting: '重连中',
failed: '失败',
stopped: '已停止',
} as Record<string, string>)[status] || status || '未知'
}
function statusKind(status: string): StatusKind {
if (status === 'connected') return 'success'
if (status === 'starting' || status === 'reconnecting') return 'warning'
if (status === 'failed') return 'error'
return 'unknown'
}
onMounted(() => {
void load()
void loadBinary()
void loadAppSettings()
void loadZones()
})
useVisibilityPolling(() => load(), 5000)
useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
</script>
<style scoped>
.cf-global {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--havline-space-4);
padding: 12px var(--havline-space-5);
margin-bottom: var(--havline-space-4);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-surface);
}
.cf-global--off { border-color: var(--havline-warning); background: var(--havline-bg); }
.cf-global__text { display: grid; gap: 4px; font-size: 13px; }
.cf-global__text span { color: var(--havline-text-secondary); }
.cf-alert { margin-bottom: var(--havline-space-4); }
.cf-empty { padding: 24px 0; color: var(--havline-text-muted); }
.cf-table { width: 100%; overflow-x: auto; }
.cf-table__head,
.cf-table__row {
display: grid;
grid-template-columns: minmax(220px, 1.6fr) 110px 70px minmax(120px, 0.8fr) 80px 90px minmax(340px, 1.8fr);
gap: 12px;
align-items: center;
min-width: 980px;
padding: 10px 0;
}
.cf-table__head {
color: var(--havline-text-muted);
font-size: 12px;
border-bottom: 1px solid var(--havline-border);
}
.cf-table__row { border-bottom: 1px solid var(--havline-border); }
.cf-table__row:last-child { border-bottom: none; }
.cf-table__tunnel { min-width: 0; }
.cf-table__meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
.cf-table__number { font-size: 14px; font-weight: 600; }
.cf-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--havline-space-4);
padding: 12px 0;
border-bottom: 1px solid var(--havline-border);
}
.cf-row:last-child { border-bottom: none; }
.cf-row__main { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.cf-row__actions { display: flex; gap: 6px; flex-wrap: wrap; }
.cf-meta { font-size: 12px; color: var(--havline-text-secondary); }
.cf-modal__body { max-height: 68vh; overflow: auto; padding-right: 4px; }
.cf-modal__footer { display: flex; justify-content: flex-end; gap: 8px; }
.cf-mode-alert { margin-bottom: 4px; }
.cf-preflight { display: grid; gap: 10px; }
.cf-preflight__row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); }
.cf-preflight__row span { flex: 1; min-width: 0; color: var(--havline-text-secondary); font-size: 12.5px; }
.cf-managed-field { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--havline-text-muted); }
.cf-rule-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
.cf-rule-row__subdomain,
.cf-rule-row__domain { min-width: 0; }
.cf-hostname-preview {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 var(--havline-space-4);
padding: 10px 12px;
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-bg);
}
.cf-hostname-preview span { color: var(--havline-text-muted); font-size: 12px; }
.cf-hostname-preview strong { color: var(--havline-text); }
.cf-origin-row { display: flex; align-items: center; width: 100%; gap: 8px; }
.cf-origin-row__type { width: 126px; flex: none; }
.cf-origin-row__scheme { color: var(--havline-text-muted); flex: none; }
.cf-diagnostics { display: grid; gap: 8px; margin-top: 10px; }
.cf-diagnostic-row { display: flex; align-items: flex-start; gap: 10px; font-size: 12.5px; line-height: 1.5; }
.cf-detail__stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin-bottom: 12px; }
.cf-stat { display: grid; gap: 4px; padding: 10px 12px; border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); background: var(--havline-bg); }
.cf-stat label { font-size: 12px; color: var(--havline-text-muted); }
.cf-stat strong { font-size: 15px; }
.cf-code-toolbar { display: flex; justify-content: flex-end; margin-bottom: 6px; }
.cf-binary { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 12px; }
.cf-binary > div { display: grid; gap: 4px; padding: 10px 12px; border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); background: var(--havline-bg); }
.cf-binary label { font-size: 12px; color: var(--havline-text-muted); }
.cf-hint { font-size: 12px; color: var(--havline-text-muted); }
.cf-pre {
max-height: 240px;
overflow: auto;
padding: 12px;
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-bg);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+216
View File
@@ -0,0 +1,216 @@
<template>
<div class="auth-page">
<div class="auth-card">
<div class="auth-brand">
<img class="auth-brand__logo" :src="logoImg" alt="Havline" />
<div>
<h1>Havline</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/havline-logo.svg'
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(--havline-space-5);
background: var(--havline-bg);
}
.auth-card {
width: 100%;
max-width: 420px;
background: var(--havline-surface);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius);
box-shadow: var(--havline-shadow-md);
padding: var(--havline-space-6) var(--havline-space-5);
}
.auth-brand {
display: flex;
align-items: center;
gap: var(--havline-space-4);
margin-bottom: var(--havline-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(--havline-text-muted);
}
.auth-title {
margin: 0 0 var(--havline-space-5);
font-size: 16px;
font-weight: 600;
color: var(--havline-text);
}
.auth-hint {
margin: var(--havline-space-4) 0 0;
font-size: 13px;
line-height: 1.5;
color: var(--havline-text-muted);
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
<template>
<div class="status-page">
<div class="status-page__card">
<header class="status-page__head">
<h1 class="status-page__title">{{ page?.title || '服务状态' }}</h1>
<n-button size="small" :loading="loading" @click="load">刷新</n-button>
</header>
<p v-if="page" class="status-page__meta">
共 {{ page.routes.length }} 项 · 正常 {{ page.up }} · 异常 {{ page.down }} · 无法判定 {{ page.unknown }}
<span v-if="page.checked_at">|数据截至 {{ formatDate(page.checked_at) }}</span>
</p>
<div v-if="needsCode" class="status-page__code">
<n-input v-model:value="code" placeholder="请输入访问码" @keyup.enter="load" />
<n-button type="primary" :loading="loading" @click="load">查看</n-button>
</div>
<p v-else-if="error" class="status-page__error">{{ error }}</p>
<div v-if="page && page.routes.length" class="status-page__list">
<div v-for="route in page.routes" :key="route.domain" class="status-route">
<div class="status-row">
<StatusBadge :kind="kindOf(route.state)" :text="stateText(route.state)" />
<span class="status-row__domain mono">{{ route.domain }}</span>
<span v-if="route.source" class="status-row__source">{{ route.source === 'cloudflare' ? 'Cloudflare' : '公网反代' }}</span>
<span class="status-row__uptime mono">{{ route.uptime_pct.toFixed(1) }}%(24h)</span>
</div>
<div v-if="route.incidents?.length" class="incident-list">
<div v-for="incident in route.incidents.slice(0, 3)" :key="incident.from" class="incident-row">
<span class="incident-row__dot" />
<span class="mono">{{ formatDate(incident.from) }} 起</span>
<span>{{ incident.minutes }} 分钟</span>
<span>{{ incident.detail || incident.reason || '服务不可用' }}</span>
</div>
</div>
</div>
</div>
<p v-else-if="page && !error" class="status-page__hint">还没有巡检记录。</p>
<p class="status-page__hint status-page__hint--foot">本页只展示域名与可用率,由 Havline 生成。</p>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { NButton, NInput } from 'naive-ui'
import StatusBadge from '../components/StatusBadge.vue'
import { api } from '../api/client'
import type { StatusPagePayload } from '../api/types'
import type { StatusKind } from '../utils/status'
import { formatDate } from '../utils/format'
const page = ref<StatusPagePayload | null>(null)
const code = ref('')
const loading = ref(false)
const error = ref('')
const needsCode = ref(false)
async function load() {
loading.value = true
try {
page.value = await api.getStatusPage(code.value.trim())
error.value = ''
needsCode.value = false
} catch (err) {
page.value = null
// 状态页关闭与访问码错误都返回 404(不向匿名访问者确认页面是否存在)
error.value = code.value.trim() ? '访问码不正确或状态页未开启' : '状态页未开启'
needsCode.value = true
} finally {
loading.value = false
}
}
function kindOf(state: string): StatusKind {
if (state === 'up') return 'success'
if (state === 'down') return 'error'
return 'warning'
}
function stateText(state: string): string {
if (state === 'up') return '正常'
if (state === 'down') return '异常'
return '无法判定'
}
onMounted(load)
</script>
<style scoped>
.status-page {
min-height: 100vh;
display: flex;
justify-content: center;
padding: var(--havline-space-6) var(--havline-space-4);
background: var(--havline-bg);
}
.status-page__card {
width: min(680px, 100%);
align-self: flex-start;
padding: var(--havline-space-5);
background: var(--havline-surface);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius);
box-shadow: var(--havline-shadow);
}
.status-page__head { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3); }
.status-page__title { margin: 0; font-size: 18px; color: var(--havline-text); }
.status-page__meta { margin: 8px 0 0; font-size: 12.5px; color: var(--havline-text-secondary); }
.status-page__code { display: flex; gap: 8px; margin-top: var(--havline-space-4); }
.status-page__error { margin: var(--havline-space-4) 0 0; font-size: 13px; color: var(--havline-error); }
.status-page__list { display: grid; gap: 6px; margin-top: var(--havline-space-4); }
.status-row {
display: flex; align-items: center; gap: var(--havline-space-3);
padding: 8px var(--havline-space-3);
border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm);
}
.status-row__domain { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.status-row__source { flex: none; font-size: 11.5px; color: var(--havline-text-muted); }
.status-row__uptime { flex: none; font-size: 12.5px; color: var(--havline-text-secondary); }
.status-route { border-bottom: 1px solid var(--havline-border); }
.status-route:last-child { border-bottom: none; }
.incident-list { display: grid; gap: 5px; padding: 0 var(--havline-space-3) 10px; }
.incident-row { display: flex; align-items: center; gap: 10px; color: var(--havline-text-muted); font-size: 12px; flex-wrap: wrap; }
.incident-row__dot { width: 6px; height: 6px; border-radius: 50%; background: var(--havline-error); }
.status-page__hint { margin: var(--havline-space-3) 0 0; font-size: 12.5px; color: var(--havline-text-muted); }
.status-page__hint--foot { margin-top: var(--havline-space-5); }
</style>