hy-app-monitor/static/index.html
2026-04-24 17:20:41 +08:00

3528 lines
222 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>
<nav class="app-switcher" aria-label="入口切换">
<a
v-for="entry in appSwitchLinks"
:key="entry.id"
:href="entry.href"
:class="['app-switch-link', currentAppEntryId === entry.id ? 'active' : '']"
>
{{ entry.label }}
</a>
</nav>
</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 :class="['badge', globalHealthTone]">{{ globalHealthLabel }}</span>
<input v-model.trim="globalSearchModel" class="search topbar-search" :placeholder="globalSearchPlaceholder" :disabled="globalSearchDisabled" />
<span v-if="auth.username" class="badge neutral mono">{{ auth.username }}</span>
<span v-if="loading" class="badge warn">刷新中</span>
<span v-if="error" class="badge bad">接口异常</span>
<label class="interval-field topbar-interval" aria-label="刷新间隔">
<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>
</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>
</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>
</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">
<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 === 'common'">
<section class="control-bar task-control-bar database-control-bar common-control-bar">
<div class="taskbar-context mongo-taskbar-context">
<strong>通用工作台</strong>
<span>{{ commonToolContextLabel }}</span>
</div>
<div class="control-group common-tool-switch">
<button
v-for="tool in commonToolTabs"
:key="tool.id"
:class="['filter-chip', commonTool === tool.id ? 'active' : '']"
@click="switchCommonTool(tool.id)"
>
{{ tool.label }}
</button>
</div>
<div class="control-group control-group-spread">
<template v-if="commonTool === 'mysql'">
<button class="refresh ghost" @click="refreshMySqlDatabases({ preserveSelection: true })" :disabled="mySqlAdmin.loading">
{{ mySqlAdmin.loading ? '刷新中...' : '刷新树' }}
</button>
<button
class="refresh"
@click="refreshMySqlCurrent"
:disabled="!mySqlAdmin.selectedDatabase || databaseRefreshBusy"
>
{{ databaseRefreshBusy ? '加载中...' : databaseRefreshLabel }}
</button>
</template>
<template v-else>
<button class="refresh ghost" @click="refreshCdnOverview()" :disabled="cdnAdmin.loading">
{{ cdnAdmin.loading ? '刷新中...' : '刷新记录' }}
</button>
<button class="refresh" @click="submitCdnPurge" :disabled="cdnAdmin.submitting || cdnFormItemCount === 0">
{{ cdnAdmin.submitting ? '提交中...' : '执行刷新' }}
</button>
</template>
</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">
<div v-if="configTab === 'go'" 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>
<p v-if="error" class="toolbar-error">加载失败:{{ error }}</p>
<template v-if="!showLoadingSkeleton && (activeView === 'overview' || activeView === 'services')">
<section v-if="activeView === 'overview' && overviewStatusSegments.length > 0" class="panel summary-strip">
<div class="summary-cards status-band-list">
<article
v-for="card in overviewStatusSegments"
:key="card.key"
:class="['summary-tile', 'status-band-item', card.key === 'overall' ? 'status-band-item-major' : '', `tone-${card.tone}`]"
>
<span class="summary-strip-label">{{ card.label }}</span>
<strong>{{ card.value }}</strong>
<span class="status-band-meta">{{ 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 host-row-list-overview">
<article
v-for="host in overviewAlertHosts"
:key="host.host"
:class="[
'host-strip',
'host-strip-overview',
host.hasAlert ? 'host-strip-alert' : '',
isHostHighlighted(host) ? 'host-strip-focused' : ''
]"
:data-group="host.group"
>
<div class="host-strip-main host-strip-main-overview" @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-metric-band">
<span
v-for="metric in hostMetricEntries(host)"
:key="host.host + '-' + metric.key"
:class="['host-metric-inline', `tone-${metric.level}`]"
>
<small>{{ metric.label }}</small>
<strong>{{ metric.display }}</strong>
</span>
<span :class="['host-metric-inline', `tone-${host.metrics.processLevel || thresholdTone('processCount', host.metrics.processCount)}`]">
<small>PROC</small>
<strong>{{ host.metrics.processCount ?? '-' }}</strong>
</span>
</div>
<div v-else class="host-metric-band host-metric-band-fallback">
<span :class="['host-metric-inline', host.hasMetricIssue ? 'tone-warn' : 'tone-neutral']">
<small>TAT</small>
<strong>{{ hostMetricStatusLabel(host) }}</strong>
</span>
</div>
<div :class="['host-service-summary', `tone-${hostSummaryTone(host)}`]">
<small>{{ host.downCount > 0 ? '异常服务' : host.hasMetricIssue ? '采集状态' : '在线服务' }}</small>
<strong class="host-service-summary-text">{{ host.downCount > 0 ? host.alertSummary : host.quietSummary }}</strong>
</div>
<div class="host-inline-state host-inline-state-overview">
<span class="badge neutral">{{ host.serviceTotal }} 服务</span>
<span :class="['badge', host.downCount > 0 ? 'bad' : 'neutral']">{{ host.downCount }} 异常</span>
<span :class="['badge', host.hasMetricIssue ? 'warn' : 'neutral']">{{ host.hasMetricIssue ? '采集异常' : (isHostExpanded(host.host) ? '收起' : '展开') }}</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="host-row-list host-row-list-overview">
<article
v-for="host in overviewQuietHosts"
:key="'quiet-' + host.host"
:class="[
'host-strip',
'host-strip-overview',
'host-strip-quiet',
isHostHighlighted(host) ? 'host-strip-focused' : ''
]"
:data-group="host.group"
@click="selectHost(host.host)"
>
<div class="host-strip-main host-strip-main-overview">
<div class="host-strip-id">
<strong>{{ host.host }}</strong>
<span>{{ host.ip }} · {{ host.groupLabel }}</span>
</div>
<div v-if="hostHasMetricData(host)" class="host-metric-band">
<span
v-for="metric in hostMetricEntries(host)"
:key="'quiet-' + host.host + '-' + metric.key"
:class="['host-metric-inline', `tone-${metric.level}`]"
>
<small>{{ metric.label }}</small>
<strong>{{ metric.display }}</strong>
</span>
<span :class="['host-metric-inline', `tone-${host.metrics.processLevel || thresholdTone('processCount', host.metrics.processCount)}`]">
<small>PROC</small>
<strong>{{ host.metrics.processCount ?? '-' }}</strong>
</span>
</div>
<div v-else class="host-metric-band host-metric-band-fallback">
<span class="host-metric-inline tone-neutral">
<small>TAT</small>
<strong>{{ hostMetricStatusLabel(host) }}</strong>
</span>
</div>
<div class="host-service-summary tone-neutral">
<small>在线服务</small>
<strong class="host-service-summary-text">{{ host.quietSummary }}</strong>
</div>
<div class="host-inline-state host-inline-state-overview">
<span class="badge neutral">{{ host.serviceTotal }} 服务</span>
<span class="badge neutral">{{ isHostExpanded(host.host) ? '收起' : '展开' }}</span>
</div>
</div>
<div v-if="isHostExpanded(host.host)" class="host-strip-detail host-strip-detail-list">
<button
v-for="item in host.items"
:key="'quiet-detail-' + 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 || 'UP' }} · {{ formatLatency(item.latencyMs) }}</span>
</button>
<div v-if="host.items.length === 0" class="empty-panel compact">当前主机没有服务实例</div>
</div>
</article>
</div>
</section>
</div>
</section>
</template>
<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="overview-infra-grid">
<article v-for="card in overviewInfraCards" :key="'infra-' + card.key" class="infra-mini-card">
<header class="infra-mini-head">
<div class="infra-mini-title">
<strong>{{ card.label }}</strong>
<small>{{ card.meta }}</small>
</div>
<span :class="['badge', card.tone === 'bad' ? 'bad' : card.tone === 'warn' ? 'warn' : 'ok']">{{ card.badge }}</span>
</header>
<div class="infra-mini-metrics">
<span v-for="item in card.metrics" :key="'infra-' + card.key + '-' + item.label" class="infra-mini-metric">
<small>{{ item.label }}</small>
<strong>{{ item.value }}</strong>
</span>
</div>
<div class="infra-freshness">
<span>最近 {{ formattedUpdatedAt }}</span>
<span :class="dataFreshnessTone === 'warn' ? 'warn-text' : ''">延迟 {{ dataFreshnessLabel }}</span>
</div>
</article>
</section>
<section v-if="infraTab === 'overview'" class="panel infra-overview-panel">
<header class="panel-header">
<div class="panel-title">
<h2>状态总览</h2>
</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="metadata-strip infra-inline-strip mongo-secondary-strip">
<span>Virtual {{ formatMb(payload.mongo.memory.virtualMb) }}</span>
<span>Resident {{ formatMb(payload.mongo.memory.residentMb) }}</span>
<span>Requests {{ formatNumber(payload.mongo.network.numRequests) }}</span>
<span>WT Cache {{ formatBytes(payload.mongo.wiredTigerCache.bytesCurrentlyInCache) }}</span>
</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>
</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>
<span>最近 {{ formattedUpdatedAt }}</span>
<span :class="dataFreshnessTone === 'warn' ? 'warn-text' : ''">延迟 {{ dataFreshnessLabel }}</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="showDatabaseWorkbench">
<section v-if="showMySqlWorkbench" 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 danger 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 danger 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 danger 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 danger 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 danger 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 danger" @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="activeView === 'common' && commonTool === 'cdn'">
<section class="panel infra-panel common-cdn-panel">
<div class="compact-workbench-head common-workbench-head">
<div class="metadata-strip metadata-strip-tight">
<span class="mono">{{ cdnAllowedDomains[0] || '未配置域名' }}</span>
<span>限制域名 {{ formatNumber(cdnAllowedDomains.length) }}</span>
<span>记录 {{ formatNumber(cdnAdmin.overview.taskTotal) }}</span>
<span>更新时间 {{ formatDateTime(cdnAdmin.overview.updatedAt) }}</span>
</div>
<span :class="['badge', cdnOverviewStatusTone]">{{ cdnOverviewStatusText }}</span>
</div>
<p v-if="cdnAdmin.error || cdnAdmin.overview.error" class="metric-error">{{ explainError(cdnAdmin.error || cdnAdmin.overview.error) }}</p>
<p v-else-if="(cdnAdmin.overview.warnings || []).length > 0" class="metric-error warn">{{ explainError(cdnAdmin.overview.warnings[0]) }}</p>
<div class="summary-cards common-cdn-summary">
<article v-for="card in cdnQuotaCards" :key="'cdn-quota-' + card.key" :class="['summary-card', `tone-${card.tone}`]">
<small>{{ card.label }}</small>
<strong>{{ card.value }}</strong>
<span>{{ card.meta }}</span>
</article>
<article v-if="cdnQuotaCards.length === 0" class="summary-card tone-neutral">
<small>CDN 配额</small>
<strong>-</strong>
<span>当前没有可展示配额</span>
</article>
</div>
<div class="mongo-panel-grid common-cdn-grid">
<article class="mongo-action-panel common-cdn-form-panel tone-write">
<header class="panel-header">
<div class="panel-title">
<h2>刷新提交</h2>
<p>仅支持 https://h5.haiyihy.com/</p>
</div>
<span class="badge neutral">{{ formatNumber(cdnFormItemCount) }} 条</span>
</header>
<div class="metadata-strip metadata-strip-tight common-cdn-domain-strip">
<span class="mono">https://h5.haiyihy.com/</span>
<span>域名白名单校验已启用</span>
</div>
<label class="update-field">
<span>刷新类型</span>
<select v-model="cdnAdmin.form.purgeType" class="interval-select">
<option value="url">URL</option>
<option value="path">目录</option>
</select>
</label>
<label class="update-field">
<span>区域</span>
<select v-model="cdnAdmin.form.area" class="interval-select">
<option value="">自动</option>
<option value="mainland">境内</option>
<option value="overseas">境外</option>
</select>
</label>
<label v-if="cdnAdmin.form.purgeType === 'path'" class="update-field">
<span>目录模式</span>
<select v-model="cdnAdmin.form.flushType" class="interval-select">
<option value="flush">flush</option>
<option value="delete">delete</option>
</select>
</label>
<label class="table-check">
<input v-model="cdnAdmin.form.urlEncode" type="checkbox" />
<span>中文路径自动编码</span>
</label>
<label class="update-field common-cdn-textarea-field">
<span>URL / 目录</span>
<textarea
v-model="cdnAdmin.form.itemsText"
class="config-textarea mongo-editor-textarea mono common-cdn-textarea"
:placeholder="cdnFormPlaceholder"
spellcheck="false"
></textarea>
</label>
<div v-if="cdnAdmin.lastResult" class="metadata-strip metadata-strip-tight common-cdn-result-strip">
<span class="mono">task {{ cdnAdmin.lastResult.taskId || '-' }}</span>
<span>{{ cdnPurgeTypeLabel(cdnAdmin.lastResult.purgeType) }}</span>
<span>{{ cdnAdmin.lastResult.flushType ? cdnFlushTypeLabel(cdnAdmin.lastResult.flushType) : '-' }}</span>
<span>{{ formatDateTime(cdnAdmin.lastResult.createdAt) }}</span>
</div>
<div class="action-group">
<button class="refresh ghost" @click="clearCdnForm" :disabled="cdnAdmin.submitting">清空</button>
<button class="refresh ghost" @click="refreshCdnOverview()" :disabled="cdnAdmin.loading">刷新记录</button>
<button class="refresh" @click="submitCdnPurge" :disabled="cdnAdmin.submitting || cdnFormItemCount === 0">
{{ cdnAdmin.submitting ? '提交中...' : '提交刷新' }}
</button>
</div>
</article>
<article class="mongo-action-panel common-cdn-task-panel tone-edit">
<header class="panel-header">
<div class="panel-title">
<h2>最近记录</h2>
<p>{{ formatNumber(filteredCdnTasks.length) }} 条</p>
</div>
<span v-if="cdnAdmin.message" :class="['badge', cdnAdmin.messageTone === 'ok' ? 'ok' : cdnAdmin.messageTone === 'warn' ? 'warn' : 'neutral']">
{{ cdnAdmin.message }}
</span>
<span v-else class="badge neutral">最近 7 天</span>
</header>
<div class="table-wrap table-scroll-x">
<table class="table compact">
<thead>
<tr>
<th>Task</th>
<th>URL / 目录</th>
<th>状态</th>
<th>类型</th>
<th>模式</th>
<th>时间</th>
</tr>
</thead>
<tbody>
<tr v-for="task in filteredCdnTasks" :key="'cdn-task-' + task.taskId + '-' + task.url">
<td class="mono">{{ truncateMiddle(task.taskId, 14, 6) }}</td>
<td class="mono common-cdn-url" :title="task.url">{{ truncateMiddle(task.url, 48, 16) }}</td>
<td>
<span :class="['badge', cdnTaskStatusTone(task.status)]">{{ cdnTaskStatusLabel(task.status) }}</span>
</td>
<td>{{ cdnPurgeTypeLabel(task.purgeType) }}</td>
<td>{{ task.purgeType === 'path' ? cdnFlushTypeLabel(task.flushType) : '-' }}</td>
<td>{{ formatDateTime(task.createTime) }}</td>
</tr>
<tr v-if="filteredCdnTasks.length === 0">
<td colspan="6" class="table-empty-cell">当前没有匹配的刷新记录</td>
</tr>
</tbody>
</table>
</div>
</article>
</div>
</section>
</template>
<template v-if="!showLoadingSkeleton && activeView === 'config'">
<section v-if="configTab === 'nacos'" class="panel infra-panel config-workbench-panel">
<div class="compact-workbench-head config-head-strip">
<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 config-only-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 || (nacosConfigDirty && configEditorMode !== 'diff')"
:title="nacosConfigDirty && configEditorMode !== 'diff' ? '保存前先切到 Diff' : ''"
>
{{ nacosConfig.saving ? '保存中...' : '保存' }}
</button>
</div>
<div class="editor-toolbar-meta">
<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>
</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 config-head-strip">
<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 config-inline-strip">
<span v-for="host in goConfig.hosts" :key="host.host" class="mono">{{ host.host }} {{ truncateMiddle(host.path, 14, 10) }}</span>
</div>
<p v-if="goConfig.error" class="metric-error">{{ explainError(goConfig.error) }}</p>
<p v-if="goConfig.diffKeys.length > 0" class="metric-error">差异 key{{ goConfig.diffKeys.join(', ') }}</p>
<section class="config-editor">
<div class="editor-toolbar sticky-toolbar">
<div class="editor-toolbar-main">
<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 || (goConfigDirty && goEditorMode !== 'diff')"
:title="goConfigDirty && goEditorMode !== 'diff' ? '保存前先切到 Diff' : ''"
>
{{ goConfig.saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
<div class="metadata-strip metadata-strip-tight config-inline-strip">
<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 config-head-strip">
<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 config-history-list">
<article v-for="item in configHistoryRows" :key="item.id" :class="['activity-item', `tone-${item.tone}`]">
<div class="activity-item-main">
<strong>{{ item.title }}</strong>
<span>{{ item.detail }}</span>
</div>
<div class="activity-item-side">
<small>{{ item.scope }}</small>
<small>{{ item.time }}</small>
</div>
</article>
<div v-if="configHistoryRows.length === 0" class="config-empty">当前没有可展示的记录</div>
</div>
</section>
</template>
<template v-if="!showLoadingSkeleton && activeView === 'release'">
<section class="panel release-status-strip sticky-release-strip">
<div class="release-command-row" aria-label="发布操作">
<div class="release-action-summary compact">
<span :class="['badge', releasePrimaryStatusTone()]">{{ releasePrimaryStatusLabel() }}</span>
<span class="badge neutral">更新 {{ updateOps.selectedServices.length }} / {{ releaseTargetRows.length }}</span>
<span class="badge neutral">重启 {{ restartOps.selectedServices.length }} / {{ restartTargetRows.length }}</span>
<span :class="['badge', releasePrecheck.ok ? 'ok' : releasePrecheck.ranAt ? 'warn' : 'neutral']">
{{ releasePrecheckBadgeLabel() }}
</span>
</div>
<div class="action-group release-command-actions">
<button class="refresh ghost" @click="buildParamsOpen = true">构建参数</button>
<template v-if="releaseIsRunning()">
<button class="refresh" @click="openUpdateLogViewer" :disabled="!updateOps.operationId && !updateOps.logs.length">查看日志</button>
<button class="refresh ghost" @click="fetchUpdateOperationStatus(updateOps.operationId)" :disabled="!updateOps.operationId || updateOps.statusRequestInFlight">
{{ updateOps.statusRequestInFlight ? '刷新中...' : '刷新状态' }}
</button>
<button class="refresh ghost" @click="stopUpdatePolling" :disabled="!updateOps.pollTimer">停止轮询</button>
<span class="badge warn">等待完成</span>
</template>
<template v-else>
<button class="refresh ghost" @click="refreshReleaseTargets" :disabled="updateOps.loading || restartOps.loading">
{{ updateOps.loading || restartOps.loading ? '刷新中...' : '刷新目标' }}
</button>
<button class="refresh ghost" @click="runReleasePrecheck">预检查</button>
<button class="refresh ghost" @click="openUpdateLogViewer" :disabled="!updateOps.operationId && !updateOps.logs.length">查看日志</button>
<button
:class="['refresh', updateOps.selectedServices.length > 0 ? 'danger' : 'ghost']"
@click="runServiceUpdate"
:disabled="updateOps.selectedServices.length === 0"
>
{{ updateOps.selectedServices.length > 0 ? `执行更新 ${updateOps.selectedServices.length}` : '执行更新' }}
</button>
<button
:class="['refresh', restartOps.selectedServices.length > 0 ? 'danger' : 'ghost']"
@click="runRollingRestart"
:disabled="restartOps.selectedServices.length === 0"
>
{{ restartOps.selectedServices.length > 0 ? `执行重启 ${restartOps.selectedServices.length}` : '执行重启' }}
</button>
</template>
</div>
</div>
<div class="metadata-strip metadata-strip-tight release-meta-strip">
<span
v-for="item in releaseStatusSummary"
:key="item.label"
:class="['release-inline-meta', `tone-${item.tone}`]"
>
<small>{{ item.label }}</small>
<strong :title="item.value">{{ item.value }}</strong>
</span>
</div>
<div v-if="releasePrecheck.ranAt" class="precheck-strip release-check-strip">
<span :class="['badge', releasePrecheck.tone]">{{ releasePrecheckBadgeLabel() }}</span>
<span class="badge neutral">执行于 {{ formatDateTime(releasePrecheck.ranAt) }}</span>
</div>
<p v-if="updateOps.error" class="metric-error">{{ explainError(updateOps.error) }}</p>
<p v-else-if="updateOps.message && !releaseIsRunning()" :class="updateOps.result && updateOps.result.ok ? 'ok-text' : 'metric-error'">{{ updateOps.message }}</p>
</section>
<section id="release-targets" class="panel infra-panel">
<header class="panel-header">
<div class="panel-title">
<h2>更新目标</h2>
</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"
role="button"
tabindex="0"
@click="toggleUpdateService(item.service)"
@keydown.enter.prevent="toggleUpdateService(item.service)"
@keydown.space.prevent="toggleUpdateService(item.service)"
>
<header class="release-service-head">
<div class="release-service-title">
<div class="release-service-title-line">
<strong>{{ item.service }}</strong>
<span class="badge neutral">{{ item.kind }}</span>
<span class="badge neutral">{{ item.hostCount }} 节点</span>
</div>
<div class="release-service-meta">
<span :title="item.hostsLabel">{{ item.hostsLabel }}</span>
<span class="mono" :title="item.currentTitle">版本 {{ item.currentLabel }}</span>
<span class="mono" :title="item.detailTitle || '-'">摘要 {{ item.detailLabel || '-' }}</span>
</div>
</div>
<div class="release-service-actions">
<span :class="['release-select-state', updateOps.selectedServices.includes(item.service) ? 'is-selected' : '']">
{{ updateOps.selectedServices.includes(item.service) ? '已选' : '未选' }}
</span>
<small class="table-subtext mono release-service-note" :title="item.repository">{{ item.repositoryShort }}</small>
<div class="release-service-action-row">
<button
v-if="item.kind === 'frontend'"
class="refresh inline-button"
@click.stop="runServiceUpdate([item.service])"
@keydown.enter.stop
@keydown.space.stop
:disabled="releaseIsRunning() || !updateOps.adminGitRef.trim()"
:title="!updateOps.adminGitRef.trim() ? '先填写 Admin Git Ref' : ''"
>
{{ item.service === 'admin' ? 'admin部署' : item.service + '部署' }}
</button>
<button
v-if="item.copyValue"
class="refresh ghost inline-button"
@click.stop="copyText(item.copyValue, 'image-' + item.service)"
@keydown.enter.stop
@keydown.space.stop
>
{{ isCopied('image-' + item.service) ? '已复制' : '复制' }}
</button>
</div>
</div>
</header>
</article>
</div>
<div v-else class="empty-panel compact">{{ updateOps.loading ? '加载服务中...' : '当前没有可展示的服务' }}</div>
</section>
<section id="release-log" class="panel infra-panel">
<header class="panel-header">
<div class="panel-title">
<h2>执行日志</h2>
</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-list">
<article v-for="entry in recentUpdateLogs" :key="entry.key" :class="['release-log-entry', entry.level === 'error' ? 'tone-bad' : 'tone-neutral']">
<strong>{{ formatDateTime(entry.time) }}</strong>
<span>{{ entry.stage || '-' }}</span>
<small>{{ entry.message || '-' }}</small>
</article>
</div>
<div v-else class="empty-panel compact">当前还没有可展示的执行日志</div>
</section>
<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>
</div>
<div class="panel-meta">
<span :class="['badge', restartOps.error ? 'bad' : restartOps.running ? 'warn' : 'ok']">
{{ restartOps.error ? '存在错误' : restartOps.running ? '执行中' : '可操作' }}
</span>
</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"
role="button"
tabindex="0"
@click="toggleRestartService(item.service)"
@keydown.enter.prevent="toggleRestartService(item.service)"
@keydown.space.prevent="toggleRestartService(item.service)"
>
<header class="release-service-head">
<div class="release-service-title">
<div class="release-service-title-line">
<strong>{{ item.service }}</strong>
<span class="badge neutral">{{ item.kind }}</span>
<span class="badge neutral">{{ item.hostCount }} 实例</span>
</div>
<div class="release-service-meta">
<span :title="item.hostsLabel">{{ item.hostsLabel }}</span>
</div>
</div>
<div class="release-service-actions release-service-actions-tight">
<span :class="['release-select-state', restartOps.selectedServices.includes(item.service) ? 'is-selected' : '']">
{{ restartOps.selectedServices.includes(item.service) ? '已选' : '未选' }}
</span>
</div>
</header>
</article>
</div>
<div v-else class="empty-panel compact">{{ restartOps.loading ? '加载重启目标中...' : '当前没有可展示的重启目标' }}</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="buildParamsOpen" class="log-viewer-overlay" @click.self="buildParamsOpen = false">
<section class="log-viewer build-params-dialog" role="dialog" aria-modal="true" aria-labelledby="build-params-title">
<header class="log-viewer-head">
<div class="panel-title">
<h2 id="build-params-title">构建参数</h2>
</div>
<div class="action-group">
<span class="badge neutral">平台 {{ updateOps.defaults.dockerPlatform }}</span>
<button class="refresh ghost" @click="buildParamsOpen = false">关闭</button>
</div>
</header>
<div class="metadata-strip">
<span class="mono">Harbor {{ updateOps.builder.harborRegistry }} / {{ updateOps.builder.harborProject }}</span>
<span class="mono">Java {{ truncateMiddle(updateOps.builder.javaRepoRoot, 18, 14) }}</span>
<span class="mono">Go {{ truncateMiddle(updateOps.builder.golangRepoRoot, 18, 14) }}</span>
<span class="mono">Admin {{ truncateMiddle(updateOps.builder.frontendRepoRoot || updateOps.builder.frontendRepoUrl, 18, 14) }}</span>
</div>
<div class="update-form-grid build-params-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>
<div class="update-field update-field-toggle build-params-toggle">
<span>构建选项</span>
<label class="update-check">
<input v-model="updateOps.skipMavenBuild" type="checkbox" />
<strong>跳过 Maven</strong>
<small>仅影响 Java 构建阶段</small>
</label>
</div>
</div>
</section>
</div>
<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>