hy-app-monitor/static/index.html
2026-04-22 14:39:40 +08:00

534 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HY App Monitor</title>
<link rel="stylesheet" href="/app.css" />
<script defer src="/vue.js"></script>
<script defer src="/app.js"></script>
</head>
<body>
<div id="boot-indicator" class="boot-indicator">加载中...</div>
<noscript>
<div class="boot-indicator">请启用 JavaScript</div>
</noscript>
<div id="app" v-cloak class="shell">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">HY APP MONITOR</p>
<h1>服务 / 主机 / Nacos / MongoDB</h1>
</div>
<div class="hero-side">
<div class="stat-block">
<span>服务总数</span>
<strong>{{ summary.total }}</strong>
</div>
<div class="stat-block ok">
<span>服务正常</span>
<strong>{{ summary.ok }}</strong>
</div>
<div class="stat-block bad">
<span>服务异常</span>
<strong>{{ summary.down }}</strong>
</div>
<div class="stat-block neutral">
<span>主机数</span>
<strong>{{ summary.hostTotal }}</strong>
</div>
<div class="stat-block neutral">
<span>Nacos 服务</span>
<strong>{{ summary.nacosServiceTotal }}</strong>
</div>
<div :class="['stat-block', summary.nacosUnhealthyInstanceTotal > 0 ? 'bad' : 'ok']">
<span>Nacos 异常实例</span>
<strong>{{ summary.nacosUnhealthyInstanceTotal }}</strong>
</div>
<div :class="['stat-block', payload.mongo && payload.mongo.ok ? 'ok' : 'bad']">
<span>Mongo 状态</span>
<strong>{{ payload.mongo && payload.mongo.ok ? 'OK' : 'ERR' }}</strong>
</div>
<div class="stat-block neutral">
<span>Mongo 集合</span>
<strong>{{ summary.mongoCollectionTotal }}</strong>
</div>
</div>
</header>
<section class="toolbar">
<div class="toolbar-left">
<button :class="['filter-chip', filter === 'all' ? 'active' : '']" @click="filter = 'all'">全部</button>
<button :class="['filter-chip', filter === 'down' ? 'active' : '']" @click="filter = 'down'">异常</button>
<button :class="['filter-chip', filter === 'java' ? 'active' : '']" @click="filter = 'java'">Java</button>
<button :class="['filter-chip', filter === 'golang' ? 'active' : '']" @click="filter = 'golang'">Golang</button>
</div>
<div class="toolbar-right">
<input v-model.trim="keyword" class="search" placeholder="搜索主机 / 服务 / IP / Nacos" />
<label class="interval-field">
<span>刷新</span>
<select v-model.number="refreshIntervalSeconds" class="interval-select">
<option v-for="seconds in refreshIntervalOptions" :key="seconds" :value="seconds">{{ seconds }}s</option>
</select>
</label>
<button class="refresh" @click="refresh" :disabled="loading">{{ loading ? '刷新中...' : '立即刷新' }}</button>
</div>
</section>
<section class="meta-strip">
<span>最后刷新:{{ formattedUpdatedAt }}</span>
<span>主机指标正常:{{ summary.hostMetricOk }} / {{ summary.hostTotal }}</span>
<span>Nacos 实例:{{ summary.nacosInstanceTotal }}</span>
<span>Mongo 数据库:{{ summary.mongoDatabaseTotal }}</span>
<span v-if="error" class="bad-text">加载失败:{{ error }}</span>
</section>
<section class="section-head">
<h2>主机与服务</h2>
</section>
<section class="hosts">
<article v-for="group in groupedHosts" :key="group.host" class="host-panel">
<header class="host-header">
<div class="host-identity">
<h2>{{ group.host }}</h2>
<p>{{ group.ip }} · {{ group.groupLabel }}</p>
</div>
<div class="host-badges">
<span class="badge neutral">{{ group.serviceSummary.total }} 项服务</span>
<span :class="['badge', group.downCount > 0 ? 'bad' : 'ok']">
{{ group.downCount > 0 ? `${group.downCount} 异常` : '服务正常' }}
</span>
<span :class="['badge', group.metrics && group.metrics.ok ? 'ok' : 'bad']">
{{ group.metrics && group.metrics.ok ? '主机指标正常' : '主机指标异常' }}
</span>
</div>
</header>
<section class="host-metrics">
<div :class="['metric-card', toneClass(group.metrics.cpuLevel)]">
<small>CPU</small>
<strong>{{ formatPercent(group.metrics.cpuPercent) }}</strong>
</div>
<div :class="['metric-card', toneClass(group.metrics.memoryLevel)]">
<small>内存</small>
<strong>{{ formatPercent(group.metrics.memoryPercent) }}</strong>
<span>{{ formatGb(group.metrics.memoryUsedGb, group.metrics.memoryTotalGb) }}</span>
</div>
<div :class="['metric-card', toneClass(group.metrics.diskLevel)]">
<small>磁盘</small>
<strong>{{ formatPercent(group.metrics.diskPercent) }}</strong>
<span>{{ formatGb(group.metrics.diskUsedGb, group.metrics.diskTotalGb) }}</span>
</div>
<div :class="['metric-card', toneClass(group.metrics.processLevel)]">
<small>进程数</small>
<strong>{{ group.metrics.processCount ?? '-' }}</strong>
<span>{{ group.metrics.hostname || group.instanceId }}</span>
</div>
</section>
<p v-if="group.metrics && group.metrics.error" class="metric-error">{{ group.metrics.error }}</p>
<div v-if="group.items.length > 0" class="service-grid">
<div v-for="item in group.items" :key="item.host + '-' + item.service" class="service-card">
<div class="service-top">
<div>
<div class="service-name">{{ item.service }}</div>
<div class="service-kind">{{ item.kind }}</div>
</div>
<div :class="['status-pill', toneClass(item.level)]">
<span class="dot"></span>
{{ item.ok ? 'UP' : 'DOWN' }}
</div>
</div>
<div class="service-data">
<div>
<small>HTTP</small>
<strong>{{ item.statusCode || '-' }}</strong>
</div>
<div>
<small>耗时</small>
<strong>{{ item.latencyMs }}ms</strong>
</div>
<div>
<small>端口</small>
<strong>{{ item.port }}</strong>
</div>
</div>
<div class="service-url">{{ item.url }}</div>
<div class="service-detail">{{ item.detail || '无返回内容' }}</div>
</div>
</div>
</article>
</section>
<section class="section-head">
<h2>Nacos 注册</h2>
</section>
<section class="infra-panel">
<div class="panel-head">
<div>
<h3>Nacos</h3>
<p>{{ payload.nacos.baseUrl }} · {{ payload.nacos.namespaceName }} · {{ payload.nacos.namespaceId }}</p>
</div>
<span :class="['badge', payload.nacos && payload.nacos.ok ? 'ok' : 'bad']">
{{ payload.nacos && payload.nacos.ok ? '连接正常' : '连接异常' }}
</span>
</div>
<div class="summary-grid">
<div class="summary-card">
<small>命名空间</small>
<strong>{{ payload.nacos.summary.namespaceTotal || 0 }}</strong>
</div>
<div class="summary-card">
<small>服务数</small>
<strong>{{ payload.nacos.summary.serviceTotal || 0 }}</strong>
</div>
<div class="summary-card">
<small>实例数</small>
<strong>{{ payload.nacos.summary.instanceTotal || 0 }}</strong>
</div>
<div :class="['summary-card', (payload.nacos.summary.unhealthyInstanceTotal || 0) > 0 ? 'tone-bad' : 'tone-ok']">
<small>异常实例</small>
<strong>{{ payload.nacos.summary.unhealthyInstanceTotal || 0 }}</strong>
</div>
</div>
<p v-if="payload.nacos.error" class="metric-error">{{ payload.nacos.error }}</p>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>命名空间</th>
<th>ID</th>
<th>配置数</th>
<th>类型</th>
</tr>
</thead>
<tbody>
<tr v-for="item in payload.nacos.namespaces" :key="item.namespaceId">
<td>{{ item.name }}</td>
<td class="mono">{{ item.namespaceId }}</td>
<td>{{ item.configCount }}</td>
<td>{{ item.type }}</td>
</tr>
</tbody>
</table>
</div>
<div class="nacos-service-grid">
<article v-for="service in filteredNacosServices" :key="service.rawServiceName" class="nacos-service-card">
<header class="nacos-service-head">
<div>
<h4>{{ service.serviceName }}</h4>
<p>{{ service.namespaceName || 'public' }} · {{ service.groupName }}</p>
</div>
<span :class="['badge', service.level === 'ok' ? 'ok' : service.level === 'warn' ? 'warn' : 'bad']">
{{ service.instanceTotal }} / {{ service.healthyTotal }}
</span>
</header>
<div class="table-wrap compact">
<table class="table compact">
<thead>
<tr>
<th>IP</th>
<th>端口</th>
<th>集群</th>
<th>健康</th>
<th>启用</th>
<th>权重</th>
</tr>
</thead>
<tbody>
<tr v-for="instance in service.instances" :key="service.rawServiceName + '-' + instance.ip + '-' + instance.port">
<td class="mono">{{ instance.ip }}</td>
<td>{{ instance.port }}</td>
<td>{{ instance.clusterName }}</td>
<td :class="instance.healthy ? 'ok-text' : 'bad-text'">{{ instance.healthy ? 'Y' : 'N' }}</td>
<td :class="instance.enabled ? 'ok-text' : 'bad-text'">{{ instance.enabled ? 'Y' : 'N' }}</td>
<td>{{ instance.weight }}</td>
</tr>
</tbody>
</table>
</div>
</article>
</div>
</section>
<section class="section-head">
<h2>Nacos 配置</h2>
</section>
<section class="infra-panel">
<div class="panel-head">
<div>
<h3>Nacos Config</h3>
<p>读取当前命名空间下的配置并直接编辑保存</p>
</div>
<span :class="['badge', nacosConfig.error || nacosConfig.detailError ? 'bad' : 'ok']">
{{ nacosConfig.error || nacosConfig.detailError ? '存在错误' : '可操作' }}
</span>
</div>
<div class="config-toolbar">
<input
v-model.trim="nacosConfig.keyword"
class="search config-search"
placeholder="搜索 dataId / group / appName"
/>
<button class="refresh" @click="refreshNacosConfigs({ preserveSelection: true, reloadSelected: false })" :disabled="nacosConfig.loading">
{{ nacosConfig.loading ? '加载中...' : '刷新配置列表' }}
</button>
</div>
<p v-if="nacosConfig.error" class="metric-error">{{ nacosConfig.error }}</p>
<p v-if="nacosConfig.detailError" class="metric-error">{{ nacosConfig.detailError }}</p>
<div class="config-layout">
<aside class="config-list">
<button
v-for="item in filteredNacosConfigs"
:key="configKey(item.group, item.dataId)"
:class="['config-item', configKey(item.group, item.dataId) === nacosConfig.selectedKey ? 'active' : '']"
@click="openNacosConfig(item)"
>
<strong>{{ item.dataId }}</strong>
<span>{{ item.group }} · {{ item.type || 'text' }}</span>
<small v-if="item.appName">{{ item.appName }}</small>
</button>
<div v-if="!nacosConfig.loading && filteredNacosConfigs.length === 0" class="config-empty">
当前没有可展示的配置项
</div>
</aside>
<section class="config-editor">
<template v-if="nacosConfig.editor.dataId">
<div class="config-meta-grid">
<div class="summary-card">
<small>Data ID</small>
<strong class="mono">{{ nacosConfig.editor.dataId }}</strong>
</div>
<div class="summary-card">
<small>Group</small>
<strong class="mono">{{ nacosConfig.editor.group }}</strong>
</div>
<div class="summary-card">
<small>Type</small>
<strong>{{ nacosConfig.editor.type || 'text' }}</strong>
</div>
<div class="summary-card">
<small>最后修改</small>
<strong>{{ formatDateTime(nacosConfig.editor.lastModifiedTime) }}</strong>
</div>
</div>
<p v-if="nacosConfig.editor.description" class="config-description">{{ nacosConfig.editor.description }}</p>
<p v-if="nacosConfig.editor.appName" class="config-description">appName{{ nacosConfig.editor.appName }}</p>
<p v-if="nacosConfig.editor.md5" class="config-description mono">md5{{ nacosConfig.editor.md5 }}</p>
<textarea
v-model="nacosConfig.editor.content"
class="config-textarea mono"
spellcheck="false"
:disabled="nacosConfig.detailLoading || nacosConfig.saving"
></textarea>
<div class="config-actions">
<span v-if="nacosConfig.saveMessage" class="ok-text">{{ nacosConfig.saveMessage }}</span>
<span v-else-if="nacosConfig.detailLoading" class="badge neutral">配置加载中</span>
<span v-else-if="nacosConfigDirty" class="badge warn">有未保存修改</span>
<span v-else class="badge neutral">内容已同步</span>
<button class="refresh" @click="saveNacosConfig" :disabled="nacosConfig.saving || nacosConfig.detailLoading">
{{ nacosConfig.saving ? '保存中...' : '保存配置' }}
</button>
</div>
</template>
<div v-else class="config-empty">
请选择左侧配置项
</div>
</section>
</div>
</section>
<section class="section-head">
<h2>MongoDB 指标</h2>
</section>
<section class="infra-panel">
<div class="panel-head">
<div>
<h3>MongoDB</h3>
<p>{{ payload.mongo.info.host || '-' }} · {{ payload.mongo.info.version || '-' }} · {{ payload.mongo.info.process || '-' }}</p>
</div>
<span :class="['badge', payload.mongo && payload.mongo.ok ? 'ok' : 'bad']">
{{ payload.mongo && payload.mongo.ok ? '连接正常' : '连接异常' }}
</span>
</div>
<div class="summary-grid">
<div class="summary-card">
<small>运行时长</small>
<strong>{{ formatDuration(payload.mongo.info.uptimeSeconds) }}</strong>
</div>
<div class="summary-card">
<small>当前连接</small>
<strong>{{ formatNumber(payload.mongo.connections.current) }}</strong>
</div>
<div class="summary-card">
<small>可用连接</small>
<strong>{{ formatNumber(payload.mongo.connections.available) }}</strong>
</div>
<div class="summary-card">
<small>数据库</small>
<strong>{{ formatNumber(payload.mongo.summary.databaseTotal) }}</strong>
</div>
<div class="summary-card">
<small>集合</small>
<strong>{{ formatNumber(payload.mongo.summary.collectionTotal) }}</strong>
</div>
<div class="summary-card">
<small>对象数</small>
<strong>{{ formatNumber(payload.mongo.summary.objectTotal) }}</strong>
</div>
<div class="summary-card">
<small>数据量</small>
<strong>{{ formatMb(payload.mongo.summary.dataSizeMb) }}</strong>
</div>
<div class="summary-card">
<small>存储量</small>
<strong>{{ formatMb(payload.mongo.summary.storageSizeMb) }}</strong>
</div>
</div>
<p v-if="payload.mongo.error" class="metric-error">{{ payload.mongo.error }}</p>
<div class="metric-grid">
<div class="summary-card">
<small>Resident</small>
<strong>{{ formatMb(payload.mongo.memory.residentMb) }}</strong>
</div>
<div class="summary-card">
<small>Virtual</small>
<strong>{{ formatMb(payload.mongo.memory.virtualMb) }}</strong>
</div>
<div class="summary-card">
<small>Mapped</small>
<strong>{{ formatMb(payload.mongo.memory.mappedMb) }}</strong>
</div>
<div class="summary-card">
<small>Requests</small>
<strong>{{ formatNumber(payload.mongo.network.numRequests) }}</strong>
</div>
<div class="summary-card">
<small>Bytes In</small>
<strong>{{ formatBytes(payload.mongo.network.bytesIn) }}</strong>
</div>
<div class="summary-card">
<small>Bytes Out</small>
<strong>{{ formatBytes(payload.mongo.network.bytesOut) }}</strong>
</div>
<div class="summary-card">
<small>WT Cache</small>
<strong>{{ formatBytes(payload.mongo.wiredTigerCache.bytesCurrentlyInCache) }}</strong>
</div>
<div class="summary-card">
<small>WT Dirty</small>
<strong>{{ formatBytes(payload.mongo.wiredTigerCache.trackedDirtyBytesInCache) }}</strong>
</div>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>操作</th>
<th>次数</th>
<th>操作</th>
<th>次数</th>
</tr>
</thead>
<tbody>
<tr>
<td>insert</td>
<td>{{ formatNumber(payload.mongo.opcounters.insert) }}</td>
<td>query</td>
<td>{{ formatNumber(payload.mongo.opcounters.query) }}</td>
</tr>
<tr>
<td>update</td>
<td>{{ formatNumber(payload.mongo.opcounters.update) }}</td>
<td>delete</td>
<td>{{ formatNumber(payload.mongo.opcounters.delete) }}</td>
</tr>
<tr>
<td>getmore</td>
<td>{{ formatNumber(payload.mongo.opcounters.getmore) }}</td>
<td>command</td>
<td>{{ formatNumber(payload.mongo.opcounters.command) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="payload.mongo.replicaSet && payload.mongo.replicaSet.configured" class="table-wrap">
<table class="table">
<thead>
<tr>
<th>成员</th>
<th>状态</th>
<th>健康</th>
<th>运行时长</th>
<th>复制延迟</th>
<th>最后 Optime</th>
</tr>
</thead>
<tbody>
<tr v-for="member in payload.mongo.replicaSet.members" :key="member.name">
<td class="mono">{{ member.name }}</td>
<td>{{ member.stateStr }}</td>
<td :class="member.health >= 1 ? 'ok-text' : 'bad-text'">{{ member.health }}</td>
<td>{{ formatDuration(member.uptime) }}</td>
<td>{{ member.lagSeconds == null ? '-' : `${member.lagSeconds}s` }}</td>
<td>{{ formatDateTime(member.optimeDate) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>数据库</th>
<th>集合</th>
<th>对象</th>
<th>数据量</th>
<th>存储量</th>
<th>索引数</th>
<th>索引量</th>
</tr>
</thead>
<tbody>
<tr v-for="db in payload.mongo.databases" :key="db.name">
<td>{{ db.name }}</td>
<td>{{ formatNumber(db.collections) }}</td>
<td>{{ formatNumber(db.objects) }}</td>
<td>{{ formatMb(db.dataSizeMb) }}</td>
<td>{{ formatMb(db.storageSizeMb) }}</td>
<td>{{ formatNumber(db.indexCount) }}</td>
<td>{{ formatMb(db.indexSizeMb) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</body>
</html>