feat: create standalone native fnOS repository
@@ -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>
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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 }),
|
||||
}),
|
||||
}
|
||||
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 977 KiB |
|
After Width: | Height: | Size: 467 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 315 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
@@ -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 |
|
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const GITHUB_REPO = 'O96u/Fonu'
|
||||
export const GITHUB_REPO_URL = `https://github.com/${GITHUB_REPO}`
|
||||
export const GITHUB_REPO_DISPLAY = `github.com/${GITHUB_REPO}`
|
||||
export const GITHUB_RELEASES_URL = `${GITHUB_REPO_URL}/releases`
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Mirrors internal/proxy.DefaultPrivateCIDRs — always exempt from china_only / global allow. */
|
||||
export const DEFAULT_PRIVATE_CIDRS = [
|
||||
'10.0.0.0/8',
|
||||
'127.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'::1/128',
|
||||
'fc00::/7',
|
||||
]
|
||||
|
||||
export function mergeIPList(defaults: string[], custom: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const cidr of [...defaults, ...custom]) {
|
||||
const value = cidr.trim()
|
||||
if (!value || seen.has(value)) continue
|
||||
seen.add(value)
|
||||
out.push(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function stripDefaultIPList(defaults: string[], list: string[]): string[] {
|
||||
const defaultSet = new Set(defaults)
|
||||
return list.map((item) => item.trim()).filter((item) => item && !defaultSet.has(item))
|
||||
}
|
||||
@@ -0,0 +1,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 },
|
||||
]
|
||||
@@ -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 = '********'
|
||||
@@ -0,0 +1,3 @@
|
||||
/** 敏感凭据字段:标签与占位符全站统一 */
|
||||
export const CONFIGURED_SECRET_TAG = '已配置'
|
||||
export const CONFIGURED_SECRET_PLACEHOLDER = '已配置,留空表示不修改'
|
||||
@@ -0,0 +1,14 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
|
||||
declare module '*.png' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { AccessLogEntry, ProxyHost, ProxyRule } from '../api/types'
|
||||
|
||||
export type ProxyBinding = {
|
||||
name: string
|
||||
upstream: string
|
||||
host: string
|
||||
port: number
|
||||
}
|
||||
|
||||
function bindingKey(host: string, port: number): string {
|
||||
return `${host.toLowerCase()}:${port}`
|
||||
}
|
||||
|
||||
function normalizeAccessHost(domain: string): string {
|
||||
const raw = domain.trim().toLowerCase()
|
||||
if (!raw) return ''
|
||||
if (raw.startsWith('[')) {
|
||||
const end = raw.indexOf(']')
|
||||
if (end > 0) return raw.slice(0, end + 1)
|
||||
}
|
||||
if (raw.includes(':') && !raw.includes(']')) {
|
||||
const idx = raw.lastIndexOf(':')
|
||||
const maybePort = Number.parseInt(raw.slice(idx + 1), 10)
|
||||
if (idx > 0 && maybePort > 0 && maybePort <= 65535) {
|
||||
return raw.slice(0, idx)
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
export function buildProxyBindingIndex(rules: ProxyRule[]): Map<string, ProxyBinding> {
|
||||
const index = new Map<string, ProxyBinding>()
|
||||
for (const rule of rules) {
|
||||
const fallbackName = rule.domain?.trim() || rule.upstream?.trim() || `规则 #${rule.id}`
|
||||
const name = rule.name?.trim() || fallbackName
|
||||
const hosts: ProxyHost[] = rule.hosts?.length
|
||||
? rule.hosts
|
||||
: rule.domain
|
||||
? [{ id: 0, hostname: rule.domain }]
|
||||
: []
|
||||
for (const host of hosts) {
|
||||
const hostname = host.hostname?.trim()
|
||||
if (!hostname) continue
|
||||
const port = host.listen_port ?? rule.listen_port
|
||||
if (port <= 0) continue
|
||||
index.set(bindingKey(hostname, port), {
|
||||
name,
|
||||
upstream: rule.upstream,
|
||||
host: hostname.toLowerCase(),
|
||||
port,
|
||||
})
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function formatUpstreamShort(upstream: string): string {
|
||||
return upstream.replace(/^https?:\/\//i, '')
|
||||
}
|
||||
|
||||
/** Raw host:port / domain / IP shown when no proxy rule matches. */
|
||||
export function formatAccessFallback(entry: AccessLogEntry): string {
|
||||
const rawDomain = entry.domain?.trim()
|
||||
const host = normalizeAccessHost(entry.domain)
|
||||
const port = entry.server_port ?? 0
|
||||
|
||||
if (rawDomain?.includes(':') && (rawDomain.includes(']') || rawDomain.lastIndexOf(':') > rawDomain.lastIndexOf(']'))) {
|
||||
return rawDomain
|
||||
}
|
||||
if (rawDomain?.includes(':') && !rawDomain.includes(']')) {
|
||||
const idx = rawDomain.lastIndexOf(':')
|
||||
const maybePort = Number.parseInt(rawDomain.slice(idx + 1), 10)
|
||||
if (idx > 0 && maybePort > 0 && maybePort <= 65535) {
|
||||
return rawDomain
|
||||
}
|
||||
}
|
||||
|
||||
if (host && port > 0) return `${host}:${port}`
|
||||
if (rawDomain) return rawDomain
|
||||
if (host) return host
|
||||
return entry.client_ip?.trim() || '—'
|
||||
}
|
||||
|
||||
export function resolveAccessServiceLabel(
|
||||
entry: AccessLogEntry,
|
||||
index: Map<string, ProxyBinding>,
|
||||
): string {
|
||||
const host = normalizeAccessHost(entry.domain)
|
||||
const port = entry.server_port ?? 0
|
||||
|
||||
// 域名 + 端口(同域多端口)
|
||||
if (host && port > 0) {
|
||||
const hit = index.get(bindingKey(host, port))
|
||||
if (hit) return hit.name
|
||||
}
|
||||
|
||||
// 仅域名(单规则或同域单端口)
|
||||
if (host) {
|
||||
const matches = [...index.values()].filter((binding) => binding.host === host)
|
||||
if (matches.length === 1) return matches[0].name
|
||||
if (matches.length > 1 && port > 0) {
|
||||
const byPort = matches.find((binding) => binding.port === port)
|
||||
if (byPort) return byPort.name
|
||||
}
|
||||
}
|
||||
|
||||
// 仅 IP / 域名直连、无 Host 匹配时走 upstream
|
||||
const upstream = entry.upstream?.trim()
|
||||
if (upstream && upstream !== '-') {
|
||||
return formatUpstreamShort(upstream)
|
||||
}
|
||||
|
||||
return formatAccessFallback(entry)
|
||||
}
|
||||
|
||||
export function accessServiceTooltip(entry: AccessLogEntry, index: Map<string, ProxyBinding>): string {
|
||||
const host = normalizeAccessHost(entry.domain)
|
||||
const port = entry.server_port ?? 0
|
||||
const label = resolveAccessServiceLabel(entry, index)
|
||||
const parts: string[] = [label]
|
||||
|
||||
const requestHost = formatAccessFallback(entry)
|
||||
if (requestHost !== label) {
|
||||
parts.push(`请求 ${requestHost}`)
|
||||
} else if (host) {
|
||||
parts.push(port > 0 ? `${host}:${port}` : host)
|
||||
}
|
||||
|
||||
const binding = host && port > 0 ? index.get(bindingKey(host, port)) : undefined
|
||||
if (binding?.upstream) {
|
||||
parts.push(`→ ${binding.upstream}`)
|
||||
} else if (entry.upstream && entry.upstream !== '-') {
|
||||
parts.push(`→ ${entry.upstream}`)
|
||||
}
|
||||
if (entry.client_ip) parts.push(`来自 ${entry.client_ip}`)
|
||||
if (entry.path) parts.push(entry.path)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
@@ -0,0 +1,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('—')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Agent 版本展示:旧 Agent 未返回 agent_version 时降级为占位符,避免页面出现空白。 */
|
||||
export function agentVersionText(version?: string): string {
|
||||
return version?.trim() || '—'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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"}')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit++
|
||||
}
|
||||
const digits = value >= 100 || unit === 0 ? 0 : value >= 10 ? 1 : 2
|
||||
return `${value.toFixed(digits)} ${units[unit]}`
|
||||
}
|
||||
|
||||
export function formatRate(bytesPerSec: number): string {
|
||||
if (!Number.isFinite(bytesPerSec) || bytesPerSec <= 0) return '0 B/s'
|
||||
return `${formatBytes(bytesPerSec)}/s`
|
||||
}
|
||||
|
||||
export function formatUptime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h > 0) return `${h} 小时 ${m} 分钟`
|
||||
return `${m} 分钟`
|
||||
}
|
||||
|
||||
export function formatRelativeTime(iso?: string): string {
|
||||
if (!iso) return '-'
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso
|
||||
const diff = Date.now() - date.getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes} 分钟前`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours} 小时前`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days} 天前`
|
||||
}
|
||||
|
||||
export function formatMs(seconds: number): string {
|
||||
const ms = Math.round(seconds * 1000)
|
||||
return `${ms} ms`
|
||||
}
|
||||
|
||||
export function formatDate(iso?: string): string {
|
||||
if (!iso) return '-'
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/** Normalize log timestamps to YYYY-MM-DD HH:mm:ss */
|
||||
export function formatLogTime(value?: string): string {
|
||||
if (!value) return '-'
|
||||
|
||||
const trimmed = value.trim()
|
||||
|
||||
const standard = trimmed.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/)
|
||||
if (standard) return standard[1]
|
||||
|
||||
const iso = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})/)
|
||||
if (iso) return `${iso[1]}-${iso[2]}-${iso[3]} ${iso[4]}:${iso[5]}:${iso[6]}`
|
||||
|
||||
const nginx = trimmed.match(/^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})/)
|
||||
if (nginx) return `${nginx[1]}-${nginx[2]}-${nginx[3]} ${nginx[4]}:${nginx[5]}:${nginx[6]}`
|
||||
|
||||
const date = new Date(trimmed)
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/** Normalize leading timestamp in a raw log line */
|
||||
export function formatLogLine(line: string): string {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return line
|
||||
|
||||
const nginx = trimmed.match(/^(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2})(.*)$/)
|
||||
if (nginx) return formatLogTime(nginx[1]) + nginx[2]
|
||||
|
||||
const iso = trimmed.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}|Z)?)(.*)$/)
|
||||
if (iso) return formatLogTime(iso[1]) + iso[2]
|
||||
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const raw = JSON.parse(trimmed) as { time?: string }
|
||||
if (raw.time) return trimmed.replace(raw.time, formatLogTime(raw.time))
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { SystemLogEntry } from '../api/types'
|
||||
import { formatLogTime } from './format'
|
||||
|
||||
export type LogTagType = 'success' | 'info' | 'warning' | 'error' | 'default'
|
||||
|
||||
export interface DisplaySystemLogEntry extends SystemLogEntry {
|
||||
displayLevel: string
|
||||
displayModule: string
|
||||
}
|
||||
|
||||
const SYSTEM_LEVEL_LABELS: Record<string, string> = {
|
||||
INFO: '信息',
|
||||
WARN: '警告',
|
||||
ERROR: '错误',
|
||||
DEBUG: '调试',
|
||||
}
|
||||
|
||||
const SYSTEM_MODULE_LABELS: Record<string, string> = {
|
||||
SYSTEM: '系统',
|
||||
NGINX: 'Nginx',
|
||||
DDNS: '域名解析',
|
||||
ACME: '证书',
|
||||
CERT: '证书',
|
||||
CHINA_CIDR: '中国 IP 段',
|
||||
API: 'API',
|
||||
HTTP: 'HTTP',
|
||||
FRP: '内网穿透',
|
||||
}
|
||||
|
||||
export function systemLevelLabel(level: string): string {
|
||||
const upper = level.trim().toUpperCase()
|
||||
return SYSTEM_LEVEL_LABELS[upper] ?? level
|
||||
}
|
||||
|
||||
export function systemModuleLabel(module: string): string {
|
||||
const trimmed = module.trim()
|
||||
if (!trimmed) return '系统'
|
||||
const upper = trimmed.toUpperCase()
|
||||
return SYSTEM_MODULE_LABELS[upper] ?? trimmed
|
||||
}
|
||||
|
||||
export function displaySystemLog(entry: SystemLogEntry): DisplaySystemLogEntry {
|
||||
const message = entry.message.trim()
|
||||
const msgLower = message.toLowerCase()
|
||||
let level = entry.level.trim().toUpperCase()
|
||||
let displayModule = systemModuleLabel(entry.module)
|
||||
|
||||
if (
|
||||
level === 'INFO' &&
|
||||
(msgLower.includes('server error') ||
|
||||
msgLower.includes(' failed') ||
|
||||
msgLower.startsWith('error') ||
|
||||
msgLower.includes('bind:'))
|
||||
) {
|
||||
level = 'ERROR'
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
displayLevel: systemLevelLabel(level),
|
||||
displayModule,
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParsedNginxError {
|
||||
time: string
|
||||
level: string
|
||||
message: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
export function parseNginxErrorLine(line: string): ParsedNginxError {
|
||||
const raw = line.trim()
|
||||
const match = raw.match(/^(\d{4}[-/]\d{2}[-/]\d{2} \d{2}:\d{2}:\d{2})\s+\[([^\]]+)\]\s+(.+)$/)
|
||||
if (!match) {
|
||||
return { time: '', level: 'unknown', message: raw, raw }
|
||||
}
|
||||
return {
|
||||
time: formatLogTime(match[1]),
|
||||
level: match[2].toLowerCase(),
|
||||
message: match[3],
|
||||
raw,
|
||||
}
|
||||
}
|
||||
|
||||
export function nginxLevelTagType(level: string): LogTagType {
|
||||
switch (level.toLowerCase()) {
|
||||
case 'emerg':
|
||||
case 'alert':
|
||||
case 'crit':
|
||||
case 'error':
|
||||
return 'error'
|
||||
case 'warn':
|
||||
case 'warning':
|
||||
return 'warning'
|
||||
case 'notice':
|
||||
case 'info':
|
||||
return 'info'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
export function systemLevelTagType(level: string): LogTagType {
|
||||
const upper = level.trim().toUpperCase()
|
||||
if (upper === 'ERROR' || level === '错误') return 'error'
|
||||
if (upper === 'WARN' || level === '警告') return 'warning'
|
||||
if (upper === 'INFO' || level === '信息') return 'info'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
export function systemModuleTagType(module: string): LogTagType {
|
||||
const upper = module.trim().toUpperCase()
|
||||
switch (upper) {
|
||||
case 'NGINX':
|
||||
case 'Nginx':
|
||||
return 'warning'
|
||||
case 'SYSTEM':
|
||||
case '系统':
|
||||
return 'success'
|
||||
case 'CHINA_CIDR':
|
||||
case '中国 IP 段':
|
||||
return 'info'
|
||||
case 'DDNS':
|
||||
case '域名解析':
|
||||
return 'info'
|
||||
case 'CERT':
|
||||
case 'ACME':
|
||||
case '证书':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
export function latencyClass(seconds: number): string {
|
||||
const ms = seconds * 1000
|
||||
if (ms >= 3000) return 'log-duration log-duration--critical'
|
||||
if (ms >= 1000) return 'log-duration log-duration--slow'
|
||||
return 'log-duration'
|
||||
}
|
||||
|
||||
export function streamLineClass(line: string): string {
|
||||
const parsed = parseNginxErrorLine(line)
|
||||
if (parsed.level !== 'unknown') {
|
||||
const type = nginxLevelTagType(parsed.level)
|
||||
if (type === 'error') return 'level-error'
|
||||
if (type === 'warning') return 'level-warn'
|
||||
if (type === 'info') return 'level-info'
|
||||
}
|
||||
|
||||
const lower = line.toLowerCase()
|
||||
if (lower.includes('[error]') || lower.includes(' emerg ') || lower.includes(' alert ') || lower.includes(' crit ')) {
|
||||
return 'level-error'
|
||||
}
|
||||
if (lower.includes('[warn]') || lower.includes(' warning ')) return 'level-warn'
|
||||
|
||||
const statusMatch = line.match(/\s(\d{3})\s+\d/)
|
||||
if (statusMatch) {
|
||||
const code = Number(statusMatch[1])
|
||||
if (code >= 500) return 'level-error'
|
||||
if (code >= 400) return 'level-warn'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
export type StatusKind = 'success' | 'warning' | 'error' | 'disabled' | 'unknown'
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
ok: '正常',
|
||||
running: '运行中',
|
||||
warning: '部分异常',
|
||||
error: '异常',
|
||||
disabled: '停用',
|
||||
none: '未配置',
|
||||
stopped: '已停止',
|
||||
unknown: '未知',
|
||||
}
|
||||
|
||||
export function statusLabel(value?: string): string {
|
||||
if (!value) return '未知'
|
||||
return LABELS[value] ?? value
|
||||
}
|
||||
|
||||
export function statusKind(value?: string): StatusKind {
|
||||
switch (value) {
|
||||
case 'ok':
|
||||
case 'running':
|
||||
return 'success'
|
||||
case 'warning':
|
||||
return 'warning'
|
||||
case 'error':
|
||||
return 'error'
|
||||
case 'disabled':
|
||||
case 'stopped':
|
||||
case 'none':
|
||||
return 'disabled'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
export function httpStatusKind(code: number): StatusKind {
|
||||
if (code >= 500) return 'error'
|
||||
if (code >= 400) return 'warning'
|
||||
if (code >= 300) return 'unknown'
|
||||
return 'success'
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { h, type VNode } from 'vue'
|
||||
import { NButton, NSpace } from 'naive-ui'
|
||||
import type { ButtonProps } from 'naive-ui'
|
||||
|
||||
export interface TableRowAction {
|
||||
label: string
|
||||
onClick: () => void
|
||||
type?: ButtonProps['type']
|
||||
/** 为 false 时隐藏该操作,默认显示 */
|
||||
show?: boolean
|
||||
}
|
||||
|
||||
/** 表格操作列:所有按钮平铺展示,不使用下拉菜单 */
|
||||
export function renderTableRowActions(actions: TableRowAction[]): VNode {
|
||||
const buttons = actions
|
||||
.filter((action) => action.show !== false)
|
||||
.map((action) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
quaternary: true,
|
||||
type: action.type,
|
||||
onClick: (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
action.onClick()
|
||||
},
|
||||
},
|
||||
() => action.label,
|
||||
),
|
||||
)
|
||||
|
||||
return h(NSpace, { size: 4, wrap: false }, () => buttons)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { GITHUB_REPO } from '../constants/app'
|
||||
|
||||
export interface LatestRelease {
|
||||
version: string
|
||||
url: string
|
||||
}
|
||||
|
||||
function parseVersion(version: string): number[] {
|
||||
const clean = version.replace(/^v/i, '').split('-')[0]
|
||||
return clean.split('.').map((part) => Number.parseInt(part, 10) || 0)
|
||||
}
|
||||
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const left = parseVersion(a)
|
||||
const right = parseVersion(b)
|
||||
const length = Math.max(left.length, right.length)
|
||||
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const diff = (left[i] ?? 0) - (right[i] ?? 0)
|
||||
if (diff !== 0) {
|
||||
return diff > 0 ? 1 : -1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
export function isNewerVersion(latest: string, current: string): boolean {
|
||||
if (!latest || !current || current === 'dev') {
|
||||
return false
|
||||
}
|
||||
return compareVersions(latest, current) > 0
|
||||
}
|
||||
|
||||
export async function fetchLatestRelease(): Promise<LatestRelease | null> {
|
||||
try {
|
||||
const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
})
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { tag_name?: string; html_url?: string }
|
||||
if (!data.tag_name || !data.html_url) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
version: data.tag_name.replace(/^v/i, ''),
|
||||
url: data.html_url,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||