精简yumi发布页并修复加载

This commit is contained in:
hy 2026-04-23 12:30:03 +08:00
parent 4f3b55468f
commit 579e8a4aa5
6 changed files with 356 additions and 260 deletions

View File

@ -6,7 +6,6 @@ from core.config import THRESHOLDS, load_hosts, settings_payload, utc_now
from .host_metrics import get_host_metrics
from .http_probe import get_service_payload
from .mongo_metrics import get_mongo_payload
from .mysql_metrics import get_mysql_overview_payload
from .nacos import get_nacos_payload
@ -117,8 +116,6 @@ def build_monitor_payload() -> dict[str, Any]:
nacos_payload = get_nacos_payload()
# 获取 Mongo 指标。
mongo_payload = get_mongo_payload()
# 获取 MySQL 指标。
mysql_payload = get_mysql_overview_payload()
# 组装主机维度视图。
grouped_hosts = build_host_payload(hosts, service_payload, metrics_by_host)
# 统计主机指标成功数。
@ -140,13 +137,13 @@ def build_monitor_payload() -> dict[str, Any]:
"mongoDatabaseTotal": int(mongo_payload.get("summary", {}).get("databaseTotal", 0)),
"mongoCollectionTotal": int(mongo_payload.get("summary", {}).get("collectionTotal", 0)),
"mongoOk": 1 if mongo_payload.get("ok") else 0,
"mysqlDatabaseTotal": int(mysql_payload.get("summary", {}).get("databaseTotal", 0)),
"mysqlTableTotal": int(mysql_payload.get("summary", {}).get("tableTotal", 0)),
"mysqlOk": 1 if mysql_payload.get("ok") else 0,
"mysqlDatabaseTotal": 0,
"mysqlTableTotal": 0,
"mysqlOk": 0,
},
"hosts": grouped_hosts,
"items": service_payload["items"],
"nacos": nacos_payload,
"mongo": mongo_payload,
"mysql": mysql_payload,
"mysql": {},
}

View File

@ -1402,42 +1402,70 @@ def build_update_targets_payload() -> dict[str, Any]:
# 页面需要展示当前可更新服务、宿主机,以及本地构建所依赖的仓路径。
records_map = service_records_by_name()
items: list[dict[str, Any]] = []
# 同一台主机会承载多个服务,这里先把 compose 模型按主机缓存起来,避免重复读模板。
compose_models_by_host: dict[str, dict[str, Any] | None] = {}
# 模板读取失败不能拖垮整页,这里保留每台主机的错误给后续条目复用。
compose_errors_by_host: dict[str, str] = {}
def host_compose_model(host_name: str) -> dict[str, Any] | None:
# 每台主机只解析一次本地 compose失败时返回空并记下错误摘要。
if host_name in compose_models_by_host:
return compose_models_by_host[host_name]
if host_name in compose_errors_by_host:
return None
try:
compose_models_by_host[host_name] = load_local_host_compose_model(host_name)
return compose_models_by_host[host_name]
except Exception as exc: # noqa: BLE001
compose_errors_by_host[host_name] = short_text(str(exc), 200)
compose_models_by_host[host_name] = None
return None
for service_name in buildable_service_names():
records = records_map.get(service_name) or []
if not records:
continue
current_images: list[str] = []
compose_warnings: list[str] = []
for record in records:
model = load_local_host_compose_model(record["host"])
model = host_compose_model(record["host"])
if model is None:
compose_error = compose_errors_by_host.get(record["host"], "compose template unavailable")
warning_text = f"{record['host']}: {compose_error}"
if warning_text not in compose_warnings:
compose_warnings.append(warning_text)
continue
image_ref = str(((model.get("services") or {}).get(service_name) or {}).get("image") or "").strip()
if image_ref and image_ref not in current_images:
current_images.append(image_ref)
display = image_release_display(current_images)
items.append(
{
"service": service_name,
"kind": service_kind(service_name),
"repository": service_repository_ref(service_name),
"currentLabel": display["currentLabel"],
"currentTitle": display["currentTitle"],
"detailLabel": display["detailLabel"],
"detailTitle": display["detailTitle"],
"copyValue": display["copyValue"],
"currentImages": current_images,
"hosts": [
{
"host": record["host"],
"ip": record["ip"],
"instanceId": record["instanceId"],
"port": record["port"],
"path": record["path"],
}
for record in records
],
}
)
item = {
"service": service_name,
"kind": service_kind(service_name),
"repository": service_repository_ref(service_name),
"currentLabel": display["currentLabel"],
"currentTitle": display["currentTitle"],
"detailLabel": display["detailLabel"],
"detailTitle": display["detailTitle"],
"copyValue": display["copyValue"],
"currentImages": current_images,
"hosts": [
{
"host": record["host"],
"ip": record["ip"],
"instanceId": record["instanceId"],
"port": record["port"],
"path": record["path"],
}
for record in records
],
}
# 某些主机模板暂时不可读时,仍然保留服务条目,只把原因透出去。
if compose_warnings:
item["warnings"] = compose_warnings
items.append(item)
if frontend_release_enabled():
frontend_service = ADMIN_FRONTEND_SERVICE_NAME

