hy-app-monitor/static/index.html

1822 lines
107 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="topbar">
<div class="brand-stack">
<div class="brand-row">
<p class="brand-mark">HY APP MONITOR</p>
<span class="env-pill">DEPLOY</span>
</div>
<p class="sync-note">最后刷新 {{ formattedUpdatedAt }}</p>
</div>
<nav class="topbar-nav" aria-label="主导航">
<button
v-for="tab in viewTabs"
:key="tab.id"
:class="['nav-chip', activeView === tab.id ? 'active' : '']"
@click="activeView = tab.id"
>
{{ tab.label }}
</button>
</nav>
<div class="topbar-status">
<span v-if="auth.username" class="badge neutral mono">{{ auth.username }}</span>
<span :class="['badge', loading ? 'warn' : 'neutral']">{{ loading ? '刷新中' : '在线' }}</span>
<span v-if="error" class="badge bad">接口异常</span>
<label class="interval-field topbar-interval">
<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>
<button class="refresh ghost" @click="logout" :disabled="auth.loggingOut">{{ auth.loggingOut ? '退出中...' : '退出' }}</button>
</div>
</header>
<template v-if="activeView === 'overview' || activeView === 'services' || activeView === 'hosts'">
<section class="control-bar">
<div class="control-group">
<button
v-for="option in primaryFilters"
:key="option.id"
:class="['filter-chip', filter === option.id ? 'active' : '']"
@click="filter = option.id"
>
{{ option.label }}
</button>
</div>
<div class="control-group control-group-spread">
<div class="density-switch">
<button :class="['filter-chip', densityMode === 'compact' ? 'active' : '']" @click="densityMode = 'compact'">紧凑</button>
<button :class="['filter-chip', densityMode === 'detail' ? 'active' : '']" @click="densityMode = 'detail'">详情</button>
</div>
<input v-model.trim="keyword" class="search" placeholder="搜索主机 / 服务 / IP" />
</div>
</section>
</template>
<template v-else-if="activeView === 'infra'">
<section class="control-bar task-control-bar">
<div class="taskbar-context">
<strong>健康与排障</strong>
<span>总览 / Nacos / Mongo / TAT</span>
</div>
<div class="control-group">
<button
v-for="tab in infraTabs"
:key="tab.id"
:class="['filter-chip', infraTab === tab.id ? 'active' : '']"
@click="infraTab = tab.id"
>
{{ tab.label }}
</button>
</div>
<div class="control-group control-group-spread">
<button
v-if="infraTab === 'nacos'"
:class="['filter-chip', infraHealthyNacosOpen ? 'active' : '']"
@click="infraHealthyNacosOpen = !infraHealthyNacosOpen"
>
{{ infraHealthyNacosOpen ? '收起正常服务' : '展开正常服务' }}
</button>
<button
v-if="infraTab === 'tat'"
:class="['filter-chip', infraHealthyTatOpen ? 'active' : '']"
@click="infraHealthyTatOpen = !infraHealthyTatOpen"
>
{{ infraHealthyTatOpen ? '收起正常主机' : '展开正常主机' }}
</button>
<button
v-if="infraTab === 'mongo'"
:class="['filter-chip', infraMongoDetailsOpen ? 'active' : '']"
@click="infraMongoDetailsOpen = !infraMongoDetailsOpen"
>
{{ infraMongoDetailsOpen ? '收起次要指标' : '展开次要指标' }}
</button>
<input
v-model.trim="infraKeyword"
class="search"
:placeholder="infraTab === 'mongo' ? '搜索数据库 / 指标' : infraTab === 'tat' ? '搜索主机 / IP / 节点' : '搜索服务 / namespace / IP'"
/>
</div>
</section>
</template>
<template v-else-if="activeView === 'config'">
<section class="control-bar task-control-bar">
<div class="taskbar-context">
<strong>编辑与校验</strong>
<span>Nacos / Go Env / 历史版本</span>
</div>
<div class="control-group">
<button
v-for="tab in configTabs"
:key="tab.id"
:class="['filter-chip', configTab === tab.id ? 'active' : '']"
@click="configTab = tab.id"
>
{{ tab.label }}
</button>
</div>
<div class="control-group control-group-spread">
<input
v-if="configTab !== 'go'"
v-model.trim="nacosConfig.keyword"
class="search"
:placeholder="configTab === 'history' ? '搜索操作 / 结果 / 时间' : '搜索 dataId / group / appName'"
/>
<div v-else class="taskbar-inline">
<span class="badge neutral">{{ goConfig.hosts.length }} 台主机</span>
<span :class="['badge', goConfig.diffKeys.length > 0 ? 'warn' : 'neutral']">{{ goConfig.diffKeys.length }} 差异 key</span>
</div>
<button
v-if="configTab === 'nacos'"
class="refresh"
@click="refreshNacosConfigs({ preserveSelection: true, reloadSelected: false })"
:disabled="nacosConfig.loading"
>
{{ nacosConfig.loading ? '加载中...' : '刷新配置列表' }}
</button>
<button
v-else-if="configTab === 'go'"
class="refresh"
@click="refreshGoConfig"
:disabled="goConfig.loading"
>
{{ goConfig.loading ? '加载中...' : '刷新 Go 配置' }}
</button>
<button
v-else
class="refresh ghost"
@click="configHistory = []"
:disabled="configHistory.length === 0"
>
清空记录
</button>
</div>
</section>
</template>
<template v-else-if="activeView === 'release'">
<section class="control-bar task-control-bar">
<div class="taskbar-context">
<strong>变更与执行</strong>
<span>构建参数 / 更新目标 / 执行日志 / 滚动重启</span>
</div>
<div class="control-group release-section-nav">
<button
v-for="section in releaseSections"
:key="section.id"
class="section-link"
@click="scrollToSection(section.id)"
>
{{ section.label }}
</button>
</div>
<div class="control-group control-group-spread">
<label class="interval-field">
<span>类型</span>
<select v-model="releaseKindFilter" class="interval-select release-kind-select">
<option value="all">全部</option>
<option value="java">Java</option>
<option value="golang">Golang</option>
<option value="frontend">前端</option>
</select>
</label>
<input v-model.trim="releaseKeyword" class="search" placeholder="搜索服务 / 主机 / 镜像" />
</div>
</section>
</template>
<p v-if="error" class="toolbar-error">加载失败:{{ error }}</p>
<template v-if="activeView === 'overview' || activeView === 'services'">
<section v-if="activeView === 'overview'" class="panel summary-strip">
<div class="summary-cards status-band-list">
<article
v-for="card in summaryStripCards"
:key="card.key"
:class="['summary-tile', 'status-band-item', `tone-${card.tone}`]"
>
<span class="summary-strip-label">{{ card.label }}</span>
<strong>{{ card.value }}</strong>
<span v-if="card.suffix" class="status-band-suffix">{{ card.suffix }}</span>
</article>
</div>
</section>
<section :class="['content-grid', detailPanelVisible ? 'service-layout-open' : 'service-layout-solo']">
<section class="panel matrix-panel">
<header class="panel-header">
<div class="panel-title">
<h2>服务状态矩阵</h2>
<p>{{ serviceMatrixRows.length }} 行 · {{ serviceMatrixColumns.length }} 列</p>
</div>
<div class="panel-meta">
<span class="badge neutral">{{ allServiceRows.length }} 服务</span>
<span :class="['badge', summary.down > 0 ? 'bad' : 'ok']">{{ summary.down }} 异常实例</span>
<span v-if="focusedHostKey" class="badge warn mono">{{ focusedHostKey }}</span>
<button v-if="focusedHostKey" class="refresh ghost" @click="clearFocusedHost">清除主机</button>
<button class="refresh ghost" @click="toggleDetailPanel">
{{ detailPanelVisible ? '收起详情' : `展开详情 ${alertFeed.length > 0 ? `(${alertFeed.length})` : ''}` }}
</button>
</div>
</header>
<div v-if="serviceMatrixSections.length > 0" class="matrix-stacks">
<section v-for="section in serviceMatrixSections" :key="section.group" class="matrix-stack">
<header class="matrix-stack-head">
<strong>{{ section.label }} · {{ section.rows.length }} 服务 / {{ section.columns.length }} 节点</strong>
</header>
<div class="matrix-scroll">
<table :class="['matrix-table', densityMode === 'detail' ? 'is-detail' : 'is-compact']">
<thead>
<tr>
<th class="matrix-sticky-col">服务</th>
<th v-if="densityMode === 'detail'">类型</th>
<th v-for="column in section.columns" :key="section.group + '-' + column.host">{{ column.host }}</th>
<th>实例</th>
<th>异常</th>
<th v-if="densityMode === 'detail'">均值</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in section.rows"
:key="row.key"
:class="[
'matrix-row',
isSelectedService(row.key) ? 'selected' : '',
focusedHostKey && row.instanceMap[focusedHostKey] ? 'focused' : ''
]"
@click="selectService(row.key)"
>
<td class="matrix-service matrix-sticky-col">
<strong>{{ row.service }}</strong>
</td>
<td v-if="densityMode === 'detail'" class="matrix-kind">{{ row.kind }}</td>
<td
v-for="cell in row.sectionCells"
:key="row.key + '-' + cell.host"
:title="matrixCellTitle(cell)"
:class="[
'matrix-state',
cell.present ? toneClass(cell.level || (cell.ok ? 'ok' : 'bad')) : 'matrix-state-empty'
]"
>
<template v-if="cell.present">
<span class="matrix-state-marker" aria-hidden="true"></span>
<span class="matrix-state-code">{{ statusCellLabel(cell) }}</span>
<small v-if="cell.latencyMs != null" class="matrix-state-latency">{{ formatLatency(cell.latencyMs) }}</small>
</template>
<span v-else class="matrix-state-empty-mark">{{ densityMode === 'detail' ? '—' : '·' }}</span>
</td>
<td>{{ row.deployedCount }}</td>
<td :class="row.downCount > 0 ? 'bad-text' : 'ok-text'">{{ row.downCount }}</td>
<td v-if="densityMode === 'detail'">{{ formatLatency(row.avgLatencyMs) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<div v-else class="empty-panel">当前筛选下没有服务</div>
</section>
<aside v-if="detailPanelVisible" class="panel detail-panel">
<header class="panel-header">
<div class="panel-title">
<h2>{{ selectedServiceDetail ? selectedServiceDetail.service : '服务详情' }}</h2>
<p v-if="selectedServiceDetail">{{ selectedServiceDetail.groupLabel }} · {{ selectedServiceDetail.kind }}</p>
<p v-else>{{ alertFeed.length }} 条异常</p>
</div>
<div class="action-group">
<button class="refresh ghost" @click="toggleDetailPanel">收起</button>
<button class="refresh ghost" @click="activeView = 'config'">配置</button>
<button class="refresh ghost" @click="activeView = 'release'">发布</button>
</div>
</header>
<section v-if="alertFeed.length > 0" class="detail-alert-panel">
<header class="detail-subhead">
<strong>最近异常</strong>
<span>{{ alertFeed.length }} 条</span>
</header>
<div class="detail-alert-feed">
<button
v-for="alert in alertFeed"
:key="'detail-' + alert.key"
:class="['alert-item', 'compact', `tone-${alert.tone}`]"
@click="jumpToAlert(alert)"
>
<strong>{{ alert.title }}</strong>
<span>{{ alert.text }}</span>
</button>
</div>
</section>
<template v-if="selectedServiceDetail">
<div class="service-meta-grid">
<div class="summary-card">
<small>部署</small>
<strong>{{ selectedServiceDetail.deployedCount }}</strong>
</div>
<div :class="['summary-card', selectedServiceDetail.downCount > 0 ? 'tone-bad' : 'tone-ok']">
<small>异常</small>
<strong>{{ selectedServiceDetail.downCount }}</strong>
</div>
<div class="summary-card">
<small>均值</small>
<strong>{{ formatLatency(selectedServiceDetail.avgLatencyMs) }}</strong>
</div>
<div v-if="densityMode === 'detail'" class="summary-card">
<small>分组</small>
<strong>{{ selectedServiceDetail.groupLabel }}</strong>
</div>
</div>
<div class="detail-instance-list">
<article v-for="instance in selectedServiceDetail.instances" :key="instance.host" class="detail-instance">
<div class="detail-instance-top">
<div>
<strong>{{ instance.host }}</strong>
<p>{{ instance.ip }} · {{ instance.port }}</p>
</div>
<div class="detail-instance-actions">
<span :class="['badge', instance.ok ? 'ok' : 'bad']">{{ instance.ok ? 'UP' : 'DOWN' }}</span>
<button
class="refresh ghost detail-log-trigger"
@click="openServiceLog(instance)"
:disabled="serviceLog.loading && isServiceLogActive(instance)"
>
{{ serviceLog.loading && isServiceLogActive(instance) ? '读取中...' : '查看日志' }}
</button>
</div>
</div>
<div class="detail-inline">
<span>HTTP {{ instance.statusCode || '-' }}</span>
<span>耗时 {{ formatLatency(instance.latencyMs) }}</span>
</div>
<div v-if="densityMode === 'detail'" class="detail-secondary mono">{{ instance.url || '-' }}</div>
<div v-if="densityMode === 'detail'" class="detail-note">{{ instance.detail || '无返回内容' }}</div>
</article>
</div>
</template>
<div v-else class="empty-panel">当前没有服务详情</div>
</aside>
</section>
<section v-if="activeView === 'overview' && overviewBridgeAlerts.length > 0" class="panel bridge-panel">
<div class="bridge-list">
<button
v-for="item in overviewBridgeAlerts"
:key="'bridge-' + item.key"
class="bridge-item"
@click="selectService(item.service); detailPanelOpen = true"
>
<strong>{{ item.service }}</strong>
<span>{{ item.hosts.join(' / ') }}</span>
<em>{{ item.downCount }} 异常</em>
</button>
</div>
</section>
<section v-if="activeView === 'overview'" class="panel hosts-panel">
<header class="panel-header">
<div class="panel-title">
<h2>主机条带</h2>
<p>{{ overviewAlertHosts.length }} 台异常业务主机 · {{ overviewQuietHosts.length }} 台正常主机</p>
</div>
<div class="panel-meta">
<span class="badge neutral">主机指标正常 {{ summary.hostMetricOk }} / {{ summary.hostTotal }}</span>
<span v-if="focusedHostKey" class="badge warn mono">锁定 {{ focusedHostKey }}</span>
</div>
</header>
<div class="host-sections">
<section class="host-section">
<div class="section-caption-row">
<div class="section-caption">异常主机</div>
<div class="section-caption-meta">
<span>{{ overviewAlertHosts.length }} 台</span>
<span>{{ overviewAlertHosts.filter((host) => host.hasMetricIssue).length }} 采集异常</span>
</div>
</div>
<div class="host-row-list">
<article
v-for="host in overviewAlertHosts"
:key="host.host"
:class="[
'host-strip',
host.hasAlert ? 'host-strip-alert' : '',
isHostHighlighted(host) ? 'host-strip-focused' : ''
]"
>
<div class="host-strip-main" @click="selectHost(host.host)">
<div class="host-strip-id">
<strong>{{ host.host }}</strong>
<span>{{ host.ip }} · {{ host.groupLabel }}</span>
</div>
<div class="host-inline-metrics compact-metrics">
<div
v-for="metric in hostMetricEntries(host)"
:key="host.host + '-' + metric.key"
:class="['metric-ring-chip', `tone-${metric.level}`]"
>
<span class="metric-ring" :style="metricRingStyle(metric.value, metric.level)">
<span class="metric-ring-hole"></span>
</span>
<span class="metric-ring-copy">
<small>{{ metric.label }}</small>
<strong>{{ metric.display }}</strong>
</span>
</div>
<span :class="['metric-stat-chip', `tone-${host.metrics.processLevel || thresholdTone('processCount', host.metrics.processCount)}`]">
<small>PROC</small>
<strong>{{ host.metrics.processCount ?? '-' }}</strong>
</span>
</div>
<div :class="['host-alert-summary', `tone-${hostSummaryTone(host)}`]">
<small>{{ host.downCount > 0 ? '异常服务' : host.hasMetricIssue ? '采集状态' : '在线服务' }}</small>
<span class="host-alert-path">{{ host.downCount > 0 ? host.alertSummary : host.quietSummary }}</span>
</div>
<div class="host-inline-state">
<span class="badge neutral">{{ host.serviceTotal }} 服务</span>
<span :class="['badge', host.downCount > 0 ? 'bad' : 'neutral']">{{ host.downCount }} 异常</span>
<span :class="['badge', host.hasMetricIssue ? 'bad' : 'neutral']">{{ host.hasMetricIssue ? '采集异常' : '指标正常' }}</span>
</div>
</div>
<div v-if="isHostExpanded(host.host)" class="host-strip-detail host-strip-detail-list">
<div v-if="host.hasMetricIssue" class="host-detail-line host-detail-line-warn">
<strong>主机指标</strong>
<span>{{ host.metrics.error || '采集异常' }}</span>
</div>
<button
v-for="item in host.alertItems"
:key="host.host + '-' + item.service"
class="host-detail-line"
@click.stop="selectService(item.service); activeView = 'services'"
>
<strong>{{ item.service }}</strong>
<span>{{ item.statusCode || 'ERR' }} · {{ formatLatency(item.latencyMs) }}</span>
</button>
<div v-if="!host.hasMetricIssue && host.alertItems.length === 0" class="empty-panel compact">当前主机没有异常服务</div>
</div>
<p v-if="isHostExpanded(host.host) && host.metrics.error" class="metric-error">{{ host.metrics.error }}</p>
</article>
</div>
<div v-if="overviewAlertHosts.length === 0" class="empty-panel compact">当前没有异常业务主机</div>
</section>
<section v-if="overviewQuietHosts.length > 0" class="host-section">
<button class="host-fold-toggle" @click="toggleOverviewHealthyHosts">
<span>正常主机</span>
<strong>{{ overviewQuietHosts.length }}</strong>
<small>{{ overviewHealthyHostsOpen ? '收起' : '展开' }}</small>
</button>
<div v-if="overviewHealthyHostsOpen" class="host-row-list">
<article
v-for="host in overviewQuietHosts"
:key="'quiet-' + host.host"
:class="[
'host-strip',
host.hasAlert ? 'host-strip-alert' : '',
isHostHighlighted(host) ? 'host-strip-focused' : ''
]"
>
<div class="host-strip-main" @click="selectHost(host.host)">
<div class="host-strip-id">
<strong>{{ host.host }}</strong>
<span>{{ host.ip }} · {{ host.groupLabel }}</span>
</div>
<div class="host-inline-metrics compact-metrics">
<div
v-for="metric in hostMetricEntries(host)"
:key="'quiet-' + host.host + '-' + metric.key"
:class="['metric-ring-chip', `tone-${metric.level}`]"
>
<span class="metric-ring" :style="metricRingStyle(metric.value, metric.level)">
<span class="metric-ring-hole"></span>
</span>
<span class="metric-ring-copy">
<small>{{ metric.label }}</small>
<strong>{{ metric.display }}</strong>
</span>
</div>
<span :class="['metric-stat-chip', `tone-${host.metrics.processLevel || thresholdTone('processCount', host.metrics.processCount)}`]">
<small>PROC</small>
<strong>{{ host.metrics.processCount ?? '-' }}</strong>
</span>
</div>
<div class="host-alert-summary tone-neutral">
<small>在线服务</small>
<span class="host-alert-path">{{ host.quietSummary }}</span>
</div>
<div class="host-inline-state">
<span class="badge neutral">{{ host.serviceTotal }} 服务</span>
<span class="badge neutral">{{ host.downCount }} 异常</span>
<span class="badge neutral">{{ host.hasMetricIssue ? '采集异常' : '指标正常' }}</span>
</div>
</div>
<div v-if="isHostExpanded(host.host)" class="host-strip-detail host-strip-detail-list">
<div v-if="host.hasMetricIssue" class="host-detail-line host-detail-line-warn">
<strong>主机指标</strong>
<span>{{ host.metrics.error || '采集异常' }}</span>
</div>
<div v-if="!host.hasMetricIssue && host.alertItems.length === 0" class="empty-panel compact">当前主机没有异常服务</div>
</div>
</article>
</div>
</section>
</div>
</section>
</template>
<template v-if="activeView === 'hosts'">
<section class="panel hosts-panel">
<header class="panel-header">
<div class="panel-title">
<h2>主机视图</h2>
<p>{{ filteredHostRows.length }} 台主机</p>
</div>
<div class="panel-meta">
<span class="badge neutral">{{ filteredHostAlertTotal }} 台异常</span>
<span :class="['badge', filteredHostMetricIssueTotal > 0 ? 'warn' : 'neutral']">{{ filteredHostMetricIssueTotal }} 采集异常</span>
</div>
</header>
<div class="host-sections">
<section v-for="section in hostRowGroups" :key="section.group" class="host-section">
<div class="section-caption-row">
<div class="section-caption">{{ section.label }}</div>
<div class="section-caption-meta">
<span>{{ section.items.length }} 台</span>
<span>{{ section.serviceCount }} 服务</span>
<span>{{ section.alertCount }} 异常主机</span>
</div>
</div>
<div class="host-row-list">
<article
v-for="host in section.items"
:key="host.host"
:class="[
'host-strip',
host.hasAlert ? 'host-strip-alert' : '',
isHostHighlighted(host) ? 'host-strip-focused' : ''
]"
>
<div class="host-strip-main" @click="selectHost(host.host)">
<div class="host-strip-id">
<strong>{{ host.host }}</strong>
<span>{{ host.ip }} · {{ host.groupLabel }} · {{ host.instanceId }}</span>
</div>
<div class="host-inline-metrics compact-metrics">
<div
v-for="metric in hostMetricEntries(host)"
:key="'host-' + host.host + '-' + metric.key"
:class="['metric-ring-chip', `tone-${metric.level}`]"
>
<span class="metric-ring" :style="metricRingStyle(metric.value, metric.level)">
<span class="metric-ring-hole"></span>
</span>
<span class="metric-ring-copy">
<small>{{ metric.label }}</small>
<strong>{{ metric.display }}</strong>
</span>
</div>
<span :class="['metric-stat-chip', `tone-${host.metrics.processLevel || thresholdTone('processCount', host.metrics.processCount)}`]">
<small>PROC</small>
<strong>{{ host.metrics.processCount ?? '-' }}</strong>
</span>
</div>
<div :class="['host-alert-summary', `tone-${hostSummaryTone(host)}`]">
<small>{{ host.downCount > 0 ? '异常服务' : host.hasMetricIssue ? '采集状态' : '在线服务' }}</small>
<span class="host-alert-path">{{ host.downCount > 0 ? host.alertSummary : host.quietSummary }}</span>
</div>
<div class="host-inline-state">
<span class="badge neutral">{{ host.serviceTotal }} 服务</span>
<span :class="['badge', host.downCount > 0 ? 'bad' : 'neutral']">{{ host.downCount }} 异常</span>
<span :class="['badge', host.hasMetricIssue ? 'bad' : 'neutral']">{{ host.hasMetricIssue ? '采集异常' : '指标正常' }}</span>
</div>
</div>
<div v-if="isHostExpanded(host.host)" class="host-strip-detail host-strip-detail-list host-strip-detail-wide">
<button
v-for="item in host.items"
:key="host.host + '-' + item.service"
:class="['host-detail-line', toneClass(item.level || (item.ok ? 'ok' : 'bad'))]"
@click.stop="selectService(item.service); activeView = 'services'"
>
<strong>{{ item.service }}</strong>
<span>{{ item.statusCode || 'ERR' }} · {{ formatLatency(item.latencyMs) }}</span>
<small v-if="densityMode === 'detail'">{{ item.url }}</small>
</button>
<div v-if="host.items.length === 0" class="empty-panel compact">当前主机没有服务实例</div>
</div>
<p v-if="isHostExpanded(host.host) && host.metrics.error" class="metric-error">{{ host.metrics.error }}</p>
</article>
</div>
</section>
</div>
</section>
</template>
<template v-if="activeView === 'infra'">
<section class="panel task-page-head">
<header class="panel-header">
<div class="panel-title">
<h2>基础设施</h2>
<p>健康与排障</p>
</div>
<div class="panel-meta">
<span class="badge neutral">Nacos {{ payload.nacos && payload.nacos.ok ? '在线' : '异常' }}</span>
<span class="badge neutral">Mongo {{ payload.mongo && payload.mongo.ok ? '在线' : '异常' }}</span>
<span :class="['badge', (summary.hostMetricDown || 0) > 0 ? 'warn' : 'neutral']">TAT {{ summary.hostMetricOk || 0 }} / {{ summary.hostTotal || 0 }}</span>
</div>
</header>
</section>
<section class="overview-infra-grid">
<article v-for="card in overviewInfraCards" :key="'infra-' + card.key" class="panel infra-mini-card">
<header class="panel-header">
<div class="panel-title">
<h2>{{ card.label }}</h2>
<p>{{ card.meta }}</p>
</div>
<span :class="['badge', card.tone === 'bad' ? 'bad' : card.tone === 'warn' ? 'warn' : 'ok']">{{ card.badge }}</span>
</header>
<div class="infra-mini-metrics">
<div v-for="item in card.metrics" :key="'infra-' + card.key + '-' + item.label" class="summary-card">
<small>{{ item.label }}</small>
<strong>{{ item.value }}</strong>
</div>
</div>
</article>
</section>
<section v-if="infraTab === 'overview'" class="panel infra-overview-panel">
<header class="panel-header">
<div class="panel-title">
<h2>状态总览</h2>
<p>异常前置,正常折叠</p>
</div>
<div class="panel-meta">
<span class="badge neutral">{{ infraAlertNacosServices.length }} Nacos 异常服务</span>
<span class="badge neutral">{{ tatAlertHosts.length }} TAT 异常主机</span>
</div>
</header>
<div class="infra-overview-grid">
<article class="infra-focus-card">
<header class="infra-focus-head">
<strong>Nacos</strong>
<span :class="['badge', payload.nacos && payload.nacos.ok ? 'ok' : 'bad']">{{ payload.nacos && payload.nacos.ok ? '连接正常' : '连接异常' }}</span>
</header>
<div class="infra-focus-metrics">
<span>命名空间 {{ payload.nacos.summary.namespaceTotal || 0 }}</span>
<span>服务 {{ payload.nacos.summary.serviceTotal || 0 }}</span>
<span>实例 {{ payload.nacos.summary.instanceTotal || 0 }}</span>
<span>异常 {{ payload.nacos.summary.unhealthyInstanceTotal || 0 }}</span>
</div>
<div class="infra-inline-list">
<span v-if="infraAlertNacosServices.length === 0" class="badge neutral">当前无异常服务</span>
<button
v-for="service in infraAlertNacosServices.slice(0, 6)"
:key="'overview-nacos-' + service.rawServiceName"
class="infra-inline-link"
@click="infraTab = 'nacos'; toggleNacosService(service.rawServiceName)"
>
{{ service.serviceName }} · {{ service.unhealthyTotal }}
</button>
</div>
</article>
<article class="infra-focus-card">
<header class="infra-focus-head">
<strong>Mongo</strong>
<span :class="['badge', payload.mongo && payload.mongo.ok ? 'ok' : 'bad']">{{ payload.mongo && payload.mongo.ok ? '连接正常' : '连接异常' }}</span>
</header>
<div class="infra-focus-metrics">
<span>连接 {{ formatNumber(payload.mongo.connections.current) }}</span>
<span>数据库 {{ formatNumber(payload.mongo.summary.databaseTotal) }}</span>
<span>集合 {{ formatNumber(payload.mongo.summary.collectionTotal) }}</span>
<span>流量 {{ formatBytes(payload.mongo.network.bytesOut) }}</span>
</div>
<button class="refresh ghost" @click="infraTab = 'mongo'">查看 Mongo 指标</button>
</article>
<article class="infra-focus-card">
<header class="infra-focus-head">
<strong>TAT</strong>
<span :class="['badge', (summary.hostMetricDown || 0) > 0 ? 'warn' : 'ok']">{{ (summary.hostMetricDown || 0) > 0 ? '采集异常' : '采集正常' }}</span>
</header>
<div class="infra-focus-metrics">
<span>主机 {{ summary.hostTotal || 0 }}</span>
<span>正常 {{ summary.hostMetricOk || 0 }}</span>
<span>异常 {{ summary.hostMetricDown || 0 }}</span>
<span>服务异常 {{ hostServiceAlertTotal }}</span>
</div>
<div class="infra-inline-list">
<span v-if="tatAlertHosts.length === 0" class="badge neutral">当前无采集异常</span>
<button
v-for="host in tatAlertHosts.slice(0, 6)"
:key="'overview-tat-' + host.host"
class="infra-inline-link"
@click="infraTab = 'tat'"
>
{{ host.host }} · {{ host.metrics.error || '采集异常' }}
</button>
</div>
</article>
</div>
</section>
<section v-if="infraTab === 'nacos'" class="panel infra-panel">
<header class="panel-header">
<div class="panel-title">
<h2>Nacos 服务</h2>
<p>{{ payload.nacos.baseUrl || '-' }} · {{ payload.nacos.namespaceName || 'default' }} · {{ truncateMiddle(payload.nacos.namespaceId, 10, 8) || '-' }}</p>
</div>
<span :class="['badge', payload.nacos && payload.nacos.ok ? 'ok' : 'bad']">
{{ payload.nacos && payload.nacos.ok ? '连接正常' : '连接异常' }}
</span>
</header>
<div class="metadata-strip">
<span>命名空间 {{ payload.nacos.summary.namespaceTotal || 0 }}</span>
<span>服务 {{ payload.nacos.summary.serviceTotal || 0 }}</span>
<span>实例 {{ payload.nacos.summary.instanceTotal || 0 }}</span>
<span :class="(payload.nacos.summary.unhealthyInstanceTotal || 0) > 0 ? 'bad-text' : 'ok-text'">异常 {{ payload.nacos.summary.unhealthyInstanceTotal || 0 }}</span>
</div>
<p v-if="payload.nacos.error" class="metric-error">{{ explainError(payload.nacos.error) }}</p>
<div class="table-wrap">
<table class="table compact">
<thead>
<tr>
<th>服务</th>
<th>命名空间</th>
<th>分组</th>
<th>实例数</th>
<th>健康数</th>
<th>展开</th>
</tr>
</thead>
<tbody>
<template v-for="service in infraAlertNacosServices" :key="'nacos-alert-' + service.rawServiceName">
<tr class="nacos-summary-row is-alert">
<td>{{ service.serviceName }}</td>
<td>{{ service.namespaceName || 'public' }}</td>
<td>{{ service.groupName }}</td>
<td>{{ service.instanceTotal }}</td>
<td :class="service.unhealthyTotal > 0 ? 'bad-text' : 'ok-text'">{{ service.healthyTotal }}</td>
<td><button class="refresh ghost inline-button" @click="toggleNacosService(service.rawServiceName)">{{ isNacosServiceExpanded(service.rawServiceName) ? '收起' : '展开' }}</button></td>
</tr>
<tr v-if="isNacosServiceExpanded(service.rawServiceName)">
<td colspan="6" class="nacos-service-detail-cell">
<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>
</td>
</tr>
</template>
<tr v-if="infraAlertNacosServices.length === 0">
<td colspan="6" class="table-empty-cell">当前没有异常 Nacos 服务</td>
</tr>
</tbody>
</table>
</div>
<section v-if="infraHealthyNacosServices.length > 0" class="fold-group">
<button class="host-fold-toggle" @click="infraHealthyNacosOpen = !infraHealthyNacosOpen">
<span>全部正常服务</span>
<strong>{{ infraHealthyNacosServices.length }}</strong>
<small>{{ infraHealthyNacosOpen ? '收起' : '展开' }}</small>
</button>
<div v-if="infraHealthyNacosOpen" class="table-wrap">
<table class="table compact">
<thead>
<tr>
<th>服务</th>
<th>命名空间</th>
<th>分组</th>
<th>实例数</th>
<th>健康数</th>
<th>展开</th>
</tr>
</thead>
<tbody>
<template v-for="service in infraVisibleHealthyNacosServices" :key="'nacos-ok-' + service.rawServiceName">
<tr class="nacos-summary-row">
<td>{{ service.serviceName }}</td>
<td>{{ service.namespaceName || 'public' }}</td>
<td>{{ service.groupName }}</td>
<td>{{ service.instanceTotal }}</td>
<td class="ok-text">{{ service.healthyTotal }}</td>
<td><button class="refresh ghost inline-button" @click="toggleNacosService(service.rawServiceName)">{{ isNacosServiceExpanded(service.rawServiceName) ? '收起' : '展开' }}</button></td>
</tr>
<tr v-if="isNacosServiceExpanded(service.rawServiceName)">
<td colspan="6" class="nacos-service-detail-cell">
<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 + '-healthy-' + 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>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
</section>
<section v-if="infraTab === 'mongo'" class="panel infra-panel">
<header class="panel-header">
<div class="panel-title">
<h2>MongoDB</h2>
<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>
</header>
<div class="mongo-group-grid">
<article class="mongo-stat-group">
<header class="infra-focus-head">
<strong>连接</strong>
<span class="badge neutral">当前 {{ formatNumber(payload.mongo.connections.current) }}</span>
</header>
<div class="infra-focus-metrics">
<span>运行时长 {{ formatDuration(payload.mongo.info.uptimeSeconds) }}</span>
<span>请求 {{ formatNumber(payload.mongo.network.numRequests) }}</span>
<span>副本 {{ payload.mongo.replicaSet && payload.mongo.replicaSet.configured ? payload.mongo.replicaSet.members.length : 0 }}</span>
</div>
</article>
<article class="mongo-stat-group">
<header class="infra-focus-head">
<strong>容量</strong>
<span class="badge neutral">数据库 {{ formatNumber(payload.mongo.summary.databaseTotal) }}</span>
</header>
<div class="infra-focus-metrics">
<span>集合 {{ formatNumber(payload.mongo.summary.collectionTotal) }}</span>
<span>对象 {{ formatNumber(payload.mongo.summary.objectTotal) }}</span>
<span>数据量 {{ formatMb(payload.mongo.summary.dataSizeMb) }}</span>
</div>
</article>
<article class="mongo-stat-group">
<header class="infra-focus-head">
<strong>流量</strong>
<span class="badge neutral">Bytes Out {{ formatBytes(payload.mongo.network.bytesOut) }}</span>
</header>
<div class="infra-focus-metrics">
<span>Bytes In {{ formatBytes(payload.mongo.network.bytesIn) }}</span>
<span>WT Cache {{ formatBytes(payload.mongo.wiredTigerCache.bytesCurrentlyInCache) }}</span>
<span>Resident {{ formatMb(payload.mongo.memory.residentMb) }}</span>
</div>
</article>
</div>
<p v-if="payload.mongo.error" class="metric-error">{{ payload.mongo.error }}</p>
<section class="fold-group">
<button class="host-fold-toggle" @click="infraMongoDetailsOpen = !infraMongoDetailsOpen">
<span>次要指标与明细</span>
<strong>{{ payload.mongo.databases.length }}</strong>
<small>{{ infraMongoDetailsOpen ? '收起' : '展开' }}</small>
</button>
<div v-if="infraMongoDetailsOpen" class="infra-detail-stack">
<div class="metric-grid">
<div class="summary-card">
<small>Virtual</small>
<strong>{{ formatMb(payload.mongo.memory.virtualMb) }}</strong>
</div>
<div class="summary-card">
<small>Resident</small>
<strong>{{ formatMb(payload.mongo.memory.residentMb) }}</strong>
</div>
<div class="summary-card">
<small>Requests</small>
<strong>{{ formatNumber(payload.mongo.network.numRequests) }}</strong>
</div>
</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 filteredMongoDatabases" :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>
<tr v-if="filteredMongoDatabases.length === 0">
<td colspan="7" class="table-empty-cell">当前没有匹配的数据库</td>
</tr>
</tbody>
</table>
</div>
<div v-if="payload.mongo.replicaSet && payload.mongo.replicaSet.configured" class="table-wrap">
<table class="table compact">
<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>
</section>
</section>
<section v-if="infraTab === 'tat'" class="panel infra-panel">
<header class="panel-header">
<div class="panel-title">
<h2>TAT 主机采集</h2>
<p>主机资源与采集状态</p>
</div>
<span :class="['badge', (summary.hostMetricDown || 0) > 0 ? 'warn' : 'ok']">{{ (summary.hostMetricDown || 0) > 0 ? '采集异常' : '采集正常' }}</span>
</header>
<div class="metadata-strip">
<span>主机 {{ summary.hostTotal || 0 }}</span>
<span>正常 {{ summary.hostMetricOk || 0 }}</span>
<span :class="(summary.hostMetricDown || 0) > 0 ? 'bad-text' : 'ok-text'">异常 {{ summary.hostMetricDown || 0 }}</span>
</div>
<div class="table-wrap">
<table class="table compact">
<thead>
<tr>
<th>主机</th>
<th>IP</th>
<th>节点</th>
<th>CPU</th>
<th>MEM</th>
<th>DISK</th>
<th>PROC</th>
<th>采集</th>
<th>服务异常</th>
</tr>
</thead>
<tbody>
<tr v-for="host in tatAlertHosts" :key="'tat-alert-' + host.host">
<td>{{ host.host }}</td>
<td class="mono">{{ host.ip }}</td>
<td>{{ host.groupLabel }}</td>
<td>{{ formatPercent(host.metrics.cpuPercent) }}</td>
<td>{{ formatPercent(host.metrics.memoryPercent) }}</td>
<td>{{ formatPercent(host.metrics.diskPercent) }}</td>
<td>{{ host.metrics.processCount ?? '-' }}</td>
<td class="bad-text">{{ host.metrics.error || '异常' }}</td>
<td>{{ host.downCount }}</td>
</tr>
<tr v-if="tatAlertHosts.length === 0">
<td colspan="9" class="table-empty-cell">当前没有采集异常主机</td>
</tr>
</tbody>
</table>
</div>
<section v-if="tatHealthyHosts.length > 0" class="fold-group">
<button class="host-fold-toggle" @click="infraHealthyTatOpen = !infraHealthyTatOpen">
<span>全部正常主机</span>
<strong>{{ tatHealthyHosts.length }}</strong>
<small>{{ infraHealthyTatOpen ? '收起' : '展开' }}</small>
</button>
<div v-if="infraHealthyTatOpen" class="table-wrap">
<table class="table compact">
<thead>
<tr>
<th>主机</th>
<th>IP</th>
<th>节点</th>
<th>CPU</th>
<th>MEM</th>
<th>DISK</th>
<th>PROC</th>
<th>采集</th>
<th>服务异常</th>
</tr>
</thead>
<tbody>
<tr v-for="host in visibleTatHealthyHosts" :key="'tat-ok-' + host.host">
<td>{{ host.host }}</td>
<td class="mono">{{ host.ip }}</td>
<td>{{ host.groupLabel }}</td>
<td>{{ formatPercent(host.metrics.cpuPercent) }}</td>
<td>{{ formatPercent(host.metrics.memoryPercent) }}</td>
<td>{{ formatPercent(host.metrics.diskPercent) }}</td>
<td>{{ host.metrics.processCount ?? '-' }}</td>
<td class="ok-text">正常</td>
<td>{{ host.downCount }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</section>
</template>
<template v-if="activeView === 'config'">
<section class="panel task-page-head">
<header class="panel-header">
<div class="panel-title">
<h2>配置</h2>
<p>{{ activeConfigTabLabel }} · 编辑与校验</p>
</div>
<div class="panel-meta">
<span :class="['badge', nacosConfig.error || nacosConfig.detailError || goConfig.error ? 'bad' : 'ok']">
{{ nacosConfig.error || nacosConfig.detailError || goConfig.error ? '存在错误' : '可操作' }}
</span>
<span class="badge neutral">本地记录 {{ configHistory.length }}</span>
</div>
</header>
</section>
<section v-if="configTab === 'nacos'" class="panel infra-panel">
<header class="panel-header">
<div class="panel-title">
<h2>Nacos 配置</h2>
<p>{{ nacosConfig.namespaceName || 'default' }} · {{ truncateMiddle(nacosConfig.namespaceId, 10, 8) || '-' }}</p>
</div>
<div class="panel-meta">
<span class="badge neutral">{{ filteredNacosConfigs.length }} 项</span>
<span :class="['badge', nacosConfig.error || nacosConfig.detailError ? 'bad' : 'ok']">{{ nacosConfig.error || nacosConfig.detailError ? '存在错误' : '已连接' }}</span>
</div>
</header>
<p v-if="nacosConfig.error" class="metric-error">{{ explainError(nacosConfig.error) }}</p>
<p v-if="nacosConfig.detailError" class="metric-error">{{ explainError(nacosConfig.detailError) }}</p>
<div class="config-layout">
<aside class="config-sidebar">
<div class="config-sidebar-head">
<div class="config-sidebar-meta">
<span class="badge neutral mono">{{ truncateMiddle(nacosConfig.baseUrl, 16, 12) || '-' }}</span>
<span class="badge neutral">{{ nacosConfigGroups.length }} 组</span>
</div>
<div class="config-sidebar-path" v-if="nacosConfig.editor.dataId">
<strong>{{ nacosConfig.editor.dataId }}</strong>
<span>{{ nacosConfig.editor.group }}</span>
</div>
</div>
<div class="config-list config-list-ide">
<section v-for="group in nacosConfigGroups" :key="group.key" class="config-group">
<button class="config-group-toggle" @click="toggleConfigGroup(group.key)">
<strong>{{ group.label }}</strong>
<span>{{ group.items.length }} 项</span>
<small>{{ isConfigGroupCollapsed(group.key) ? '展开' : '收起' }}</small>
</button>
<div v-if="!isConfigGroupCollapsed(group.key)" class="config-group-items">
<button
v-for="item in group.items"
:key="configKey(item.group, item.dataId)"
:id="`config-item-${configKey(item.group, item.dataId)}`"
:class="['config-item', 'config-item-ide', configKey(item.group, item.dataId) === nacosConfig.selectedKey ? 'active' : '']"
@click="openNacosConfig(item)"
>
<div class="config-item-main">
<strong>{{ item.dataId }}</strong>
<small v-if="item.appName">{{ item.appName }}</small>
</div>
<div class="config-item-side">
<span>{{ item.type || 'text' }}</span>
</div>
</button>
</div>
</section>
<div v-if="!nacosConfig.loading && filteredNacosConfigs.length === 0" class="config-empty">
当前没有可展示的配置项
</div>
</div>
</aside>
<section class="config-editor">
<template v-if="nacosConfig.editor.dataId">
<div class="editor-toolbar sticky-toolbar">
<div class="editor-toolbar-main">
<span v-if="nacosConfig.saveMessage" class="badge ok">{{ nacosConfig.saveMessage }}</span>
<span v-else-if="nacosConfig.validationMessage" class="badge ok">{{ nacosConfig.validationMessage }}</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>
<div class="density-switch">
<button :class="['filter-chip', configEditorMode === 'diff' ? 'active' : '']" @click="configEditorMode = 'diff'">Diff</button>
<button :class="['filter-chip', configEditorMode === 'source' ? 'active' : '']" @click="configEditorMode = 'source'">源码</button>
</div>
<div class="config-jump-group">
<button class="refresh ghost" @click="openPrevNacosConfig" :disabled="!selectedPrevNacosConfigKey || nacosConfig.detailLoading || nacosConfig.saving">上一项</button>
<button class="refresh ghost" @click="openNextNacosConfig" :disabled="!selectedNextNacosConfigKey || nacosConfig.detailLoading || nacosConfig.saving">下一项</button>
</div>
</div>
<div class="action-group">
<button class="refresh ghost" @click="validateNacosConfig" :disabled="nacosConfig.validating || nacosConfig.saving || nacosConfig.detailLoading">
{{ nacosConfig.validating ? '校验中...' : '校验' }}
</button>
<button class="refresh ghost" @click="restoreNacosContent" :disabled="!nacosConfigDirty || nacosConfig.saving || nacosConfig.detailLoading">恢复</button>
<button class="refresh" @click="saveNacosConfig" :disabled="nacosConfig.saving || nacosConfig.detailLoading">
{{ nacosConfig.saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
<div class="metadata-strip">
<span class="mono">dataId {{ nacosConfig.editor.dataId }}</span>
<span class="mono">group {{ nacosConfig.editor.group }}</span>
<span>type {{ nacosConfig.editor.type || 'text' }}</span>
<span>最近修改 {{ formatDateTime(nacosConfig.editor.lastModifiedTime) }}</span>
<span>{{ nacosConfig.validationMessage ? '已校验' : '未校验' }}</span>
</div>
<p v-if="nacosConfig.editor.description || nacosConfig.editor.appName || nacosConfig.editor.md5" class="config-description">
<span v-if="nacosConfig.editor.appName">appName {{ nacosConfig.editor.appName }}</span>
<span v-if="nacosConfig.editor.description">{{ nacosConfig.editor.description }}</span>
<span v-if="nacosConfig.editor.md5" class="mono">{{ truncateMiddle(nacosConfig.editor.md5, 8, 8) }}</span>
</p>
<section v-if="configEditorMode === 'diff'" class="diff-panel">
<div v-if="showNacosDiffEmpty" class="config-empty">当前没有变更,切到源码继续编辑</div>
<div v-else class="diff-list mono">
<div v-for="row in currentNacosDiffRows" :key="row.key" :class="['diff-row', row.type === 'add' ? 'is-add' : 'is-remove']">
<span class="diff-gutter">{{ row.leftNo || '-' }}</span>
<span class="diff-gutter">{{ row.rightNo || '-' }}</span>
<span class="diff-mark">{{ row.type === 'add' ? '+' : '-' }}</span>
<code>{{ row.text || ' ' }}</code>
</div>
</div>
</section>
<textarea
v-else
v-model="nacosConfig.editor.content"
class="config-textarea mono"
spellcheck="false"
:disabled="nacosConfig.detailLoading || nacosConfig.saving"
></textarea>
</template>
<div v-else class="config-empty">
请选择左侧配置项
</div>
</section>
</div>
</section>
<section v-if="configTab === 'go'" class="panel infra-panel">
<header class="panel-header">
<div class="panel-title">
<h2>Go Env</h2>
<p>app 节点 service.env</p>
</div>
<span :class="['badge', goConfig.error ? 'bad' : goConfig.diffKeys.length > 0 ? 'warn' : 'ok']">
{{ goConfig.error ? '存在错误' : goConfig.diffKeys.length > 0 ? '双机有差异' : '已同步' }}
</span>
</header>
<div class="metadata-strip">
<span v-for="host in goConfig.hosts" :key="host.host" class="mono">{{ host.host }} {{ truncateMiddle(host.path, 14, 10) }}</span>
</div>
<p v-if="goConfig.error" class="metric-error">{{ explainError(goConfig.error) }}</p>
<p v-if="goConfig.diffKeys.length > 0" class="metric-error">差异 key{{ goConfig.diffKeys.join(', ') }}</p>
<section class="config-editor">
<div class="editor-toolbar sticky-toolbar">
<div class="editor-toolbar-main">
<span v-if="goConfig.saveMessage" class="badge ok">{{ goConfig.saveMessage }}</span>
<span v-else-if="goConfig.validationMessage" class="badge ok">{{ goConfig.validationMessage }}</span>
<span v-else-if="goConfigDirty" class="badge warn">有未保存修改</span>
<span v-else class="badge neutral">内容已同步</span>
<div class="density-switch">
<button :class="['filter-chip', goEditorMode === 'diff' ? 'active' : '']" @click="goEditorMode = 'diff'">Diff</button>
<button :class="['filter-chip', goEditorMode === 'source' ? 'active' : '']" @click="goEditorMode = 'source'">源码</button>
</div>
</div>
<div class="action-group">
<button class="refresh ghost" @click="validateGoConfig" :disabled="goConfig.validating || goConfig.saving || goConfig.loading">
{{ goConfig.validating ? '校验中...' : '校验' }}
</button>
<button class="refresh ghost" @click="restoreGoContent" :disabled="!goConfigDirty || goConfig.loading || goConfig.saving">恢复</button>
<button class="refresh" @click="saveGoConfig" :disabled="goConfig.saving || goConfig.loading">
{{ goConfig.saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
<div class="metadata-strip">
<span>主机 {{ goConfig.hosts.length }}</span>
<span>差异 key {{ goConfig.diffKeys.length }}</span>
<span>基线 key {{ goConfig.hosts.length > 0 ? '已加载' : '未加载' }}</span>
</div>
<section v-if="goEditorMode === 'diff'" class="diff-panel">
<div v-if="showGoDiffEmpty" class="config-empty">当前没有变更,切到源码继续编辑</div>
<div v-else class="diff-list mono">
<div v-for="row in currentGoDiffRows" :key="row.key" :class="['diff-row', row.type === 'add' ? 'is-add' : 'is-remove']">
<span class="diff-gutter">{{ row.leftNo || '-' }}</span>
<span class="diff-gutter">{{ row.rightNo || '-' }}</span>
<span class="diff-mark">{{ row.type === 'add' ? '+' : '-' }}</span>
<code>{{ row.text || ' ' }}</code>
</div>
</div>
</section>
<textarea
v-else
v-model="goConfig.editorContent"
class="config-textarea mono"
spellcheck="false"
:disabled="goConfig.loading || goConfig.saving"
></textarea>
</section>
</section>
<section v-if="configTab === 'history'" class="panel infra-panel">
<header class="panel-header">
<div class="panel-title">
<h2>历史版本</h2>
<p>当前接口未返回远端版本历史,先保留本地操作记录</p>
</div>
<span class="badge neutral">本地记录</span>
</header>
<div class="activity-list">
<article v-for="item in configHistoryRows" :key="item.id" :class="['activity-item', `tone-${item.tone}`]">
<div class="activity-item-main">
<strong>{{ item.title }}</strong>
<span>{{ item.detail }}</span>
</div>
<div class="activity-item-side">
<small>{{ item.scope }}</small>
<small>{{ item.time }}</small>
</div>
</article>
<div v-if="configHistoryRows.length === 0" class="config-empty">当前没有可展示的记录</div>
</div>
</section>
</template>
<template v-if="activeView === 'release'">
<section class="panel task-page-head">
<header class="panel-header">
<div class="panel-title">
<h2>发布</h2>
<p>变更与执行</p>
</div>
<div class="panel-meta">
<span :class="['badge', updateStatusBadgeClass()]">{{ updateStatusLabel() }}</span>
<span class="badge neutral">已选 {{ updateOps.selectedServices.length }} 项更新</span>
<span class="badge neutral">已选 {{ restartOps.selectedServices.length }} 项重启</span>
</div>
</header>
<div class="release-action-bar">
<div class="release-action-summary">
<span class="badge neutral">更新目标 {{ releaseTargetRows.length }}</span>
<span class="badge neutral">重启目标 {{ restartTargetRows.length }}</span>
<span :class="['badge', updateOps.error || restartOps.error ? 'bad' : releasePrecheck.ok ? 'ok' : 'neutral']">
{{ releasePrecheck.ranAt ? releasePrecheck.title : '未预检查' }}
</span>
</div>
<div class="action-group">
<button class="refresh ghost" @click="refreshReleaseTargets" :disabled="updateOps.loading || restartOps.loading || updateOps.running || restartOps.running">
{{ updateOps.loading || restartOps.loading ? '刷新中...' : '刷新目标' }}
</button>
<button class="refresh ghost" @click="runReleasePrecheck" :disabled="updateOps.running || restartOps.running">预检查</button>
<button class="refresh ghost" @click="openUpdateLogViewer" :disabled="!updateOps.operationId && !updateOps.logs.length">
查看日志
</button>
<button class="refresh danger" @click="runServiceUpdate" :disabled="updateOps.running || updateOps.selectedServices.length === 0">
{{ updateOps.running ? '更新中...' : `执行更新 ${updateOps.selectedServices.length}` }}
</button>
</div>
</div>
</section>
<section class="panel release-status-strip sticky-release-strip">
<div class="release-status-grid">
<article v-for="item in releaseStatusSummary" :key="item.label" :class="['release-status-item', `tone-${item.tone}`]">
<small>{{ item.label }}</small>
<strong :title="item.value">{{ item.value }}</strong>
</article>
</div>
<div v-if="releasePrecheck.ranAt" class="precheck-strip">
<span :class="['badge', releasePrecheck.tone]">{{ releasePrecheck.title }}</span>
<span class="badge neutral">执行于 {{ formatDateTime(releasePrecheck.ranAt) }}</span>
<span
v-for="check in releasePrecheck.checks"
:key="'precheck-' + check.label"
:class="['badge', check.ok ? 'ok' : 'warn']"
:title="check.detail"
>
{{ check.label }}
</span>
</div>
<p v-if="updateOps.error" class="metric-error">{{ explainError(updateOps.error) }}</p>
<p v-else-if="updateOps.message" :class="updateOps.result && updateOps.result.ok ? 'ok-text' : 'metric-error'">{{ updateOps.message }}</p>
</section>
<section id="release-build" class="panel infra-panel">
<header class="panel-header">
<div class="panel-title">
<h2>构建参数</h2>
<p>先定参数,再选目标</p>
</div>
<span class="badge neutral">平台 {{ updateOps.defaults.dockerPlatform }}</span>
</header>
<div class="metadata-strip">
<span class="mono">Harbor {{ updateOps.builder.harborRegistry }} / {{ updateOps.builder.harborProject }}</span>
<span class="mono">Java {{ truncateMiddle(updateOps.builder.javaRepoRoot, 16, 12) }}</span>
<span class="mono">Go {{ truncateMiddle(updateOps.builder.golangRepoRoot, 16, 12) }}</span>
<span class="mono">Admin {{ truncateMiddle(updateOps.builder.frontendRepoRoot || updateOps.builder.frontendRepoUrl, 16, 12) }}</span>
</div>
<div class="update-form-grid">
<label class="update-field">
<span>Java Git Ref</span>
<input v-model.trim="updateOps.javaGitRef" class="search" placeholder="main / branch / tag / commit" />
</label>
<label class="update-field">
<span>Go Git Ref</span>
<input v-model.trim="updateOps.goGitRef" class="search" placeholder="main / branch / tag / commit" />
</label>
<label class="update-field">
<span>Admin Git Ref</span>
<input v-model.trim="updateOps.adminGitRef" class="search" placeholder="main / branch / tag / commit" />
</label>
<label class="update-field">
<span>Image Tag</span>
<input v-model.trim="updateOps.imageTag" class="search" placeholder="可选,不填则自动生成" />
</label>
<label class="update-check">
<input v-model="updateOps.skipMavenBuild" type="checkbox" />
<span>跳过 Maven</span>
</label>
</div>
</section>
<section id="release-targets" class="panel infra-panel">
<header class="panel-header">
<div class="panel-title">
<h2>更新目标</h2>
<p>紧凑表格</p>
</div>
<div class="panel-meta">
<span class="badge neutral">{{ releaseTargetRows.length }} 项</span>
<span class="badge neutral">已选 {{ updateOps.selectedServices.length }} 项</span>
</div>
</header>
<p v-if="updateOps.error" class="metric-error">{{ explainError(updateOps.error) }}</p>
<div class="table-wrap">
<table class="table compact release-table">
<thead>
<tr>
<th>服务</th>
<th>语言</th>
<th>部署节点</th>
<th>当前版本</th>
<th>明细</th>
<th>选择</th>
</tr>
</thead>
<tbody>
<tr v-for="item in releaseTargetRows" :key="'update-' + item.service">
<td>
<strong>{{ item.service }}</strong>
<small class="table-subtext mono" :title="item.repository">{{ item.repositoryShort }}</small>
</td>
<td>{{ item.kind }}</td>
<td :title="item.hostsLabel">{{ item.hostsLabel }}</td>
<td :title="item.currentTitle">{{ item.currentLabel }}</td>
<td>
<div class="inline-copy">
<span class="mono" :title="item.detailTitle || '-'">{{ item.detailLabel || '-' }}</span>
<button
v-if="item.copyValue"
class="refresh ghost inline-button"
@click="copyText(item.copyValue, 'image-' + item.service)"
>
{{ isCopied('image-' + item.service) ? '已复制' : '复制' }}
</button>
</div>
</td>
<td>
<label class="table-check">
<input
:checked="updateOps.selectedServices.includes(item.service)"
type="checkbox"
@change="toggleUpdateService(item.service)"
/>
<span>{{ updateOps.selectedServices.includes(item.service) ? '已选' : '选择' }}</span>
</label>
</td>
</tr>
<tr v-if="!updateOps.loading && releaseTargetRows.length === 0">
<td colspan="6" class="table-empty-cell">当前没有可展示的更新目标</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="release-log" class="panel infra-panel">
<header class="panel-header">
<div class="panel-title">
<h2>执行日志</h2>
<p>页内只看摘要,完整日志弹出</p>
</div>
<div class="panel-meta">
<span v-if="updateOps.operationId" class="badge neutral mono">任务 {{ updateOps.operationId }}</span>
<span class="badge neutral mono">阶段 {{ updateOps.stage || '-' }}</span>
<button class="refresh ghost" @click="openUpdateLogViewer" :disabled="!updateOps.operationId && !updateOps.logs.length">查看日志</button>
</div>
</header>
<div class="metadata-strip">
<span>开始 {{ formatDateTime(updateOps.startedAt) }}</span>
<span>结束 {{ formatDateTime(updateOps.finishedAt) }}</span>
<span>状态 {{ updateStatusLabel() }}</span>
<span>影响服务 {{ updateAffectedServicesCount }}</span>
</div>
<section class="release-log-preview">
<div class="release-log-preview-head">
<strong>{{ updateOps.logs.length }} 条日志</strong>
<span v-if="updateOps.logDroppedCount > 0" class="badge warn">截断 {{ updateOps.logDroppedCount }}</span>
</div>
<div v-if="recentUpdateLogs.length > 0" class="release-log-snippets">
<article v-for="entry in recentUpdateLogs" :key="entry.key" :class="['release-log-snippet', entry.level === 'error' ? 'tone-bad' : 'tone-neutral']">
<strong>{{ formatDateTime(entry.time) }}</strong>
<span>{{ entry.stage || '-' }}</span>
<p>{{ entry.message || '-' }}</p>
</article>
</div>
<div v-else class="empty-panel compact">当前还没有可展示的执行日志</div>
</section>
<div class="activity-list compact" v-if="releaseHistoryRows.length > 0">
<article v-for="item in releaseHistoryRows" :key="item.id" :class="['activity-item', 'compact', `tone-${item.tone}`]">
<div class="activity-item-main">
<strong>{{ item.title }}</strong>
<span>{{ item.detail }}</span>
</div>
<div class="activity-item-side">
<small>{{ item.scope }}</small>
<small>{{ item.time }}</small>
</div>
</article>
</div>
<details v-if="updateOps.result && ((updateOps.result.build || updateOps.result.builds) || updateOps.result.preparedHosts || updateOps.result.steps)" class="result-disclosure">
<summary>执行结果明细</summary>
<div v-if="(updateOps.result.build || updateOps.result.builds) && (updateOps.result.build || updateOps.result.builds).length > 0" class="table-wrap">
<table class="table compact">
<thead>
<tr>
<th>仓库类型</th>
<th>Git Ref</th>
<th>Commit</th>
<th>版本</th>
<th>服务</th>
</tr>
</thead>
<tbody>
<tr v-for="build in (updateOps.result.build || updateOps.result.builds)" :key="build.kind + '-' + build.commit">
<td>{{ build.kind }}</td>
<td class="mono">{{ build.gitRef }}</td>
<td class="mono">{{ truncateMiddle(build.commit, 10, 6) }}</td>
<td class="mono">{{ build.imageTag || '-' }}</td>
<td>{{ (build.services || []).join(', ') }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="updateOps.result && updateOps.result.preparedHosts && updateOps.result.preparedHosts.length > 0" class="table-wrap">
<table class="table compact">
<thead>
<tr>
<th>主机</th>
<th>服务</th>
<th>Compose</th>
<th>预拉镜像</th>
</tr>
</thead>
<tbody>
<tr v-for="item in updateOps.result.preparedHosts" :key="item.host">
<td>{{ item.host }}</td>
<td>{{ (item.services || []).join(', ') }}</td>
<td>{{ item.compose && item.compose.changed ? '已更新' : '未变化' }}</td>
<td :class="item.prepull && item.prepull.status === 'SUCCESS' ? 'ok-text' : 'bad-text'">
{{ item.prepull ? item.prepull.status : '-' }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="updateOps.result && updateOps.result.steps && updateOps.result.steps.length > 0" class="table-wrap">
<table class="table compact">
<thead>
<tr>
<th>服务</th>
<th>主机</th>
<th>版本</th>
<th>状态</th>
<th>HTTP</th>
<th>Nacos</th>
<th>耗时</th>
</tr>
</thead>
<tbody>
<tr v-for="step in updateOps.result.steps" :key="step.service + '-' + step.host + '-' + step.image">
<td>{{ step.service }}</td>
<td>{{ step.host }}</td>
<td class="mono" :title="step.image">{{ truncateMiddle(step.image, 14, 12) }}</td>
<td :class="step.status === 'success' ? 'ok-text' : 'bad-text'">{{ step.status }}</td>
<td>
<span v-if="step.http">{{ step.http.statusCode }} / {{ step.http.latencyMs }}ms</span>
<span v-else>-</span>
</td>
<td>
<span v-if="step.nacos">{{ step.nacos.source || 'ok' }}</span>
<span v-else>-</span>
</td>
<td>{{ step.durationSeconds || '-' }}s</td>
</tr>
</tbody>
</table>
</div>
</details>
</section>
<section id="release-restart" class="panel infra-panel">
<header class="panel-header">
<div class="panel-title">
<h2>滚动重启</h2>
<p>按服务勾选执行</p>
</div>
<div class="panel-meta">
<span :class="['badge', restartOps.error ? 'bad' : restartOps.running ? 'warn' : 'ok']">
{{ restartOps.error ? '存在错误' : restartOps.running ? '执行中' : '可操作' }}
</span>
<button class="refresh danger" @click="runRollingRestart" :disabled="restartOps.running || restartOps.selectedServices.length === 0">
{{ restartOps.running ? '重启中...' : `执行重启 ${restartOps.selectedServices.length}` }}
</button>
</div>
</header>
<p v-if="restartOps.error" class="metric-error">{{ explainError(restartOps.error) }}</p>
<p v-if="restartOps.message" :class="restartOps.result && restartOps.result.ok ? 'ok-text' : 'metric-error'">{{ restartOps.message }}</p>
<div class="table-wrap">
<table class="table compact release-table">
<thead>
<tr>
<th>服务</th>
<th>语言</th>
<th>主机</th>
<th>实例数</th>
<th>选择</th>
</tr>
</thead>
<tbody>
<tr v-for="item in restartTargetRows" :key="'restart-' + item.service">
<td><strong>{{ item.service }}</strong></td>
<td>{{ item.kind }}</td>
<td :title="item.hostsLabel">{{ item.hostsLabel }}</td>
<td>{{ item.hostCount }}</td>
<td>
<label class="table-check">
<input
:checked="restartOps.selectedServices.includes(item.service)"
type="checkbox"
@change="toggleRestartService(item.service)"
/>
<span>{{ restartOps.selectedServices.includes(item.service) ? '已选' : '选择' }}</span>
</label>
</td>
</tr>
<tr v-if="!restartOps.loading && restartTargetRows.length === 0">
<td colspan="5" class="table-empty-cell">当前没有可展示的重启目标</td>
</tr>
</tbody>
</table>
</div>
<details v-if="restartOps.result && restartOps.result.steps && restartOps.result.steps.length > 0" class="result-disclosure">
<summary>重启结果明细</summary>
<div class="table-wrap">
<table class="table compact">
<thead>
<tr>
<th>服务</th>
<th>主机</th>
<th>类型</th>
<th>状态</th>
<th>HTTP</th>
<th>Nacos</th>
<th>耗时</th>
</tr>
</thead>
<tbody>
<tr v-for="step in restartOps.result.steps" :key="step.service + '-' + step.host">
<td>{{ step.service }}</td>
<td>{{ step.host }}</td>
<td>{{ step.kind }}</td>
<td :class="step.status === 'success' ? 'ok-text' : 'bad-text'">{{ step.status }}</td>
<td>
<span v-if="step.http">{{ step.http.statusCode }} / {{ step.http.latencyMs }}ms</span>
<span v-else>-</span>
</td>
<td>
<span v-if="step.nacos">{{ step.nacos.source || 'ok' }}</span>
<span v-else>-</span>
</td>
<td>{{ step.durationSeconds || '-' }}s</td>
</tr>
</tbody>
</table>
</div>
</details>
</section>
</template>
<div v-if="logViewer.open" class="log-viewer-overlay" @click.self="closeLogViewer">
<section class="log-viewer" role="dialog" aria-modal="true" :aria-labelledby="'log-viewer-title'">
<header class="log-viewer-head">
<div class="panel-title">
<h2 id="log-viewer-title">{{ activeLogViewerTitle }}</h2>
<p>{{ activeLogViewerSubtitle }}</p>
</div>
<div class="action-group">
<button class="refresh ghost" @click="refreshActiveLogViewer" :disabled="activeLogViewerRefreshDisabled">
{{ activeLogViewerLoading ? '刷新中...' : '刷新' }}
</button>
<button class="refresh ghost" @click="downloadActiveLogViewer" :disabled="activeLogViewerDownloadDisabled">
{{ activeLogViewerExporting ? '导出中...' : '导出' }}
</button>
<button class="refresh ghost" @click="closeLogViewer">关闭</button>
</div>
</header>
<div class="log-viewer-toolbar">
<span v-if="activeLogViewerBadge" class="badge neutral mono">{{ activeLogViewerBadge }}</span>
<span v-if="activeLogViewerSecondaryBadge" class="badge neutral">{{ activeLogViewerSecondaryBadge }}</span>
<label v-if="logViewer.kind === 'service'" class="interval-field detail-log-field">
<span>导出</span>
<input v-model.number="serviceLog.exportLines" type="number" min="1" :max="serviceLog.exportMaxLines" class="detail-log-input" />
</label>
</div>
<p v-if="activeLogViewerError" class="metric-error">{{ activeLogViewerError }}</p>
<pre id="service-update-log-output" class="log-viewer-output mono">{{ activeLogViewerContent || '暂无日志' }}</pre>
</section>
</div>
</div>
</body>
</html>