2739 lines
92 KiB
JavaScript
2739 lines
92 KiB
JavaScript
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");
|