hy-app-monitor/static/index.html
2026-04-23 11:59:58 +08:00

3425 lines
213 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', showLoadingSkeleton ? 'shell-loading' : '']">
<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 v-if="viewTabs.length > 1" 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>
<section v-if="showLoadingSkeleton" class="panel loading-stage">
<div class="loading-stage-head">
<div class="loading-chip-row">
<span class="loading-chip"></span>
<span class="loading-chip"></span>
<span class="loading-chip"></span>
<span class="loading-chip"></span>
</div>
<div class="loading-line loading-line-short"></div>
</div>
<div class="loading-band-grid">
<span v-for="index in 5" :key="'loading-band-' + index" class="loading-band"></span>
</div>
<div class="loading-panels">
<article class="loading-card loading-card-wide">
<div class="loading-card-head">
<span class="loading-line loading-line-medium"></span>
<span class="loading-pill"></span>
</div>
<div class="loading-table">
<span v-for="index in 6" :key="'loading-row-' + index" class="loading-table-row"></span>
</div>
</article>
<article class="loading-card">
<div class="loading-card-head">
<span class="loading-line loading-line-medium"></span>
<span class="loading-pill"></span>
</div>
<div class="loading-stack">
<span v-for="index in 5" :key="'loading-side-' + index" class="loading-block"></span>
</div>
</article>
</div>
</section>
<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 === 'database'">
<section class="control-bar task-control-bar database-control-bar">
<div class="taskbar-context mongo-taskbar-context">
<strong>数据库工作台</strong>
<span>{{ databaseContextLabel }}</span>
</div>
<div v-if="databaseEngines.length > 1" class="control-group database-engine-switch">
<button
v-for="engine in databaseEngines"
:key="engine.id"
:class="['filter-chip', databaseEngine === engine.id ? 'active' : '']"
@click="switchDatabaseEngine(engine.id)"
>
{{ engine.label }}
</button>
</div>
<div class="control-group">
<button
v-for="tab in activeDatabaseTabs"
:key="tab.id"
:class="['filter-chip', activeDatabaseTabId === tab.id ? 'active' : '']"
@click="activeDatabaseTabId = tab.id"
>
{{ tab.label }}
</button>
</div>
<div class="control-group control-group-spread">
<input v-model.trim="databaseKeywordModel" class="search" :placeholder="databaseSearchPlaceholder" />
<button class="refresh ghost" @click="refreshDatabaseTree" :disabled="databaseTreeLoading">
{{ databaseTreeLoading ? '刷新中...' : '刷新树' }}
</button>
<button
class="refresh"
@click="refreshDatabaseCurrent"
:disabled="!databaseHasSelection || databaseRefreshBusy"
>
{{ databaseRefreshBusy ? '加载中...' : databaseRefreshLabel }}
</button>
</div>
</section>
</template>
<template v-else-if="activeView === 'config'">
<section class="control-bar task-control-bar">
<div class="taskbar-context">
<strong>编辑与校验</strong>
<span>{{ configContextLabel }}</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="!showLoadingSkeleton && (activeView === 'overview' || activeView === 'services')">
<section v-if="activeView === 'overview' && summaryStripCards.length > 0" 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 v-else-if="activeView === 'overview' && overviewHealthyCards.length > 0" class="panel overview-calm-strip">
<div class="overview-calm-head">
<span class="badge ok">当前无异常</span>
<span class="overview-calm-meta">{{ overviewHealthyCards.length }} 组健康状态</span>
</div>
<div class="overview-calm-grid">
<article
v-for="card in overviewHealthyCards"
:key="'healthy-' + card.key"
:class="['overview-calm-card', `tone-${card.tone}`]"
>
<small>{{ card.label }}</small>
<strong>{{ card.value }}</strong>
<span>{{ card.meta }}</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" :data-group="section.group">
<header class="matrix-stack-head">
<div class="matrix-stack-headline">
<strong>{{ section.label }}</strong>
<span>{{ section.rows.length }} 服务 · {{ section.columns.length }} 节点</span>
</div>
<div class="matrix-stack-badges">
<span :class="['badge', matrixSectionDownCount(section) > 0 ? 'bad' : 'ok']">{{ matrixSectionDownCount(section) }} 异常</span>
</div>
</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>{{ showSelectedServiceDetail ? selectedServiceDetail.service : activeView === 'overview' ? '运行状态' : '服务详情' }}</h2>
<p v-if="showSelectedServiceDetail">{{ selectedServiceDetail.groupLabel }} · {{ selectedServiceDetail.kind }}</p>
<p v-else-if="activeView === 'overview'">{{ overviewDetailSubtitle }}</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="showSelectedServiceDetail">
<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>
<section v-else-if="activeView === 'overview'" class="overview-side-stack">
<div v-if="overviewHealthyInfraCards.length > 0" class="overview-side-grid">
<article v-for="card in overviewHealthyInfraCards" :key="'side-' + card.key" :class="['overview-side-card', `tone-${card.tone}`]">
<small>{{ card.label }}</small>
<strong>{{ card.value }}</strong>
<span>{{ card.meta }}</span>
</article>
</div>
<div v-if="overviewHealthyHostGroups.length > 0" class="overview-side-hosts">
<header class="detail-subhead">
<strong>正常主机</strong>
<span>{{ overviewQuietHosts.length }} 台</span>
</header>
<div class="overview-side-host-list">
<article v-for="group in overviewHealthyHostGroups" :key="'side-group-' + group.key" class="overview-side-host-chip" :data-group="group.key">
<div class="overview-side-host-chip-head">
<strong>{{ group.label }}</strong>
<em>{{ group.count }} 台</em>
</div>
<span>{{ group.summary }}</span>
</article>
</div>
</div>
<div v-if="overviewHealthyInfraCards.length === 0 && overviewHealthyHostGroups.length === 0" class="empty-panel compact">当前没有可展示的状态</div>
</section>
<div v-else class="empty-panel">当前没有服务详情</div>
</aside>
</section>
<section v-if="activeView === 'overview' && overviewBridgeAlerts.length > 0" class="panel bridge-panel">
<header class="bridge-head">
<strong>异常索引</strong>
<span>{{ overviewBridgeAlerts.length }} 项</span>
</header>
<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>{{ overviewHostPanelSummary }}</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 v-if="overviewAlertHosts.length > 0" 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' : ''
]"
:data-group="host.group"
>
<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 v-if="hostHasMetricData(host)" 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 v-else class="host-inline-metrics compact-metrics compact-metrics-fallback">
<span :class="['metric-state-chip', host.hasMetricIssue ? 'tone-warn' : 'tone-neutral']">
<small>TAT</small>
<strong>{{ hostMetricStatusLabel(host) }}</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>
</section>
<section v-if="overviewQuietHosts.length > 0" class="host-section">
<div v-if="overviewAlertHosts.length === 0" class="section-caption-row section-caption-row-quiet">
<div class="section-caption">正常主机</div>
<div class="section-caption-meta">
<span>{{ overviewQuietHosts.length }} 台</span>
<span>{{ overviewQuietServiceTotal }} 服务</span>
</div>
</div>
<button v-else class="host-fold-toggle" @click="toggleOverviewHealthyHosts">
<span>正常主机</span>
<strong>{{ overviewQuietHosts.length }}</strong>
<small>{{ overviewHealthyHostsOpen ? '收起' : '展开' }}</small>
</button>
<div v-if="overviewHealthyHostsOpen || overviewAlertHosts.length === 0" class="quiet-host-grid">
<button
v-for="host in overviewQuietHosts"
:key="'quiet-' + host.host"
:class="['quiet-host-card', isHostHighlighted(host) ? 'active' : '']"
:data-group="host.group"
@click="selectHost(host.host)"
>
<div class="quiet-host-head">
<strong>{{ host.host }}</strong>
<span>{{ host.ip }} · {{ host.groupLabel }}</span>
</div>
<div v-if="hostHasMetricData(host)" class="quiet-host-metrics">
<div
v-for="metric in hostMetricEntries(host)"
:key="'quiet-' + host.host + '-' + metric.key"
:class="['metric-ring-chip', 'metric-ring-chip-quiet', `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>
</div>
<div v-else class="quiet-host-metrics quiet-host-metrics-fallback">
<span class="metric-state-chip tone-neutral">
<small>TAT</small>
<strong>{{ hostMetricStatusLabel(host) }}</strong>
</span>
</div>
<div class="quiet-host-foot">
<span class="badge neutral">{{ host.serviceTotal }} 服务</span>
<span class="badge neutral">{{ host.quietSummary }}</span>
</div>
</button>
</div>
</section>
</div>
</section>
<template v-if="!showLoadingSkeleton && 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' : ''
]"
:data-group="host.group"
>
<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 v-if="hostHasMetricData(host)" 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 v-else class="host-inline-metrics compact-metrics compact-metrics-fallback">
<span :class="['metric-state-chip', host.hasMetricIssue ? 'tone-warn' : 'tone-neutral']">
<small>TAT</small>
<strong>{{ hostMetricStatusLabel(host) }}</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="!showLoadingSkeleton && 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 mongo-workbench-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="metadata-strip metadata-strip-tight mongo-workbench-strip">
<span class="mono">{{ truncateMiddle(payload.mongo.info.host || '-', 18, 12) }}</span>
<span>{{ formatNumber(payload.mongo.summary.databaseTotal) }} 库</span>
<span>{{ formatNumber(payload.mongo.summary.collectionTotal) }} 集合</span>
<span>连接 {{ formatNumber(payload.mongo.connections.current) }}</span>
<span>请求 {{ formatNumber(payload.mongo.network.numRequests) }}</span>
<span>副本 {{ payload.mongo.replicaSet && payload.mongo.replicaSet.configured ? payload.mongo.replicaSet.members.length : 0 }}</span>
</div>
<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>{{ filteredMongoDatabases.length }}</strong>
<small>{{ infraMongoDetailsOpen ? '收起' : '展开' }}</small>
</button>
<div v-if="infraMongoDetailsOpen" class="infra-detail-stack mongo-detail-stack">
<div class="metric-grid mongo-secondary-grid">
<div class="summary-card mongo-mini-card">
<small>Virtual</small>
<strong>{{ formatMb(payload.mongo.memory.virtualMb) }}</strong>
</div>
<div class="summary-card mongo-mini-card">
<small>Resident</small>
<strong>{{ formatMb(payload.mongo.memory.residentMb) }}</strong>
</div>
<div class="summary-card mongo-mini-card">
<small>Requests</small>
<strong>{{ formatNumber(payload.mongo.network.numRequests) }}</strong>
</div>
<div class="summary-card mongo-mini-card">
<small>WT Cache</small>
<strong>{{ formatBytes(payload.mongo.wiredTigerCache.bytesCurrentlyInCache) }}</strong>
</div>
</div>
<section class="mongo-detail-block">
<header class="mongo-detail-head">
<div class="panel-title">
<h2>数据库</h2>
<p>{{ formatNumber(filteredMongoDatabases.length) }} 项</p>
</div>
<div class="panel-meta">
<span class="badge neutral">集合 {{ formatNumber(payload.mongo.summary.collectionTotal) }}</span>
<span class="badge neutral">对象 {{ formatNumber(payload.mongo.summary.objectTotal) }}</span>
</div>
</header>
<div class="table-wrap">
<table class="table compact">
<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>
</section>
<section v-if="payload.mongo.replicaSet && payload.mongo.replicaSet.configured" class="mongo-detail-block">
<header class="mongo-detail-head">
<div class="panel-title">
<h2>副本集</h2>
<p>{{ payload.mongo.replicaSet.members.length }} 成员</p>
</div>
<div class="panel-meta">
<span class="badge neutral">角色 {{ payload.mongo.replicaSet.setName || '-' }}</span>
</div>
</header>
<div 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>
</section>
</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 === 'database'">
<section v-if="databaseEngine === 'mysql'" class="panel infra-panel database-workbench-panel mysql-workbench-panel">
<div class="compact-workbench-head mysql-workbench-head">
<div class="metadata-strip metadata-strip-tight">
<span class="mono">{{ truncateMiddle(mySqlAdmin.overview.info.host || '未配置 MySQL', 20, 12) }}</span>
<span>{{ selectedMySqlDatabaseItem ? `db ${selectedMySqlDatabaseItem.name}` : `${formatNumber(mySqlFilteredDatabases.length)} 库` }}</span>
<span>{{ selectedMySqlTableItem ? `table ${selectedMySqlTableItem.name}` : `${formatNumber(mySqlAdmin.tables.length)} 表` }}</span>
<span>连接 {{ formatNumber(mySqlAdmin.overview.connections.current) }} / {{ formatNumber(mySqlAdmin.overview.connections.max) }}</span>
<span>复制 {{ mySqlReplicationLabel(mySqlAdmin.overview.replication) }}</span>
<span>只读 {{ (mySqlAdmin.overview.replication.readOnly || mySqlAdmin.overview.replication.superReadOnly) ? 'Y' : 'N' }}</span>
</div>
<span :class="['badge', mySqlOverviewStatusTone]">{{ mySqlOverviewStatusText }}</span>
</div>
<p v-if="mySqlAdmin.error" class="metric-error">{{ explainError(mySqlAdmin.error) }}</p>
<p v-else-if="mySqlOverviewWarningText" class="metric-error warn">{{ mySqlOverviewWarningText }}</p>
<div class="summary-cards mysql-overview-grid">
<article v-for="card in mySqlOverviewCards" :key="'mysql-overview-' + card.key" :class="['summary-card', 'mysql-overview-card', `tone-${card.tone}`]">
<small>{{ card.label }}</small>
<strong>{{ card.value }}</strong>
<span>{{ card.meta }}</span>
</article>
</div>
<div class="config-layout mongo-admin-layout mysql-admin-layout">
<aside class="config-sidebar mongo-sidebar mysql-sidebar">
<div class="metadata-strip metadata-strip-tight mysql-sidebar-strip">
<span>{{ formatNumber(mySqlFilteredDatabases.length) }} 库</span>
<span>{{ selectedMySqlDatabaseItem ? `${selectedMySqlDatabaseItem.tableTotal} 表` : `${formatNumber(mySqlAdmin.tables.length)} 表` }}</span>
<span v-if="selectedMySqlDatabaseItem" class="mono">{{ selectedMySqlDatabaseItem.name }}</span>
<span v-if="selectedMySqlDatabaseItem">{{ formatMb(selectedMySqlDatabaseItem.totalSizeMb) }}</span>
</div>
<section class="mysql-sidebar-section">
<div class="section-caption-row">
<div class="section-caption">数据库</div>
<div class="section-caption-meta">
<span>{{ formatNumber(mySqlFilteredDatabases.length) }}</span>
</div>
</div>
<div class="config-list config-list-ide mysql-sidebar-list">
<button
v-for="database in mySqlFilteredDatabases"
:key="'mysql-db-' + database.name"
:class="['config-item', 'config-item-ide', mySqlAdmin.selectedDatabase === database.name ? 'active' : '']"
@click="selectMySqlDatabase(database.name, { preserveTable: true })"
>
<strong>{{ database.name }}</strong>
<span>{{ database.tableTotal }} 表 · {{ formatMb(database.totalSizeMb) }}</span>
<small>{{ database.system ? 'system' : `${formatNumber(database.rowEstimate)} rows` }}</small>
</button>
<div v-if="!mySqlAdmin.loading && mySqlFilteredDatabases.length === 0" class="config-empty">
当前没有可展示的数据库
</div>
</div>
</section>
<section v-if="selectedMySqlDatabaseItem" class="mysql-sidebar-section">
<div class="section-caption-row">
<div class="section-caption"></div>
<div class="section-caption-meta">
<span>{{ formatNumber(mySqlVisibleTables.length) }}</span>
</div>
</div>
<div class="config-list config-list-ide mysql-sidebar-list">
<button
v-for="table in mySqlVisibleTables"
:key="'mysql-table-' + table.name"
:class="['config-item', 'config-item-ide', mySqlAdmin.selectedTable === table.name ? 'active' : '']"
@click="selectMySqlTable(mySqlAdmin.selectedDatabase, table.name)"
>
<strong>{{ table.name }}</strong>
<span>{{ formatNumber(table.rowEstimate) }} rows · {{ formatMb(table.totalSizeMb) }}</span>
<small>{{ table.engine || '-' }} · {{ table.collation || '-' }}</small>
</button>
<div v-if="mySqlAdmin.tablesLoading" class="config-empty">
表加载中
</div>
<div v-else-if="mySqlVisibleTables.length === 0" class="config-empty">
当前没有匹配表
</div>
</div>
</section>
</aside>
<section class="config-editor mongo-admin-editor mysql-admin-editor">
<div class="editor-toolbar sticky-toolbar mysql-editor-toolbar">
<div class="editor-toolbar-main">
<span
v-if="mySqlAdmin.message"
:class="[
'badge',
mySqlAdmin.messageTone === 'ok'
? 'ok'
: mySqlAdmin.messageTone === 'warn'
? 'warn'
: 'neutral',
]"
>
{{ mySqlAdmin.message }}
</span>
<span v-else-if="mySqlAdmin.tablesLoading" class="badge neutral">表加载中</span>
<span v-else-if="mySqlAdmin.schemaLoading" class="badge neutral">结构加载中</span>
<span v-else-if="mySqlAdmin.rowsLoading" class="badge neutral">查询中</span>
<span v-else-if="mySqlAdmin.sqlConsole.running" class="badge neutral">SQL 执行中</span>
<span v-else class="badge neutral">{{ activeMySqlAdminTabLabel || 'MySQL' }}</span>
</div>
<div class="action-group">
<button class="refresh ghost" @click="refreshMySqlSchema()" :disabled="!mySqlAdmin.selectedTable || mySqlAdmin.schemaLoading">
{{ mySqlAdmin.schemaLoading ? '加载中...' : '刷新结构' }}
</button>
<button
class="refresh ghost"
@click="mySqlAdmin.activeTab === 'sql' ? executeMySqlSql() : refreshMySqlRows({ resetPage: mySqlAdmin.activeTab === 'browse' })"
:disabled="!mySqlAdmin.selectedTable || mySqlAdmin.rowsLoading || mySqlAdmin.sqlConsole.running"
>
{{ mySqlAdmin.rowsLoading || mySqlAdmin.sqlConsole.running ? '执行中...' : '刷新结果' }}
</button>
</div>
</div>
<div v-if="selectedMySqlDatabaseItem" class="metadata-strip mysql-context-strip">
<span class="mono">db {{ selectedMySqlDatabaseItem.name }}</span>
<span v-if="selectedMySqlTableItem" class="mono">table {{ selectedMySqlTableItem.name }}</span>
<span v-if="selectedMySqlTableItem">engine {{ selectedMySqlTableItem.engine || '-' }}</span>
<span v-if="selectedMySqlTableItem">rows {{ formatNumber(mySqlAdmin.rows.totalRows) }}</span>
<span v-if="selectedMySqlTableItem">size {{ formatMb(selectedMySqlTableItem.totalSizeMb) }}</span>
<span>只读 {{ (mySqlAdmin.overview.replication.readOnly || mySqlAdmin.overview.replication.superReadOnly) ? 'Y' : 'N' }}</span>
</div>
<div v-if="!selectedMySqlDatabaseItem" class="config-empty">
当前没有可操作的数据库
</div>
<template v-else-if="!selectedMySqlTableItem">
<section class="panel mongo-admin-subpanel mysql-table-browser">
<header class="panel-header">
<div class="panel-title">
<h2></h2>
<p>{{ selectedMySqlDatabaseItem.name }}</p>
</div>
<span class="badge neutral">{{ formatNumber(mySqlAdmin.tables.length) }} 表</span>
</header>
<div class="table-wrap table-scroll-x">
<table class="table compact mysql-data-table">
<thead>
<tr>
<th>表名</th>
<th>Engine</th>
<th>Rows</th>
<th>大小</th>
<th>更新时间</th>
</tr>
</thead>
<tbody>
<tr v-for="table in mySqlAdmin.tables" :key="'table-list-' + table.name">
<td>
<button class="refresh ghost inline-button" @click="selectMySqlTable(mySqlAdmin.selectedDatabase, table.name)">{{ table.name }}</button>
</td>
<td>{{ table.engine || '-' }}</td>
<td>{{ formatNumber(table.rowEstimate) }}</td>
<td>{{ formatMb(table.totalSizeMb) }}</td>
<td>{{ formatDateTime(table.updateTime) }}</td>
</tr>
<tr v-if="mySqlAdmin.tables.length === 0">
<td colspan="5" class="table-empty-cell">当前没有表</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<template v-else-if="mySqlAdmin.activeTab === 'browse'">
<section class="panel mongo-admin-subpanel mysql-browse-panel">
<header class="panel-header">
<div class="panel-title">
<h2>数据</h2>
<p>{{ selectedMySqlTableItem.name }}</p>
</div>
<span class="badge neutral">{{ mySqlRowsRangeText }}</span>
</header>
<div class="mysql-query-stack">
<div class="mysql-filter-grid">
<label class="update-field">
<span>关键字</span>
<input v-model.trim="mySqlAdmin.rows.keyword" class="search" />
</label>
<label class="update-field">
<span>排序列</span>
<select v-model="mySqlAdmin.rows.orderBy" class="interval-select">
<option value="">自动</option>
<option v-for="column in mySqlAdmin.schema.columns" :key="'sort-' + column.name" :value="column.name">{{ column.name }}</option>
</select>
</label>
<label class="update-field">
<span>方向</span>
<select v-model="mySqlAdmin.rows.orderDirection" class="interval-select">
<option value="asc">ASC</option>
<option value="desc">DESC</option>
</select>
</label>
<label class="update-field">
<span>每页</span>
<select v-model.number="mySqlAdmin.rows.pageSize" class="interval-select">
<option v-for="size in mySqlAdmin.pageSizeOptions" :key="'mysql-size-' + size" :value="size">{{ size }}</option>
</select>
</label>
</div>
<div class="mysql-filter-list">
<div v-for="(filter, index) in mySqlAdmin.rows.filters" :key="'mysql-filter-' + index" class="mysql-filter-row">
<label class="update-field">
<span></span>
<select v-model="filter.column" class="interval-select">
<option value="">选择列</option>
<option v-for="column in mySqlAdmin.schema.columns" :key="'filter-column-' + index + '-' + column.name" :value="column.name">{{ column.name }}</option>
</select>
</label>
<label class="update-field">
<span>条件</span>
<select v-model="filter.operator" class="interval-select">
<option v-for="option in mySqlAdmin.filterOperators" :key="'filter-op-' + index + '-' + option.id" :value="option.id">{{ option.label }}</option>
</select>
</label>
<label class="update-field">
<span></span>
<input
v-if="mySqlFilterNeedsValue(filter.operator)"
v-model="filter.value"
class="search mono"
:placeholder="
filter.operator === 'in' || filter.operator === 'not_in'
? '[1,2]'
: filter.operator === 'between' || filter.operator === 'not_between'
? '[min,max]'
: ''
"
/>
<div v-else class="mysql-filter-static">无需值</div>
</label>
<div class="action-group">
<button class="refresh ghost inline-button" @click="removeMySqlFilterRow(index)">删除</button>
</div>
</div>
</div>
</div>
<div class="editor-toolbar mongo-subtoolbar mysql-result-toolbar">
<div class="editor-toolbar-main">
<span class="badge neutral">总数 {{ formatNumber(mySqlAdmin.rows.totalRows) }}</span>
<span class="badge neutral">页数 {{ mySqlAdmin.rows.page }} / {{ mySqlAdmin.rows.totalPages }}</span>
</div>
<div class="action-group">
<button class="refresh ghost" @click="fillMySqlInsertTemplate" :disabled="mySqlAdmin.rowsLoading">
插入模板
</button>
<button class="refresh ghost" @click="addMySqlFilterRow">增加筛选</button>
<button class="refresh ghost" @click="resetMySqlRowQuery" :disabled="mySqlAdmin.rowsLoading">重置</button>
<button class="refresh" @click="refreshMySqlRows({ resetPage: true })" :disabled="mySqlAdmin.rowsLoading">
{{ mySqlAdmin.rowsLoading ? '查询中...' : '查询' }}
</button>
</div>
</div>
<div class="table-wrap table-scroll-x">
<table class="table compact mysql-data-table">
<thead>
<tr>
<th>操作</th>
<th v-for="column in mySqlAdmin.rows.columns" :key="'header-' + column.name">
<button class="mysql-sort-button" @click="toggleMySqlSort(column.name)">
<span>{{ column.name }}</span>
<small v-if="mySqlSortLabel(column.name)" class="mysql-sort-indicator">{{ mySqlSortLabel(column.name) }}</small>
</button>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in mySqlAdmin.rows.items" :key="'mysql-row-' + mySqlRowKey(row)" :class="isMySqlRowSelected(row) ? 'selected' : ''">
<td class="mysql-row-action-cell">
<div class="action-group action-group-tight">
<button class="refresh ghost inline-button" @click="loadMySqlRowForEdit(row)">编辑</button>
<button class="refresh ghost inline-button" @click="copyMySqlRowToInsert(row)">复制</button>
<button class="refresh ghost inline-button" @click="deleteMySqlRow(row)">删除</button>
</div>
</td>
<td
v-for="column in mySqlAdmin.rows.columns"
:key="'cell-' + mySqlRowKey(row) + '-' + column.name"
:class="['mono', 'mysql-cell', row.values[column.name] == null ? 'is-null' : '']"
:title="mySqlCellText(row.values[column.name])"
>
{{ mySqlCellPreview(row.values[column.name]) }}
</td>
</tr>
<tr v-if="mySqlAdmin.rows.items.length === 0">
<td :colspan="Math.max(1, mySqlAdmin.rows.columns.length + 1)" class="table-empty-cell">当前没有数据</td>
</tr>
</tbody>
</table>
</div>
<div class="mongo-pager">
<button class="refresh ghost" @click="changeMySqlRowsPage(mySqlAdmin.rows.page - 1)" :disabled="mySqlAdmin.rows.page <= 1 || mySqlAdmin.rowsLoading">上一页</button>
<span>{{ mySqlAdmin.rows.page }} / {{ mySqlAdmin.rows.totalPages }}</span>
<button class="refresh ghost" @click="changeMySqlRowsPage(mySqlAdmin.rows.page + 1)" :disabled="mySqlAdmin.rows.page >= mySqlAdmin.rows.totalPages || mySqlAdmin.rowsLoading">下一页</button>
</div>
</section>
</template>
<template v-else-if="mySqlAdmin.activeTab === 'write'">
<div class="mongo-panel-grid mysql-write-grid">
<article class="mongo-action-panel mysql-action-panel tone-write">
<header class="panel-header">
<div class="panel-title">
<h2>新增行</h2>
<p>{{ selectedMySqlTableItem.name }}</p>
</div>
<button class="refresh ghost" @click="fillMySqlInsertTemplate" :disabled="mySqlAdmin.insertForm.saving">生成模板</button>
</header>
<textarea
v-model="mySqlAdmin.insertForm.valuesText"
class="config-textarea mongo-editor-textarea mono"
spellcheck="false"
></textarea>
<div class="action-group">
<button class="refresh" @click="insertMySqlRow" :disabled="mySqlAdmin.insertForm.saving">
{{ mySqlAdmin.insertForm.saving ? '提交中...' : '插入' }}
</button>
</div>
</article>
<article class="mongo-action-panel mysql-action-panel tone-edit">
<header class="panel-header">
<div class="panel-title">
<h2>编辑行</h2>
<p>{{ mySqlRowIdentityText(mySqlAdmin.rowEditor.identity) }}</p>
</div>
<button class="refresh ghost" @click="clearMySqlRowEditor" :disabled="mySqlAdmin.rowEditor.saving">清空</button>
</header>
<textarea
v-model="mySqlAdmin.rowEditor.valuesText"
class="config-textarea mongo-editor-textarea mono"
spellcheck="false"
></textarea>
<div class="action-group">
<button class="refresh ghost" @click="copyMySqlRowToInsert" :disabled="!mySqlAdmin.rowEditor.identity || mySqlAdmin.rowEditor.saving">
复制为新增
</button>
<button class="refresh" @click="updateMySqlRow" :disabled="!mySqlAdmin.rowEditor.identity || mySqlAdmin.rowEditor.saving">
{{ mySqlAdmin.rowEditor.saving ? '保存中...' : '更新' }}
</button>
<button class="refresh danger" @click="deleteMySqlRow()" :disabled="!mySqlAdmin.rowEditor.identity || mySqlAdmin.rowEditor.saving">
删除
</button>
</div>
</article>
</div>
</template>
<template v-else-if="mySqlAdmin.activeTab === 'schema'">
<section class="panel mongo-admin-subpanel">
<header class="panel-header">
<div class="panel-title">
<h2></h2>
<p>{{ selectedMySqlTableItem.name }}</p>
</div>
<span class="badge neutral">{{ formatNumber(mySqlAdmin.schema.columns.length) }} 列</span>
</header>
<div class="table-wrap table-scroll-x">
<table class="table compact mysql-data-table">
<thead>
<tr>
<th>列名</th>
<th>类型</th>
<th>默认值</th>
<th>属性</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="column in mySqlAdmin.schema.columns" :key="'column-' + column.name">
<td class="mono">{{ column.name }}</td>
<td>{{ column.columnType }}</td>
<td class="mono">{{ mySqlCellPreview(column.default, 48) }}</td>
<td>
<div class="mysql-tag-list">
<span class="badge neutral">nullable {{ column.nullable ? 'Y' : 'N' }}</span>
<span v-if="column.key" class="badge neutral">{{ column.key }}</span>
<span v-if="column.extra" class="badge neutral">{{ column.extra }}</span>
</div>
</td>
<td>
<button class="refresh ghost inline-button" @click="dropMySqlColumn(column.name)" :disabled="mySqlAdmin.columnForm.saving">删除</button>
</td>
</tr>
<tr v-if="mySqlAdmin.schema.columns.length === 0">
<td colspan="5" class="table-empty-cell">当前没有列</td>
</tr>
</tbody>
</table>
</div>
</section>
<div class="mongo-panel-grid mysql-schema-grid">
<article class="mongo-action-panel mysql-action-panel tone-write">
<header class="panel-header">
<div class="panel-title">
<h2>新增列</h2>
</div>
</header>
<label class="update-field">
<span>列名</span>
<input v-model.trim="mySqlAdmin.columnForm.columnName" class="search mono" />
</label>
<label class="update-field">
<span>类型</span>
<input v-model.trim="mySqlAdmin.columnForm.columnType" class="search mono" />
</label>
<label class="table-check">
<input v-model="mySqlAdmin.columnForm.nullable" type="checkbox" />
<span>允许 NULL</span>
</label>
<label class="update-field">
<span>默认值</span>
<select v-model="mySqlAdmin.columnForm.defaultMode" class="interval-select">
<option v-for="mode in mySqlAdmin.defaultModes" :key="'default-mode-' + mode.id" :value="mode.id">{{ mode.label }}</option>
</select>
</label>
<label v-if="mySqlAdmin.columnForm.defaultMode === 'literal'" class="update-field">
<span>默认值内容</span>
<input v-model="mySqlAdmin.columnForm.defaultValue" class="search mono" />
</label>
<label class="update-field">
<span>注释</span>
<input v-model="mySqlAdmin.columnForm.comment" class="search" />
</label>
<label class="update-field">
<span>位置</span>
<select v-model="mySqlAdmin.columnForm.position" class="interval-select">
<option value="last">末尾</option>
<option value="first">首列</option>
<option value="after">指定列后</option>
</select>
</label>
<label v-if="mySqlAdmin.columnForm.position === 'after'" class="update-field">
<span>After</span>
<select v-model="mySqlAdmin.columnForm.afterColumn" class="interval-select">
<option value="">选择列</option>
<option v-for="column in mySqlAdmin.schema.columns" :key="'after-column-' + column.name" :value="column.name">{{ column.name }}</option>
</select>
</label>
<div class="action-group">
<button class="refresh" @click="addMySqlColumn" :disabled="mySqlAdmin.columnForm.saving">
{{ mySqlAdmin.columnForm.saving ? '提交中...' : '增加列' }}
</button>
</div>
</article>
<article class="mongo-action-panel mysql-action-panel tone-structure">
<header class="panel-header">
<div class="panel-title">
<h2>新增索引</h2>
<p>{{ selectedMySqlTableItem.name }}</p>
</div>
</header>
<label class="update-field">
<span>索引名</span>
<input v-model.trim="mySqlAdmin.indexForm.indexName" class="search mono" />
</label>
<label class="update-field">
<span>类型</span>
<select v-model="mySqlAdmin.indexForm.indexMode" class="interval-select">
<option v-for="mode in mySqlAdmin.indexModes" :key="'index-mode-' + mode.id" :value="mode.id">{{ mode.label }}</option>
</select>
</label>
<div class="mysql-checkbox-grid">
<label v-for="column in mySqlAdmin.schema.columns" :key="'index-column-' + column.name" class="mysql-checkbox">
<input :checked="mySqlAdmin.indexForm.columns.includes(column.name)" type="checkbox" @change="toggleMySqlIndexColumn(column.name)" />
<span>{{ column.name }}</span>
</label>
</div>
<div class="action-group">
<button class="refresh" @click="addMySqlIndex" :disabled="mySqlAdmin.indexForm.saving">
{{ mySqlAdmin.indexForm.saving ? '提交中...' : '增加索引' }}
</button>
</div>
</article>
</div>
<section class="panel mongo-admin-subpanel">
<header class="panel-header">
<div class="panel-title">
<h2>索引</h2>
<p>{{ selectedMySqlTableItem.name }}</p>
</div>
<span class="badge neutral">{{ formatNumber(mySqlAdmin.schema.indexes.length) }} 项</span>
</header>
<div class="table-wrap table-scroll-x">
<table class="table compact mysql-data-table">
<thead>
<tr>
<th>索引名</th>
<th></th>
<th>属性</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="index in mySqlAdmin.schema.indexes" :key="'mysql-index-' + index.name">
<td class="mono">{{ index.name }}</td>
<td class="mono">{{ (index.columns || []).map((column) => column.name).join(', ') }}</td>
<td>
<div class="mysql-tag-list">
<span class="badge neutral">{{ index.type || '-' }}</span>
<span class="badge neutral">primary {{ index.primary ? 'Y' : 'N' }}</span>
<span class="badge neutral">unique {{ index.unique ? 'Y' : 'N' }}</span>
</div>
</td>
<td>
<button class="refresh ghost inline-button" @click="dropMySqlIndex(index.name)" :disabled="mySqlAdmin.indexForm.saving">删除</button>
</td>
</tr>
<tr v-if="mySqlAdmin.schema.indexes.length === 0">
<td colspan="4" class="table-empty-cell">当前没有索引</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="panel mongo-admin-subpanel mysql-ddl-panel">
<header class="panel-header">
<div class="panel-title">
<h2>DDL</h2>
<p>{{ selectedMySqlTableItem.name }}</p>
</div>
</header>
<textarea
:value="mySqlAdmin.schema.createTable"
class="config-textarea mongo-editor-textarea mono mysql-ddl-textarea"
spellcheck="false"
readonly
></textarea>
</section>
</template>
<template v-else-if="mySqlAdmin.activeTab === 'sql'">
<section class="panel mongo-admin-subpanel mysql-console-panel">
<header class="panel-header">
<div class="panel-title">
<h2>SQL</h2>
<p>{{ selectedMySqlDatabaseItem.name }}</p>
</div>
<span class="badge neutral">{{ mySqlAdmin.sqlConsole.result ? (mySqlAdmin.sqlConsole.result.kind || '-') : '-' }}</span>
</header>
<div class="mysql-shortcut-grid">
<button
v-for="shortcut in mySqlSqlShortcuts"
:key="'mysql-shortcut-' + shortcut.id"
class="refresh ghost inline-button"
:disabled="shortcut.disabled"
@click="applyMySqlSqlShortcut(shortcut)"
>
{{ shortcut.label }}
</button>
</div>
<textarea
v-model="mySqlAdmin.sqlConsole.text"
class="config-textarea mongo-editor-textarea mono mysql-console-textarea"
spellcheck="false"
></textarea>
<div class="action-group">
<button class="refresh" @click="executeMySqlSql" :disabled="mySqlAdmin.sqlConsole.running">
{{ mySqlAdmin.sqlConsole.running ? '执行中...' : '执行 SQL' }}
</button>
</div>
</section>
<section v-if="mySqlAdmin.sqlConsole.result" class="panel mongo-admin-subpanel mysql-console-result">
<header class="panel-header">
<div class="panel-title">
<h2>结果</h2>
<p>{{ mySqlAdmin.sqlConsole.result.kind || '-' }}</p>
</div>
<div class="mysql-result-meta">
<span v-if="mySqlAdmin.sqlConsole.result.rowCount != null" class="badge neutral">{{ formatNumber(mySqlAdmin.sqlConsole.result.rowCount) }} 行</span>
<span v-if="mySqlAdmin.sqlConsole.result.affectedRows != null" class="badge neutral">影响 {{ formatNumber(mySqlAdmin.sqlConsole.result.affectedRows) }}</span>
<span v-if="mySqlAdmin.sqlConsole.result.lastInsertId" class="badge neutral">insertId {{ mySqlAdmin.sqlConsole.result.lastInsertId }}</span>
<span v-if="mySqlAdmin.sqlConsole.result.truncated" class="badge warn">已截断</span>
</div>
</header>
<div v-if="Array.isArray(mySqlAdmin.sqlConsole.result.columns) && mySqlAdmin.sqlConsole.result.columns.length > 0" class="table-wrap table-scroll-x">
<table class="table compact mysql-data-table">
<thead>
<tr>
<th v-for="column in mySqlAdmin.sqlConsole.result.columns" :key="'sql-header-' + column">{{ column }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in mySqlAdmin.sqlConsole.result.rows || []" :key="'sql-row-' + rowIndex">
<td
v-for="column in mySqlAdmin.sqlConsole.result.columns"
:key="'sql-cell-' + rowIndex + '-' + column"
:class="['mono', 'mysql-cell', row[column] == null ? 'is-null' : '']"
:title="mySqlCellText(row[column])"
>
{{ mySqlCellPreview(row[column]) }}
</td>
</tr>
<tr v-if="(mySqlAdmin.sqlConsole.result.rows || []).length === 0">
<td :colspan="mySqlAdmin.sqlConsole.result.columns.length" class="table-empty-cell">当前没有结果</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="metadata-strip mysql-result-strip">
<span class="mono">{{ mySqlAdmin.sqlConsole.result.message || '执行完成' }}</span>
<span>影响 {{ formatNumber(mySqlAdmin.sqlConsole.result.affectedRows || 0) }}</span>
<span v-if="mySqlAdmin.sqlConsole.result.lastInsertId">insertId {{ mySqlAdmin.sqlConsole.result.lastInsertId }}</span>
</div>
</section>
</template>
</section>
</div>
</section>
<section v-else class="panel infra-panel database-workbench-panel mongo-workbench-panel">
<div class="compact-workbench-head mongo-workbench-head">
<div class="metadata-strip metadata-strip-tight">
<span class="mono">{{ truncateMiddle(payload.mongo.info.host || '未配置 MongoDB', 20, 12) }}</span>
<span>{{ selectedMongoDatabaseItem ? `db ${selectedMongoDatabaseItem.name}` : `${formatNumber(mongoAdmin.databases.length)} 库` }}</span>
<span>{{ selectedMongoCollectionItem ? `collection ${selectedMongoCollectionItem.name}` : `${formatNumber(mongoAdminTotalCollectionCount)} 集合` }}</span>
<span>文档 {{ formatNumber(mongoAdminCollectionStats.count) }}</span>
<span>索引 {{ formatNumber(mongoAdminCollectionStats.indexCount) }}</span>
<span v-if="selectedMongoCollectionItem">容量 {{ formatMb(mongoAdminCollectionStats.storageSizeMb) }}</span>
</div>
<span :class="['badge', mongoAdmin.error ? 'bad' : 'ok']">{{ mongoAdmin.error ? '存在错误' : '已连接' }}</span>
</div>
<p v-if="mongoAdmin.error" class="metric-error">{{ explainError(mongoAdmin.error) }}</p>
<div class="summary-cards mongo-admin-overview-grid">
<article v-for="card in mongoAdminOverviewCards" :key="'mongo-overview-' + card.key" :class="['summary-card', 'mongo-admin-overview-card', `tone-${card.tone}`]">
<small>{{ card.label }}</small>
<strong>{{ card.value }}</strong>
<span>{{ card.meta }}</span>
</article>
</div>
<div class="config-layout mongo-admin-layout database-admin-layout">
<aside class="config-sidebar mongo-sidebar database-sidebar">
<div class="metadata-strip metadata-strip-tight mongo-sidebar-strip">
<span>{{ formatNumber(mongoAdmin.databases.length) }} 库</span>
<span>{{ formatNumber(mongoAdminTotalCollectionCount) }} 集合</span>
<span v-if="selectedMongoDatabaseItem" class="mono">{{ selectedMongoDatabaseItem.name }}</span>
<span v-if="selectedMongoCollectionItem">{{ selectedMongoCollectionItem.type || 'collection' }}</span>
</div>
<section class="mongo-sidebar-section">
<div class="section-caption-row">
<div class="section-caption">数据库</div>
<div class="section-caption-meta">
<span>{{ formatNumber(mongoAdminFilteredDatabases.length) }}</span>
</div>
</div>
<div class="config-list config-list-ide mongo-sidebar-list">
<div v-for="database in mongoAdminFilteredDatabases" :key="'mongo-db-' + database.name" class="config-group">
<button
:class="['config-group-toggle', mongoAdmin.selectedDatabase === database.name ? 'active' : '']"
@click="toggleMongoDatabaseGroup(database.name)"
>
<strong>{{ database.name }}</strong>
<span>{{ formatNumber(database.collectionCount || (database.collections || []).length) }} 项</span>
<small>{{ isMongoDatabaseCollapsed(database.name) ? '展开' : '收起' }}</small>
</button>
<div v-if="!isMongoDatabaseCollapsed(database.name)" class="config-group-items">
<button
v-for="collection in database.visibleCollections"
:key="'mongo-collection-' + database.name + '-' + collection.name"
:class="['config-item', 'config-item-ide', isMongoCollectionSelected(database.name, collection.name) ? 'active' : '']"
@click="selectMongoCollection(database.name, collection.name)"
>
<div class="config-item-main">
<strong>{{ collection.name }}</strong>
<span>{{ collection.type || 'collection' }}</span>
</div>
<div class="config-item-side">
<small>{{ formatNumber(collection.documentCount) }} docs</small>
<small>{{ formatNumber(collection.indexCount) }} idx</small>
</div>
</button>
<div v-if="database.visibleCollections.length === 0" class="config-empty compact">
当前没有匹配集合
</div>
</div>
</div>
<div v-if="!mongoAdmin.loading && mongoAdminFilteredDatabases.length === 0" class="config-empty">
当前没有可展示的数据库
</div>
</div>
</section>
</aside>
<section class="config-editor mongo-admin-editor database-admin-editor">
<div class="editor-toolbar sticky-toolbar mongo-editor-toolbar">
<div class="editor-toolbar-main">
<span
v-if="mongoAdmin.message"
:class="[
'badge',
mongoAdmin.messageTone === 'ok'
? 'ok'
: mongoAdmin.messageTone === 'warn'
? 'warn'
: 'neutral',
]"
>
{{ mongoAdmin.message }}
</span>
<span v-else-if="mongoAdmin.loading" class="badge neutral">数据库加载中</span>
<span v-else-if="mongoAdmin.detailLoading" class="badge neutral">集合明细加载中</span>
<span v-else-if="mongoAdmin.querying" class="badge neutral">查询中</span>
<span v-else-if="mongoAdmin.aggregate.running" class="badge neutral">聚合中</span>
<span v-else class="badge neutral">{{ activeDatabaseTabLabel || 'MongoDB' }}</span>
</div>
<div class="action-group">
<button class="refresh ghost" @click="resetMongoQueryForm" :disabled="mongoAdmin.querying || mongoAdmin.aggregate.running">重置查询</button>
<button
class="refresh ghost"
@click="mongoAdmin.activeTab === 'aggregate' ? runMongoAggregate() : refreshMongoCollectionDetail()"
:disabled="!selectedMongoCollectionItem || databaseRefreshBusy"
>
{{ databaseRefreshBusy ? '执行中...' : (mongoAdmin.activeTab === 'aggregate' ? '刷新聚合' : '刷新明细') }}
</button>
</div>
</div>
<div v-if="selectedMongoDatabaseItem" class="metadata-strip mongo-context-strip">
<span class="mono">db {{ selectedMongoDatabaseItem.name }}</span>
<span v-if="selectedMongoCollectionItem" class="mono">collection {{ selectedMongoCollectionItem.name }}</span>
<span v-if="selectedMongoCollectionItem">docs {{ formatNumber(mongoAdminCollectionStats.count) }}</span>
<span v-if="selectedMongoCollectionItem">size {{ formatMb(mongoAdminCollectionStats.storageSizeMb) }}</span>
<span v-if="selectedMongoCollectionItem">index {{ formatNumber(mongoAdminCollectionStats.indexCount) }}</span>
<span v-if="selectedMongoCollectionItem">capped {{ mongoAdminCollectionStats.capped ? 'Y' : 'N' }}</span>
<span v-if="mongoAdmin.collectionDetail.updatedAt">更新 {{ formatDateTime(mongoAdmin.collectionDetail.updatedAt) }}</span>
</div>
<div v-if="!selectedMongoDatabaseItem" class="config-empty">
当前没有可操作的数据库
</div>
<template v-else-if="!selectedMongoCollectionItem">
<section class="panel mongo-admin-subpanel">
<header class="panel-header">
<div class="panel-title">
<h2>集合</h2>
<p>{{ selectedMongoDatabaseItem.name }}</p>
</div>
<span class="badge neutral">{{ formatNumber(selectedMongoDatabaseItem.collectionCount || (selectedMongoDatabaseItem.collections || []).length) }} 项</span>
</header>
<div class="table-wrap table-scroll-x">
<table class="table compact mongo-document-table">
<thead>
<tr>
<th>集合</th>
<th>类型</th>
<th>文档</th>
<th>容量</th>
<th>索引</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="collection in selectedMongoDatabaseItem.collections || []" :key="'mongo-table-list-' + collection.name">
<td class="mono">{{ collection.name }}</td>
<td>{{ collection.type || 'collection' }}</td>
<td>{{ formatNumber(collection.documentCount) }}</td>
<td>{{ formatMb(collection.storageSizeMb) }}</td>
<td>{{ formatNumber(collection.indexCount) }}</td>
<td>
<button class="refresh ghost inline-button" @click="selectMongoCollection(selectedMongoDatabaseItem.name, collection.name)">打开</button>
</td>
</tr>
<tr v-if="(selectedMongoDatabaseItem.collections || []).length === 0">
<td colspan="6" class="table-empty-cell">当前没有集合</td>
</tr>
</tbody>
</table>
</div>
</section>
<article class="mongo-action-panel mysql-action-panel tone-write">
<header class="panel-header">
<div class="panel-title">
<h2>新建集合</h2>
<p>{{ selectedMongoDatabaseItem.name }}</p>
</div>
</header>
<label class="update-field">
<span></span>
<input v-model.trim="mongoAdmin.collectionOps.createDatabaseName" class="search mono" />
</label>
<label class="update-field">
<span>集合</span>
<input v-model.trim="mongoAdmin.collectionOps.createName" class="search mono" />
</label>
<label class="update-field">
<span>Options</span>
<textarea
v-model="mongoAdmin.collectionOps.createOptionsText"
class="config-textarea mongo-json-textarea mono"
spellcheck="false"
></textarea>
</label>
<div class="action-group">
<button class="refresh" @click="createMongoCollection" :disabled="mongoAdmin.collectionOps.running">
{{ mongoAdmin.collectionOps.running ? '提交中...' : '创建集合' }}
</button>
</div>
</article>
</template>
<template v-else-if="mongoAdmin.activeTab === 'browse'">
<section class="panel mongo-admin-subpanel">
<header class="panel-header">
<div class="panel-title">
<h2>文档</h2>
<p>{{ selectedMongoCollectionItem.name }}</p>
</div>
<span class="badge neutral">{{ mongoQueryRangeText }}</span>
</header>
<div class="mongo-query-grid">
<label class="update-field">
<span>Filter</span>
<textarea
v-model="mongoAdmin.query.filterText"
class="config-textarea mongo-json-textarea mono"
spellcheck="false"
></textarea>
</label>
<label class="update-field">
<span>Sort</span>
<textarea
v-model="mongoAdmin.query.sortText"
class="config-textarea mongo-json-textarea mono"
spellcheck="false"
></textarea>
</label>
<label class="update-field">
<span>Projection</span>
<textarea
v-model="mongoAdmin.query.projectionText"
class="config-textarea mongo-json-textarea mono"
spellcheck="false"
></textarea>
</label>
</div>
<div class="editor-toolbar mongo-subtoolbar">
<div class="editor-toolbar-main">
<span class="badge neutral">总数 {{ formatNumber(mongoAdmin.query.total) }}</span>
<span class="badge neutral">页数 {{ mongoAdmin.query.page }} / {{ mongoAdmin.query.pageCount }}</span>
<span class="badge neutral">{{ selectedMongoCollectionItem.type || 'collection' }}</span>
</div>
<div class="action-group">
<button class="refresh ghost" @click="resetMongoQueryForm" :disabled="mongoAdmin.querying">重置</button>
<button class="refresh" @click="runMongoQuery({ resetPage: true })" :disabled="mongoAdmin.querying">
{{ mongoAdmin.querying ? '查询中...' : '查询' }}
</button>
</div>
</div>
<div class="table-wrap table-scroll-x">
<table class="table compact mongo-document-table">
<thead>
<tr>
<th>操作</th>
<th>_id</th>
<th>预览</th>
</tr>
</thead>
<tbody>
<tr v-for="item in mongoAdmin.query.items" :key="'mongo-query-row-' + item.key">
<td class="mysql-row-action-cell">
<div class="action-group action-group-tight">
<button class="refresh ghost inline-button" @click="loadMongoDocumentForEdit(item)">编辑</button>
<button class="refresh ghost inline-button" @click="copyText(item.jsonText, 'mongo-doc-' + item.key)">
{{ isCopied('mongo-doc-' + item.key) ? '已复制' : '复制' }}
</button>
<button class="refresh ghost inline-button" @click="deleteMongoDocument(item)">删除</button>
</div>
</td>
<td class="mono">{{ item.idText || '-' }}</td>
<td class="mono mongo-cell-preview" :title="item.previewText">{{ item.previewText }}</td>
</tr>
<tr v-if="mongoAdmin.query.items.length === 0">
<td colspan="3" class="table-empty-cell">当前没有文档</td>
</tr>
</tbody>
</table>
</div>
<div class="mongo-pager">
<button class="refresh ghost" @click="changeMongoQueryPage(mongoAdmin.query.page - 1)" :disabled="mongoAdmin.query.page <= 1 || mongoAdmin.querying">上一页</button>
<span>{{ mongoAdmin.query.page }} / {{ mongoAdmin.query.pageCount }}</span>
<button class="refresh ghost" @click="changeMongoQueryPage(mongoAdmin.query.page + 1)" :disabled="mongoAdmin.query.page >= mongoAdmin.query.pageCount || mongoAdmin.querying">下一页</button>
</div>
</section>
<div class="mongo-panel-grid">
<article class="mongo-action-panel mysql-action-panel tone-edit">
<header class="panel-header">
<div class="panel-title">
<h2>编辑文档</h2>
<p>{{ mongoAdmin.editor.idText || selectedMongoCollectionItem.name }}</p>
</div>
<button class="refresh ghost" @click="clearMongoDocumentEditor" :disabled="mongoAdmin.editor.saving">清空</button>
</header>
<label class="update-field">
<span>Filter</span>
<textarea
v-model="mongoAdmin.editor.filterText"
class="config-textarea mongo-json-textarea mono"
spellcheck="false"
></textarea>
</label>
<label class="update-field">
<span>Document</span>
<textarea
v-model="mongoAdmin.editor.documentText"
class="config-textarea mongo-editor-textarea mono"
spellcheck="false"
></textarea>
</label>
<label class="table-check">
<input v-model="mongoAdmin.editor.upsert" type="checkbox" />
<span>upsert</span>
</label>
<div class="action-group">
<button class="refresh" @click="saveMongoEditedDocument" :disabled="mongoAdmin.editor.saving">
{{ mongoAdmin.editor.saving ? '保存中...' : '保存文档' }}
</button>
</div>
</article>
<article class="mongo-action-panel mysql-action-panel tone-write">
<header class="panel-header">
<div class="panel-title">
<h2>新增文档</h2>
<p>{{ selectedMongoCollectionItem.name }}</p>
</div>
</header>
<label class="update-field">
<span>Document</span>
<textarea
v-model="mongoAdmin.createDocument.documentText"
class="config-textarea mongo-editor-textarea mono"
spellcheck="false"
></textarea>
</label>
<label class="table-check">
<input v-model="mongoAdmin.createDocument.ordered" type="checkbox" />
<span>ordered</span>
</label>
<div class="action-group">
<button class="refresh" @click="insertMongoDocument" :disabled="mongoAdmin.createDocument.saving">
{{ mongoAdmin.createDocument.saving ? '写入中...' : '插入文档' }}
</button>
</div>
</article>
</div>
</template>
<template v-else-if="mongoAdmin.activeTab === 'write'">
<div class="mongo-panel-grid">
<article class="mongo-action-panel mysql-action-panel tone-edit">
<header class="panel-header">
<div class="panel-title">
<h2>更新文档</h2>
<p>{{ selectedMongoCollectionItem.name }}</p>
</div>
</header>
<label class="update-field">
<span>Filter</span>
<textarea
v-model="mongoAdmin.updateOp.filterText"
class="config-textarea mongo-json-textarea mono"
spellcheck="false"
></textarea>
</label>
<label class="update-field">
<span>Update</span>
<textarea
v-model="mongoAdmin.updateOp.updateText"
class="config-textarea mongo-editor-textarea mono"
spellcheck="false"
></textarea>
</label>
<div class="mongo-check-grid">
<label class="table-check">
<input v-model="mongoAdmin.updateOp.many" type="checkbox" />
<span>updateMany</span>
</label>
<label class="table-check">
<input v-model="mongoAdmin.updateOp.upsert" type="checkbox" />
<span>upsert</span>
</label>
</div>
<div class="action-group">
<button class="refresh" @click="runMongoUpdateDocuments" :disabled="mongoAdmin.updateOp.running">
{{ mongoAdmin.updateOp.running ? '执行中...' : '执行更新' }}
</button>
</div>
</article>
<article class="mongo-action-panel mysql-action-panel tone-edit mongo-danger-panel">
<header class="panel-header">
<div class="panel-title">
<h2>删除文档</h2>
<p>{{ selectedMongoCollectionItem.name }}</p>
</div>
</header>
<label class="update-field">
<span>Filter</span>
<textarea
v-model="mongoAdmin.deleteOp.filterText"
class="config-textarea mongo-json-textarea mono"
spellcheck="false"
></textarea>
</label>
<label class="table-check">
<input v-model="mongoAdmin.deleteOp.many" type="checkbox" />
<span>deleteMany</span>
</label>
<div class="action-group">
<button class="refresh danger" @click="runMongoDeleteDocuments" :disabled="mongoAdmin.deleteOp.running">
{{ mongoAdmin.deleteOp.running ? '执行中...' : '删除文档' }}
</button>
</div>
</article>
<article class="mongo-action-panel mysql-action-panel tone-structure">
<header class="panel-header">
<div class="panel-title">
<h2>Distinct</h2>
<p>{{ selectedMongoCollectionItem.name }}</p>
</div>
</header>
<label class="update-field">
<span>Field</span>
<input v-model.trim="mongoAdmin.distinct.field" class="search mono" />
</label>
<label class="update-field">
<span>Filter</span>
<textarea
v-model="mongoAdmin.distinct.filterText"
class="config-textarea mongo-json-textarea mono"
spellcheck="false"
></textarea>
</label>
<div class="action-group">
<button class="refresh" @click="runMongoDistinct" :disabled="mongoAdmin.distinct.running">
{{ mongoAdmin.distinct.running ? '执行中...' : '执行去重' }}
</button>
</div>
<div class="metadata-strip mongo-result-strip">
<span>{{ formatNumber(mongoAdmin.distinct.count) }} 项</span>
<span class="mono">{{ truncateMiddle(mongoAdmin.distinct.field || '-', 20, 8) }}</span>
</div>
<textarea
:value="mongoAdmin.distinct.valuesText"
class="config-textarea mongo-json-textarea mono"
spellcheck="false"
readonly
></textarea>
</article>
</div>
</template>
<template v-else-if="mongoAdmin.activeTab === 'aggregate'">
<section class="panel mongo-admin-subpanel">
<header class="panel-header">
<div class="panel-title">
<h2>聚合</h2>
<p>{{ selectedMongoCollectionItem.name }}</p>
</div>
<span class="badge neutral">{{ mongoAggregateRangeText }}</span>
</header>
<div class="mongo-panel-grid narrow">
<label class="update-field">
<span>Pipeline</span>
<textarea
v-model="mongoAdmin.aggregate.pipelineText"
class="config-textarea mongo-editor-textarea mono"
spellcheck="false"
></textarea>
</label>
<div class="mongo-action-panel mongo-aggregate-side">
<label class="update-field">
<span>每页</span>
<select v-model.number="mongoAdmin.aggregate.pageSize" class="interval-select">
<option v-for="size in mongoAdmin.pageSizeOptions" :key="'mongo-agg-size-' + size" :value="size">{{ size }}</option>
</select>
</label>
<div class="metadata-strip mongo-result-strip">
<span>{{ formatNumber(mongoAdmin.aggregate.total) }} 条</span>
<span>{{ selectedMongoCollectionItem.type || 'collection' }}</span>
</div>
<div class="action-group">
<button class="refresh ghost" @click="mongoAdmin.aggregate.page = 1" :disabled="mongoAdmin.aggregate.running">回到第一页</button>
<button class="refresh" @click="runMongoAggregate({ resetPage: true })" :disabled="mongoAdmin.aggregate.running">
{{ mongoAdmin.aggregate.running ? '执行中...' : '执行聚合' }}
</button>
</div>
</div>
</div>
</section>
<section class="panel mongo-admin-subpanel">
<header class="panel-header">
<div class="panel-title">
<h2>结果</h2>
<p>{{ selectedMongoCollectionItem.name }}</p>
</div>
<span class="badge neutral">{{ formatNumber(mongoAdmin.aggregate.items.length) }} 条</span>
</header>
<div class="table-wrap table-scroll-x">
<table class="table compact mongo-document-table">
<thead>
<tr>
<th>操作</th>
<th>_id</th>
<th>预览</th>
</tr>
</thead>
<tbody>
<tr v-for="item in mongoAdmin.aggregate.items" :key="'mongo-agg-row-' + item.key">
<td class="mysql-row-action-cell">
<div class="action-group action-group-tight">
<button v-if="item.idFilterText !== '{}'" class="refresh ghost inline-button" @click="loadMongoDocumentForEdit(item)">装载</button>
<button class="refresh ghost inline-button" @click="copyText(item.jsonText, 'mongo-agg-' + item.key)">
{{ isCopied('mongo-agg-' + item.key) ? '已复制' : '复制' }}
</button>
</div>
</td>
<td class="mono">{{ item.idText || '-' }}</td>
<td class="mono mongo-cell-preview" :title="item.previewText">{{ item.previewText }}</td>
</tr>
<tr v-if="mongoAdmin.aggregate.items.length === 0">
<td colspan="3" class="table-empty-cell">当前没有结果</td>
</tr>
</tbody>
</table>
</div>
<div class="mongo-pager">
<button class="refresh ghost" @click="changeMongoAggregatePage(mongoAdmin.aggregate.page - 1)" :disabled="mongoAdmin.aggregate.page <= 1 || mongoAdmin.aggregate.running">上一页</button>
<span>{{ mongoAdmin.aggregate.page }} / {{ mongoAdmin.aggregate.pageCount }}</span>
<button class="refresh ghost" @click="changeMongoAggregatePage(mongoAdmin.aggregate.page + 1)" :disabled="mongoAdmin.aggregate.page >= mongoAdmin.aggregate.pageCount || mongoAdmin.aggregate.running">下一页</button>
</div>
</section>
</template>
<template v-else-if="mongoAdmin.activeTab === 'indexes'">
<section class="panel mongo-admin-subpanel">
<header class="panel-header">
<div class="panel-title">
<h2>索引</h2>
<p>{{ selectedMongoCollectionItem.name }}</p>
</div>
<span class="badge neutral">{{ formatNumber(mongoAdmin.collectionDetail.indexes.length) }} 项</span>
</header>
<div class="table-wrap table-scroll-x">
<table class="table compact mongo-document-table">
<thead>
<tr>
<th>索引名</th>
<th>Keys</th>
<th>属性</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="index in mongoAdmin.collectionDetail.indexes" :key="'mongo-index-' + index.name">
<td class="mono">{{ index.name }}</td>
<td class="mono">
<pre class="mongo-index-text">{{ index.keysText }}</pre>
</td>
<td>
<div class="mongo-tag-list">
<span v-if="index.unique" class="badge neutral">unique</span>
<span v-if="index.sparse" class="badge neutral">sparse</span>
<span v-if="index.hidden" class="badge neutral">hidden</span>
<span v-if="index.expireAfterSeconds != null" class="badge neutral">ttl {{ index.expireAfterSeconds }}</span>
</div>
</td>
<td>
<button class="refresh ghost inline-button" @click="dropMongoIndex(index.name)" :disabled="mongoAdmin.indexForm.saving || index.name === '_id_'">删除</button>
</td>
</tr>
<tr v-if="mongoAdmin.collectionDetail.indexes.length === 0">
<td colspan="4" class="table-empty-cell">当前没有索引</td>
</tr>
</tbody>
</table>
</div>
</section>
<div class="mongo-panel-grid">
<article class="mongo-action-panel mysql-action-panel tone-structure">
<header class="panel-header">
<div class="panel-title">
<h2>新增索引</h2>
<p>{{ selectedMongoCollectionItem.name }}</p>
</div>
</header>
<label class="update-field">
<span>Keys</span>
<textarea
v-model="mongoAdmin.indexForm.keysText"
class="config-textarea mongo-json-textarea mono"
spellcheck="false"
></textarea>
</label>
<label class="update-field">
<span>Options</span>
<textarea
v-model="mongoAdmin.indexForm.optionsText"
class="config-textarea mongo-json-textarea mono"
spellcheck="false"
></textarea>
</label>
<div class="action-group">
<button class="refresh" @click="createMongoIndex" :disabled="mongoAdmin.indexForm.saving">
{{ mongoAdmin.indexForm.saving ? '提交中...' : '创建索引' }}
</button>
</div>
</article>
<article class="mongo-action-panel mysql-action-panel tone-write">
<header class="panel-header">
<div class="panel-title">
<h2>新建集合</h2>
<p>{{ selectedMongoDatabaseItem.name }}</p>
</div>
</header>
<label class="update-field">
<span></span>
<input v-model.trim="mongoAdmin.collectionOps.createDatabaseName" class="search mono" />
</label>
<label class="update-field">
<span>集合</span>
<input v-model.trim="mongoAdmin.collectionOps.createName" class="search mono" />
</label>
<label class="update-field">
<span>Options</span>
<textarea
v-model="mongoAdmin.collectionOps.createOptionsText"
class="config-textarea mongo-json-textarea mono"
spellcheck="false"
></textarea>
</label>
<div class="action-group">
<button class="refresh" @click="createMongoCollection" :disabled="mongoAdmin.collectionOps.running">
{{ mongoAdmin.collectionOps.running ? '提交中...' : '创建集合' }}
</button>
</div>
</article>
</div>
<div class="mongo-panel-grid">
<article class="mongo-action-panel mysql-action-panel tone-edit">
<header class="panel-header">
<div class="panel-title">
<h2>重命名集合</h2>
<p>{{ selectedMongoCollectionItem.name }}</p>
</div>
</header>
<label class="update-field">
<span>目标名</span>
<input v-model.trim="mongoAdmin.collectionOps.renameName" class="search mono" />
</label>
<label class="table-check">
<input v-model="mongoAdmin.collectionOps.renameDropTarget" type="checkbox" />
<span>dropTarget</span>
</label>
<div class="action-group">
<button class="refresh" @click="renameMongoCollection" :disabled="mongoAdmin.collectionOps.running">
{{ mongoAdmin.collectionOps.running ? '执行中...' : '重命名集合' }}
</button>
</div>
</article>
<article class="mongo-action-panel mysql-action-panel tone-edit mongo-danger-panel">
<header class="panel-header">
<div class="panel-title">
<h2>危险操作</h2>
<p>{{ selectedMongoDatabaseItem.name }}</p>
</div>
</header>
<div class="action-group action-group-stack">
<button class="refresh ghost" @click="dropMongoSecondaryIndexes" :disabled="mongoAdmin.indexForm.dropAllSaving">
{{ mongoAdmin.indexForm.dropAllSaving ? '执行中...' : '删除二级索引' }}
</button>
<button class="refresh danger" @click="dropMongoCollection" :disabled="mongoAdmin.collectionOps.dropRunning">
{{ mongoAdmin.collectionOps.dropRunning ? '执行中...' : '删除集合' }}
</button>
<button class="refresh danger" @click="dropMongoDatabase" :disabled="mongoAdmin.collectionOps.databaseDropRunning">
{{ mongoAdmin.collectionOps.databaseDropRunning ? '执行中...' : '删除数据库' }}
</button>
</div>
</article>
</div>
</template>
</section>
</div>
</section>
</template>
<template v-if="!showLoadingSkeleton && activeView === 'config'">
<section class="panel task-page-head">
<header class="panel-header">
<div class="panel-title">
<h2>配置</h2>
<p>{{ configContextLabel }}</p>
</div>
<div class="panel-meta">
<span class="badge neutral">{{ activeConfigTabLabel }}</span>
<span v-if="configTab === 'nacos'" class="badge neutral">{{ filteredNacosConfigs.length }} 项</span>
<span v-if="configTab === 'go'" class="badge neutral">{{ goConfig.hosts.length }} 台主机</span>
<span v-if="configTab === 'history'" class="badge neutral">{{ configHistoryRows.length }} 条记录</span>
<span
:class="[
'badge',
configTab === 'nacos'
? (nacosConfig.error || nacosConfig.detailError ? 'bad' : 'ok')
: configTab === 'go'
? (goConfig.error ? 'bad' : goConfig.diffKeys.length > 0 ? 'warn' : 'ok')
: 'neutral'
]"
>
{{
configTab === 'nacos'
? (nacosConfig.error || nacosConfig.detailError ? '存在错误' : '已连接')
: configTab === 'go'
? (goConfig.error ? '存在错误' : goConfig.diffKeys.length > 0 ? '双机有差异' : '已同步')
: '本地记录'
}}
</span>
</div>
</header>
</section>
<section v-if="configTab === 'nacos'" class="panel infra-panel config-workbench-panel">
<div class="compact-workbench-head">
<div class="metadata-strip metadata-strip-tight">
<span class="mono">{{ truncateMiddle(nacosConfig.baseUrl, 18, 12) || '-' }}</span>
<span>{{ nacosConfig.namespaceName || 'default' }}</span>
<span class="mono">{{ truncateMiddle(nacosConfig.namespaceId, 10, 8) || '-' }}</span>
<span>{{ nacosConfigGroups.length }} 组</span>
<span>{{ filteredNacosConfigs.length }} 项</span>
</div>
<span :class="['badge', nacosConfig.error || nacosConfig.detailError ? 'bad' : 'ok']">{{ nacosConfig.error || nacosConfig.detailError ? '存在错误' : '已连接' }}</span>
</div>
<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-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">
<div class="editor-doc-pill">
<strong>{{ nacosConfig.editor.dataId }}</strong>
<small>{{ nacosConfig.editor.group }} · {{ nacosConfig.editor.type || 'text' }}</small>
</div>
<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 metadata-strip-tight">
<span>最近修改 {{ formatDateTime(nacosConfig.editor.lastModifiedTime) }}</span>
<span>{{ nacosConfig.validationMessage ? '已校验' : '未校验' }}</span>
<span v-if="nacosConfig.editor.appName">app {{ 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>
</div>
<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 config-workbench-panel">
<div class="compact-workbench-head">
<div class="metadata-strip metadata-strip-tight">
<span>{{ goConfig.hosts.length }} 台主机</span>
<span>差异 key {{ goConfig.diffKeys.length }}</span>
<span>service.env</span>
</div>
<span :class="['badge', goConfig.error ? 'bad' : goConfig.diffKeys.length > 0 ? 'warn' : 'ok']">
{{ goConfig.error ? '存在错误' : goConfig.diffKeys.length > 0 ? '双机有差异' : '已同步' }}
</span>
</div>
<div class="metadata-strip metadata-strip-tight">
<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">
<div class="editor-doc-pill">
<strong>service.env</strong>
<small>Go Env</small>
</div>
<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 metadata-strip-tight">
<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 config-workbench-panel">
<div class="compact-workbench-head">
<div class="metadata-strip metadata-strip-tight">
<span>本地记录 {{ configHistoryRows.length }}</span>
<span>当前未接远端版本历史</span>
</div>
<span class="badge neutral">历史版本</span>
</div>
<div class="activity-list activity-grid">
<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="!showLoadingSkeleton && 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 v-if="releaseTargetRows.length > 0" class="release-card-grid">
<article
v-for="item in releaseTargetRows"
:key="'update-' + item.service"
:class="['release-service-card', updateOps.selectedServices.includes(item.service) ? 'selected' : '']"
:data-kind="item.kind"
>
<header class="release-service-head">
<div class="release-service-title">
<strong>{{ item.service }}</strong>
<small class="table-subtext mono" :title="item.repository">{{ item.repositoryShort }}</small>
</div>
<label :class="['table-check', updateOps.selectedServices.includes(item.service) ? 'checked' : '']">
<input
:checked="updateOps.selectedServices.includes(item.service)"
type="checkbox"
@change="toggleUpdateService(item.service)"
/>
<span>{{ updateOps.selectedServices.includes(item.service) ? '已选' : '选择' }}</span>
</label>
</header>
<div class="release-service-tags">
<span class="badge neutral">{{ item.kind }}</span>
<span class="badge neutral">{{ item.hostCount }} 节点</span>
</div>
<div class="release-service-grid">
<div class="release-service-metric">
<small>部署节点</small>
<strong :title="item.hostsLabel">{{ item.hostsLabel }}</strong>
</div>
<div class="release-service-metric">
<small>当前版本</small>
<strong :title="item.currentTitle">{{ item.currentLabel }}</strong>
</div>
<div class="release-service-metric release-service-metric-wide">
<small>镜像摘要</small>
<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>
</div>
</div>
</article>
</div>
<div v-else-if="!updateOps.loading" class="empty-panel compact">当前没有可展示的更新目标</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 activity-grid" 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 v-if="restartTargetRows.length > 0" class="release-card-grid">
<article
v-for="item in restartTargetRows"
:key="'restart-' + item.service"
:class="['release-service-card', restartOps.selectedServices.includes(item.service) ? 'selected' : '']"
:data-kind="item.kind"
>
<header class="release-service-head">
<div class="release-service-title">
<strong>{{ item.service }}</strong>
<small class="table-subtext">{{ item.kind }}</small>
</div>
<label :class="['table-check', restartOps.selectedServices.includes(item.service) ? 'checked' : '']">
<input
:checked="restartOps.selectedServices.includes(item.service)"
type="checkbox"
@change="toggleRestartService(item.service)"
/>
<span>{{ restartOps.selectedServices.includes(item.service) ? '已选' : '选择' }}</span>
</label>
</header>
<div class="release-service-tags">
<span class="badge neutral">{{ item.kind }}</span>
<span class="badge neutral">{{ item.hostCount }} 实例</span>
</div>
<div class="release-service-grid">
<div class="release-service-metric release-service-metric-wide">
<small>主机</small>
<strong :title="item.hostsLabel">{{ item.hostsLabel }}</strong>
</div>
</div>
</article>
</div>
<div v-else-if="!restartOps.loading" class="empty-panel compact">当前没有可展示的重启目标</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>