2739 lines
92 KiB
JavaScript
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.

const { createApp } = Vue;
const APP_TEMPLATE = document.getElementById("app")?.innerHTML || "";
const APP_BASE_PATH = window.location.pathname === "/yumi" || window.location.pathname.startsWith("/yumi/") ? "/yumi" : "";
const STORAGE_KEY = "hy-app-monitor-refresh-interval";
const UPDATE_OPERATION_STORAGE_KEY = "hy-app-monitor-service-update-operation";
const GROUP_LABELS = {
app: "应用节点",
pay: "支付节点",
gateway: "网关节点",
nacos: "Nacos 节点",
mongo: "MongoDB 节点",
};
const GROUP_ORDER = {
gateway: 10,
app: 20,
pay: 30,
nacos: 40,
mongo: 50,
};
const VIEW_TABS = [
{ id: "overview", label: "总览" },
{ id: "services", label: "服务" },
{ id: "hosts", label: "主机" },
{ id: "infra", label: "基础设施" },
{ id: "config", label: "配置" },
{ id: "release", label: "发布" },
];
const PRIMARY_FILTERS = [
{ id: "all", label: "全部" },
{ id: "down", label: "异常" },
{ id: "gateway", label: "网关" },
{ id: "app", label: "应用" },
{ id: "pay", label: "支付" },
{ id: "infra", label: "基础设施" },
];
const BUSINESS_GROUPS = new Set(["gateway", "app", "pay"]);
const RELEASE_SECTIONS = [
{ id: "release-build", label: "构建参数" },
{ id: "release-targets", label: "更新目标" },
{ id: "release-log", label: "执行日志" },
{ id: "release-restart", label: "滚动重启" },
];
const CONFIG_TABS = [
{ id: "nacos", label: "Nacos" },
{ id: "go", label: "Go Env" },
{ id: "history", label: "历史版本" },
];
const INFRA_TABS = [
{ id: "overview", label: "状态总览" },
{ id: "nacos", label: "Nacos 服务" },
{ id: "mongo", label: "Mongo 指标" },
{ id: "tat", label: "TAT" },
];
function rootPath(path) {
const normalized = typeof path === "string" && path.startsWith("/") ? path : `/${path || ""}`;
return normalized;
}
function appPath(path) {
const normalized = typeof path === "string" && path.startsWith("/") ? path : `/${path || ""}`;
return `${APP_BASE_PATH}${normalized}` || "/";
}
function loginRedirectPath() {
return rootPath(`/login?next=${encodeURIComponent(`${window.location.pathname}${window.location.search}`)}`);
}
createApp({
template: APP_TEMPLATE,
data() {
return {
activeView: "overview",
densityMode: "compact",
detailPanelOpen: false,
overviewHealthyHostsOpen: false,
viewTabs: VIEW_TABS,
primaryFilters: PRIMARY_FILTERS,
releaseSections: RELEASE_SECTIONS,
configTabs: CONFIG_TABS,
infraTabs: INFRA_TABS,
expandedHosts: [],
expandedNacosServices: [],
focusedHostKey: "",
selectedServiceKey: "",
loading: false,
error: "",
filter: "all",
keyword: "",
infraKeyword: "",
releaseKeyword: "",
releaseKindFilter: "all",
configTab: "nacos",
configEditorMode: "diff",
goEditorMode: "diff",
infraTab: "overview",
infraHealthyNacosOpen: false,
infraHealthyTatOpen: false,
infraMongoDetailsOpen: false,
collapsedConfigGroups: [],
copiedToken: "",
copiedTokenTimer: null,
refreshIntervalSeconds: 10,
refreshIntervalOptions: [5, 10, 15, 30, 60],
auth: {
username: "",
checking: false,
redirecting: false,
loggingOut: false,
},
configHistory: [],
releaseHistory: [],
releasePrecheck: {
ok: false,
ranAt: "",
tone: "neutral",
title: "",
checks: [],
},
payload: {
updatedAt: "",
settings: {
refreshIntervalSeconds: 10,
refreshIntervalOptions: [5, 10, 15, 30, 60],
thresholds: {},
},
summary: {
total: 0,
ok: 0,
down: 0,
hostTotal: 0,
hostMetricOk: 0,
hostMetricDown: 0,
nacosServiceTotal: 0,
nacosInstanceTotal: 0,
nacosUnhealthyInstanceTotal: 0,
mongoDatabaseTotal: 0,
mongoCollectionTotal: 0,
mongoOk: 0,
},
hosts: [],
nacos: {
ok: false,
error: "",
baseUrl: "",
namespaceName: "",
namespaceId: "",
summary: {},
services: [],
namespaces: [],
},
mongo: {
ok: false,
error: "",
info: {},
summary: {},
connections: {},
memory: {},
network: {},
opcounters: {},
wiredTigerCache: {},
databases: [],
replicaSet: {
configured: false,
members: [],
},
},
},
nacosConfig: {
loading: false,
error: "",
keyword: "",
items: [],
selectedKey: "",
detailLoading: false,
detailError: "",
validating: false,
validationMessage: "",
saving: false,
saveMessage: "",
baseUrl: "",
namespaceId: "",
namespaceName: "",
loadedContent: "",
editor: {
group: "",
dataId: "",
type: "",
appName: "",
description: "",
lastModifiedTime: "",
md5: "",
content: "",
},
},
goConfig: {
loading: false,
error: "",
validating: false,
validationMessage: "",
saving: false,
saveMessage: "",
loadedContent: "",
editorContent: "",
diffKeys: [],
hosts: [],
},
updateOps: {
loading: false,
error: "",
running: false,
operationId: "",
status: "",
stage: "",
startedAt: "",
finishedAt: "",
logDroppedCount: 0,
logs: [],
message: "",
items: [],
selectedServices: [],
javaGitRef: "main",
goGitRef: "main",
adminGitRef: "main",
imageTag: "",
skipMavenBuild: false,
defaults: {
javaGitRef: "main",
goGitRef: "main",
adminGitRef: "main",
dockerPlatform: "linux/amd64",
},
builder: {
harborRegistry: "",
harborProject: "",
javaRepoRoot: "",
golangRepoRoot: "",
frontendRepoRoot: "",
frontendRepoUrl: "",
frontendPublicUrl: "",
},
result: null,
pollTimer: null,
statusRequestInFlight: false,
statusRequestSeq: 0,
statusAppliedSeq: 0,
requestEpoch: 0,
},
restartOps: {
loading: false,
error: "",
running: false,
message: "",
items: [],
selectedServices: [],
result: null,
},
serviceLog: {
loading: false,
error: "",
service: "",
host: "",
container: "",
content: "",
updatedAt: "",
previewLines: 1000,
exportLines: 1000,
exportMaxLines: 10000,
exporting: false,
},
logViewer: {
open: false,
kind: "",
},
timer: null,
};
},
computed: {
summary() {
return this.payload.summary || {};
},
thresholds() {
return this.payload.settings?.thresholds || {};
},
formattedUpdatedAt() {
return this.formatDateTime(this.payload.updatedAt);
},
normalizedKeyword() {
return this.keyword.trim().toLowerCase();
},
normalizedInfraKeyword() {
return this.infraKeyword.trim().toLowerCase();
},
normalizedReleaseKeyword() {
return this.releaseKeyword.trim().toLowerCase();
},
allHostRows() {
return this.buildHostRows({ businessOnly: false, applyFilter: false, applyKeyword: false });
},
hostServiceAlertTotal() {
return this.allHostRows.filter((host) => host.downCount > 0).length;
},
businessHosts() {
return this.buildHostRows({ businessOnly: true, applyFilter: false, applyKeyword: false });
},
overviewHostRows() {
return this.buildHostRows({ businessOnly: true, applyFilter: true, applyKeyword: true });
},
overviewAlertHosts() {
return this.overviewHostRows.filter((host) => host.hasAlert);
},
overviewQuietHosts() {
return this.overviewHostRows.filter((host) => !host.hasAlert);
},
filteredHostRows() {
return this.buildHostRows({ businessOnly: false, applyFilter: true, applyKeyword: true });
},
filteredHostAlertTotal() {
return this.filteredHostRows.filter((host) => host.hasAlert).length;
},
filteredHostMetricIssueTotal() {
return this.filteredHostRows.filter((host) => host.hasMetricIssue).length;
},
overviewHostRowGroups() {
return this.groupHosts(this.overviewHostRows);
},
hostRowGroups() {
return this.groupHosts(this.filteredHostRows);
},
detailPanelVisible() {
return this.detailPanelOpen;
},
serviceMatrixColumns() {
if (this.filter === "infra") {
return [];
}
let columns = this.businessHosts;
if (["gateway", "app", "pay"].includes(this.filter)) {
columns = columns.filter((host) => host.group === this.filter);
}
if (this.focusedHostKey) {
columns = columns.filter((host) => host.host === this.focusedHostKey);
}
if (!this.normalizedKeyword) {
return columns;
}
const keywordMatched = columns.filter((host) => host.searchText.includes(this.normalizedKeyword));
return keywordMatched.length > 0 ? keywordMatched : columns;
},
serviceMatrixColumnGroups() {
const groups = [];
this.serviceMatrixColumns.forEach((host) => {
const last = groups[groups.length - 1];
if (last && last.group === host.group) {
last.span += 1;
return;
}
groups.push({
group: host.group,
label: this.groupLabel(host.group),
span: 1,
});
});
return groups;
},
allServiceRows() {
return this.buildServiceRows({
applyFilter: false,
applyKeyword: false,
columns: this.businessHosts,
});
},
serviceMatrixRows() {
return this.buildServiceRows({
applyFilter: true,
applyKeyword: true,
columns: this.serviceMatrixColumns,
});
},
serviceMatrixSections() {
const groups = new Map();
this.serviceMatrixRows.forEach((row) => {
if (!groups.has(row.group)) {
groups.set(row.group, {
group: row.group,
label: this.groupLabel(row.group),
rows: [],
columns: this.serviceMatrixColumns.filter((host) => host.group === row.group),
});
}
groups.get(row.group).rows.push(row);
});
return [...groups.values()]
.filter((section) => section.rows.length > 0 && section.columns.length > 0)
.map((section) => ({
...section,
rows: section.rows.map((row) => ({
...row,
sectionCells: section.columns.map((host) => row.instanceMap[host.host] || {
host: host.host,
ip: host.ip,
group: host.group,
groupLabel: host.groupLabel,
present: false,
ok: false,
level: "neutral",
statusCode: null,
latencyMs: null,
port: null,
detail: "",
url: "",
}),
})),
}))
.sort((left, right) => this.groupRank(left.group) - this.groupRank(right.group));
},
selectedServiceDetail() {
return this.serviceMatrixRows.find((row) => row.key === this.selectedServiceKey) || this.serviceMatrixRows[0] || null;
},
selectedServiceHosts() {
return new Set((this.selectedServiceDetail?.instances || []).map((instance) => instance.host));
},
nacosConfigGroups() {
const groups = new Map();
this.filteredNacosConfigs.forEach((item) => {
const groupKey = item.group || "default";
if (!groups.has(groupKey)) {
groups.set(groupKey, {
key: groupKey,
label: groupKey,
items: [],
});
}
groups.get(groupKey).items.push(item);
});
return [...groups.values()].map((group) => ({
...group,
items: group.items.sort((left, right) => left.dataId.localeCompare(right.dataId, "zh-CN")),
}));
},
selectedNacosConfigGroup() {
return this.nacosConfigGroups.find((group) =>
group.items.some((item) => this.configKey(item.group, item.dataId) === this.nacosConfig.selectedKey)
)?.key || "";
},
flatNacosConfigKeys() {
return this.nacosConfigGroups.flatMap((group) =>
group.items.map((item) => this.configKey(item.group, item.dataId))
);
},
selectedNacosConfigIndex() {
return this.flatNacosConfigKeys.indexOf(this.nacosConfig.selectedKey);
},
selectedPrevNacosConfigKey() {
return this.selectedNacosConfigIndex > 0
? this.flatNacosConfigKeys[this.selectedNacosConfigIndex - 1]
: "";
},
selectedNextNacosConfigKey() {
return this.selectedNacosConfigIndex >= 0 && this.selectedNacosConfigIndex < this.flatNacosConfigKeys.length - 1
? this.flatNacosConfigKeys[this.selectedNacosConfigIndex + 1]
: "";
},
configHistoryRows() {
const keyword = this.nacosConfig.keyword.trim().toLowerCase();
return this.configHistory.filter((item) => (
!keyword
|| [item.scope, item.title, item.detail, item.time]
.join(" ")
.toLowerCase()
.includes(keyword)
));
},
currentNacosDiffRows() {
return this.buildLineDiffRows(this.nacosConfig.loadedContent, this.nacosConfig.editor.content);
},
currentGoDiffRows() {
return this.buildLineDiffRows(this.goConfig.loadedContent, this.goConfig.editorContent);
},
showNacosDiffEmpty() {
return this.configEditorMode === "diff" && this.currentNacosDiffRows.length === 0;
},
showGoDiffEmpty() {
return this.goEditorMode === "diff" && this.currentGoDiffRows.length === 0;
},
filteredNacosServices() {
const keyword = this.normalizedInfraKeyword;
return (this.payload.nacos?.services || []).filter((service) => {
if (!keyword) {
return true;
}
const serviceMatched = [service.serviceName, service.groupName, service.rawServiceName]
.join(" ")
.toLowerCase()
.includes(keyword);
if (serviceMatched) {
return true;
}
return (service.instances || []).some((instance) =>
[instance.ip, instance.port, instance.clusterName]
.join(" ")
.toLowerCase()
.includes(keyword)
);
});
},
filteredNacosServiceRows() {
return this.filteredNacosServices.sort((left, right) => (
Number((right.unhealthyTotal || 0) > 0) - Number((left.unhealthyTotal || 0) > 0)
|| (right.unhealthyTotal || 0) - (left.unhealthyTotal || 0)
|| left.serviceName.localeCompare(right.serviceName, "zh-CN")
));
},
infraAlertNacosServices() {
return this.filteredNacosServiceRows.filter((service) => (service.unhealthyTotal || 0) > 0);
},
infraHealthyNacosServices() {
return this.filteredNacosServiceRows.filter((service) => (service.unhealthyTotal || 0) === 0);
},
infraVisibleHealthyNacosServices() {
return this.infraHealthyNacosOpen ? this.infraHealthyNacosServices : [];
},
filteredMongoDatabases() {
const keyword = this.normalizedInfraKeyword;
return (this.payload.mongo.databases || []).filter((db) => (
!keyword
|| [db.name, db.collections, db.objects, db.dataSizeMb, db.storageSizeMb]
.join(" ")
.toLowerCase()
.includes(keyword)
));
},
tatAlertHosts() {
return this.allHostRows.filter((host) => (
host.hasMetricIssue
&& (
!this.normalizedInfraKeyword
|| host.searchText.includes(this.normalizedInfraKeyword)
)
));
},
tatHealthyHosts() {
return this.allHostRows.filter((host) => (
!host.hasMetricIssue
&& (
!this.normalizedInfraKeyword
|| host.searchText.includes(this.normalizedInfraKeyword)
)
));
},
visibleTatHealthyHosts() {
return this.infraHealthyTatOpen ? this.tatHealthyHosts : [];
},
filteredNacosConfigs() {
const keyword = this.nacosConfig.keyword.trim().toLowerCase();
return (this.nacosConfig.items || []).filter((item) => {
if (!keyword) {
return true;
}
return [item.group, item.dataId, item.type, item.appName, item.description]
.join(" ")
.toLowerCase()
.includes(keyword);
});
},
nacosConfigDirty() {
return this.nacosConfig.editor.content !== this.nacosConfig.loadedContent;
},
goConfigDirty() {
return this.goConfig.editorContent !== this.goConfig.loadedContent;
},
releaseTargetRows() {
const keyword = this.normalizedReleaseKeyword;
return (this.updateOps.items || [])
.map((item) => {
const images = Array.isArray(item.currentImages) ? item.currentImages : [];
const primaryImage = images[0] || "";
const imageMeta = this.imageMeta(primaryImage);
const imageSummary = images.length > 1
? `${images.length} 个版本`
: primaryImage || "-";
const currentLabel = item.currentLabel || (images.length > 1 ? "多版本" : (imageMeta.tag || "-"));
const currentTitle = item.currentTitle || imageSummary;
const detailLabel = item.detailLabel || (images.length > 1 ? `${images.length}` : imageMeta.digestShort);
const detailTitle = item.detailTitle || primaryImage || "-";
const copyValue = item.copyValue || primaryImage || "";
return {
...item,
hostCount: (item.hosts || []).length,
hostsLabel: (item.hosts || []).map((host) => host.host).join(" / "),
imageTitle: images.join("\n"),
imageSummary,
currentLabel,
currentTitle,
detailLabel,
detailTitle,
copyValue,
digestFull: imageMeta.digestFull,
repositoryShort: item.repository ? this.truncateMiddle(item.repository, 18, 10) : imageMeta.repositoryShort,
currentImagePrimary: primaryImage,
currentImageCount: images.length,
searchText: [
item.service,
item.kind,
item.repository,
currentLabel,
detailLabel,
...(item.hosts || []).flatMap((host) => [host.host, host.ip, host.path, host.port]),
...images,
].join(" ").toLowerCase(),
};
})
.filter((item) => {
if (this.releaseKindFilter !== "all" && item.kind !== this.releaseKindFilter) {
return false;
}
if (keyword && !item.searchText.includes(keyword)) {
return false;
}
return true;
})
.sort((left, right) => left.service.localeCompare(right.service, "zh-CN"));
},
restartTargetRows() {
const keyword = this.normalizedReleaseKeyword;
return (this.restartOps.items || [])
.map((item) => ({
...item,
hostCount: (item.hosts || []).length,
hostsLabel: (item.hosts || []).map((host) => host.host).join(" / "),
searchText: [
item.service,
item.kind,
...(item.hosts || []).flatMap((host) => [host.host, host.ip, host.path, host.port]),
].join(" ").toLowerCase(),
}))
.filter((item) => {
if (this.releaseKindFilter !== "all" && item.kind !== this.releaseKindFilter) {
return false;
}
if (keyword && !item.searchText.includes(keyword)) {
return false;
}
return true;
})
.sort((left, right) => left.service.localeCompare(right.service, "zh-CN"));
},
releaseStatusSummary() {
const affectedServices = this.updateOps.result?.services?.length
|| this.updateOps.selectedServices.length
|| 0;
const lastLog = (this.updateOps.logs || []).slice(-1)[0];
return [
{
label: "上次执行",
value: this.formatDateTime(this.updateOps.finishedAt || this.updateOps.startedAt),
tone: "neutral",
},
{
label: "最近结果",
value: this.updateStatusLabel(),
tone: this.updateStatusBadgeClass(),
},
{
label: "失败阶段",
value: this.updateOps.error ? (this.updateOps.stage || "failed") : "-",
tone: this.updateOps.error ? "bad" : "neutral",
},
{
label: "影响服务",
value: String(affectedServices),
tone: affectedServices > 0 ? "warn" : "neutral",
},
{
label: "最近日志",
value: lastLog?.message ? this.truncateMiddle(lastLog.message, 24, 14) : "-",
tone: lastLog?.level === "error" ? "bad" : "neutral",
},
];
},
releaseHistoryRows() {
return this.releaseHistory.slice(0, 8);
},
activeConfigTabLabel() {
return this.configTabs.find((item) => item.id === this.configTab)?.label || "";
},
updateAffectedServicesCount() {
return this.updateOps.result?.services?.length || this.updateOps.selectedServices.length || 0;
},
summaryStripCards() {
const serviceDownTotal = this.allServiceRows.filter((row) => row.downCount > 0).length;
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 tatOk = this.summary.hostMetricOk || 0;
const tatTotal = this.summary.hostTotal || 0;
return [
{
key: "service-down",
label: "服务",
value: `${serviceDownTotal}/${this.allServiceRows.length}`,
suffix: "异常",
tone: serviceDownTotal > 0 ? "bad" : "ok",
},
{
key: "instance-down",
label: "实例",
value: `${this.summary.down || 0}/${this.summary.total || 0}`,
suffix: "异常",
tone: (this.summary.down || 0) > 0 ? "bad" : "ok",
},
{
key: "host-alert",
label: "主机",
value: `${hostAlertTotal}/${this.summary.hostTotal || 0}`,
suffix: "异常",
tone: hostAlertTotal > 0 ? "warn" : "ok",
},
{
key: "nacos",
label: "Nacos",
value: nacosUnavailable ? "ERR" : String(this.summary.nacosUnhealthyInstanceTotal || 0),
suffix: nacosUnavailable ? "" : "异常",
tone: nacosUnavailable || (this.summary.nacosUnhealthyInstanceTotal || 0) > 0 ? "bad" : "ok",
},
{
key: "mongo",
label: "Mongo",
value: mongoUnavailable ? "ERR" : String(this.summary.mongoDatabaseTotal || 0),
suffix: mongoUnavailable ? "" : "库",
tone: mongoUnavailable ? "bad" : "ok",
},
{
key: "tat",
label: "TAT",
value: `${tatOk}/${tatTotal}`,
suffix: "正常",
tone: (this.summary.hostMetricDown || 0) > 0 ? "warn" : "ok",
},
];
},
overviewBridgeAlerts() {
return this.serviceMatrixRows
.filter((row) => row.downCount > 0)
.map((row) => ({
key: row.key,
service: row.service,
groupLabel: row.groupLabel,
downCount: row.downCount,
hosts: row.instances
.filter((instance) => !instance.ok)
.map((instance) => instance.host),
}))
.slice(0, 12);
},
overviewInfraCards() {
const nacosTone = !(this.payload.nacos && this.payload.nacos.ok)
? "bad"
: (this.summary.nacosUnhealthyInstanceTotal || 0) > 0
? "warn"
: "ok";
const mongoTone = this.payload.mongo && this.payload.mongo.ok ? "ok" : "bad";
const tatTone = (this.summary.hostMetricDown || 0) > 0 ? "warn" : "ok";
return [
{
key: "nacos",
view: "infra",
label: "Nacos",
badge: this.payload.nacos && this.payload.nacos.ok ? "连接正常" : "连接异常",
tone: nacosTone,
meta: this.payload.nacos.baseUrl || "deploy 直连",
metrics: [
{ label: "服务", value: this.payload.nacos.summary.serviceTotal || 0 },
{ label: "实例", value: this.payload.nacos.summary.instanceTotal || 0 },
{ label: "异常", value: this.payload.nacos.summary.unhealthyInstanceTotal || 0 },
],
},
{
key: "mongo",
view: "infra",
label: "MongoDB",
badge: this.payload.mongo && this.payload.mongo.ok ? "连接正常" : "连接异常",
tone: mongoTone,
meta: this.payload.mongo.info.host || "deploy 直连",
metrics: [
{ label: "数据库", value: this.payload.mongo.summary.databaseTotal || 0 },
{ label: "集合", value: this.payload.mongo.summary.collectionTotal || 0 },
{ label: "连接", value: this.payload.mongo.connections.current ?? 0 },
],
},
{
key: "tat",
view: "hosts",
label: "TAT",
badge: (this.summary.hostMetricDown || 0) > 0 ? "采集异常" : "采集正常",
tone: tatTone,
meta: "主机资源采集",
metrics: [
{ label: "主机", value: this.summary.hostTotal || 0 },
{ label: "正常", value: this.summary.hostMetricOk || 0 },
{ label: "异常", value: this.summary.hostMetricDown || 0 },
],
},
];
},
alertFeed() {
const alerts = [];
this.allServiceRows.forEach((row) => {
row.instances
.filter((instance) => !instance.ok)
.forEach((instance) => {
alerts.push({
key: `service-${row.service}-${instance.host}`,
type: "service",
service: row.service,
tone: "bad",
title: row.service,
text: `${instance.host} · ${instance.detail || "HTTP 异常"}`,
});
});
});
this.allHostRows
.filter((host) => host.hasMetricIssue)
.forEach((host) => {
alerts.push({
key: `host-${host.host}`,
type: "host",
host: host.host,
tone: "warn",
title: host.host,
text: host.metrics.error || "主机指标异常",
});
});
if (this.payload.nacos.error || !(this.payload.nacos && this.payload.nacos.ok)) {
alerts.push({
key: "nacos-error",
type: "infra",
view: "infra",
tone: "bad",
title: "Nacos",
text: this.payload.nacos.error || "连接异常",
});
}
if (this.payload.mongo.error || !(this.payload.mongo && this.payload.mongo.ok)) {
alerts.push({
key: "mongo-error",
type: "infra",
view: "infra",
tone: "bad",
title: "MongoDB",
text: this.payload.mongo.error || "连接异常",
});
}
return alerts.slice(0, 5);
},
restartTargetMap() {
return Object.fromEntries((this.restartOps.items || []).map((item) => [item.service, item]));
},
updateTargetMap() {
return Object.fromEntries((this.updateOps.items || []).map((item) => [item.service, item]));
},
updateLogText() {
return (this.updateOps.logs || [])
.map((entry) => {
const time = this.formatDateTime(entry.time);
const stage = entry.stage ? ` [${entry.stage}]` : "";
const level = entry.level && entry.level !== "info" ? ` (${entry.level})` : "";
return `${time}${stage}${level} ${entry.message || ""}`.trim();
})
.join("\n");
},
recentUpdateLogs() {
return (this.updateOps.logs || [])
.slice(-5)
.reverse()
.map((entry, index) => ({
...entry,
key: `${entry.time || "log"}-${entry.stage || "stage"}-${index}`,
}));
},
serviceLogDownloadUrl() {
if (!this.serviceLog.service || !this.serviceLog.host) {
return "";
}
const requested = Number(this.serviceLog.exportLines || this.serviceLog.previewLines || 1000);
const fallback = Number(this.serviceLog.previewLines || 1000);
const lines = Math.min(
Math.max(1, Number.isNaN(requested) ? fallback : requested),
Number(this.serviceLog.exportMaxLines || 10000),
);
const query = new URLSearchParams({
service: this.serviceLog.service,
host: this.serviceLog.host,
lines: String(lines),
});
return `/api/ops/service-log/export?${query.toString()}`;
},
activeLogViewerTitle() {
if (this.logViewer.kind === "service") {
return `${this.serviceLog.service || "-"} · ${this.serviceLog.host || "-"}`;
}
return "执行日志";
},
activeLogViewerSubtitle() {
if (this.logViewer.kind === "service") {
const subtitleParts = [];
if (this.serviceLog.container) {
subtitleParts.push(this.serviceLog.container);
}
subtitleParts.push(`更新于 ${this.formatDateTime(this.serviceLog.updatedAt)}`);
return subtitleParts.join(" · ");
}
return `任务 ${this.updateOps.operationId || "-"} · ${this.updateStatusLabel()} · ${this.formatDateTime(this.updateOps.finishedAt || this.updateOps.startedAt)}`;
},
activeLogViewerBadge() {
if (this.logViewer.kind === "service") {
return this.serviceLog.container || "";
}
return this.updateOps.operationId || "";
},
activeLogViewerSecondaryBadge() {
if (this.logViewer.kind === "service") {
return `最近 ${this.serviceLog.previewLines}`;
}
return this.updateOps.stage ? `阶段 ${this.updateOps.stage}` : "";
},
activeLogViewerContent() {
if (this.logViewer.kind === "service") {
return this.serviceLog.content;
}
return this.updateLogText;
},
activeLogViewerError() {
if (this.logViewer.kind === "service") {
return this.serviceLog.error;
}
return this.updateOps.error ? this.explainError(this.updateOps.error) : "";
},
activeLogViewerLoading() {
if (this.logViewer.kind === "service") {
return this.serviceLog.loading;
}
return this.updateOps.statusRequestInFlight;
},
activeLogViewerExporting() {
if (this.logViewer.kind === "service") {
return this.serviceLog.exporting;
}
return false;
},
activeLogViewerRefreshDisabled() {
if (this.logViewer.kind === "service") {
return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.loading;
}
return !this.updateOps.operationId || this.updateOps.statusRequestInFlight;
},
activeLogViewerDownloadDisabled() {
if (this.logViewer.kind === "service") {
return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.exporting;
}
return !this.updateOps.operationId;
},
},
watch: {
refreshIntervalSeconds() {
window.localStorage.setItem(STORAGE_KEY, String(this.refreshIntervalSeconds));
this.applyRefreshTimer();
},
activeView(nextValue) {
if (nextValue === "services") {
this.detailPanelOpen = true;
}
if (nextValue === "config") {
this.configTab = this.configTab || "nacos";
}
if (nextValue === "infra") {
this.infraTab = this.infraTab || "overview";
}
},
selectedServiceKey() {
this.syncServiceLogTarget();
},
},
methods: {
groupHosts(hostRows) {
const groups = new Map();
hostRows.forEach((host) => {
if (!groups.has(host.group)) {
groups.set(host.group, {
group: host.group,
label: this.groupLabel(host.group),
items: [],
alertCount: 0,
metricIssueCount: 0,
serviceCount: 0,
});
}
const target = groups.get(host.group);
target.items.push(host);
target.serviceCount += host.serviceTotal || 0;
if (host.hasAlert) {
target.alertCount += 1;
}
if (host.hasMetricIssue) {
target.metricIssueCount += 1;
}
});
return [...groups.values()].sort((left, right) => this.groupRank(left.group) - this.groupRank(right.group));
},
groupRank(group) {
return GROUP_ORDER[group] || 999;
},
groupLabel(group) {
return GROUP_LABELS[group] || group;
},
toneClass(level) {
return `tone-${level || "neutral"}`;
},
recordConfigHistory(scope, title, detail, tone = "neutral") {
this.configHistory = [
{
id: `config-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
scope,
title,
detail,
tone,
time: this.formatDateTime(new Date().toISOString()),
},
...this.configHistory,
].slice(0, 40);
},
recordReleaseHistory(scope, title, detail, tone = "neutral") {
this.releaseHistory = [
{
id: `release-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
scope,
title,
detail,
tone,
time: this.formatDateTime(new Date().toISOString()),
},
...this.releaseHistory,
].slice(0, 40);
},
explainError(message) {
const text = String(message || "").trim();
if (!text) {
return "";
}
if (text.includes("yaml renderer unavailable") || text.includes("No module named 'yaml'")) {
return "缺少 PyYAML当前无法渲染 compose 模板";
}
return text;
},
scrollToSection(sectionId) {
this.$nextTick(() => {
const node = document.getElementById(sectionId);
if (node) {
node.scrollIntoView({ behavior: "smooth", block: "start" });
}
});
},
imageMeta(imageRef) {
const raw = String(imageRef || "").trim();
if (!raw) {
return {
repository: "",
repositoryShort: "-",
tag: "",
digestFull: "",
digestShort: "-",
};
}
const [withoutDigest, digestRaw = ""] = raw.split("@");
const lastSlash = withoutDigest.lastIndexOf("/");
const colon = withoutDigest.lastIndexOf(":");
const hasTag = colon > lastSlash;
const tag = hasTag ? withoutDigest.slice(colon + 1) : "";
const repository = hasTag ? withoutDigest.slice(0, colon) : withoutDigest;
const digestFull = digestRaw ? (digestRaw.startsWith("sha256:") ? digestRaw : `sha256:${digestRaw}`) : "";
return {
repository,
repositoryShort: this.truncateMiddle(repository, 18, 10),
tag,
digestFull,
digestShort: digestFull ? this.truncateMiddle(digestFull, 10, 6) : "-",
};
},
truncateMiddle(value, start = 12, end = 10) {
const text = String(value || "");
if (!text) {
return "";
}
if (text.length <= start + end + 3) {
return text;
}
return `${text.slice(0, start)}...${text.slice(-end)}`;
},
async copyText(text, token = "") {
const value = String(text || "");
if (!value) {
return;
}
try {
await navigator.clipboard.writeText(value);
this.copiedToken = token || value;
if (this.copiedTokenTimer) {
window.clearTimeout(this.copiedTokenTimer);
}
this.copiedTokenTimer = window.setTimeout(() => {
this.copiedToken = "";
this.copiedTokenTimer = null;
}, 1600);
} catch (error) {
this.recordReleaseHistory("copy", "复制失败", error instanceof Error ? error.message : String(error), "bad");
}
},
isCopied(token) {
return this.copiedToken === token;
},
runReleasePrecheck() {
const checks = [
{
label: "更新目标",
ok: this.updateOps.items.length > 0,
detail: this.updateOps.items.length > 0 ? `${this.updateOps.items.length} 项可更新服务` : this.explainError(this.updateOps.error) || "暂无更新目标",
},
{
label: "更新选择",
ok: this.updateOps.selectedServices.length > 0,
detail: this.updateOps.selectedServices.length > 0 ? this.updateOps.selectedServices.join(", ") : "未选择更新服务",
},
{
label: "滚动重启目标",
ok: this.restartOps.items.length > 0,
detail: this.restartOps.items.length > 0 ? `${this.restartOps.items.length} 项可重启服务` : (this.restartOps.error || "暂无重启目标"),
},
{
label: "构建参数",
ok: (() => {
const selectedKinds = new Set(
(this.updateOps.items || [])
.filter((item) => this.updateOps.selectedServices.includes(item.service))
.map((item) => item.kind)
);
return (
(!selectedKinds.has("java") || Boolean(this.updateOps.javaGitRef.trim()))
&& (!selectedKinds.has("golang") || Boolean(this.updateOps.goGitRef.trim()))
&& (!selectedKinds.has("frontend") || Boolean(this.updateOps.adminGitRef.trim()))
);
})(),
detail: `Java ${this.updateOps.javaGitRef || "-"} / Go ${this.updateOps.goGitRef || "-"} / Admin ${this.updateOps.adminGitRef || "-"}`,
},
];
const ok = checks.every((item) => item.ok);
this.releasePrecheck = {
ok,
ranAt: new Date().toISOString(),
tone: ok ? "ok" : "warn",
title: ok ? "预检查通过" : "预检查未通过",
checks,
};
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();
},
toggleConfigGroup(groupKey) {
const next = new Set(this.collapsedConfigGroups);
if (next.has(groupKey)) {
next.delete(groupKey);
} else {
next.add(groupKey);
}
this.collapsedConfigGroups = [...next];
},
syncConfigExplorerState(selectedKey = this.nacosConfig.selectedKey, { force = false } = {}) {
const groupKeys = [...new Set((this.nacosConfig.items || []).map((item) => item.group || "default"))];
if (groupKeys.length === 0) {
this.collapsedConfigGroups = [];
return;
}
const selectedItem = (this.nacosConfig.items || []).find((item) => this.configKey(item.group, item.dataId) === selectedKey);
const selectedGroup = selectedItem?.group || groupKeys[0];
if (force || this.collapsedConfigGroups.length === 0) {
this.collapsedConfigGroups = groupKeys.filter((groupKey) => groupKey !== selectedGroup);
return;
}
if (this.collapsedConfigGroups.includes(selectedGroup)) {
this.collapsedConfigGroups = this.collapsedConfigGroups.filter((groupKey) => groupKey !== selectedGroup);
}
},
isConfigGroupCollapsed(groupKey) {
return this.collapsedConfigGroups.includes(groupKey);
},
scrollSelectedConfigItemIntoView() {
this.$nextTick(() => {
const node = document.getElementById(`config-item-${this.nacosConfig.selectedKey}`);
if (node) {
node.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
});
},
openPrevNacosConfig() {
if (!this.selectedPrevNacosConfigKey) {
return;
}
this.nacosConfig.selectedKey = this.selectedPrevNacosConfigKey;
this.openNacosConfigByKey(this.selectedPrevNacosConfigKey);
},
openNextNacosConfig() {
if (!this.selectedNextNacosConfigKey) {
return;
}
this.nacosConfig.selectedKey = this.selectedNextNacosConfigKey;
this.openNacosConfigByKey(this.selectedNextNacosConfigKey);
},
toggleNacosService(serviceName) {
const next = new Set(this.expandedNacosServices);
if (next.has(serviceName)) {
next.delete(serviceName);
} else {
next.add(serviceName);
}
this.expandedNacosServices = [...next];
},
isNacosServiceExpanded(serviceName) {
return this.expandedNacosServices.includes(serviceName);
},
restoreNacosContent() {
this.nacosConfig.editor.content = this.nacosConfig.loadedContent;
this.recordConfigHistory("nacos", "恢复编辑内容", this.nacosConfig.editor.dataId || "-", "warn");
},
restoreGoContent() {
this.goConfig.editorContent = this.goConfig.loadedContent;
this.recordConfigHistory("go", "恢复 Go 配置", "已回退到当前线上版本", "warn");
},
splitLines(text) {
return String(text || "").replace(/\r\n/g, "\n").split("\n");
},
buildLineDiffRows(beforeText, afterText) {
const before = this.splitLines(beforeText);
const after = this.splitLines(afterText);
const rows = [];
const max = Math.max(before.length, after.length);
let leftNo = 1;
let rightNo = 1;
for (let index = 0; index < max; index += 1) {
const left = before[index];
const right = after[index];
if (left === right) {
if (left !== undefined) {
leftNo += 1;
rightNo += 1;
}
continue;
}
if (left !== undefined) {
rows.push({
key: `remove-${index}-${leftNo}`,
type: "remove",
leftNo,
rightNo: "",
text: left,
});
leftNo += 1;
}
if (right !== undefined) {
rows.push({
key: `add-${index}-${rightNo}`,
type: "add",
leftNo: "",
rightNo,
text: right,
});
rightNo += 1;
}
}
return rows;
},
toggleOverviewHealthyHosts() {
this.overviewHealthyHostsOpen = !this.overviewHealthyHostsOpen;
},
toggleDetailPanel() {
this.detailPanelOpen = !this.detailPanelOpen;
},
isSelectedService(serviceKey) {
return Boolean(this.selectedServiceDetail && this.selectedServiceDetail.key === serviceKey);
},
selectService(serviceKey) {
this.selectedServiceKey = serviceKey;
this.detailPanelOpen = true;
},
openUpdateLogViewer() {
this.logViewer.open = true;
this.logViewer.kind = "update";
this.$nextTick(() => this.scrollUpdateLogToBottom());
},
closeLogViewer() {
const previousKind = this.logViewer.kind;
this.logViewer.open = false;
this.logViewer.kind = "";
if (previousKind === "service") {
this.clearServiceLog();
}
},
async refreshActiveLogViewer() {
if (this.logViewer.kind === "service") {
if (!this.serviceLog.host) {
return;
}
await this.openServiceLog({ host: this.serviceLog.host });
return;
}
if (!this.updateOps.operationId) {
return;
}
await this.fetchUpdateOperationStatus(this.updateOps.operationId);
this.scrollUpdateLogToBottom();
},
async downloadActiveLogViewer() {
if (this.logViewer.kind === "service") {
await this.downloadServiceLog();
return;
}
if (!this.updateOps.operationId) {
return;
}
try {
await this.downloadFile(this.updateLogDownloadUrl(), `service-update-${this.updateOps.operationId}.log`);
} catch (error) {
this.updateOps.error = error instanceof Error ? error.message : String(error);
}
},
clearServiceLog() {
this.serviceLog.loading = false;
this.serviceLog.exporting = false;
this.serviceLog.error = "";
this.serviceLog.service = "";
this.serviceLog.host = "";
this.serviceLog.container = "";
this.serviceLog.content = "";
this.serviceLog.updatedAt = "";
this.serviceLog.previewLines = 1000;
this.serviceLog.exportLines = 1000;
this.serviceLog.exportMaxLines = 10000;
},
syncServiceLogTarget() {
if (!this.serviceLog.service || !this.serviceLog.host) {
return;
}
if (!this.selectedServiceDetail || this.selectedServiceDetail.service !== this.serviceLog.service) {
if (this.logViewer.kind === "service" && this.logViewer.open) {
this.closeLogViewer();
} else {
this.clearServiceLog();
}
return;
}
const exists = (this.selectedServiceDetail.instances || []).some((instance) => instance.host === this.serviceLog.host);
if (!exists) {
if (this.logViewer.kind === "service" && this.logViewer.open) {
this.closeLogViewer();
} else {
this.clearServiceLog();
}
}
},
isServiceLogActive(instance) {
return this.serviceLog.service === (this.selectedServiceDetail?.service || "") && this.serviceLog.host === instance.host;
},
async openServiceLog(instance) {
const serviceName = this.selectedServiceDetail?.service || "";
const hostName = instance?.host || "";
if (!serviceName || !hostName) {
return;
}
this.logViewer.open = true;
this.logViewer.kind = "service";
this.serviceLog.loading = true;
this.serviceLog.error = "";
this.serviceLog.service = serviceName;
this.serviceLog.host = hostName;
this.serviceLog.container = "";
this.serviceLog.content = "";
try {
const query = new URLSearchParams({ service: serviceName, host: hostName });
const { response, payload } = await this.requestJson(`/api/ops/service-log?${query.toString()}`);
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
this.serviceLog.service = payload.service || serviceName;
this.serviceLog.host = payload.host || hostName;
this.serviceLog.container = payload.container || "";
this.serviceLog.content = typeof payload.content === "string" ? payload.content : "";
this.serviceLog.updatedAt = payload.updatedAt || "";
this.serviceLog.previewLines = Number(payload.previewLines || payload.lines || 1000);
this.serviceLog.exportMaxLines = Number(payload.exportMaxLines || 10000);
this.serviceLog.exportLines = Math.min(
Math.max(1, Number(this.serviceLog.exportLines || this.serviceLog.previewLines)),
this.serviceLog.exportMaxLines,
);
this.$nextTick(() => this.scrollUpdateLogToBottom());
} catch (error) {
this.serviceLog.error = error instanceof Error ? error.message : String(error);
} finally {
this.serviceLog.loading = false;
}
},
clearFocusedHost() {
this.focusedHostKey = "";
},
selectHost(hostName) {
const next = new Set(this.expandedHosts);
if (next.has(hostName)) {
next.delete(hostName);
} else {
next.add(hostName);
}
this.expandedHosts = [...next];
this.focusedHostKey = this.focusedHostKey === hostName ? "" : hostName;
},
toggleHost(hostName) {
const next = new Set(this.expandedHosts);
if (next.has(hostName)) {
next.delete(hostName);
} else {
next.add(hostName);
}
this.expandedHosts = [...next];
},
isHostExpanded(hostName) {
return this.expandedHosts.includes(hostName);
},
isHostHighlighted(host) {
return this.focusedHostKey === host.host || this.selectedServiceHosts.has(host.host);
},
jumpToAlert(alert) {
if (alert.type === "service" && alert.service) {
this.selectService(alert.service);
this.activeView = "services";
return;
}
if (alert.type === "host" && alert.host) {
this.activeView = "hosts";
this.focusedHostKey = alert.host;
if (!this.expandedHosts.includes(alert.host)) {
this.expandedHosts = [...this.expandedHosts, alert.host];
}
return;
}
this.activeView = alert.view || "infra";
},
matrixCellTitle(cell) {
if (!cell.present) {
return "未部署";
}
const parts = [
cell.host || "",
`HTTP ${cell.statusCode || (cell.ok ? "UP" : "ERR")}`,
];
if (cell.latencyMs != null) {
parts.push(this.formatLatency(cell.latencyMs));
}
if (cell.detail) {
parts.push(cell.detail);
}
return parts.filter(Boolean).join(" · ");
},
hostMetricEntries(host) {
const metrics = host?.metrics || {};
return [
{
key: "cpu",
label: "CPU",
value: metrics.cpuPercent,
display: this.formatPercent(metrics.cpuPercent),
level: metrics.cpuLevel || this.thresholdTone("cpuPercent", metrics.cpuPercent),
},
{
key: "memory",
label: "MEM",
value: metrics.memoryPercent,
display: this.formatPercent(metrics.memoryPercent),
level: metrics.memoryLevel || this.thresholdTone("memoryPercent", metrics.memoryPercent),
},
{
key: "disk",
label: "DISK",
value: metrics.diskPercent,
display: this.formatPercent(metrics.diskPercent),
level: metrics.diskLevel || this.thresholdTone("diskPercent", metrics.diskPercent),
},
];
},
thresholdTone(metricKey, value) {
if (value == null || Number.isNaN(Number(value))) {
return "neutral";
}
const rule = this.thresholds?.[metricKey];
if (!rule) {
return "neutral";
}
const amount = Number(value);
const bad = Number(rule.bad);
const warn = Number(rule.warn);
if (!Number.isNaN(bad) && amount >= bad) {
return "bad";
}
if (!Number.isNaN(warn) && amount >= warn) {
return "warn";
}
return "ok";
},
metricRingStyle(value, level = "neutral") {
const amount = value == null || Number.isNaN(Number(value))
? 0
: Math.max(0, Math.min(100, Number(value)));
const tone = level || "neutral";
return {
"--metric-value": `${amount}%`,
"--metric-color": `var(--${tone === "bad" ? "bad" : tone === "warn" ? "warn" : tone === "ok" ? "ok" : "line-strong"})`,
};
},
hostServiceSummary(host, { quiet = false } = {}) {
const source = quiet ? host.items : (host.alertItems.length > 0 ? host.alertItems : host.items);
const services = [...new Set((source || []).map((item) => item.service).filter(Boolean))];
if (services.length === 0) {
return host.hasMetricIssue ? "指标采集异常" : "无服务";
}
if (services.length <= 4) {
return services.join(" / ");
}
return `${services.slice(0, 4).join(" / ")} +${services.length - 4}`;
},
hostSummaryTone(host) {
if (host.downCount > 0) {
return "bad";
}
if (host.hasMetricIssue) {
return "warn";
}
return "neutral";
},
buildHostRows({ businessOnly = false, applyFilter = false, applyKeyword = false } = {}) {
const keyword = applyKeyword ? this.normalizedKeyword : "";
return (this.payload.hosts || [])
.filter((host) => !businessOnly || ((host.items || []).length > 0))
.map((host) => {
const metrics = host.metrics || {};
const items = (host.items || [])
.map((item) => ({
...item,
level: item.level || (item.ok ? "ok" : "bad"),
}))
.sort((left, right) => (
Number(right.ok === false) - Number(left.ok === false)
|| left.service.localeCompare(right.service, "zh-CN")
));
const alertItems = items.filter((item) => !item.ok);
const downCount = alertItems.length;
const hasMetricIssue = metrics.ok === false || Boolean(metrics.error);
const alertServices = [...new Set(alertItems.map((item) => item.service))];
const alertSummary = alertServices.length > 0
? this.hostServiceSummary({ items, alertItems, hasMetricIssue }, { quiet: false })
: hasMetricIssue
? "指标采集异常"
: "无服务异常";
const quietSummary = this.hostServiceSummary({ items, alertItems, hasMetricIssue }, { quiet: true });
const searchText = [
host.host,
host.ip,
host.group,
host.instanceId,
...items.flatMap((item) => [
item.service,
item.kind,
item.detail,
item.url,
item.port,
item.statusCode,
]),
]
.join(" ")
.toLowerCase();
return {
...host,
metrics,
items,
alertItems,
alertSummary,
quietSummary,
groupLabel: this.groupLabel(host.group),
serviceTotal: items.length,
downCount,
hasMetricIssue,
hasAlert: downCount > 0 || hasMetricIssue,
searchText,
};
})
.filter((host) => {
if (applyFilter) {
if (this.filter === "down" && !host.hasAlert) {
return false;
}
if (this.filter === "infra" && BUSINESS_GROUPS.has(host.group)) {
return false;
}
if (["gateway", "app", "pay"].includes(this.filter) && host.group !== this.filter) {
return false;
}
}
if (keyword && !host.searchText.includes(keyword)) {
return false;
}
return true;
})
.sort((left, right) => (
Number(right.hasAlert) - Number(left.hasAlert)
|| this.groupRank(left.group) - this.groupRank(right.group)
|| left.host.localeCompare(right.host, "zh-CN")
));
},
buildServiceRows({ applyFilter = false, applyKeyword = false, columns = null } = {}) {
const columnHosts = Array.isArray(columns) ? columns : this.businessHosts;
const keyword = applyKeyword ? this.normalizedKeyword : "";
const rowMap = new Map();
this.businessHosts.forEach((host) => {
(host.items || []).forEach((item) => {
const rowKey = item.service;
if (!rowMap.has(rowKey)) {
rowMap.set(rowKey, {
key: rowKey,
service: item.service,
kind: item.kind || "-",
group: item.group || host.group,
groupLabel: this.groupLabel(item.group || host.group),
deployedCount: 0,
downCount: 0,
okCount: 0,
latencyValues: [],
instanceMap: {},
instances: [],
});
}
const row = rowMap.get(rowKey);
const latencyMs = item.latencyMs == null || Number.isNaN(Number(item.latencyMs))
? null
: Number(item.latencyMs);
const instance = {
host: host.host,
ip: host.ip,
group: host.group,
groupLabel: host.groupLabel,
present: true,
ok: Boolean(item.ok),
level: item.level || (item.ok ? "ok" : "bad"),
statusCode: item.statusCode,
latencyMs,
port: item.port,
detail: item.detail || "",
url: item.url || "",
kind: item.kind || row.kind,
};
row.instanceMap[host.host] = instance;
row.instances.push(instance);
row.deployedCount += 1;
row.kind = item.kind || row.kind;
if (instance.ok) {
row.okCount += 1;
} else {
row.downCount += 1;
}
if (latencyMs != null) {
row.latencyValues.push(latencyMs);
}
});
});
return [...rowMap.values()]
.map((row) => {
const cells = columnHosts.map((host) => row.instanceMap[host.host] || {
host: host.host,
ip: host.ip,
group: host.group,
groupLabel: host.groupLabel,
present: false,
ok: false,
level: "neutral",
statusCode: null,
latencyMs: null,
port: null,
detail: "",
url: "",
});
const avgLatencyMs = row.latencyValues.length > 0
? Math.round(row.latencyValues.reduce((sum, value) => sum + value, 0) / row.latencyValues.length)
: null;
const searchText = [
row.service,
row.kind,
row.group,
...row.instances.flatMap((instance) => [
instance.host,
instance.ip,
instance.detail,
instance.url,
instance.port,
instance.statusCode,
]),
]
.join(" ")
.toLowerCase();
return {
...row,
cells,
avgLatencyMs,
searchText,
};
})
.filter((row) => {
if (this.focusedHostKey && !row.instanceMap[this.focusedHostKey]) {
return false;
}
if (applyFilter) {
if (this.filter === "down" && row.downCount === 0) {
return false;
}
if (this.filter === "infra") {
return false;
}
if (["gateway", "app", "pay"].includes(this.filter) && row.group !== this.filter) {
return false;
}
}
if (keyword && !row.searchText.includes(keyword)) {
return false;
}
return true;
})
.sort((left, right) => (
Number(right.downCount > 0) - Number(left.downCount > 0)
|| right.downCount - left.downCount
|| left.service.localeCompare(right.service, "zh-CN")
));
},
statusCellLabel(cell) {
if (!cell.present) {
return "";
}
if (cell.statusCode && Number(cell.statusCode) > 0) {
return String(cell.statusCode);
}
return cell.ok ? "UP" : "ERR";
},
configKey(group, dataId) {
return `${group}@@${dataId}`;
},
formatPercent(value) {
if (value == null || Number.isNaN(Number(value))) {
return "-";
}
return `${Number(value).toFixed(2)}%`;
},
formatGb(used, total) {
if (used == null || total == null) {
return "-";
}
return `${Number(used).toFixed(2)} / ${Number(total).toFixed(2)} GB`;
},
formatMb(value) {
if (value == null || Number.isNaN(Number(value))) {
return "-";
}
return `${Number(value).toFixed(2)} MB`;
},
formatBytes(value) {
if (value == null || Number.isNaN(Number(value))) {
return "-";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let amount = Number(value);
let index = 0;
while (amount >= 1024 && index < units.length - 1) {
amount /= 1024;
index += 1;
}
return `${amount.toFixed(2)} ${units[index]}`;
},
formatNumber(value) {
if (value == null || Number.isNaN(Number(value))) {
return "-";
}
return Number(value).toLocaleString();
},
formatLatency(value) {
if (value == null || Number.isNaN(Number(value))) {
return "-";
}
return `${Math.round(Number(value))}ms`;
},
formatDateTime(value) {
if (!value) {
return "-";
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
},
formatDuration(seconds) {
if (seconds == null || Number.isNaN(Number(seconds))) {
return "-";
}
const total = Math.max(0, Number(seconds));
const days = Math.floor(total / 86400);
const hours = Math.floor((total % 86400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
const remainSeconds = Math.floor(total % 60);
if (days > 0) {
return `${days}d ${hours}h ${minutes}m`;
}
if (hours > 0) {
return `${hours}h ${minutes}m ${remainSeconds}s`;
}
if (minutes > 0) {
return `${minutes}m ${remainSeconds}s`;
}
return `${remainSeconds}s`;
},
restartTargetLabel(item) {
return `${item.service} · ${item.kind} · ${item.hosts.map((host) => host.host).join(" / ")}`;
},
updateTargetLabel(item) {
return `${item.service} · ${item.kind} · ${item.hosts.map((host) => host.host).join(" / ")}`;
},
updateLogDownloadUrl() {
if (!this.updateOps.operationId) {
return "";
}
const query = new URLSearchParams({ id: this.updateOps.operationId });
return `/api/ops/update-log?${query.toString()}`;
},
updateStatusBadgeClass() {
if (this.updateOps.error || this.updateOps.status === "failed") {
return "bad";
}
if (this.updateOps.running || this.updateOps.status === "running" || this.updateOps.status === "queued") {
return "warn";
}
if (this.updateOps.status === "success") {
return "ok";
}
return "neutral";
},
updateStatusLabel() {
if (this.updateOps.error || this.updateOps.status === "failed") {
return "失败";
}
if (this.updateOps.status === "queued") {
return "排队中";
}
if (this.updateOps.running || this.updateOps.status === "running") {
return "执行中";
}
if (this.updateOps.status === "success") {
return "已完成";
}
return "未开始";
},
syncRefreshSettings() {
const settings = this.payload.settings || {};
const options = Array.isArray(settings.refreshIntervalOptions)
? settings.refreshIntervalOptions.map((item) => Number(item)).filter((item) => !Number.isNaN(item) && item > 0)
: this.refreshIntervalOptions;
if (options.length > 0) {
this.refreshIntervalOptions = options;
}
const stored = Number(window.localStorage.getItem(STORAGE_KEY) || 0);
const configured = Number(settings.refreshIntervalSeconds || 0);
const fallback = this.refreshIntervalOptions[0] || 10;
const candidate = this.refreshIntervalOptions.includes(stored)
? stored
: this.refreshIntervalOptions.includes(configured)
? configured
: fallback;
if (candidate !== this.refreshIntervalSeconds) {
this.refreshIntervalSeconds = candidate;
} else {
this.applyRefreshTimer();
}
},
persistUpdateOperationId() {
if (this.updateOps.operationId) {
window.sessionStorage.setItem(UPDATE_OPERATION_STORAGE_KEY, this.updateOps.operationId);
return;
}
window.sessionStorage.removeItem(UPDATE_OPERATION_STORAGE_KEY);
},
stopUpdatePolling() {
if (this.updateOps.pollTimer) {
window.clearInterval(this.updateOps.pollTimer);
this.updateOps.pollTimer = null;
}
},
scrollUpdateLogToBottom() {
this.$nextTick(() => {
const node = document.getElementById("service-update-log-output");
if (node) {
node.scrollTop = node.scrollHeight;
}
});
},
applyUpdateOperation(operation) {
if (!operation) {
return;
}
this.updateOps.operationId = operation.operationId || "";
this.updateOps.status = operation.status || "";
this.updateOps.stage = operation.stage || "";
this.updateOps.startedAt = operation.startedAt || "";
this.updateOps.finishedAt = operation.finishedAt || "";
this.updateOps.logDroppedCount = Number(operation.logDroppedCount || 0);
this.updateOps.logs = Array.isArray(operation.logs) ? operation.logs : [];
this.updateOps.result = operation.result || null;
this.updateOps.running = operation.status === "queued" || operation.status === "running";
if (operation.status === "success") {
this.updateOps.message = operation.message || operation.result?.message || "服务更新完成";
this.updateOps.error = "";
} else if (operation.status === "failed") {
this.updateOps.error = operation.error || operation.result?.error || "服务更新失败";
this.updateOps.message = operation.message || "";
} else {
this.updateOps.message = operation.message || "";
}
if (operation.status === "success") {
this.recordReleaseHistory(
"update",
"服务更新完成",
`${(operation.result?.services || []).join(", ") || "-"} · ${this.updateOps.finishedAt || this.updateOps.updatedAt || "-"}`,
"ok",
);
} else if (operation.status === "failed") {
this.recordReleaseHistory(
"update",
"服务更新失败",
this.explainError(this.updateOps.error || operation.message || "服务更新失败"),
"bad",
);
}
this.persistUpdateOperationId();
this.scrollUpdateLogToBottom();
},
startUpdatePolling() {
this.stopUpdatePolling();
if (!this.updateOps.operationId) {
return;
}
this.updateOps.pollTimer = window.setInterval(() => {
this.fetchUpdateOperationStatus(this.updateOps.operationId, { silent: true });
}, 2000);
},
async fetchUpdateOperationStatus(operationId, { silent = false } = {}) {
if (!operationId) {
return;
}
if (this.updateOps.statusRequestInFlight) {
return;
}
const wasRunning = this.updateOps.running;
const requestSeq = this.updateOps.statusRequestSeq + 1;
const requestEpoch = this.updateOps.requestEpoch;
this.updateOps.statusRequestSeq = requestSeq;
this.updateOps.statusRequestInFlight = true;
try {
const query = new URLSearchParams({ id: operationId });
const { response, payload } = await this.requestJson(`/api/ops/update-status?${query.toString()}`);
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
if (requestEpoch !== this.updateOps.requestEpoch) {
return;
}
if (this.updateOps.operationId && this.updateOps.operationId !== operationId) {
return;
}
if (requestSeq < this.updateOps.statusAppliedSeq) {
return;
}
this.updateOps.statusAppliedSeq = requestSeq;
this.applyUpdateOperation(payload.operation || {});
if (!this.updateOps.running) {
this.stopUpdatePolling();
if (wasRunning) {
await this.refresh();
await this.refreshUpdateTargets();
await this.refreshRestartTargets();
}
}
} catch (error) {
if (requestEpoch !== this.updateOps.requestEpoch) {
return;
}
if (this.updateOps.operationId && this.updateOps.operationId !== operationId) {
return;
}
const errorMessage = error instanceof Error ? error.message : String(error);
const notFound = errorMessage.includes("update operation not found") || errorMessage.includes("HTTP 404");
if (silent && !notFound) {
return;
}
this.stopUpdatePolling();
this.updateOps.running = false;
if (notFound) {
this.updateOps.status = "failed";
this.updateOps.error = "更新任务状态不存在,可能因为服务重启或旧任务已清理";
this.updateOps.message = "";
this.updateOps.operationId = "";
this.persistUpdateOperationId();
return;
}
if (this.updateOps.status === "queued" || this.updateOps.status === "running") {
this.updateOps.status = "failed";
this.updateOps.message = "更新状态查询失败";
}
this.updateOps.error = errorMessage;
} finally {
if (requestEpoch === this.updateOps.requestEpoch && this.updateOps.statusRequestSeq === requestSeq) {
this.updateOps.statusRequestInFlight = false;
}
}
},
applyRefreshTimer() {
if (this.timer) {
window.clearInterval(this.timer);
}
this.timer = window.setInterval(() => this.refresh(), this.refreshIntervalSeconds * 1000);
},
resetNacosEditor() {
this.nacosConfig.selectedKey = "";
this.nacosConfig.detailError = "";
this.nacosConfig.validationMessage = "";
this.nacosConfig.saveMessage = "";
this.nacosConfig.loadedContent = "";
this.nacosConfig.editor = {
group: "",
dataId: "",
type: "",
appName: "",
description: "",
lastModifiedTime: "",
md5: "",
content: "",
};
},
async readJsonResponse(response) {
const text = await response.text();
if (!text) {
return {};
}
try {
return JSON.parse(text);
} catch (error) {
throw new Error(text);
}
},
handleUnauthorized() {
if (this.auth.redirecting) {
return;
}
this.auth.redirecting = true;
this.stopUpdatePolling();
if (this.timer) {
window.clearInterval(this.timer);
this.timer = null;
}
window.sessionStorage.removeItem(UPDATE_OPERATION_STORAGE_KEY);
window.location.replace(loginRedirectPath());
},
async authFetch(url, options = {}) {
const requestOptions = {
credentials: "same-origin",
...options,
};
if (typeof requestOptions.cache === "undefined") {
requestOptions.cache = "no-store";
}
const actualUrl = typeof url === "string" && url.startsWith("/") ? appPath(url) : url;
return fetch(actualUrl, requestOptions);
},
async requestJson(url, options = {}) {
const response = await this.authFetch(url, options);
const payload = await this.readJsonResponse(response);
if (response.status === 401) {
this.handleUnauthorized();
throw new Error(payload.error || "authentication required");
}
return { response, payload };
},
async refreshSession() {
this.auth.checking = true;
try {
const response = await this.authFetch("/api/auth/session");
const payload = await this.readJsonResponse(response);
if (response.status === 401) {
this.handleUnauthorized();
return false;
}
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
this.auth.username = payload.username || "";
return true;
} finally {
this.auth.checking = false;
}
},
async bootstrap() {
const root = document.getElementById("app");
const bootIndicator = document.getElementById("boot-indicator");
try {
const authenticated = await this.refreshSession();
if (!authenticated) {
return;
}
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
} finally {
if (root) {
root.removeAttribute("v-cloak");
}
if (bootIndicator) {
bootIndicator.hidden = true;
}
}
if (this.error) {
return;
}
await Promise.allSettled([
this.refresh(),
this.refreshNacosConfigs({ preserveSelection: false, reloadSelected: true }),
this.refreshGoConfig(),
this.refreshUpdateTargets(),
this.refreshRestartTargets(),
]);
const lastOperationId = window.sessionStorage.getItem(UPDATE_OPERATION_STORAGE_KEY) || "";
if (!lastOperationId) {
return;
}
await this.fetchUpdateOperationStatus(lastOperationId, { silent: true });
if (this.updateOps.operationId) {
this.startUpdatePolling();
}
},
async logout() {
if (this.auth.loggingOut) {
return;
}
this.auth.loggingOut = true;
try {
await this.authFetch("/api/auth/logout", {
method: "POST",
});
} finally {
this.handleUnauthorized();
}
},
async downloadFile(url, fallbackName) {
const response = await this.authFetch(url);
if (response.status === 401) {
this.handleUnauthorized();
return;
}
if (!response.ok) {
const contentType = response.headers.get("Content-Type") || "";
if (contentType.includes("application/json")) {
const payload = await this.readJsonResponse(response);
throw new Error(payload.error || `HTTP ${response.status}`);
}
throw new Error((await response.text()) || `HTTP ${response.status}`);
}
const blob = await response.blob();
const contentDisposition = response.headers.get("Content-Disposition") || "";
const matched = contentDisposition.match(/filename="?([^";]+)"?/i);
const fileName = matched?.[1] || fallbackName;
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(objectUrl);
},
async downloadServiceLog() {
if (!this.serviceLogDownloadUrl) {
return;
}
this.serviceLog.exporting = true;
this.serviceLog.error = "";
try {
await this.downloadFile(
this.serviceLogDownloadUrl,
`${this.serviceLog.service}-${this.serviceLog.host}-${this.serviceLog.exportLines}.log`,
);
} catch (error) {
this.serviceLog.error = error instanceof Error ? error.message : String(error);
} finally {
this.serviceLog.exporting = false;
}
},
async refresh() {
this.loading = true;
this.error = "";
try {
const { response, payload } = await this.requestJson("/api/monitor/services");
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
this.payload = payload;
this.syncRefreshSettings();
if (!this.selectedServiceDetail && this.allServiceRows.length > 0) {
this.selectedServiceKey = this.allServiceRows[0].key;
}
if (this.selectedServiceKey && !this.allServiceRows.some((row) => row.key === this.selectedServiceKey)) {
this.selectedServiceKey = this.allServiceRows[0]?.key || "";
}
this.syncServiceLogTarget();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
} finally {
this.loading = false;
}
},
async refreshNacosConfigs({ preserveSelection = true, reloadSelected = false } = {}) {
const previousKey = preserveSelection ? this.nacosConfig.selectedKey : "";
this.nacosConfig.loading = true;
this.nacosConfig.error = "";
this.nacosConfig.saveMessage = "";
try {
const { response, payload } = await this.requestJson("/api/nacos/configs");
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
this.nacosConfig.items = Array.isArray(payload.items) ? payload.items : [];
this.nacosConfig.baseUrl = payload.baseUrl || "";
this.nacosConfig.namespaceId = payload.namespaceId || "";
this.nacosConfig.namespaceName = payload.namespaceName || "";
const hasPrevious = previousKey
&& this.nacosConfig.items.some((item) => this.configKey(item.group, item.dataId) === previousKey);
const nextKey = hasPrevious
? previousKey
: (this.nacosConfig.items[0]
? this.configKey(this.nacosConfig.items[0].group, this.nacosConfig.items[0].dataId)
: "");
if (!nextKey) {
this.resetNacosEditor();
return;
}
this.nacosConfig.selectedKey = nextKey;
this.syncConfigExplorerState(nextKey, { force: !preserveSelection || this.collapsedConfigGroups.length === 0 });
const sameAsCurrent = this.configKey(this.nacosConfig.editor.group, this.nacosConfig.editor.dataId) === nextKey;
if (!sameAsCurrent || reloadSelected || !this.nacosConfig.loadedContent) {
await this.openNacosConfigByKey(nextKey);
}
this.recordConfigHistory("nacos", "刷新 Nacos 配置", `${this.nacosConfig.items.length}`, "neutral");
} catch (error) {
this.nacosConfig.error = error instanceof Error ? error.message : String(error);
this.recordConfigHistory("nacos", "刷新 Nacos 配置失败", this.explainError(this.nacosConfig.error), "bad");
} finally {
this.nacosConfig.loading = false;
}
},
async openNacosConfig(item) {
const nextKey = this.configKey(item.group, item.dataId);
this.nacosConfig.selectedKey = nextKey;
await this.openNacosConfigByKey(nextKey);
},
async openNacosConfigByKey(configKey) {
const item = (this.nacosConfig.items || []).find((entry) => this.configKey(entry.group, entry.dataId) === configKey);
if (!item) {
return;
}
this.nacosConfig.detailLoading = true;
this.nacosConfig.detailError = "";
this.nacosConfig.validationMessage = "";
this.nacosConfig.saveMessage = "";
try {
const query = new URLSearchParams({ group: item.group, dataId: item.dataId });
const { response, payload } = await this.requestJson(`/api/nacos/config?${query.toString()}`);
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
this.nacosConfig.loadedContent = typeof payload.content === "string" ? payload.content : "";
this.nacosConfig.editor = {
group: item.group,
dataId: item.dataId,
type: item.type || payload.item?.type || "",
appName: item.appName || "",
description: item.description || "",
lastModifiedTime: item.lastModifiedTime || "",
md5: item.md5 || "",
content: this.nacosConfig.loadedContent,
};
this.syncConfigExplorerState(configKey);
this.scrollSelectedConfigItemIntoView();
this.recordConfigHistory("nacos", "打开配置", `${item.group}/${item.dataId}`, "neutral");
} catch (error) {
this.nacosConfig.detailError = error instanceof Error ? error.message : String(error);
this.recordConfigHistory("nacos", "打开配置失败", this.explainError(this.nacosConfig.detailError), "bad");
} finally {
this.nacosConfig.detailLoading = false;
}
},
async validateNacosConfig() {
if (!this.nacosConfig.editor.dataId) {
return;
}
this.nacosConfig.validating = true;
this.nacosConfig.detailError = "";
this.nacosConfig.validationMessage = "";
try {
const { response, payload } = await this.requestJson("/api/nacos/config/validate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
group: this.nacosConfig.editor.group,
dataId: this.nacosConfig.editor.dataId,
type: this.nacosConfig.editor.type,
content: this.nacosConfig.editor.content,
}),
});
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
if (!payload.ok) {
throw new Error(payload.message || "config validation failed");
}
this.nacosConfig.validationMessage = payload.message || "格式校验通过";
this.recordConfigHistory("nacos", "校验通过", `${this.nacosConfig.editor.group}/${this.nacosConfig.editor.dataId}`, "ok");
} catch (error) {
this.nacosConfig.detailError = error instanceof Error ? error.message : String(error);
this.recordConfigHistory("nacos", "校验失败", this.explainError(this.nacosConfig.detailError), "bad");
} finally {
this.nacosConfig.validating = false;
}
},
async saveNacosConfig() {
if (!this.nacosConfig.editor.group || !this.nacosConfig.editor.dataId) {
return;
}
this.nacosConfig.saving = true;
this.nacosConfig.detailError = "";
this.nacosConfig.saveMessage = "";
try {
const { response, payload } = await this.requestJson("/api/nacos/config", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
group: this.nacosConfig.editor.group,
dataId: this.nacosConfig.editor.dataId,
type: this.nacosConfig.editor.type,
content: this.nacosConfig.editor.content,
}),
});
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
this.nacosConfig.loadedContent = this.nacosConfig.editor.content;
this.nacosConfig.saveMessage = `已保存 ${this.nacosConfig.editor.group}/${this.nacosConfig.editor.dataId}`;
this.recordConfigHistory("nacos", "保存成功", `${this.nacosConfig.editor.group}/${this.nacosConfig.editor.dataId}`, "ok");
await this.refreshNacosConfigs({ preserveSelection: true, reloadSelected: true });
} catch (error) {
this.nacosConfig.detailError = error instanceof Error ? error.message : String(error);
this.recordConfigHistory("nacos", "保存失败", this.explainError(this.nacosConfig.detailError), "bad");
} finally {
this.nacosConfig.saving = false;
}
},
async refreshGoConfig() {
this.goConfig.loading = true;
this.goConfig.error = "";
this.goConfig.saveMessage = "";
try {
const { response, payload } = await this.requestJson("/api/runtime/golang-config");
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
this.goConfig.hosts = Array.isArray(payload.hosts) ? payload.hosts : [];
this.goConfig.diffKeys = Array.isArray(payload.diffKeys) ? payload.diffKeys : [];
this.goConfig.loadedContent = typeof payload.editorContent === "string" ? payload.editorContent : "";
this.goConfig.editorContent = this.goConfig.loadedContent;
this.recordConfigHistory("go", "刷新 Go 配置", `${this.goConfig.hosts.length} 台主机`, "neutral");
} catch (error) {
this.goConfig.error = error instanceof Error ? error.message : String(error);
this.recordConfigHistory("go", "刷新 Go 配置失败", this.explainError(this.goConfig.error), "bad");
} finally {
this.goConfig.loading = false;
}
},
async validateGoConfig() {
this.goConfig.validating = true;
this.goConfig.error = "";
this.goConfig.validationMessage = "";
try {
const { response, payload } = await this.requestJson("/api/runtime/golang-config/validate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ content: this.goConfig.editorContent }),
});
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
if (!payload.ok) {
throw new Error(payload.message || "golang config validation failed");
}
this.goConfig.validationMessage = payload.message || "格式校验通过";
this.recordConfigHistory("go", "校验通过", `${this.goConfig.diffKeys.length} 个差异 key`, "ok");
} catch (error) {
this.goConfig.error = error instanceof Error ? error.message : String(error);
this.recordConfigHistory("go", "校验失败", this.explainError(this.goConfig.error), "bad");
} finally {
this.goConfig.validating = false;
}
},
async saveGoConfig() {
this.goConfig.saving = true;
this.goConfig.error = "";
this.goConfig.saveMessage = "";
try {
const { response, payload } = await this.requestJson("/api/runtime/golang-config", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ content: this.goConfig.editorContent }),
});
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
this.goConfig.loadedContent = this.goConfig.editorContent;
this.goConfig.saveMessage = payload.message || "golang 配置已保存";
this.recordConfigHistory("go", "保存成功", payload.message || "Go 配置已下发", "ok");
await this.refreshGoConfig();
} catch (error) {
this.goConfig.error = error instanceof Error ? error.message : String(error);
this.recordConfigHistory("go", "保存失败", this.explainError(this.goConfig.error), "bad");
} finally {
this.goConfig.saving = false;
}
},
async refreshRestartTargets() {
this.restartOps.loading = true;
this.restartOps.error = "";
try {
const { response, payload } = await this.requestJson("/api/ops/restart-targets");
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
this.restartOps.items = Array.isArray(payload.items) ? payload.items : [];
const available = new Set(this.restartOps.items.map((item) => item.service));
this.restartOps.selectedServices = this.restartOps.selectedServices.filter((service) => available.has(service));
this.recordReleaseHistory("restart", "刷新滚动重启目标", `${this.restartOps.items.length}`, "neutral");
} catch (error) {
this.restartOps.error = error instanceof Error ? error.message : String(error);
this.recordReleaseHistory("restart", "刷新滚动重启目标失败", this.explainError(this.restartOps.error), "bad");
} finally {
this.restartOps.loading = false;
}
},
async refreshUpdateTargets() {
this.updateOps.loading = true;
this.updateOps.error = "";
try {
const { response, payload } = await this.requestJson("/api/ops/update-targets");
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
this.updateOps.items = Array.isArray(payload.items) ? payload.items : [];
this.updateOps.defaults = payload.defaults || this.updateOps.defaults;
this.updateOps.builder = payload.builder || this.updateOps.builder;
if (!this.updateOps.javaGitRef) {
this.updateOps.javaGitRef = this.updateOps.defaults.javaGitRef || "main";
} else if (this.updateOps.javaGitRef === "main" && this.updateOps.defaults.javaGitRef) {
this.updateOps.javaGitRef = this.updateOps.defaults.javaGitRef;
}
if (!this.updateOps.goGitRef) {
this.updateOps.goGitRef = this.updateOps.defaults.goGitRef || "main";
} else if (this.updateOps.goGitRef === "main" && this.updateOps.defaults.goGitRef) {
this.updateOps.goGitRef = this.updateOps.defaults.goGitRef;
}
if (!this.updateOps.adminGitRef) {
this.updateOps.adminGitRef = this.updateOps.defaults.adminGitRef || "main";
} else if (this.updateOps.adminGitRef === "main" && this.updateOps.defaults.adminGitRef) {
this.updateOps.adminGitRef = this.updateOps.defaults.adminGitRef;
}
const available = new Set(this.updateOps.items.map((item) => item.service));
this.updateOps.selectedServices = this.updateOps.selectedServices.filter((service) => available.has(service));
this.recordReleaseHistory("update", "刷新更新目标", `${this.updateOps.items.length}`, "neutral");
} catch (error) {
this.updateOps.error = error instanceof Error ? error.message : String(error);
this.recordReleaseHistory("update", "刷新更新目标失败", this.explainError(this.updateOps.error), "bad");
} finally {
this.updateOps.loading = false;
}
},
toggleUpdateService(serviceName) {
const next = new Set(this.updateOps.selectedServices);
if (next.has(serviceName)) {
next.delete(serviceName);
} else {
next.add(serviceName);
}
this.updateOps.selectedServices = [...next];
},
async runServiceUpdate() {
if (this.updateOps.selectedServices.length === 0) {
return;
}
this.stopUpdatePolling();
this.updateOps.requestEpoch += 1;
this.updateOps.statusRequestInFlight = false;
this.updateOps.statusAppliedSeq = 0;
this.updateOps.running = true;
this.updateOps.error = "";
this.updateOps.message = "已提交更新任务,等待开始执行";
this.updateOps.operationId = "";
this.updateOps.status = "queued";
this.updateOps.stage = "queued";
this.updateOps.startedAt = "";
this.updateOps.finishedAt = "";
this.updateOps.logDroppedCount = 0;
this.updateOps.logs = [];
this.updateOps.result = null;
this.persistUpdateOperationId();
this.recordReleaseHistory("update", "提交服务更新", this.updateOps.selectedServices.join(", "), "warn");
try {
const { response, payload } = await this.requestJson("/api/ops/update-services", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
services: this.updateOps.selectedServices,
javaGitRef: this.updateOps.javaGitRef,
goGitRef: this.updateOps.goGitRef,
adminGitRef: this.updateOps.adminGitRef,
imageTag: this.updateOps.imageTag,
skipMavenBuild: this.updateOps.skipMavenBuild,
}),
});
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
this.applyUpdateOperation(payload.operation || {});
this.startUpdatePolling();
await this.fetchUpdateOperationStatus(this.updateOps.operationId, { silent: true });
} catch (error) {
this.updateOps.error = error instanceof Error ? error.message : String(error);
this.updateOps.running = false;
this.recordReleaseHistory("update", "提交服务更新失败", this.explainError(this.updateOps.error), "bad");
} finally {
this.scrollUpdateLogToBottom();
}
},
toggleRestartService(serviceName) {
const next = new Set(this.restartOps.selectedServices);
if (next.has(serviceName)) {
next.delete(serviceName);
} else {
next.add(serviceName);
}
this.restartOps.selectedServices = [...next];
},
async runRollingRestart() {
if (this.restartOps.selectedServices.length === 0) {
return;
}
this.restartOps.running = true;
this.restartOps.error = "";
this.restartOps.message = "";
try {
const { response, payload } = await this.requestJson("/api/ops/rolling-restart", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ services: this.restartOps.selectedServices }),
});
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
this.restartOps.result = payload;
this.restartOps.message = payload.ok
? (payload.message || "滚动重启完成")
: (payload.error || "滚动重启失败");
this.recordReleaseHistory(
"restart",
payload.ok ? "滚动重启完成" : "滚动重启失败",
(payload.steps || []).map((step) => `${step.service}@${step.host}:${step.status}`).join(" | "),
payload.ok ? "ok" : "bad",
);
await this.refresh();
await this.refreshRestartTargets();
} catch (error) {
this.restartOps.error = error instanceof Error ? error.message : String(error);
this.recordReleaseHistory("restart", "滚动重启失败", this.explainError(this.restartOps.error), "bad");
} finally {
this.restartOps.running = false;
}
},
},
mounted() {
this.bootstrap();
},
beforeUnmount() {
if (this.timer) {
window.clearInterval(this.timer);
}
if (this.copiedTokenTimer) {
window.clearTimeout(this.copiedTokenTimer);
}
this.stopUpdatePolling();
},
}).mount("#app");