feat(agent): agent 0.10.0 新增资源/日志/配置回读端点,前端接入 VPS 资源与公网 Nginx 日志(v1.2.4)

agent 0.10.0:新增 GET /api/v1/metrics(资源采集,复用 internal/sysinfo;CPU 两次采样差值 + 1 秒内复用上次结果)、GET /api/v1/logs/nginx(日志尾部,路径白名单来自 nginx -T / 环境变量 / 默认目录,32KB 块倒读、单行 4KB 截断、上限 2000 行)、GET /api/v1/frps/config(frps.toml 回读,256KB 上限)

internal/sysinfo:补 LoadAvg 与 UptimeSeconds(Linux /proc/loadavg 与 /proc/uptime、Windows GetTickCount64、其它平台空值)

应用侧:AgentClient 三个只读方法;request 错误改为带状态码的 statusError,新增 ErrAgentEndpointMissing,把 404/405 统一转成「agent 版本过低需 ≥ 0.10.0」提示;补 service/handler/router

前端:公网 Agent 页「VPS 资源」横排带(占满环境与依赖整行);日志中心「公网 Nginx」页签;frps 同源配置弹窗「比对 VPS 实际文件」

二进制:内嵌 fonu-agent-linux-amd64 / arm64 用新代码重编;VERSION → 1.2.4
This commit is contained in:
2026-09-24 10:15:11 +08:00
parent f75e672a45
commit 2f59f5adc4
19 changed files with 953 additions and 7 deletions
+9 -1
View File
@@ -290,7 +290,15 @@ export const api = {
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`),
getFrpAgentRouteDrift: (id: number) =>
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`, {
+29
View File
@@ -736,6 +736,35 @@ export interface FrpRouteHealthSummary {
failed_servers?: string[]
}
export interface FrpAgentMetrics {
cpu_percent: number
mem_used_bytes: number
mem_total_bytes: number
disk_used_bytes: number
disk_total_bytes: number
disk_path?: string
load_avg?: number[]
uptime_seconds?: number
checked_at: string
}
export interface FrpAgentNginxLogs {
type: 'access' | 'error'
path: string
lines: string[]
truncated: boolean
checked_at: string
}
export interface FrpAgentFRPSConfig {
path: string
exists: boolean
content: string
truncated?: boolean
mod_time?: string
checked_at: string
}
export interface FrpRouteDriftItem {
domain: string
kind: 'missing' | 'orphan' | 'not_loaded' | 'content_mismatch'
+161 -1
View File
@@ -83,6 +83,26 @@
<div class="stat-card__label">端口监听</div>
<div class="stat-card__sub">frps 运行所需端口</div>
</div>
<FonuCard class="section-card vps-resource-card" title="VPS 资源">
<div class="section-body">
<p v-if="metricsError" class="form-hint">{{ metricsError }}</p>
<div v-else-if="agentMetrics" class="res-strip">
<div v-for="row in metricsRows" :key="row.label" class="res-row">
<span class="res-row__label">{{ row.label }}</span>
<span class="res-row__value">{{ row.value }}</span>
<span class="res-row__detail">{{ row.detail }}</span>
<span class="res-bar" :class="`res-bar--${row.tone}`">
<span class="res-bar__fill" :style="{ width: `${row.percent.toFixed(1)}%` }" />
</span>
</div>
<div class="res-meta">
<span>平均负载 {{ metricsLoadText }}</span>
<span>运行时长 {{ metricsUptimeText }}</span>
</div>
</div>
<p v-else class="form-hint">{{ metricsLoading ? '正在采集 VPS 资源…' : '暂无 VPS 资源数据' }}</p>
</div>
</FonuCard>
</section>
<!-- ② 环境与依赖 -->
@@ -260,7 +280,7 @@ import PageHeader from '../components/PageHeader.vue'
import StatusBadge from '../components/StatusBadge.vue'
import { api } from '../api/client'
import type { FrpAgentStatus, FrpRelease, FrpServer } from '../api/types'
import { formatRelativeTime } from '../utils/format'
import { formatBytes, formatRelativeTime, formatUptime } from '../utils/format'
import { copyText } from '../utils/clipboard'
// 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈"
@@ -435,6 +455,60 @@ async function load() {
} catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' }
}
// VPS 资源(需 agent ≥ 0.10.0):老版本 agent 没有该端点,错误信息里已带升级提示
const agentMetrics = ref<import('../api/types').FrpAgentMetrics | null>(null)
const metricsError = ref('')
const metricsLoading = ref(false)
async function loadMetrics(serverID: number) {
metricsLoading.value = true
try {
agentMetrics.value = await api.getFrpAgentMetrics(serverID)
metricsError.value = ''
} catch (error) {
agentMetrics.value = null
metricsError.value = error instanceof Error ? error.message : 'VPS 资源获取失败'
} finally {
metricsLoading.value = false
}
}
function usagePercentOf(used?: number, total?: number) {
if (!used || !total || total <= 0) return 0
return Math.min(100, (used / total) * 100)
}
const metricsRows = computed(() => {
const data = agentMetrics.value
if (!data) return []
const rows = [
{ label: 'CPU', percent: Math.min(100, Math.max(0, data.cpu_percent ?? 0)), detail: '整机占用率' },
{
label: '内存',
percent: usagePercentOf(data.mem_used_bytes, data.mem_total_bytes),
detail: `${formatBytes(data.mem_used_bytes)} / ${formatBytes(data.mem_total_bytes)}`,
},
{
label: '磁盘',
percent: usagePercentOf(data.disk_used_bytes, data.disk_total_bytes),
detail: `${formatBytes(data.disk_used_bytes)} / ${formatBytes(data.disk_total_bytes)}`,
},
]
return rows.map((row) => ({
...row,
value: `${Math.round(row.percent)}%`,
tone: row.percent >= 90 ? 'danger' : row.percent >= 75 ? 'warn' : 'ok',
}))
})
const metricsLoadText = computed(() =>
agentMetrics.value?.load_avg?.length ? agentMetrics.value.load_avg.map((value) => value.toFixed(2)).join(' / ') : '—',
)
const metricsUptimeText = computed(() =>
agentMetrics.value?.uptime_seconds ? formatUptime(agentMetrics.value.uptime_seconds) : '—',
)
// 轮询走静默(不触碰 busy);首次加载显示 statusLoading
async function refreshAll(options: { silent?: boolean } = { silent: true }) {
const current = server.value
@@ -444,6 +518,8 @@ async function refreshAll(options: { silent?: boolean } = { silent: true }) {
agentStatus.value = await api.getFrpAgentStatus(current.id)
agentStatusOk.value = true
statusLoaded.value = true
// 资源采集单独走,不阻断状态加载
void loadMetrics(current.id)
} catch (error) {
agentStatusOk.value = false
statusLoaded.value = true
@@ -659,4 +735,88 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
.agent-actions .n-button { min-height: 44px; flex: 1 1 auto; }
.entrance-row { flex-direction: column; align-items: flex-start; }
}
/* VPS 资源:占满「环境与依赖」整行,避免单独占一格显得突兀 */
.vps-resource-card {
grid-column: 1 / -1;
}
.res-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)) auto;
align-items: center;
gap: var(--fonu-space-5);
}
.res-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-areas:
'label value'
'detail detail'
'bar bar';
align-items: center;
gap: var(--fonu-space-2);
font-size: 13px;
}
.res-row__label {
grid-area: label;
color: var(--fonu-text-secondary);
}
.res-row__detail {
grid-area: detail;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--fonu-text-muted);
}
.res-row__value {
grid-area: value;
font-weight: 600;
color: var(--fonu-text);
font-variant-numeric: tabular-nums;
}
.res-bar {
grid-area: bar;
display: block;
height: 4px;
border-radius: 2px;
background: var(--fonu-border);
overflow: hidden;
}
.res-bar__fill {
display: block;
height: 100%;
border-radius: 2px;
background: var(--fonu-brand);
transition: width 0.3s ease;
}
.res-bar--warn .res-bar__fill {
background: var(--fonu-warning);
}
.res-bar--danger .res-bar__fill {
background: var(--fonu-error);
}
.res-meta {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: var(--fonu-space-4);
border-left: 1px solid var(--fonu-border);
font-size: 12px;
color: var(--fonu-text-muted);
}
@media (max-width: 900px) {
.res-strip { grid-template-columns: 1fr; }
.res-meta { padding-left: 0; border-left: none; }
}
</style>
+54 -1
View File
@@ -589,9 +589,17 @@
已启用的 TCP/UDP 隧道远程端口:<span class="mono">{{ tcpRemotePorts.join('、') }}</span>
—— 请确认 VPS 防火墙 / 安全组已放行这些端口(frps 侧 allowPorts 已按此生成,未放行时隧道会连不上)。
</p>
<p v-if="frpsCompare" class="form-hint" :class="{ 'frps-compare--warn': !frpsCompare.ok }">
{{ frpsCompare.text }}
</p>
<pre class="logs-box">{{ frpsConfigContent || '生成中…' }}</pre>
</div>
<div class="modal-footer"><n-button @click="showFRPSConfig = false">关闭</n-button></div>
<div class="modal-footer">
<n-button size="small" :loading="comparingFrps" :disabled="!frpsConfigTarget" @click="compareFrpsConfig">
比对 VPS 实际文件
</n-button>
<n-button @click="showFRPSConfig = false">关闭</n-button>
</div>
</div>
</n-modal>
@@ -940,6 +948,47 @@ const showServerDetail = ref(false)
// frps.toml 同源配置弹窗:按服务端生成,与 frpc 侧端口/认证一致
const showFRPSConfig = ref(false)
const frpsConfigContent = ref('')
// frps.toml 与 VPS 实际文件的比对结果(P1-3,需 agent ≥ 0.10.0)
const frpsCompare = ref<{ ok: boolean; text: string } | null>(null)
const comparingFrps = ref(false)
// 比对前统一去注释、空行与首尾空白:注释和排版差异不算「被手工修改」
function normalizedConfigLines(text: string) {
return text
.split('\n')
.map((line) => line.trim())
.filter((line) => line !== '' && !line.startsWith('#'))
}
async function compareFrpsConfig() {
const target = frpsConfigTarget.value
if (!target) return
comparingFrps.value = true
frpsCompare.value = null
try {
const remote = await api.getFrpAgentFRPSConfig(target.id)
if (!remote.exists) {
frpsCompare.value = { ok: false, text: `VPS 上还没有 ${remote.path || 'frps.toml'},可直接下发写入` }
return
}
const expected = normalizedConfigLines(frpsConfigContent.value)
const actual = normalizedConfigLines(remote.content)
const index = expected.findIndex((line, i) => actual[i] !== line)
if (index === -1 && expected.length === actual.length) {
frpsCompare.value = { ok: true, text: `与 VPS 上的 ${remote.path} 一致(已忽略注释与空行)` }
return
}
const at = index === -1 ? Math.min(expected.length, actual.length) : index
frpsCompare.value = {
ok: false,
text: `不一致:第 ${at + 1} 行 下发「${expected[at] ?? '(无)'}」/ VPS「${actual[at] ?? '(无)'}」——VPS 上的 frps.toml 可能被手工改过,重新下发会覆盖`,
}
} catch (error) {
frpsCompare.value = { ok: false, text: error instanceof Error ? error.message : '比对失败' }
} finally {
comparingFrps.value = false
}
}
const frpsConfigTarget = ref<FrpServer | null>(null)
const frpsConfigLoading = ref(false)
const showRuntimeModal = ref(false)
@@ -1747,6 +1796,7 @@ async function loadExport() {
async function openFRPSConfig(server: FrpServer) {
frpsConfigTarget.value = server
showFRPSConfig.value = true
frpsCompare.value = null
frpsConfigLoading.value = true
try { frpsConfigContent.value = (await api.getFrpFRPSConfig(server.id)).content }
catch (error) {
@@ -2148,4 +2198,7 @@ onUnmounted(() => { if (timer) window.clearInterval(timer); stopDownloadPolling(
@media (max-width: 1100px) { .stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 700px) { .stats-row, .form-grid, .download-proxy-form, .server-detail__grid, .detail-fields, .detail-stats, .server-status-summary { grid-template-columns: 1fr; } .server-status-summary__item--state { min-height: 0; padding-right: 0; padding-bottom: var(--fonu-space-3); border-right: 0; border-bottom: 1px solid var(--fonu-border); } .status-bars { gap: 3px; } .status-bar { height: 28px; } .binary-row { align-items: flex-start; flex-direction: column; } .release-row { grid-template-columns: 1fr; } .release-progress-placeholder { display: none; } .switch-row span:nth-of-type(2) { margin-left: 0; } }
@media (max-width: 760px) { .frp-proxy-modal { display: block; max-height: 92vh; overflow-y: auto; } .frp-proxy-modal .proxy-modal__form { max-height: none; } .frp-proxy-modal .proxy-modal__scroll { overflow: visible; } .frp-proxy-modal .proxy-modal__help { width: auto; border-top: 1px solid var(--fonu-border); border-left: 0; } }
.frps-compare--warn {
color: var(--fonu-warning);
}
</style>
+154 -1
View File
@@ -66,6 +66,42 @@
/>
</n-tab-pane>
<n-tab-pane name="remote-nginx" tab="公网 Nginx">
<LogToolbar v-model:keyword="nginxKeyword" @refresh="loadRemoteNginxLogs" />
<div class="remote-nginx-bar">
<n-select
v-model:value="nginxTargetId"
:options="nginxTargetOptions"
placeholder="选择服务端"
size="small"
class="remote-nginx-bar__server"
/>
<n-select v-model:value="nginxKind" :options="nginxKindOptions" size="small" style="width: 120px" />
<n-select v-model:value="nginxLines" :options="nginxLineOptions" size="small" style="width: 110px" />
<span v-if="nginxPath" class="tab-hint mono remote-nginx-bar__path">
{{ nginxPath }}{{ nginxTruncated ? '(仅显示尾部)' : '' }}
</span>
</div>
<n-data-table
v-if="filteredNginxLines.length > 0"
class="log-table"
:columns="nginxColumns"
:data="pagedNginxLines"
:loading="nginxLoading"
:bordered="false"
/>
<LogPagination
v-if="filteredNginxLines.length > 0"
v-model:page="nginxPage"
:item-count="filteredNginxLines.length"
/>
<EmptyState
v-if="!nginxLoading && filteredNginxLines.length === 0"
title="暂无公网 Nginx 日志"
:description="nginxError || 'VPS 上产生访问后,这里会显示 Nginx 的访问 / 错误日志(需 agent ≥ 0.10.0)。'"
/>
</n-tab-pane>
<n-tab-pane name="access" tab="访问日志">
<LogToolbar
v-model:keyword="accessKeyword"
@@ -245,7 +281,7 @@ const LogToolbar = defineComponent({
const message = useMessage()
const route = useRoute()
const LOG_TABS = ['system', 'access', 'nginx'] as const
const LOG_TABS = ['system', 'frp', 'remote-nginx', 'access', 'nginx'] as const
type LogTab = (typeof LOG_TABS)[number]
function resolveTab(queryTab: unknown): LogTab {
@@ -451,6 +487,105 @@ function frpRowClassName(row: FrpLogEntry) {
return 'log-row'
}
// 公网 Nginx 日志(VPS 侧,需 agent ≥ 0.10.0):路径由 agent 从 nginx -T 解析,前端只展示原始行
const nginxTargets = ref<import('../api/types').FrpServer[]>([])
const nginxTargetId = ref<number | null>(null)
const nginxKind = ref<'access' | 'error'>('access')
const nginxLines = ref(200)
const nginxRaw = ref<string[]>([])
const nginxPath = ref('')
const nginxTruncated = ref(false)
const nginxLoading = ref(false)
const nginxError = ref('')
const nginxKeyword = ref('')
const nginxPage = ref(1)
const nginxPageSize = 50
const nginxTargetOptions = computed(() =>
nginxTargets.value.map((item) => ({ label: item.name || item.agent_url || `服务端 #${item.id}`, value: item.id })),
)
const nginxKindOptions = [
{ label: '访问日志', value: 'access' },
{ label: '错误日志', value: 'error' },
]
const nginxLineOptions = [
{ label: '200 行', value: 200 },
{ label: '500 行', value: 500 },
{ label: '2000 行', value: 2000 },
]
const filteredNginxLines = computed(() => {
const rows = nginxRaw.value.map((line) => ({ line }))
const keyword = nginxKeyword.value.trim().toLowerCase()
if (!keyword) return rows
return rows.filter((row) => row.line.toLowerCase().includes(keyword))
})
const pagedNginxLines = computed(() => {
const start = (nginxPage.value - 1) * nginxPageSize
return filteredNginxLines.value.slice(start, start + nginxPageSize)
})
const nginxColumns: DataTableColumns<{ line: string }> = [
{
title: '日志',
key: 'line',
ellipsis: { tooltip: true },
render: (row) => h('span', { class: 'log-message' }, row.line),
},
]
async function loadNginxTargets() {
try {
nginxTargets.value = asList(await api.listFrpServers()).filter((item) => item.agent_configured)
} catch {
nginxTargets.value = []
}
if (nginxTargetId.value === null || !nginxTargets.value.some((item) => item.id === nginxTargetId.value)) {
nginxTargetId.value = nginxTargets.value[0]?.id ?? null
}
}
async function loadRemoteNginxLogs() {
if (nginxTargetId.value === null) {
nginxRaw.value = []
nginxError.value = '没有已配置 Agent 的服务端'
return
}
nginxLoading.value = true
nginxError.value = ''
try {
const result = await api.getFrpAgentNginxLogs(nginxTargetId.value, nginxKind.value, nginxLines.value)
nginxRaw.value = result.lines ?? []
nginxPath.value = result.path ?? ''
nginxTruncated.value = result.truncated ?? false
nginxPage.value = 1
} catch (error) {
nginxRaw.value = []
nginxError.value = error instanceof Error ? error.message : '读取公网 Nginx 日志失败'
} finally {
nginxLoading.value = false
}
}
watch([nginxTargetId, nginxKind, nginxLines], () => {
nginxPage.value = 1
if (nginxTargetId.value !== null) void loadRemoteNginxLogs()
})
watch(nginxKeyword, () => {
nginxPage.value = 1
})
// 切到本页签时才拉服务端列表与日志,避免影响其它页签的首屏
watch(tab, (value) => {
if (value === 'remote-nginx' && nginxTargets.value.length === 0) {
void loadNginxTargets().then(() => loadRemoteNginxLogs())
}
})
async function loadFrpLogs() {
loadingFrp.value = true
try {
@@ -766,4 +901,22 @@ onUnmounted(() => {
.logs-card :deep(.log-module-tag) {
white-space: nowrap;
}
.remote-nginx-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--fonu-space-3);
margin-bottom: var(--fonu-space-2);
}
.remote-nginx-bar__server {
width: 200px;
}
.remote-nginx-bar__path {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 420px;
}
</style>