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");