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:
+161
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user