View File

@ -2720,7 +2720,9 @@ body.auth-page {
.release-status-strip {
margin-top: 8px;
padding: 10px 12px;
padding: 12px;
background: var(--panel);
box-shadow: none;
}
.sticky-release-strip {
@ -2730,17 +2732,12 @@ body.auth-page {
}
.release-action-bar {
position: relative;
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-top: 10px;
padding: 10px 12px;
border: 1px solid color-mix(in oklab, var(--line) 92%, var(--panel-strong));
border-radius: 14px;
background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 96%, var(--panel-strong)) 0%, var(--panel-muted) 100%);
box-shadow: var(--shadow-soft), inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 72%, transparent);
align-items: flex-start;
padding-bottom: 10px;
border-bottom: 1px solid var(--line);
}
.release-action-summary {
@ -2748,38 +2745,34 @@ body.auth-page {
gap: 8px;
flex-wrap: wrap;
align-items: center;
padding: 4px;
border: 1px solid color-mix(in oklab, var(--line) 92%, var(--panel-strong));
border-radius: 12px;
background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 98%, var(--panel-muted)) 0%, var(--panel-strong) 100%);
box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 72%, transparent);
}
.release-status-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
margin-top: 10px;
}
.release-status-item {
position: relative;
overflow: hidden;
min-height: 56px;
min-height: 0;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-muted);
box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 68%, transparent);
border-radius: 10px;
background: var(--panel-strong);
box-shadow: none;
}
.release-status-item::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
height: 2px;
background: linear-gradient(90deg, color-mix(in oklab, currentColor 46%, transparent) 0%, transparent 78%);
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 999px;
background: color-mix(in oklab, currentColor 46%, transparent);
opacity: 0.82;
}
@ -2819,46 +2812,47 @@ body.auth-page {
.release-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(228px, 1fr));
gap: 12px;
margin-top: 12px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 10px;
margin-top: 10px;
}
.release-service-card {
--release-kind-color: var(--accent);
position: relative;
display: grid;
gap: 8px;
padding: 11px 11px 10px;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 15px;
background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 96%, var(--panel-strong)) 0%, var(--panel-muted) 100%);
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, box-shadow 180ms ease;
border-radius: 12px;
background: var(--panel-strong);
transition: border-color 180ms ease, background 180ms ease, box-shadow 180ms ease;
box-shadow: none;
}
.release-service-card::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
height: 3px;
background: linear-gradient(90deg, var(--release-kind-color) 0%, transparent 78%);
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 999px;
background: var(--release-kind-color);
}
.release-service-card:hover {
transform: translateY(-1px);
border-color: color-mix(in oklab, var(--accent) 28%, var(--line));
}
.release-service-card.selected {
border-color: color-mix(in oklab, var(--accent) 38%, var(--line));
background: linear-gradient(180deg, color-mix(in oklab, var(--accent-soft) 38%, var(--panel-strong)) 0%, color-mix(in oklab, var(--accent-soft) 20%, var(--panel-muted)) 100%);
background: color-mix(in oklab, var(--accent-soft) 26%, var(--panel-strong));
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 18%, transparent);
}
.release-service-card.selected::before {
background: linear-gradient(90deg, color-mix(in oklab, var(--accent) 78%, var(--release-kind-color)) 0%, transparent 84%);
background: color-mix(in oklab, var(--accent) 78%, var(--release-kind-color));
}
.release-service-card.selected .release-service-title strong {
@ -2880,17 +2874,22 @@ body.auth-page {
.release-service-head {
display: flex;
justify-content: space-between;
gap: 10px;
gap: 12px;
align-items: flex-start;
}
.release-service-title {
display: grid;
gap: 6px;
flex: 1 1 auto;
min-width: 0;
}
.release-service-title strong,
.release-service-title small {
display: block;
.release-service-title-line {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.release-service-title strong {
@ -2898,46 +2897,46 @@ body.auth-page {
line-height: 1.3;
}
.release-service-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.release-service-tags .badge {
min-height: 24px;
.release-service-title-line .badge {
min-height: 22px;
padding: 0 8px;
font-size: 10px;
}
.release-service-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.release-service-metric {
display: grid;
gap: 4px;
min-width: 0;
padding: 8px 9px;
border: 1px solid var(--line);
border-radius: 12px;
background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 98%, var(--accent-soft)) 0%, var(--panel-strong) 100%);
}
.release-service-metric strong,
.release-service-metric span {
display: block;
min-width: 0;
.release-service-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
color: var(--muted);
font-size: 11px;
line-height: 1.4;
line-height: 1.45;
}
.release-service-meta span {
min-width: 0;
word-break: break-word;
}
.release-service-metric-wide {
grid-column: 1 / -1;
.release-service-actions {
display: grid;
gap: 8px;
justify-items: end;
align-self: center;
}
.release-service-actions-tight {
align-self: flex-start;
}
.release-service-note {
color: var(--muted);
margin: 0;
}
.release-service-actions .inline-copy {
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.precheck-strip {
@ -2946,16 +2945,16 @@ body.auth-page {
flex-wrap: wrap;
align-items: center;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--line);
}
.release-log-preview {
display: grid;
gap: 10px;
gap: 8px;
margin-top: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 96%, var(--panel-strong)) 0%, var(--panel-muted) 100%);
padding-top: 10px;
border-top: 1px solid var(--line);
}
.release-log-preview-head {
@ -2969,55 +2968,50 @@ body.auth-page {
font-size: 12px;
}
.release-log-snippets {
.release-log-list {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 6px;
}
.release-log-snippet {
.release-log-entry {
display: grid;
gap: 4px;
min-height: 86px;
padding: 10px 11px;
grid-template-columns: minmax(112px, 132px) 72px minmax(0, 1fr);
gap: 10px;
align-items: start;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 12px;
background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 96%, var(--accent-soft)) 0%, var(--panel-strong) 100%);
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease;
border-radius: 10px;
background: var(--panel-strong);
}
.release-log-snippet:hover {
transform: translateY(-1px);
border-color: color-mix(in oklab, var(--accent) 20%, var(--line));
}
.release-log-snippet strong,
.release-log-snippet span,
.release-log-snippet p {
.release-log-entry strong,
.release-log-entry span,
.release-log-entry small {
margin: 0;
min-width: 0;
}
.release-log-snippet strong {
.release-log-entry strong {
font-size: 11px;
}
.release-log-snippet span {
.release-log-entry span {
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.release-log-snippet p {
.release-log-entry small {
color: var(--text);
font-size: 11px;
line-height: 1.45;
word-break: break-word;
}
.release-log-snippet.tone-bad {
.release-log-entry.tone-bad {
border-color: color-mix(in oklab, var(--bad) 28%, var(--line));
background: color-mix(in oklab, var(--bad-soft) 44%, var(--panel-strong));
background: color-mix(in oklab, var(--bad-soft) 28%, var(--panel-strong));
}
.release-table td strong {
@ -3442,15 +3436,14 @@ body.auth-page {
}
.task-page-head {
margin-top: 12px;
padding: 12px 14px;
background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 96%, var(--accent-soft)) 0%, color-mix(in oklab, var(--panel) 98%, var(--panel-muted)) 100%);
box-shadow: var(--shadow-soft), inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 76%, transparent);
margin-top: 8px;
padding: 10px 12px;
background: var(--panel);
box-shadow: none;
}
.task-page-head::before,
.config-workbench-panel::before,
.release-action-bar::before,
.editor-toolbar::before {
content: "";
position: absolute;
@ -3473,10 +3466,10 @@ body.auth-page {
}
.task-page-head .panel-title p {
margin-top: 5px;
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-top: 4px;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: none;
}
.task-page-head .panel-meta .badge.neutral {
@ -4496,6 +4489,18 @@ body.auth-page {
align-items: stretch;
}
.release-service-actions {
justify-items: start;
}
.release-service-actions .inline-copy {
justify-content: flex-start;
}
.release-log-entry {
grid-template-columns: 1fr;
}
.diff-row {
grid-template-columns: 34px 34px 22px minmax(0, 1fr);
gap: 8px;

View File

@ -359,14 +359,14 @@ createApp({
data() {
return {
activeView: CURRENT_APP_CONTEXT.mode === "mysql" ? "database" : "overview",
databaseEngine: "mysql",
databaseEngine: CURRENT_APP_CONTEXT.mode === "mysql" ? "mysql" : "mongo",
densityMode: "compact",
detailPanelOpen: false,
overviewHealthyHostsOpen: false,
viewTabs: CURRENT_APP_CONTEXT.mode === "mysql" ? MYSQL_MODULE_VIEW_TABS : VIEW_TABS,
databaseEngines: CURRENT_APP_CONTEXT.mode === "mysql"
? DATABASE_ENGINES.filter((engine) => engine.id === "mysql")
: DATABASE_ENGINES,
: DATABASE_ENGINES.filter((engine) => engine.id === "mongo"),
primaryFilters: PRIMARY_FILTERS,
releaseSections: RELEASE_SECTIONS,
configTabs: CONFIG_TABS,
@ -508,6 +508,7 @@ createApp({
mySqlAdmin: createMySqlAdminState(),
updateOps: {
loading: false,
loaded: false,
error: "",
running: false,
operationId: "",
@ -549,6 +550,7 @@ createApp({
},
restartOps: {
loading: false,
loaded: false,
error: "",
running: false,
message: "",
@ -1574,7 +1576,6 @@ createApp({
const hostAlertTotal = this.allHostRows.filter((host) => host.hasAlert).length;
const nacosUnavailable = !(this.payload.nacos && this.payload.nacos.ok);
const mongoUnavailable = !(this.payload.mongo && this.payload.mongo.ok);
const mysqlUnavailable = !(this.payload.mysql && this.payload.mysql.ok);
const tatOk = this.summary.hostMetricOk || 0;
const tatTotal = this.summary.hostTotal || 0;
@ -1619,14 +1620,6 @@ createApp({
tone: mongoUnavailable ? "bad" : "ok",
visible: mongoUnavailable,
},
{
key: "mysql",
label: "MySQL",
value: mysqlUnavailable ? "ERR" : String(this.summary.mysqlDatabaseTotal || 0),
suffix: mysqlUnavailable ? "" : "库",
tone: mysqlUnavailable ? "bad" : "ok",
visible: mysqlUnavailable,
},
{
key: "tat",
label: "TAT",
@ -1666,15 +1659,6 @@ createApp({
tone: "ok",
});
}
if (this.payload.mysql && this.payload.mysql.ok) {
cards.push({
key: "mysql",
label: "MySQL",
value: `${this.summary.mysqlDatabaseTotal || 0}`,
meta: "连接正常",
tone: "ok",
});
}
if ((this.summary.hostMetricDown || 0) === 0 && (this.summary.hostTotal || 0) > 0) {
cards.push({
key: "tat",
@ -1868,17 +1852,6 @@ createApp({
});
}
if (this.payload.mysql.error || !(this.payload.mysql && this.payload.mysql.ok)) {
alerts.push({
key: "mysql-error",
type: "database",
view: "database",
tone: "bad",
title: "MySQL",
text: this.payload.mysql.error || "连接异常",
});
}
return alerts.slice(0, 5);
},
restartTargetMap() {
@ -2001,6 +1974,9 @@ createApp({
if (nextValue === "database") {
this.ensureDatabaseWorkbenchLoaded();
}
if (nextValue === "release") {
this.ensureReleaseWorkbenchLoaded();
}
if (nextValue === "config") {
this.configTab = this.configTab || "nacos";
}
@ -2018,6 +1994,28 @@ createApp({
},
},
methods: {
async ensureReleaseWorkbenchLoaded({ force = false } = {}) {
const shouldRefreshUpdate = force
|| ((!this.updateOps.loaded || this.updateOps.items.length === 0 || Boolean(this.updateOps.error))
&& !this.updateOps.loading);
const shouldRefreshRestart = force
|| ((!this.restartOps.loaded || this.restartOps.items.length === 0 || Boolean(this.restartOps.error))
&& !this.restartOps.loading);
const jobs = [];
if (shouldRefreshUpdate) {
jobs.push(this.refreshUpdateTargets());
}
if (shouldRefreshRestart) {
jobs.push(this.refreshRestartTargets());
}
if (jobs.length > 0) {
await Promise.allSettled(jobs);
}
this.runReleasePrecheck();
},
async ensureDatabaseWorkbenchLoaded(engine = this.databaseEngine) {
if (engine === "mongo") {
if (!this.mongoAdmin.loaded && !this.mongoAdmin.loading) {
@ -2233,9 +2231,7 @@ createApp({
this.recordReleaseHistory("precheck", this.releasePrecheck.title, checks.map((item) => `${item.label}: ${item.detail}`).join(" | "), ok ? "ok" : "warn");
},
async refreshReleaseTargets() {
await this.refreshUpdateTargets();
await this.refreshRestartTargets();
this.runReleasePrecheck();
await this.ensureReleaseWorkbenchLoaded({ force: true });
},
toggleConfigGroup(groupKey) {
const next = new Set(this.collapsedConfigGroups);
@ -5455,7 +5451,11 @@ createApp({
this.restartOps.error = error instanceof Error ? error.message : String(error);
this.recordReleaseHistory("restart", "刷新滚动重启目标失败", this.explainError(this.restartOps.error), "bad");
} finally {
this.restartOps.loaded = true;
this.restartOps.loading = false;
if (this.activeView === "release" && !this.updateOps.loading) {
this.runReleasePrecheck();
}
}
},
async refreshUpdateTargets() {
@ -5497,7 +5497,11 @@ createApp({
this.updateOps.error = error instanceof Error ? error.message : String(error);
this.recordReleaseHistory("update", "刷新更新目标失败", this.explainError(this.updateOps.error), "bad");
} finally {
this.updateOps.loaded = true;
this.updateOps.loading = false;
if (this.activeView === "release" && !this.restartOps.loading) {
this.runReleasePrecheck();
}
}
},
toggleUpdateService(serviceName) {

View File

@ -117,8 +117,7 @@
<template v-else-if="activeView === 'infra'">
<section class="control-bar task-control-bar">
<div class="taskbar-context">
<strong>健康与排障</strong>
<span>总览 / Nacos / Mongo / TAT</span>
<strong>基础设施</strong>
</div>
<div class="control-group">
@ -269,8 +268,7 @@
<template v-else-if="activeView === 'release'">
<section class="control-bar task-control-bar">
<div class="taskbar-context">
<strong>变更与执行</strong>
<span>构建参数 / 更新目标 / 执行日志 / 滚动重启</span>
<strong>发布</strong>
</div>
<div class="control-group release-section-nav">
@ -815,7 +813,6 @@
<header class="panel-header">
<div class="panel-title">
<h2>基础设施</h2>
<p>健康与排障</p>
</div>
<div class="panel-meta">
<span class="badge neutral">Nacos {{ payload.nacos && payload.nacos.ok ? '在线' : '异常' }}</span>
@ -847,7 +844,6 @@
<header class="panel-header">
<div class="panel-title">
<h2>状态总览</h2>
<p>异常前置,正常折叠</p>
</div>
<div class="panel-meta">
<span class="badge neutral">{{ infraAlertNacosServices.length }} Nacos 异常服务</span>
@ -1237,7 +1233,6 @@
<header class="panel-header">
<div class="panel-title">
<h2>TAT 主机采集</h2>
<p>主机资源与采集状态</p>
</div>
<span :class="['badge', (summary.hostMetricDown || 0) > 0 ? 'warn' : 'ok']">{{ (summary.hostMetricDown || 0) > 0 ? '采集异常' : '采集正常' }}</span>
</header>
@ -2991,23 +2986,14 @@
</template>
<template v-if="!showLoadingSkeleton && activeView === 'release'">
<section class="panel task-page-head">
<header class="panel-header">
<div class="panel-title">
<h2>发布</h2>
<p>变更与执行</p>
</div>
<div class="panel-meta">
<span :class="['badge', updateStatusBadgeClass()]">{{ updateStatusLabel() }}</span>
<span class="badge neutral">已选 {{ updateOps.selectedServices.length }} 项更新</span>
<span class="badge neutral">已选 {{ restartOps.selectedServices.length }} 项重启</span>
</div>
</header>
<section class="panel release-status-strip sticky-release-strip">
<div class="release-action-bar">
<div class="release-action-summary">
<span :class="['badge', updateStatusBadgeClass()]">{{ updateStatusLabel() }}</span>
<span class="badge neutral">更新目标 {{ releaseTargetRows.length }}</span>
<span class="badge neutral">重启目标 {{ restartTargetRows.length }}</span>
<span class="badge neutral">已选更新 {{ updateOps.selectedServices.length }}</span>
<span class="badge neutral">已选重启 {{ restartOps.selectedServices.length }}</span>
<span :class="['badge', updateOps.error || restartOps.error ? 'bad' : releasePrecheck.ok ? 'ok' : 'neutral']">
{{ releasePrecheck.ranAt ? releasePrecheck.title : '未预检查' }}
</span>
@ -3025,9 +3011,7 @@
</button>
</div>
</div>
</section>
<section class="panel release-status-strip sticky-release-strip">
<div class="release-status-grid">
<article v-for="item in releaseStatusSummary" :key="item.label" :class="['release-status-item', `tone-${item.tone}`]">
<small>{{ item.label }}</small>
@ -3056,7 +3040,6 @@
<header class="panel-header">
<div class="panel-title">
<h2>构建参数</h2>
<p>先定参数,再选目标</p>
</div>
<span class="badge neutral">平台 {{ updateOps.defaults.dockerPlatform }}</span>
</header>
@ -3096,7 +3079,6 @@
<header class="panel-header">
<div class="panel-title">
<h2>更新目标</h2>
<p>多列卡片</p>
</div>
<div class="panel-meta">
<span class="badge neutral">{{ releaseTargetRows.length }} 项</span>
@ -3115,37 +3097,20 @@
>
<header class="release-service-head">
<div class="release-service-title">
<strong>{{ item.service }}</strong>
<small class="table-subtext mono" :title="item.repository">{{ item.repositoryShort }}</small>
<div 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>
<label :class="['table-check', updateOps.selectedServices.includes(item.service) ? 'checked' : '']">
<input
:checked="updateOps.selectedServices.includes(item.service)"
type="checkbox"
@change="toggleUpdateService(item.service)"
/>
<span>{{ updateOps.selectedServices.includes(item.service) ? '已选' : '选择' }}</span>
</label>
</header>
<div class="release-service-tags">
<span class="badge neutral">{{ item.kind }}</span>
<span class="badge neutral">{{ item.hostCount }} 节点</span>
</div>
<div class="release-service-grid">
<div class="release-service-metric">
<small>部署节点</small>
<strong :title="item.hostsLabel">{{ item.hostsLabel }}</strong>
</div>
<div class="release-service-metric">
<small>当前版本</small>
<strong :title="item.currentTitle">{{ item.currentLabel }}</strong>
</div>
<div class="release-service-metric release-service-metric-wide">
<small>镜像摘要</small>
<div class="release-service-actions">
<small class="table-subtext mono release-service-note" :title="item.repository">{{ item.repositoryShort }}</small>
<div class="inline-copy">
<span class="mono" :title="item.detailTitle || '-'">{{ item.detailLabel || '-' }}</span>
<button
v-if="item.copyValue"
class="refresh ghost inline-button"
@ -3153,19 +3118,26 @@
>
{{ isCopied('image-' + item.service) ? '已复制' : '复制' }}
</button>
<label :class="['table-check', updateOps.selectedServices.includes(item.service) ? 'checked' : '']">
<input
:checked="updateOps.selectedServices.includes(item.service)"
type="checkbox"
@change="toggleUpdateService(item.service)"
/>
<span>{{ updateOps.selectedServices.includes(item.service) ? '已选' : '选择' }}</span>
</label>
</div>
</div>
</div>
</header>
</article>
</div>
<div v-else-if="!updateOps.loading" class="empty-panel compact">当前没有可展示的更新目标</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>
<p>页内只看摘要,完整日志弹出</p>
</div>
<div class="panel-meta">
<span v-if="updateOps.operationId" class="badge neutral mono">任务 {{ updateOps.operationId }}</span>
@ -3186,11 +3158,11 @@
<strong>{{ updateOps.logs.length }} 条日志</strong>
<span v-if="updateOps.logDroppedCount > 0" class="badge warn">截断 {{ updateOps.logDroppedCount }}</span>
</div>
<div v-if="recentUpdateLogs.length > 0" class="release-log-snippets">
<article v-for="entry in recentUpdateLogs" :key="entry.key" :class="['release-log-snippet', entry.level === 'error' ? 'tone-bad' : 'tone-neutral']">
<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>
<p>{{ entry.message || '-' }}</p>
<small>{{ entry.message || '-' }}</small>
</article>
</div>
<div v-else class="empty-panel compact">当前还没有可展示的执行日志</div>
@ -3297,7 +3269,6 @@
<header class="panel-header">
<div class="panel-title">
<h2>滚动重启</h2>
<p>多列卡片</p>
</div>
<div class="panel-meta">
<span :class="['badge', restartOps.error ? 'bad' : restartOps.running ? 'warn' : 'ok']">
@ -3321,33 +3292,29 @@
>
<header class="release-service-head">
<div class="release-service-title">
<strong>{{ item.service }}</strong>
<small class="table-subtext">{{ item.kind }}</small>
<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">
<label :class="['table-check', restartOps.selectedServices.includes(item.service) ? 'checked' : '']">
<input
:checked="restartOps.selectedServices.includes(item.service)"
type="checkbox"
@change="toggleRestartService(item.service)"
/>
<span>{{ restartOps.selectedServices.includes(item.service) ? '已选' : '选择' }}</span>
</label>
</div>
<label :class="['table-check', restartOps.selectedServices.includes(item.service) ? 'checked' : '']">
<input
:checked="restartOps.selectedServices.includes(item.service)"
type="checkbox"
@change="toggleRestartService(item.service)"
/>
<span>{{ restartOps.selectedServices.includes(item.service) ? '已选' : '选择' }}</span>
</label>
</header>
<div class="release-service-tags">
<span class="badge neutral">{{ item.kind }}</span>
<span class="badge neutral">{{ item.hostCount }} 实例</span>
</div>
<div class="release-service-grid">
<div class="release-service-metric release-service-metric-wide">
<small>主机</small>
<strong :title="item.hostsLabel">{{ item.hostsLabel }}</strong>
</div>
</div>
</article>
</div>
<div v-else-if="!restartOps.loading" class="empty-panel compact">当前没有可展示的重启目标</div>
<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>

View File

@ -0,0 +1,95 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from monitors.yumi.service_release import build_update_targets_payload
class ServiceReleaseTargetsTests(unittest.TestCase):
@patch("monitors.yumi.service_release.frontend_release_enabled", return_value=False)
@patch("monitors.yumi.service_release.service_repository_ref", side_effect=lambda service: f"repo/{service}")
@patch("monitors.yumi.service_release.buildable_service_names", return_value=["auth", "gateway"])
@patch("monitors.yumi.service_release.service_records_by_name")
@patch("monitors.yumi.service_release.load_local_host_compose_model")
def test_build_update_targets_payload_reuses_host_compose_model(
self,
load_local_host_compose_model_mock,
service_records_by_name_mock,
buildable_service_names_mock,
service_repository_ref_mock,
frontend_release_enabled_mock,
) -> None:
del buildable_service_names_mock, service_repository_ref_mock, frontend_release_enabled_mock
service_records_by_name_mock.return_value = {
"auth": [
{
"host": "app-1",
"ip": "10.0.0.1",
"instanceId": "ins-app-1",
"port": 8080,
"path": "/auth",
}
],
"gateway": [
{
"host": "app-1",
"ip": "10.0.0.1",
"instanceId": "ins-app-1",
"port": 9000,
"path": "/gateway",
}
],
}
load_local_host_compose_model_mock.return_value = {
"services": {
"auth": {"image": "registry/auth:main"},
"gateway": {"image": "registry/gateway:main"},
}
}
payload = build_update_targets_payload()
self.assertTrue(payload["ok"])
self.assertEqual(load_local_host_compose_model_mock.call_count, 1)
self.assertEqual([item["service"] for item in payload["items"]], ["auth", "gateway"])
self.assertEqual(payload["items"][0]["currentImages"], ["registry/auth:main"])
self.assertEqual(payload["items"][1]["currentImages"], ["registry/gateway:main"])
@patch("monitors.yumi.service_release.frontend_release_enabled", return_value=False)
@patch("monitors.yumi.service_release.service_repository_ref", side_effect=lambda service: f"repo/{service}")
@patch("monitors.yumi.service_release.buildable_service_names", return_value=["auth"])
@patch("monitors.yumi.service_release.service_records_by_name")
@patch("monitors.yumi.service_release.load_local_host_compose_model", side_effect=RuntimeError("broken compose"))
def test_build_update_targets_payload_keeps_items_when_compose_unavailable(
self,
load_local_host_compose_model_mock,
service_records_by_name_mock,
buildable_service_names_mock,
service_repository_ref_mock,
frontend_release_enabled_mock,
) -> None:
del load_local_host_compose_model_mock
del buildable_service_names_mock, service_repository_ref_mock, frontend_release_enabled_mock
service_records_by_name_mock.return_value = {
"auth": [
{
"host": "app-1",
"ip": "10.0.0.1",
"instanceId": "ins-app-1",
"port": 8080,
"path": "/auth",
}
]
}
payload = build_update_targets_payload()
self.assertTrue(payload["ok"])
self.assertEqual(len(payload["items"]), 1)
self.assertEqual(payload["items"][0]["currentImages"], [])
self.assertEqual(payload["items"][0]["warnings"], ["app-1: broken compose"])
if __name__ == "__main__":
unittest.main()