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: "database", label: "数据库" }, { id: "config", label: "配置" }, { id: "release", label: "发布" }, ]; const DATABASE_ENGINES = [ { id: "mysql", label: "MySQL" }, { id: "mongo", label: "MongoDB" }, ]; 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" }, ]; const MONGO_ADMIN_TABS = [ { id: "browse", label: "浏览" }, { id: "write", label: "写入" }, { id: "aggregate", label: "聚合" }, { id: "indexes", label: "索引" }, ]; const MYSQL_ADMIN_TABS = [ { id: "browse", label: "数据" }, { id: "write", label: "写入" }, { id: "schema", label: "结构" }, { id: "sql", label: "SQL" }, ]; const MYSQL_FILTER_OPERATORS = [ { id: "eq", label: "=" }, { id: "ne", label: "!=" }, { id: "gt", label: ">" }, { id: "ge", label: ">=" }, { id: "lt", label: "<" }, { id: "le", label: "<=" }, { id: "in", label: "IN" }, { id: "not_in", label: "NOT IN" }, { id: "between", label: "BETWEEN" }, { id: "not_between", label: "NOT BETWEEN" }, { id: "like", label: "LIKE" }, { id: "not_like", label: "NOT LIKE" }, { id: "is_null", label: "IS NULL" }, { id: "is_not_null", label: "IS NOT NULL" }, ]; const MYSQL_INDEX_MODES = [ { id: "normal", label: "普通" }, { id: "unique", label: "唯一" }, { id: "fulltext", label: "全文" }, ]; const MYSQL_DEFAULT_MODES = [ { id: "none", label: "无默认值" }, { id: "literal", label: "字面量" }, { id: "null", label: "NULL" }, ]; 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}`)}`); } function createMongoAdminState() { return { loaded: false, loading: false, detailLoading: false, querying: false, error: "", message: "", messageTone: "neutral", keyword: "", activeTab: "browse", pageSizeOptions: [50, 100, 200], tabs: MONGO_ADMIN_TABS, databases: [], selectedDatabase: "", selectedCollection: "", collectionDetail: { updatedAt: "", stats: { count: 0, sizeMb: 0, storageSizeMb: 0, avgObjSizeBytes: 0, indexCount: 0, totalIndexSizeMb: 0, capped: false, wiredTigerUri: "", }, indexes: [], }, query: { filterText: "{}", sortText: JSON.stringify({ _id: -1 }, null, 2), projectionText: "{}", page: 1, pageSize: 100, total: 0, pageCount: 1, items: [], }, editor: { selectedKey: "", idText: "", filterText: "{}", documentText: "{}", upsert: false, saving: false, }, createDocument: { documentText: "{}", ordered: true, saving: false, }, updateOp: { filterText: "{}", updateText: JSON.stringify({ $set: {} }, null, 2), many: false, upsert: false, running: false, }, deleteOp: { filterText: "{}", many: false, running: false, }, aggregate: { pipelineText: "[]", page: 1, pageSize: 100, total: 0, pageCount: 1, items: [], running: false, }, distinct: { field: "", filterText: "{}", values: [], valuesText: "[]", running: false, count: 0, }, indexForm: { keysText: JSON.stringify({ field: 1 }, null, 2), optionsText: "{}", saving: false, dropAllSaving: false, }, collectionOps: { createDatabaseName: "", createName: "", createOptionsText: "{}", renameName: "", renameDropTarget: false, running: false, dropRunning: false, databaseDropRunning: false, }, }; } function createMySqlOverviewState() { return { ok: false, updatedAt: "", info: {}, connections: {}, throughput: {}, replication: { configured: false, role: "", readOnly: false, superReadOnly: false, sourceHost: "", secondsBehindSource: null, ioRunning: false, sqlRunning: false, state: "", error: "", }, summary: { databaseTotal: 0, tableTotal: 0, dataSizeMb: 0, indexSizeMb: 0, totalSizeMb: 0, }, databases: [], error: "", }; } function createMySqlFilterRow() { return { column: "", operator: "eq", value: "", }; } function createMySqlAdminState() { return { loaded: false, loading: false, tablesLoading: false, schemaLoading: false, rowsLoading: false, error: "", message: "", messageTone: "neutral", keyword: "", activeTab: "browse", pageSizeOptions: [50, 100, 200], tabs: MYSQL_ADMIN_TABS, filterOperators: MYSQL_FILTER_OPERATORS, indexModes: MYSQL_INDEX_MODES, defaultModes: MYSQL_DEFAULT_MODES, overview: createMySqlOverviewState(), databases: [], tables: [], selectedDatabase: "", selectedTable: "", schema: { updatedAt: "", tableSummary: {}, columns: [], indexes: [], identity: { mode: "all", columns: [], }, createTable: "", }, rows: { page: 1, pageSize: 100, totalRows: 0, totalPages: 1, keyword: "", orderBy: "", orderDirection: "desc", filters: [createMySqlFilterRow()], columns: [], indexes: [], identity: { mode: "all", columns: [], }, items: [], }, rowEditor: { identityKey: "", identity: null, valuesText: "{}", saving: false, }, insertForm: { valuesText: "{}", saving: false, }, columnForm: { columnName: "", columnType: "varchar(255)", nullable: true, defaultMode: "none", defaultValue: "", comment: "", position: "last", afterColumn: "", saving: false, }, indexForm: { indexName: "", indexMode: "normal", columns: [], saving: false, }, sqlConsole: { text: "", running: false, result: null, }, }; } createApp({ template: APP_TEMPLATE, data() { return { activeView: "overview", databaseEngine: "mysql", densityMode: "compact", detailPanelOpen: false, overviewHealthyHostsOpen: false, viewTabs: VIEW_TABS, databaseEngines: DATABASE_ENGINES, primaryFilters: PRIMARY_FILTERS, releaseSections: RELEASE_SECTIONS, configTabs: CONFIG_TABS, infraTabs: INFRA_TABS, mongoAdminTabs: MONGO_ADMIN_TABS, mySqlAdminTabs: MYSQL_ADMIN_TABS, expandedHosts: [], expandedNacosServices: [], collapsedMongoDatabases: [], 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, mysqlDatabaseTotal: 0, mysqlTableTotal: 0, mysqlOk: 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: [], }, }, mysql: createMySqlOverviewState(), }, 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: [], }, mongoAdmin: createMongoAdminState(), mySqlAdmin: createMySqlAdminState(), 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); }, hasRenderableData() { return ( Boolean(this.payload.updatedAt) || this.allServiceRows.length > 0 || this.allHostRows.length > 0 || (this.payload.nacos?.services || []).length > 0 || (this.payload.mongo?.databases || []).length > 0 || (this.nacosConfig.items || []).length > 0 || (this.goConfig.hosts || []).length > 0 || (this.updateOps.items || []).length > 0 || (this.restartOps.items || []).length > 0 ); }, showLoadingSkeleton() { return this.loading && !this.hasRenderableData && !this.error; }, 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) )); }, mongoAdminKeyword() { return this.mongoAdmin.keyword.trim().toLowerCase(); }, mongoAdminFilteredDatabases() { return (this.mongoAdmin.databases || []) .map((database) => { const collections = Array.isArray(database.collections) ? database.collections : []; if (!this.mongoAdminKeyword) { return { ...database, visibleCollections: collections, }; } const databaseMatched = database.name.toLowerCase().includes(this.mongoAdminKeyword); const visibleCollections = databaseMatched ? collections : collections.filter((collection) => ( [collection.name, collection.type, collection.documentCount, collection.indexCount] .join(" ") .toLowerCase() .includes(this.mongoAdminKeyword) )); if (!databaseMatched && visibleCollections.length === 0) { return null; } return { ...database, visibleCollections, }; }) .filter(Boolean); }, mongoAdminTotalCollectionCount() { return (this.mongoAdmin.databases || []).reduce( (total, database) => total + ((database.collections || []).length || 0), 0, ); }, selectedMongoDatabaseItem() { return (this.mongoAdmin.databases || []).find((database) => database.name === this.mongoAdmin.selectedDatabase) || null; }, selectedMongoCollectionItem() { return (this.selectedMongoDatabaseItem?.collections || []).find( (collection) => collection.name === this.mongoAdmin.selectedCollection, ) || null; }, activeMongoAdminTabLabel() { return this.mongoAdminTabs.find((item) => item.id === this.mongoAdmin.activeTab)?.label || ""; }, mongoAdminCollectionStats() { return this.mongoAdmin.collectionDetail?.stats || { count: 0, sizeMb: 0, storageSizeMb: 0, avgObjSizeBytes: 0, indexCount: 0, totalIndexSizeMb: 0, capped: false, wiredTigerUri: "", }; }, mongoAdminOverviewCards() { const selectedDatabase = this.selectedMongoDatabaseItem; const selectedCollection = this.selectedMongoCollectionItem; const stats = this.mongoAdminCollectionStats; const totalDatabaseCount = this.mongoAdmin.databases.length; const totalCollectionCount = this.mongoAdminTotalCollectionCount; const selectedDatabaseDocumentTotal = (selectedDatabase?.collections || []).reduce( (total, collection) => total + Number(collection.documentCount || 0), 0, ); const selectedDatabaseIndexTotal = (selectedDatabase?.collections || []).reduce( (total, collection) => total + Number(collection.indexCount || 0), 0, ); return [ { key: "database", label: "数据库", value: selectedDatabase ? this.truncateMiddle(selectedDatabase.name, 14, 8) : `${this.formatNumber(totalDatabaseCount)} 个`, meta: `${this.formatNumber(totalDatabaseCount)} 库`, tone: this.mongoAdmin.error ? "bad" : "neutral", }, { key: "collection", label: "集合", value: selectedCollection ? this.truncateMiddle(selectedCollection.name, 16, 8) : `${this.formatNumber(selectedDatabase?.collectionCount || totalCollectionCount)} 个`, meta: selectedCollection ? (selectedCollection.type || "collection") : (selectedDatabase ? selectedDatabase.name : "全部集合"), tone: selectedCollection?.error ? "bad" : "neutral", }, { key: "documents", label: "文档", value: selectedCollection ? this.formatNumber(stats.count) : this.formatNumber(selectedDatabaseDocumentTotal), meta: selectedCollection ? `平均 ${this.formatBytes(stats.avgObjSizeBytes || 0)}` : "当前库", tone: this.mongoAdmin.error ? "bad" : "neutral", }, { key: "storage", label: "容量", value: selectedCollection ? this.formatMb(stats.storageSizeMb || 0) : this.formatMb(selectedDatabase?.sizeOnDiskMb || 0), meta: selectedCollection ? `data ${this.formatMb(stats.sizeMb || 0)}` : "磁盘占用", tone: this.mongoAdmin.error ? "bad" : "neutral", }, { key: "indexes", label: "索引", value: selectedCollection ? this.formatNumber(stats.indexCount) : this.formatNumber(selectedDatabaseIndexTotal), meta: selectedCollection ? `size ${this.formatMb(stats.totalIndexSizeMb || 0)}` : `${this.formatNumber(totalCollectionCount)} 集合`, tone: this.mongoAdmin.error ? "bad" : "neutral", }, ]; }, mongoQueryRangeText() { if (!this.mongoAdmin.query.total || this.mongoAdmin.query.items.length === 0) { return "0 / 0"; } const start = (this.mongoAdmin.query.page - 1) * this.mongoAdmin.query.pageSize + 1; const end = start + this.mongoAdmin.query.items.length - 1; return `${start}-${end} / ${this.mongoAdmin.query.total}`; }, mongoAggregateRangeText() { if (!this.mongoAdmin.aggregate.total || this.mongoAdmin.aggregate.items.length === 0) { return "0 / 0"; } const start = (this.mongoAdmin.aggregate.page - 1) * this.mongoAdmin.aggregate.pageSize + 1; const end = start + this.mongoAdmin.aggregate.items.length - 1; return `${start}-${end} / ${this.mongoAdmin.aggregate.total}`; }, mySqlAdminKeyword() { return this.mySqlAdmin.keyword.trim().toLowerCase(); }, mySqlFilteredDatabases() { return (this.mySqlAdmin.databases || []).filter((database) => { if (!this.mySqlAdminKeyword) { return true; } if ( [ database.name, database.tableTotal, database.rowEstimate, database.totalSizeMb, ] .join(" ") .toLowerCase() .includes(this.mySqlAdminKeyword) ) { return true; } if (database.name !== this.mySqlAdmin.selectedDatabase) { return false; } return (this.mySqlAdmin.tables || []).some((table) => ( [ table.name, table.engine, table.rowEstimate, table.totalSizeMb, ] .join(" ") .toLowerCase() .includes(this.mySqlAdminKeyword) )); }); }, selectedMySqlDatabaseItem() { return (this.mySqlAdmin.databases || []).find((database) => database.name === this.mySqlAdmin.selectedDatabase) || null; }, mySqlVisibleTables() { const tables = this.mySqlAdmin.tables || []; if (!this.mySqlAdminKeyword) { return tables; } const selectedMatched = this.selectedMySqlDatabaseItem && [ this.selectedMySqlDatabaseItem.name, this.selectedMySqlDatabaseItem.tableTotal, this.selectedMySqlDatabaseItem.rowEstimate, this.selectedMySqlDatabaseItem.totalSizeMb, ] .join(" ") .toLowerCase() .includes(this.mySqlAdminKeyword); if (selectedMatched) { return tables; } return tables.filter((table) => ( [ table.name, table.engine, table.rowEstimate, table.totalSizeMb, table.collation, ] .join(" ") .toLowerCase() .includes(this.mySqlAdminKeyword) )); }, selectedMySqlTableItem() { return (this.mySqlAdmin.tables || []).find((table) => table.name === this.mySqlAdmin.selectedTable) || null; }, activeMySqlAdminTabLabel() { return this.mySqlAdminTabs.find((item) => item.id === this.mySqlAdmin.activeTab)?.label || ""; }, activeDatabaseTabs() { return this.databaseEngine === "mongo" ? this.mongoAdminTabs : this.mySqlAdminTabs; }, activeDatabaseTabId: { get() { return this.databaseEngine === "mongo" ? this.mongoAdmin.activeTab : this.mySqlAdmin.activeTab; }, set(value) { if (this.databaseEngine === "mongo") { this.mongoAdmin.activeTab = value; } else { this.mySqlAdmin.activeTab = value; } }, }, activeDatabaseTabLabel() { return this.databaseEngine === "mongo" ? this.activeMongoAdminTabLabel : this.activeMySqlAdminTabLabel; }, databaseKeywordModel: { get() { return this.databaseEngine === "mongo" ? this.mongoAdmin.keyword : this.mySqlAdmin.keyword; }, set(value) { if (this.databaseEngine === "mongo") { this.mongoAdmin.keyword = value; } else { this.mySqlAdmin.keyword = value; } }, }, databaseContextLabel() { return this.databaseEngine === "mongo" ? "MongoDB / 浏览 / 写入 / 聚合 / 索引" : "MySQL / 数据 / 写入 / 结构 / SQL"; }, databaseSearchPlaceholder() { return this.databaseEngine === "mongo" ? "搜索数据库 / 集合" : "搜索数据库 / 表"; }, databaseTreeLoading() { return this.databaseEngine === "mongo" ? this.mongoAdmin.loading : this.mySqlAdmin.loading; }, databaseRefreshBusy() { if (this.databaseEngine === "mongo") { return this.mongoAdmin.loading || this.mongoAdmin.detailLoading || this.mongoAdmin.querying || this.mongoAdmin.aggregate.running || this.mongoAdmin.distinct.running || this.mongoAdmin.editor.saving || this.mongoAdmin.createDocument.saving || this.mongoAdmin.updateOp.running || this.mongoAdmin.deleteOp.running || this.mongoAdmin.indexForm.saving || this.mongoAdmin.indexForm.dropAllSaving || this.mongoAdmin.collectionOps.running || this.mongoAdmin.collectionOps.dropRunning || this.mongoAdmin.collectionOps.databaseDropRunning; } return this.mySqlAdmin.loading || this.mySqlAdmin.tablesLoading || this.mySqlAdmin.schemaLoading || this.mySqlAdmin.rowsLoading || this.mySqlAdmin.sqlConsole.running || this.mySqlAdmin.rowEditor.saving || this.mySqlAdmin.insertForm.saving || this.mySqlAdmin.columnForm.saving || this.mySqlAdmin.indexForm.saving; }, databaseHasSelection() { return this.databaseEngine === "mongo" ? Boolean(this.mongoAdmin.selectedDatabase) : Boolean(this.mySqlAdmin.selectedDatabase); }, databaseRefreshLabel() { if (this.databaseEngine === "mongo") { if (!this.selectedMongoDatabaseItem) { return "刷新库"; } if (!this.selectedMongoCollectionItem) { return "刷新集合"; } return this.mongoAdmin.activeTab === "aggregate" ? "刷新聚合" : "刷新结果"; } return this.selectedMySqlTableItem ? "刷新结果" : "刷新库"; }, mySqlRowsRangeText() { if (!this.mySqlAdmin.rows.totalRows || this.mySqlAdmin.rows.items.length === 0) { return "0 / 0"; } const start = (this.mySqlAdmin.rows.page - 1) * this.mySqlAdmin.rows.pageSize + 1; const end = start + this.mySqlAdmin.rows.items.length - 1; return `${start}-${end} / ${this.mySqlAdmin.rows.totalRows}`; }, mySqlOverviewCards() { const overview = this.mySqlAdmin.overview || {}; const info = overview.info || {}; const connections = overview.connections || {}; const throughput = overview.throughput || {}; const summary = overview.summary || {}; const replication = overview.replication || {}; return [ { key: "version", label: "版本", value: info.version || "-", meta: info.versionComment || "-", tone: overview.ok ? "ok" : "bad", }, { key: "connections", label: "连接", value: `${this.formatNumber(connections.current)} / ${this.formatNumber(connections.max)}`, meta: `运行 ${this.formatNumber(connections.running)}`, tone: overview.ok ? "neutral" : "bad", }, { key: "throughput", label: "QPS / TPS", value: `${throughput.qps ?? 0} / ${throughput.tps ?? 0}`, meta: `采样 ${throughput.sampleSeconds ?? 0}s`, tone: overview.ok ? "neutral" : "bad", }, { key: "storage", label: "容量", value: `${this.formatMb(summary.totalSizeMb || 0)}`, meta: `${this.formatNumber(summary.databaseTotal || 0)} 库 / ${this.formatNumber(summary.tableTotal || 0)} 表`, tone: overview.ok ? "neutral" : "bad", }, { key: "replication", label: "主从", value: this.mySqlReplicationLabel(replication), meta: this.mySqlReplicationMeta(replication), tone: this.mySqlReplicationTone(replication), }, { key: "uptime", label: "运行时长", value: this.formatDuration(info.uptimeSeconds), meta: info.serverId ? `serverId ${info.serverId}` : "-", tone: overview.ok ? "neutral" : "bad", }, ]; }, mySqlSqlShortcuts() { const tableName = this.selectedMySqlTableItem?.name || ""; const quotedTableName = tableName ? this.quoteMySqlIdentifier(tableName) : ""; const quotedNewTableName = this.quoteMySqlIdentifier(tableName ? `${tableName}_new` : "new_table"); const selectedDatabase = this.mySqlAdmin.selectedDatabase || ""; return [ { id: "show-databases", label: "SHOW DATABASES", sql: "SHOW DATABASES", disabled: false, }, { id: "create-database-template", label: "CREATE DB", sql: "CREATE DATABASE `new_database` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci", disabled: false, }, { id: "drop-database-template", label: "DROP DB", sql: selectedDatabase ? `DROP DATABASE ${this.quoteMySqlIdentifier(selectedDatabase)}` : "", disabled: !selectedDatabase, }, { id: "show-tables", label: "SHOW TABLES", sql: "SHOW TABLES", disabled: !this.mySqlAdmin.selectedDatabase, }, { id: "show-create-database", label: "SHOW CREATE DB", sql: selectedDatabase ? `SHOW CREATE DATABASE ${this.quoteMySqlIdentifier(selectedDatabase)}` : "", disabled: !selectedDatabase, }, { id: "describe", label: "DESC", sql: quotedTableName ? `DESC ${quotedTableName}` : "", disabled: !quotedTableName, }, { id: "count", label: "COUNT", sql: quotedTableName ? `SELECT COUNT(*) AS total FROM ${quotedTableName}` : "", disabled: !quotedTableName, }, { id: "sample", label: "SELECT 100", sql: quotedTableName ? `SELECT * FROM ${quotedTableName} LIMIT 100` : "", disabled: !quotedTableName, }, { id: "create", label: "SHOW CREATE", sql: quotedTableName ? `SHOW CREATE TABLE ${quotedTableName}` : "", disabled: !quotedTableName, }, { id: "status", label: "TABLE STATUS", sql: tableName ? `SHOW TABLE STATUS LIKE '${this.escapeMySqlString(tableName)}'` : "", disabled: !tableName, }, { id: "indexes", label: "SHOW INDEX", sql: quotedTableName ? `SHOW INDEX FROM ${quotedTableName}` : "", disabled: !quotedTableName, }, { id: "show-columns", label: "SHOW COLUMNS", sql: quotedTableName ? `SHOW FULL COLUMNS FROM ${quotedTableName}` : "", disabled: !quotedTableName, }, { id: "explain", label: "EXPLAIN", sql: quotedTableName ? `EXPLAIN SELECT * FROM ${quotedTableName} LIMIT 100` : "", disabled: !quotedTableName, }, { id: "processlist", label: "PROCESSLIST", sql: "SHOW FULL PROCESSLIST", disabled: false, }, { id: "replication", label: "READ ONLY", sql: "SHOW GLOBAL VARIABLES LIKE 'read_only'", disabled: false, }, { id: "insert-template", label: "INSERT 模板", sql: quotedTableName ? `INSERT INTO ${quotedTableName} () VALUES ()` : "", disabled: !quotedTableName, }, { id: "update-template", label: "UPDATE 模板", sql: quotedTableName ? `UPDATE ${quotedTableName} SET WHERE LIMIT 1` : "", disabled: !quotedTableName, }, { id: "delete-template", label: "DELETE 模板", sql: quotedTableName ? `DELETE FROM ${quotedTableName} WHERE LIMIT 1` : "", disabled: !quotedTableName, }, { id: "add-column-template", label: "ADD COLUMN", sql: quotedTableName ? `ALTER TABLE ${quotedTableName} ADD COLUMN \`new_column\` VARCHAR(255) NULL` : "", disabled: !quotedTableName, }, { id: "add-index-template", label: "CREATE INDEX", sql: quotedTableName ? `CREATE INDEX \`idx_${tableName || "table"}_column\` ON ${quotedTableName} (\`column\`)` : "", disabled: !quotedTableName, }, { id: "create-table-template", label: "CREATE TABLE", sql: `CREATE TABLE ${quotedNewTableName} (\n \`id\` BIGINT NOT NULL AUTO_INCREMENT,\n PRIMARY KEY (\`id\`)\n)`, disabled: false, }, { id: "rename-table-template", label: "RENAME TABLE", sql: quotedTableName ? `RENAME TABLE ${quotedTableName} TO ${quotedNewTableName}` : "", disabled: !quotedTableName, }, { id: "truncate-template", label: "TRUNCATE", sql: quotedTableName ? `TRUNCATE TABLE ${quotedTableName}` : "", disabled: !quotedTableName, }, { id: "drop-table-template", label: "DROP TABLE", sql: quotedTableName ? `DROP TABLE ${quotedTableName}` : "", disabled: !quotedTableName, }, { id: "analyze-table-template", label: "ANALYZE TABLE", sql: quotedTableName ? `ANALYZE TABLE ${quotedTableName}` : "", disabled: !quotedTableName, }, { id: "optimize-table-template", label: "OPTIMIZE TABLE", sql: quotedTableName ? `OPTIMIZE TABLE ${quotedTableName}` : "", disabled: !quotedTableName, }, { id: "use-database", label: "USE", sql: selectedDatabase ? `USE ${this.quoteMySqlIdentifier(selectedDatabase)}` : "", disabled: !selectedDatabase, }, ]; }, 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 || ""; }, configContextLabel() { if (this.configTab === "nacos") { return `Nacos / ${this.nacosConfig.namespaceName || "default"} / ${this.filteredNacosConfigs.length} 项`; } if (this.configTab === "go") { return `Go Env / ${this.goConfig.hosts.length} 台 / ${this.goConfig.diffKeys.length} 差异`; } return `历史版本 / ${this.configHistoryRows.length} 条`; }, 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 mysqlUnavailable = !(this.payload.mysql && this.payload.mysql.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", visible: serviceDownTotal > 0, }, { key: "instance-down", label: "实例", value: `${this.summary.down || 0}/${this.summary.total || 0}`, suffix: "异常", tone: (this.summary.down || 0) > 0 ? "bad" : "ok", visible: (this.summary.down || 0) > 0, }, { key: "host-alert", label: "主机", value: `${hostAlertTotal}/${this.summary.hostTotal || 0}`, suffix: "异常", tone: hostAlertTotal > 0 ? "warn" : "ok", visible: hostAlertTotal > 0, }, { key: "nacos", label: "Nacos", value: nacosUnavailable ? "ERR" : String(this.summary.nacosUnhealthyInstanceTotal || 0), suffix: nacosUnavailable ? "" : "异常", tone: nacosUnavailable || (this.summary.nacosUnhealthyInstanceTotal || 0) > 0 ? "bad" : "ok", visible: nacosUnavailable || (this.summary.nacosUnhealthyInstanceTotal || 0) > 0, }, { key: "mongo", label: "Mongo", value: mongoUnavailable ? "ERR" : String(this.summary.mongoDatabaseTotal || 0), suffix: mongoUnavailable ? "" : "库", tone: mongoUnavailable ? "bad" : "ok", visible: mongoUnavailable, }, { key: "mysql", label: "MySQL", value: mysqlUnavailable ? "ERR" : String(this.summary.mysqlDatabaseTotal || 0), suffix: mysqlUnavailable ? "" : "库", tone: mysqlUnavailable ? "bad" : "ok", visible: mysqlUnavailable, }, { key: "tat", label: "TAT", value: `${tatOk}/${tatTotal}`, suffix: "正常", tone: (this.summary.hostMetricDown || 0) > 0 ? "warn" : "ok", visible: (this.summary.hostMetricDown || 0) > 0, }, ].filter((card) => card.visible); }, overviewHealthyCards() { const cards = []; if (this.overviewQuietHosts.length > 0) { cards.push({ key: "hosts", label: "业务主机", value: `${this.overviewQuietHosts.length} 台`, meta: "正常", tone: "ok", }); } if (this.payload.nacos && this.payload.nacos.ok && (this.summary.nacosUnhealthyInstanceTotal || 0) === 0) { cards.push({ key: "nacos", label: "Nacos", value: "连接正常", meta: `${this.payload.nacos.summary.serviceTotal || 0} 服务`, tone: "ok", }); } if (this.payload.mongo && this.payload.mongo.ok) { cards.push({ key: "mongo", label: "Mongo", value: `${this.summary.mongoDatabaseTotal || 0} 库`, meta: "连接正常", tone: "ok", }); } if (this.payload.mysql && this.payload.mysql.ok) { cards.push({ key: "mysql", label: "MySQL", value: `${this.summary.mysqlDatabaseTotal || 0} 库`, meta: "连接正常", tone: "ok", }); } if ((this.summary.hostMetricDown || 0) === 0 && (this.summary.hostTotal || 0) > 0) { cards.push({ key: "tat", label: "TAT", value: `${this.summary.hostMetricOk || 0}/${this.summary.hostTotal || 0}`, meta: "采集正常", tone: "ok", }); } return cards; }, overviewHealthyInfraCards() { return this.overviewHealthyCards.filter((card) => card.key !== "hosts"); }, overviewHealthyHostGroups() { const groups = new Map(); this.overviewQuietHosts.forEach((host) => { if (!groups.has(host.group)) { groups.set(host.group, { key: host.group, label: host.groupLabel, hosts: [], }); } groups.get(host.group).hosts.push(host.host); }); return [...groups.values()] .map((group) => ({ ...group, count: group.hosts.length, summary: group.hosts.join(" / "), })) .sort((left, right) => this.groupRank(left.key) - this.groupRank(right.key)); }, overviewHealthyStateCount() { return this.overviewHealthyInfraCards.length + this.overviewHealthyHostGroups.length; }, overviewQuietServiceTotal() { return this.overviewQuietHosts.reduce((total, host) => total + (host.serviceTotal || 0), 0); }, overviewDetailSubtitle() { if (this.showSelectedServiceDetail) { return ""; } if (this.activeView === "overview") { if (this.alertFeed.length > 0) { return `${this.alertFeed.length} 条异常 · ${this.overviewHealthyStateCount} 组正常状态`; } if (this.overviewHealthyStateCount > 0) { return `${this.overviewHealthyStateCount} 组正常状态`; } return "当前没有可展示的状态"; } return `${this.alertFeed.length} 条异常`; }, overviewHostPanelSummary() { if (this.overviewAlertHosts.length > 0) { return `${this.overviewAlertHosts.length} 台异常业务主机 · ${this.overviewQuietHosts.length} 台正常主机`; } return `${this.overviewQuietHosts.length} 台业务主机稳定`; }, showSelectedServiceDetail() { if (!this.selectedServiceDetail) { return false; } if (this.activeView === "services") { return true; } return ( this.selectedServiceDetail.downCount > 0 || Boolean(this.focusedHostKey) || this.filter === "down" || Boolean(this.normalizedKeyword) ); }, 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 || "连接异常", }); } if (this.payload.mysql.error || !(this.payload.mysql && this.payload.mysql.ok)) { alerts.push({ key: "mysql-error", type: "database", view: "database", tone: "bad", title: "MySQL", text: this.payload.mysql.error || "连接异常", }); } return alerts.slice(0, 5); }, restartTargetMap() { 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 === "database") { this.ensureDatabaseWorkbenchLoaded(); } if (nextValue === "config") { this.configTab = this.configTab || "nacos"; } if (nextValue === "infra") { this.infraTab = this.infraTab || "overview"; } }, selectedServiceKey() { this.syncServiceLogTarget(); }, databaseEngine() { if (this.activeView === "database") { this.ensureDatabaseWorkbenchLoaded(); } }, }, methods: { async ensureDatabaseWorkbenchLoaded(engine = this.databaseEngine) { if (engine === "mongo") { if (!this.mongoAdmin.loaded && !this.mongoAdmin.loading) { await this.refreshMongoAdminOverview(); } return; } if (!this.mySqlAdmin.loaded && !this.mySqlAdmin.loading) { await this.loadMySqlWorkbench(); } }, async switchDatabaseEngine(engine) { if (!engine || this.databaseEngine === engine) { return; } this.databaseEngine = engine; if (this.activeView === "database") { await this.ensureDatabaseWorkbenchLoaded(engine); } }, async refreshDatabaseTree() { if (this.databaseEngine === "mongo") { await this.refreshMongoAdminOverview({ preserveSelection: true }); return; } await this.refreshMySqlDatabases({ preserveSelection: true }); }, async refreshDatabaseCurrent() { if (this.databaseEngine === "mongo") { await this.refreshMongoAdminCurrent(); return; } await this.refreshMySqlCurrent(); }, 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(" · "); }, matrixSectionDownCount(section) { return (section?.rows || []).reduce((total, row) => total + (row.downCount || 0), 0); }, 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), }, ]; }, hostHasMetricData(host) { const metrics = host?.metrics || {}; return ["cpuPercent", "memoryPercent", "diskPercent"].some((key) => metrics[key] != null && !Number.isNaN(Number(metrics[key]))); }, hostMetricStatusLabel(host) { if (host?.metrics?.error) { return "采集异常"; } return "暂无指标"; }, 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.applyMySqlOverviewToPayload(payload.mysql || createMySqlOverviewState()); 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; } }, applyMySqlOverviewToPayload(overview) { const nextOverview = overview && typeof overview === "object" ? overview : createMySqlOverviewState(); this.payload.mysql = nextOverview; this.payload.summary.mysqlDatabaseTotal = Number(nextOverview.summary?.databaseTotal || 0); this.payload.summary.mysqlTableTotal = Number(nextOverview.summary?.tableTotal || 0); this.payload.summary.mysqlOk = nextOverview.ok ? 1 : 0; this.mySqlAdmin.overview = nextOverview; }, setMySqlAdminMessage(message, tone = "neutral") { this.mySqlAdmin.message = message || ""; this.mySqlAdmin.messageTone = tone || "neutral"; }, clearMySqlSchemaState() { this.mySqlAdmin.schema = { updatedAt: "", tableSummary: {}, columns: [], indexes: [], identity: { mode: "all", columns: [], }, createTable: "", }; this.mySqlAdmin.indexForm.columns = []; }, clearMySqlRowsState() { this.mySqlAdmin.rows.totalRows = 0; this.mySqlAdmin.rows.totalPages = 1; this.mySqlAdmin.rows.columns = []; this.mySqlAdmin.rows.indexes = []; this.mySqlAdmin.rows.identity = { mode: "all", columns: [], }; this.mySqlAdmin.rows.items = []; }, clearMySqlRowEditor() { this.mySqlAdmin.rowEditor.identityKey = ""; this.mySqlAdmin.rowEditor.identity = null; this.mySqlAdmin.rowEditor.valuesText = "{}"; }, mySqlFilterNeedsValue(operator) { return !["is_null", "is_not_null"].includes(String(operator || "").trim().toLowerCase()); }, parseMySqlScalarValue(value) { const text = String(value ?? "").trim(); if (!text.length) { return ""; } if (text === "null") { return null; } if (text === "true") { return true; } if (text === "false") { return false; } if (/^-?\d+(\.\d+)?$/.test(text)) { return Number(text); } try { return JSON.parse(text); } catch (_error) { return text; } }, parseMySqlObjectText(text, label = "JSON") { const raw = String(text || "").trim(); if (!raw) { return {}; } let parsed; try { parsed = JSON.parse(raw); } catch (error) { throw new Error(`${label} 解析失败`); } if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") { throw new Error(`${label} 需要 JSON 对象`); } return parsed; }, stringifyMySqlJson(value) { return JSON.stringify(value ?? {}, null, 2); }, quoteMySqlIdentifier(name) { return `\`${String(name || "").replaceAll("`", "``")}\``; }, escapeMySqlString(value) { return String(value || "").replaceAll("\\", "\\\\").replaceAll("'", "\\'"); }, mySqlRowKey(row) { return JSON.stringify(row?.identity || {}); }, mySqlCellText(value) { if (value == null) { return "NULL"; } if (typeof value === "object") { return JSON.stringify(value); } return String(value); }, mySqlCellPreview(value, limit = 88) { const text = this.mySqlCellText(value).replace(/\s+/g, " ").trim(); if (!text) { return "-"; } return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`; }, mySqlReplicationLabel(replication) { if (!replication || typeof replication !== "object") { return "-"; } if (replication.configured) { return replication.role === "replica" ? "从库" : "主库"; } return replication.readOnly || replication.superReadOnly ? "只读" : "单节点"; }, mySqlReplicationMeta(replication) { if (!replication || typeof replication !== "object") { return "-"; } const parts = []; if (replication.configured && replication.sourceHost) { parts.push(replication.sourceHost); } if (replication.secondsBehindSource != null) { parts.push(`延迟 ${replication.secondsBehindSource}s`); } if (replication.readOnly || replication.superReadOnly) { parts.push("只读"); } if (!parts.length && replication.state) { parts.push(replication.state); } return parts.join(" / ") || "-"; }, mySqlReplicationTone(replication) { if (!replication || typeof replication !== "object") { return "neutral"; } if (replication.error) { return "bad"; } if (replication.configured) { return replication.ioRunning && replication.sqlRunning ? "ok" : "warn"; } if (replication.readOnly || replication.superReadOnly) { return "warn"; } return "neutral"; }, mySqlRowIdentityText(identity) { if (!identity || !Array.isArray(identity.columns) || identity.columns.length === 0) { return "-"; } return identity.columns.map((column) => { const rawValue = identity.values?.[column]; return `${column}=${this.mySqlCellPreview(rawValue, 32)}`; }).join(" / "); }, isMySqlRowSelected(row) { return this.mySqlAdmin.rowEditor.identityKey && this.mySqlAdmin.rowEditor.identityKey === this.mySqlRowKey(row); }, mySqlSortLabel(columnName) { if (!columnName || this.mySqlAdmin.rows.orderBy !== columnName) { return ""; } return this.mySqlAdmin.rows.orderDirection === "asc" ? "ASC" : "DESC"; }, buildMySqlInsertValues(seedValues = null) { const seeded = seedValues && typeof seedValues === "object" && !Array.isArray(seedValues) ? seedValues : {}; const values = {}; (this.mySqlAdmin.schema.columns || []).forEach((column) => { const extra = String(column.extra || "").trim().toLowerCase(); if (extra.includes("generated") || extra.includes("auto_increment")) { return; } if (Object.prototype.hasOwnProperty.call(seeded, column.name)) { values[column.name] = seeded[column.name]; return; } if (column.default != null && column.default !== "") { values[column.name] = column.default; return; } values[column.name] = column.nullable ? null : ""; }); return values; }, fillMySqlInsertTemplate() { this.mySqlAdmin.insertForm.valuesText = this.stringifyMySqlJson(this.buildMySqlInsertValues()); this.mySqlAdmin.activeTab = "write"; this.setMySqlAdminMessage("已生成新增模板", "neutral"); }, copyMySqlRowToInsert(row = null) { try { const sourceValues = row?.values || (this.mySqlAdmin.rowEditor.valuesText ? this.parseMySqlObjectText(this.mySqlAdmin.rowEditor.valuesText, "编辑行") : {}); this.mySqlAdmin.insertForm.valuesText = this.stringifyMySqlJson(this.buildMySqlInsertValues(sourceValues)); this.mySqlAdmin.activeTab = "write"; this.setMySqlAdminMessage("已复制为新增模板", "neutral"); } catch (error) { this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); } }, async toggleMySqlSort(columnName) { const actualColumn = String(columnName || "").trim(); if (!actualColumn) { return; } if (this.mySqlAdmin.rows.orderBy === actualColumn) { this.mySqlAdmin.rows.orderDirection = this.mySqlAdmin.rows.orderDirection === "asc" ? "desc" : "asc"; } else { this.mySqlAdmin.rows.orderBy = actualColumn; this.mySqlAdmin.rows.orderDirection = "asc"; } await this.refreshMySqlRows({ resetPage: true, silent: true }); }, addMySqlFilterRow() { this.mySqlAdmin.rows.filters.push(createMySqlFilterRow()); }, removeMySqlFilterRow(index) { const next = [...this.mySqlAdmin.rows.filters]; next.splice(index, 1); this.mySqlAdmin.rows.filters = next.length > 0 ? next : [createMySqlFilterRow()]; }, buildMySqlFiltersPayload() { return (this.mySqlAdmin.rows.filters || []) .filter((item) => String(item.column || "").trim()) .map((item) => { const payload = { column: item.column, operator: item.operator || "eq", }; if (this.mySqlFilterNeedsValue(item.operator)) { payload.value = this.parseMySqlScalarValue(item.value); } return payload; }); }, async loadMySqlWorkbench(options = {}) { await this.refreshMySqlDatabases(options); }, async refreshMySqlOverview({ silent = false } = {}) { try { const { response, payload } = await this.requestJson("/api/mysql/overview"); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.applyMySqlOverviewToPayload(payload); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.applyMySqlOverviewToPayload({ ...createMySqlOverviewState(), updatedAt: new Date().toISOString(), error: message, }); if (!silent) { this.mySqlAdmin.error = message; } } }, async refreshMySqlDatabases({ preserveSelection = true, silent = false } = {}) { this.mySqlAdmin.loading = true; if (!silent) { this.mySqlAdmin.error = ""; } const previousDatabase = preserveSelection ? this.mySqlAdmin.selectedDatabase : ""; const previousTable = preserveSelection ? this.mySqlAdmin.selectedTable : ""; try { await this.refreshMySqlOverview({ silent: true }); const { response, payload } = await this.requestJson("/api/mysql/databases"); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.mySqlAdmin.databases = Array.isArray(payload.items) ? payload.items : []; this.mySqlAdmin.loaded = true; const nextDatabase = previousDatabase && this.mySqlAdmin.databases.some((database) => database.name === previousDatabase) ? previousDatabase : (payload.selectedDatabase || this.mySqlAdmin.databases[0]?.name || ""); if (!nextDatabase) { this.mySqlAdmin.selectedDatabase = ""; this.mySqlAdmin.selectedTable = ""; this.mySqlAdmin.tables = []; this.clearMySqlSchemaState(); this.clearMySqlRowsState(); this.clearMySqlRowEditor(); if (!silent) { this.setMySqlAdminMessage("当前没有可操作的数据库", "neutral"); } return; } await this.selectMySqlDatabase(nextDatabase, { preserveTable: preserveSelection, preferredTable: previousTable, silent: true, }); if (!silent) { this.setMySqlAdminMessage(`已加载 ${this.mySqlAdmin.databases.length} 个数据库`, "neutral"); } } catch (error) { this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mySqlAdmin.loading = false; } }, async selectMySqlDatabase(databaseName, { preserveTable = true, preferredTable = "", silent = false } = {}) { const actualDatabase = String(databaseName || "").trim(); if (!actualDatabase) { return; } this.mySqlAdmin.selectedDatabase = actualDatabase; await this.refreshMySqlTables({ databaseName: actualDatabase, preserveTable, preferredTable, silent, }); }, async refreshMySqlTables({ databaseName = this.mySqlAdmin.selectedDatabase, preserveTable = true, preferredTable = "", silent = false, loadRows = true, } = {}) { const actualDatabase = String(databaseName || "").trim(); if (!actualDatabase) { this.mySqlAdmin.selectedDatabase = ""; this.mySqlAdmin.selectedTable = ""; this.mySqlAdmin.tables = []; this.clearMySqlSchemaState(); this.clearMySqlRowsState(); this.clearMySqlRowEditor(); return; } this.mySqlAdmin.tablesLoading = true; if (!silent) { this.mySqlAdmin.error = ""; } try { const query = new URLSearchParams({ database: actualDatabase }); const { response, payload } = await this.requestJson(`/api/mysql/tables?${query.toString()}`); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.mySqlAdmin.tables = Array.isArray(payload.items) ? payload.items : []; this.mySqlAdmin.selectedDatabase = payload.database || actualDatabase; const currentTable = preserveTable ? (preferredTable || this.mySqlAdmin.selectedTable) : ""; const nextTable = currentTable && this.mySqlAdmin.tables.some((table) => table.name === currentTable) ? currentTable : (payload.selectedTable || this.mySqlAdmin.tables[0]?.name || ""); if (!nextTable) { this.mySqlAdmin.selectedTable = ""; this.clearMySqlSchemaState(); this.clearMySqlRowsState(); this.clearMySqlRowEditor(); return; } if (loadRows) { await this.selectMySqlTable(this.mySqlAdmin.selectedDatabase, nextTable, { silent: true }); return; } this.mySqlAdmin.selectedTable = nextTable; } catch (error) { this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mySqlAdmin.tablesLoading = false; } }, async selectMySqlTable(databaseName, tableName, { silent = false } = {}) { const actualDatabase = String(databaseName || "").trim(); const actualTable = String(tableName || "").trim(); if (!actualDatabase || !actualTable) { return; } this.mySqlAdmin.selectedDatabase = actualDatabase; this.mySqlAdmin.selectedTable = actualTable; this.clearMySqlRowEditor(); await this.refreshMySqlSchema({ silent: true }); await this.refreshMySqlRows({ resetPage: true, silent: true }); if (!silent) { this.setMySqlAdminMessage(`已切换到 ${actualTable}`, "neutral"); } }, async refreshMySqlSchema({ silent = false } = {}) { if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable) { return; } this.mySqlAdmin.schemaLoading = true; if (!silent) { this.mySqlAdmin.error = ""; } try { const query = new URLSearchParams({ database: this.mySqlAdmin.selectedDatabase, table: this.mySqlAdmin.selectedTable, }); const { response, payload } = await this.requestJson(`/api/mysql/table-schema?${query.toString()}`); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.mySqlAdmin.schema = { updatedAt: payload.updatedAt || "", tableSummary: payload.tableSummary || {}, columns: Array.isArray(payload.columns) ? payload.columns : [], indexes: Array.isArray(payload.indexes) ? payload.indexes : [], identity: payload.identity || { mode: "all", columns: [] }, createTable: payload.createTable || "", }; const availableColumns = new Set(this.mySqlAdmin.schema.columns.map((column) => column.name)); this.mySqlAdmin.indexForm.columns = (this.mySqlAdmin.indexForm.columns || []) .filter((columnName) => availableColumns.has(columnName)); this.mySqlAdmin.rows.filters = (this.mySqlAdmin.rows.filters || []) .map((item) => (availableColumns.has(item.column) || !item.column ? item : createMySqlFilterRow())); if (!this.mySqlAdmin.rows.filters.length) { this.mySqlAdmin.rows.filters = [createMySqlFilterRow()]; } if (!availableColumns.has(this.mySqlAdmin.rows.orderBy)) { this.mySqlAdmin.rows.orderBy = payload.identity?.columns?.[0] || this.mySqlAdmin.schema.columns[0]?.name || ""; } if (!availableColumns.has(this.mySqlAdmin.columnForm.afterColumn)) { this.mySqlAdmin.columnForm.afterColumn = this.mySqlAdmin.schema.columns.slice(-1)[0]?.name || ""; } } catch (error) { this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mySqlAdmin.schemaLoading = false; } }, async refreshMySqlRows({ resetPage = false, silent = false } = {}) { if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable) { return; } if (resetPage) { this.mySqlAdmin.rows.page = 1; } this.mySqlAdmin.rowsLoading = true; if (!silent) { this.mySqlAdmin.error = ""; } try { const { response, payload } = await this.requestJson("/api/mysql/rows/query", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mySqlAdmin.selectedDatabase, table: this.mySqlAdmin.selectedTable, page: this.mySqlAdmin.rows.page, pageSize: this.mySqlAdmin.rows.pageSize, keyword: this.mySqlAdmin.rows.keyword, orderBy: this.mySqlAdmin.rows.orderBy, orderDirection: this.mySqlAdmin.rows.orderDirection, filters: this.buildMySqlFiltersPayload(), }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.mySqlAdmin.rows.page = Number(payload.page || this.mySqlAdmin.rows.page); this.mySqlAdmin.rows.pageSize = Number(payload.pageSize || this.mySqlAdmin.rows.pageSize); this.mySqlAdmin.rows.totalRows = Number(payload.totalRows || 0); this.mySqlAdmin.rows.totalPages = Number(payload.totalPages || 1); this.mySqlAdmin.rows.orderBy = payload.orderBy || this.mySqlAdmin.rows.orderBy; this.mySqlAdmin.rows.orderDirection = payload.orderDirection || this.mySqlAdmin.rows.orderDirection; this.mySqlAdmin.rows.columns = Array.isArray(payload.columns) ? payload.columns : []; this.mySqlAdmin.rows.indexes = Array.isArray(payload.indexes) ? payload.indexes : []; this.mySqlAdmin.rows.identity = payload.identity || { mode: "all", columns: [] }; this.mySqlAdmin.rows.items = Array.isArray(payload.rows) ? payload.rows : []; if ( this.mySqlAdmin.rowEditor.identityKey && !this.mySqlAdmin.rows.items.some((item) => this.mySqlRowKey(item) === this.mySqlAdmin.rowEditor.identityKey) ) { this.clearMySqlRowEditor(); } if (!silent) { this.setMySqlAdminMessage(`已加载 ${this.mySqlAdmin.rows.items.length} 行`, "neutral"); } } catch (error) { this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mySqlAdmin.rowsLoading = false; } }, resetMySqlRowQuery() { this.mySqlAdmin.rows.keyword = ""; this.mySqlAdmin.rows.page = 1; this.mySqlAdmin.rows.pageSize = 100; this.mySqlAdmin.rows.filters = [createMySqlFilterRow()]; this.mySqlAdmin.rows.orderBy = this.mySqlAdmin.schema.identity?.columns?.[0] || this.mySqlAdmin.schema.columns[0]?.name || ""; this.mySqlAdmin.rows.orderDirection = "desc"; }, async changeMySqlRowsPage(nextPage) { const page = Math.max(1, Math.min(Number(nextPage || 1), this.mySqlAdmin.rows.totalPages || 1)); if (page === this.mySqlAdmin.rows.page) { return; } this.mySqlAdmin.rows.page = page; await this.refreshMySqlRows({ silent: true }); }, async refreshMySqlCurrent() { const tasks = [this.refreshMySqlOverview({ silent: true })]; if (this.mySqlAdmin.selectedDatabase) { tasks.push(this.refreshMySqlTables({ databaseName: this.mySqlAdmin.selectedDatabase, preserveTable: true, preferredTable: this.mySqlAdmin.selectedTable, silent: true, loadRows: false, })); } if (this.mySqlAdmin.selectedTable) { tasks.push(this.refreshMySqlSchema({ silent: true })); if (this.mySqlAdmin.activeTab !== "sql") { tasks.push(this.refreshMySqlRows({ silent: true })); } } await Promise.allSettled(tasks); }, loadMySqlRowForEdit(row) { this.mySqlAdmin.rowEditor.identityKey = this.mySqlRowKey(row); this.mySqlAdmin.rowEditor.identity = row.identity || null; this.mySqlAdmin.rowEditor.valuesText = this.stringifyMySqlJson(row.values || {}); this.mySqlAdmin.activeTab = "write"; }, async refreshMySqlAfterMutation({ resetPage = false } = {}) { await this.refreshMySqlOverview({ silent: true }); if (this.mySqlAdmin.selectedDatabase) { await this.refreshMySqlTables({ databaseName: this.mySqlAdmin.selectedDatabase, preserveTable: true, preferredTable: this.mySqlAdmin.selectedTable, silent: true, loadRows: false, }); } if (this.mySqlAdmin.selectedTable) { await this.refreshMySqlSchema({ silent: true }); await this.refreshMySqlRows({ resetPage, silent: true }); } }, async insertMySqlRow() { if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable) { return; } this.mySqlAdmin.insertForm.saving = true; this.mySqlAdmin.error = ""; try { const values = this.parseMySqlObjectText(this.mySqlAdmin.insertForm.valuesText, "新增行"); const { response, payload } = await this.requestJson("/api/mysql/rows/insert", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mySqlAdmin.selectedDatabase, table: this.mySqlAdmin.selectedTable, values, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.mySqlAdmin.insertForm.valuesText = "{}"; this.setMySqlAdminMessage(payload.message || "已插入 1 行", "ok"); await this.refreshMySqlAfterMutation({ resetPage: true }); } catch (error) { this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mySqlAdmin.insertForm.saving = false; } }, async updateMySqlRow() { if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable || !this.mySqlAdmin.rowEditor.identity) { return; } this.mySqlAdmin.rowEditor.saving = true; this.mySqlAdmin.error = ""; try { const values = this.parseMySqlObjectText(this.mySqlAdmin.rowEditor.valuesText, "编辑行"); const { response, payload } = await this.requestJson("/api/mysql/rows/update", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mySqlAdmin.selectedDatabase, table: this.mySqlAdmin.selectedTable, identity: this.mySqlAdmin.rowEditor.identity, values, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.setMySqlAdminMessage(payload.message || "已更新 1 行", "ok"); await this.refreshMySqlAfterMutation(); } catch (error) { this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mySqlAdmin.rowEditor.saving = false; } }, async deleteMySqlRow(row = null) { if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable) { return; } const identity = row?.identity || this.mySqlAdmin.rowEditor.identity; if (!identity) { return; } if (!window.confirm(`删除当前行?\n${this.mySqlRowIdentityText(identity)}`)) { return; } this.mySqlAdmin.rowEditor.saving = true; this.mySqlAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mysql/rows/delete", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mySqlAdmin.selectedDatabase, table: this.mySqlAdmin.selectedTable, identity, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } if (this.mySqlRowKey({ identity }) === this.mySqlAdmin.rowEditor.identityKey) { this.clearMySqlRowEditor(); } this.setMySqlAdminMessage(payload.message || "已删除 1 行", "ok"); await this.refreshMySqlAfterMutation({ resetPage: true }); } catch (error) { this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mySqlAdmin.rowEditor.saving = false; } }, async addMySqlColumn() { if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable || !this.mySqlAdmin.columnForm.columnName.trim()) { return; } if (this.mySqlAdmin.columnForm.position === "after" && !this.mySqlAdmin.columnForm.afterColumn.trim()) { this.mySqlAdmin.error = "afterColumn is required"; return; } this.mySqlAdmin.columnForm.saving = true; this.mySqlAdmin.error = ""; try { const payloadBody = { database: this.mySqlAdmin.selectedDatabase, table: this.mySqlAdmin.selectedTable, columnName: this.mySqlAdmin.columnForm.columnName.trim(), columnType: this.mySqlAdmin.columnForm.columnType.trim(), nullable: this.mySqlAdmin.columnForm.nullable, defaultMode: this.mySqlAdmin.columnForm.defaultMode, defaultValue: this.mySqlAdmin.columnForm.defaultMode === "literal" ? this.parseMySqlScalarValue(this.mySqlAdmin.columnForm.defaultValue) : "", comment: this.mySqlAdmin.columnForm.comment, position: this.mySqlAdmin.columnForm.position, afterColumn: this.mySqlAdmin.columnForm.position === "after" ? this.mySqlAdmin.columnForm.afterColumn : "", }; const { response, payload } = await this.requestJson("/api/mysql/columns/add", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(payloadBody), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.mySqlAdmin.columnForm.columnName = ""; this.mySqlAdmin.columnForm.comment = ""; this.mySqlAdmin.columnForm.defaultValue = ""; this.mySqlAdmin.columnForm.position = "last"; this.setMySqlAdminMessage(payload.message || "列已新增", "ok"); await this.refreshMySqlAfterMutation({ resetPage: true }); } catch (error) { this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mySqlAdmin.columnForm.saving = false; } }, async dropMySqlColumn(columnName) { if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable || !columnName) { return; } if (!window.confirm(`删除列 ${columnName}?`)) { return; } this.mySqlAdmin.columnForm.saving = true; this.mySqlAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mysql/columns/drop", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mySqlAdmin.selectedDatabase, table: this.mySqlAdmin.selectedTable, columnName, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.setMySqlAdminMessage(payload.message || "列已删除", "ok"); await this.refreshMySqlAfterMutation({ resetPage: true }); } catch (error) { this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mySqlAdmin.columnForm.saving = false; } }, toggleMySqlIndexColumn(columnName) { const next = new Set(this.mySqlAdmin.indexForm.columns); if (next.has(columnName)) { next.delete(columnName); } else { next.add(columnName); } this.mySqlAdmin.indexForm.columns = [...next]; }, async addMySqlIndex() { if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable) { return; } if (!this.mySqlAdmin.indexForm.indexName.trim() || this.mySqlAdmin.indexForm.columns.length === 0) { return; } this.mySqlAdmin.indexForm.saving = true; this.mySqlAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mysql/indexes/add", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mySqlAdmin.selectedDatabase, table: this.mySqlAdmin.selectedTable, indexName: this.mySqlAdmin.indexForm.indexName.trim(), indexMode: this.mySqlAdmin.indexForm.indexMode, columns: this.mySqlAdmin.indexForm.columns, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.mySqlAdmin.indexForm.indexName = ""; this.mySqlAdmin.indexForm.columns = []; this.setMySqlAdminMessage(payload.message || "索引已新增", "ok"); await this.refreshMySqlAfterMutation(); } catch (error) { this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mySqlAdmin.indexForm.saving = false; } }, async dropMySqlIndex(indexName) { if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable || !indexName) { return; } if (!window.confirm(`删除索引 ${indexName}?`)) { return; } this.mySqlAdmin.indexForm.saving = true; this.mySqlAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mysql/indexes/drop", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mySqlAdmin.selectedDatabase, table: this.mySqlAdmin.selectedTable, indexName, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.setMySqlAdminMessage(payload.message || "索引已删除", "ok"); await this.refreshMySqlAfterMutation(); } catch (error) { this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mySqlAdmin.indexForm.saving = false; } }, applyMySqlSqlShortcut(shortcut) { if (!shortcut || shortcut.disabled || !shortcut.sql) { return; } this.mySqlAdmin.sqlConsole.text = `${shortcut.sql.trim()};`; }, async executeMySqlSql() { const sqlText = String(this.mySqlAdmin.sqlConsole.text || "").trim(); if (!sqlText) { return; } this.mySqlAdmin.sqlConsole.running = true; this.mySqlAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mysql/sql/execute", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mySqlAdmin.selectedDatabase, sql: sqlText, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.mySqlAdmin.sqlConsole.result = payload; if (Array.isArray(payload.columns) && payload.columns.length > 0) { this.setMySqlAdminMessage(`SQL 返回 ${payload.rowCount || 0} 行`, "neutral"); } else { this.setMySqlAdminMessage(payload.message || `${String(payload.kind || "SQL").toUpperCase()} 完成`, "ok"); await this.refreshMySqlAfterMutation({ resetPage: false }); } } catch (error) { this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mySqlAdmin.sqlConsole.running = false; } }, setMongoAdminMessage(message, tone = "neutral") { this.mongoAdmin.message = message || ""; this.mongoAdmin.messageTone = tone || "neutral"; }, clearMongoAdminCollectionState() { this.mongoAdmin.collectionDetail = { updatedAt: "", stats: { count: 0, sizeMb: 0, storageSizeMb: 0, avgObjSizeBytes: 0, indexCount: 0, totalIndexSizeMb: 0, capped: false, wiredTigerUri: "", }, indexes: [], }; this.mongoAdmin.query.total = 0; this.mongoAdmin.query.pageCount = 1; this.mongoAdmin.query.items = []; this.mongoAdmin.aggregate.total = 0; this.mongoAdmin.aggregate.pageCount = 1; this.mongoAdmin.aggregate.items = []; this.mongoAdmin.distinct.count = 0; this.mongoAdmin.distinct.values = []; this.mongoAdmin.distinct.valuesText = "[]"; this.mongoAdmin.createDocument.ordered = true; this.mongoAdmin.collectionOps.renameDropTarget = false; this.clearMongoDocumentEditor(); }, clearMongoDocumentEditor() { this.mongoAdmin.editor.selectedKey = ""; this.mongoAdmin.editor.idText = ""; this.mongoAdmin.editor.filterText = "{}"; this.mongoAdmin.editor.documentText = "{}"; this.mongoAdmin.editor.upsert = false; }, syncMongoExplorerState(selectedDatabase = this.mongoAdmin.selectedDatabase, { force = false } = {}) { const databaseNames = (this.mongoAdmin.databases || []).map((database) => database.name); if (databaseNames.length === 0) { this.collapsedMongoDatabases = []; return; } if (force || this.collapsedMongoDatabases.length === 0) { this.collapsedMongoDatabases = databaseNames.filter((databaseName) => databaseName !== selectedDatabase); return; } this.collapsedMongoDatabases = this.collapsedMongoDatabases.filter((databaseName) => databaseNames.includes(databaseName)); if (selectedDatabase && this.collapsedMongoDatabases.includes(selectedDatabase)) { this.collapsedMongoDatabases = this.collapsedMongoDatabases.filter((databaseName) => databaseName !== selectedDatabase); } }, toggleMongoDatabaseGroup(databaseName) { const next = new Set(this.collapsedMongoDatabases); if (next.has(databaseName)) { next.delete(databaseName); } else { next.add(databaseName); } this.collapsedMongoDatabases = [...next]; }, isMongoDatabaseCollapsed(databaseName) { return this.collapsedMongoDatabases.includes(databaseName); }, isMongoCollectionSelected(databaseName, collectionName) { return this.mongoAdmin.selectedDatabase === databaseName && this.mongoAdmin.selectedCollection === collectionName; }, isMongoEmptyFilterText(value) { const text = String(value || "").trim(); return !text || text === "{}"; }, async refreshMongoAdminOverview({ preserveSelection = true, silent = false } = {}) { this.mongoAdmin.loading = true; if (!silent) { this.mongoAdmin.error = ""; } const previousDatabase = preserveSelection ? this.mongoAdmin.selectedDatabase : ""; const previousCollection = preserveSelection ? this.mongoAdmin.selectedCollection : ""; const preserveDatabaseOnlyView = preserveSelection && Boolean(previousDatabase) && !previousCollection; try { const { response, payload } = await this.requestJson("/api/mongo-admin/overview"); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.mongoAdmin.databases = Array.isArray(payload.items) ? payload.items : []; this.mongoAdmin.loaded = true; const hasPreviousDatabase = previousDatabase && this.mongoAdmin.databases.some((database) => database.name === previousDatabase); const nextDatabase = hasPreviousDatabase ? previousDatabase : (payload.selectedDatabase || this.mongoAdmin.databases[0]?.name || ""); const databaseItem = this.mongoAdmin.databases.find((database) => database.name === nextDatabase) || null; const hasPreviousCollection = previousCollection && (databaseItem?.collections || []).some((collection) => collection.name === previousCollection); const nextCollection = preserveDatabaseOnlyView ? "" : hasPreviousCollection ? previousCollection : (databaseItem?.collections || [])[0]?.name || ""; this.mongoAdmin.selectedDatabase = nextDatabase; this.mongoAdmin.selectedCollection = nextCollection; this.mongoAdmin.collectionOps.createDatabaseName = nextDatabase || payload.focusedDatabase || ""; this.mongoAdmin.collectionOps.renameName = nextCollection || ""; this.syncMongoExplorerState(nextDatabase, { force: !preserveSelection }); if (!nextCollection) { this.clearMongoAdminCollectionState(); if (!silent) { this.setMongoAdminMessage(`已加载 ${this.mongoAdmin.databases.length} 个库`, "neutral"); } return; } if (previousDatabase !== nextDatabase || previousCollection !== nextCollection) { this.clearMongoAdminCollectionState(); } await Promise.allSettled([ this.refreshMongoCollectionDetail({ silent: true }), this.runMongoQuery({ resetPage: true, silent: true }), ]); if (!silent) { this.setMongoAdminMessage(`已加载 ${this.mongoAdmin.databases.length} 个库`, "neutral"); } } catch (error) { this.mongoAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mongoAdmin.loading = false; } }, async selectMongoDatabase(databaseName, { loadCollection = true } = {}) { const database = (this.mongoAdmin.databases || []).find((item) => item.name === databaseName); if (!database) { return; } this.mongoAdmin.selectedDatabase = database.name; this.mongoAdmin.collectionOps.createDatabaseName = database.name; this.syncMongoExplorerState(database.name); if (!loadCollection) { this.mongoAdmin.selectedCollection = ""; this.mongoAdmin.collectionOps.renameName = ""; this.clearMongoAdminCollectionState(); return; } const currentCollectionStillExists = (database.collections || []).some( (collection) => collection.name === this.mongoAdmin.selectedCollection, ); const nextCollection = currentCollectionStillExists ? this.mongoAdmin.selectedCollection : (database.collections || [])[0]?.name || ""; if (!nextCollection) { this.mongoAdmin.selectedCollection = ""; this.mongoAdmin.collectionOps.renameName = ""; this.clearMongoAdminCollectionState(); return; } await this.selectMongoCollection(database.name, nextCollection); }, async selectMongoCollection(databaseName, collectionName) { if (!databaseName || !collectionName) { return; } this.mongoAdmin.selectedDatabase = databaseName; this.mongoAdmin.selectedCollection = collectionName; this.mongoAdmin.collectionOps.createDatabaseName = databaseName; this.mongoAdmin.collectionOps.renameName = collectionName; this.syncMongoExplorerState(databaseName); this.clearMongoAdminCollectionState(); await Promise.allSettled([ this.refreshMongoCollectionDetail({ silent: true }), this.runMongoQuery({ resetPage: true, silent: true }), ]); }, async refreshMongoCollectionDetail({ silent = false } = {}) { if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { return; } this.mongoAdmin.detailLoading = true; if (!silent) { this.mongoAdmin.error = ""; } try { const query = new URLSearchParams({ database: this.mongoAdmin.selectedDatabase, collection: this.mongoAdmin.selectedCollection, }); const { response, payload } = await this.requestJson(`/api/mongo-admin/collection-detail?${query.toString()}`); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.mongoAdmin.collectionDetail = payload; this.mongoAdmin.collectionOps.renameName = this.mongoAdmin.selectedCollection; } catch (error) { this.mongoAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mongoAdmin.detailLoading = false; } }, async refreshMongoAdminCurrent() { if (!this.mongoAdmin.selectedCollection) { await this.refreshMongoAdminOverview({ preserveSelection: true }); return; } const tasks = [this.refreshMongoCollectionDetail()]; if (this.mongoAdmin.activeTab === "aggregate") { tasks.push(this.runMongoAggregate({ silent: true })); } else { tasks.push(this.runMongoQuery({ silent: true })); } await Promise.allSettled(tasks); }, resetMongoQueryForm() { this.mongoAdmin.query.filterText = "{}"; this.mongoAdmin.query.sortText = JSON.stringify({ _id: -1 }, null, 2); this.mongoAdmin.query.projectionText = "{}"; this.mongoAdmin.query.page = 1; this.mongoAdmin.query.pageSize = 100; }, async runMongoQuery({ resetPage = false, silent = false } = {}) { if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { return; } if (resetPage) { this.mongoAdmin.query.page = 1; } this.mongoAdmin.querying = true; if (!silent) { this.mongoAdmin.error = ""; } try { const { response, payload } = await this.requestJson("/api/mongo-admin/query", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mongoAdmin.selectedDatabase, collection: this.mongoAdmin.selectedCollection, filter: this.mongoAdmin.query.filterText, sort: this.mongoAdmin.query.sortText, projection: this.mongoAdmin.query.projectionText, page: this.mongoAdmin.query.page, pageSize: this.mongoAdmin.query.pageSize, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.mongoAdmin.query.page = Number(payload.page || this.mongoAdmin.query.page); this.mongoAdmin.query.pageSize = Number(payload.pageSize || this.mongoAdmin.query.pageSize); this.mongoAdmin.query.total = Number(payload.total || 0); this.mongoAdmin.query.pageCount = Number(payload.pageCount || 1); this.mongoAdmin.query.items = Array.isArray(payload.items) ? payload.items : []; this.mongoAdmin.query.filterText = payload.filterText || this.mongoAdmin.query.filterText; this.mongoAdmin.query.sortText = payload.sortText || this.mongoAdmin.query.sortText; this.mongoAdmin.query.projectionText = payload.projectionText || this.mongoAdmin.query.projectionText; if ( this.mongoAdmin.editor.selectedKey && !this.mongoAdmin.query.items.some((item) => item.key === this.mongoAdmin.editor.selectedKey) ) { this.clearMongoDocumentEditor(); } if (!silent) { this.setMongoAdminMessage(`已加载 ${this.mongoAdmin.query.items.length} 条文档`, "neutral"); } } catch (error) { this.mongoAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mongoAdmin.querying = false; } }, async changeMongoQueryPage(nextPage) { const page = Math.max(1, Math.min(Number(nextPage || 1), this.mongoAdmin.query.pageCount || 1)); if (page === this.mongoAdmin.query.page) { return; } this.mongoAdmin.query.page = page; await this.runMongoQuery({ silent: true }); }, loadMongoDocumentForEdit(item) { this.mongoAdmin.editor.selectedKey = item.key || ""; this.mongoAdmin.editor.idText = item.idText || ""; this.mongoAdmin.editor.filterText = item.idFilterText || "{}"; this.mongoAdmin.editor.documentText = item.jsonText || "{}"; this.mongoAdmin.editor.upsert = false; this.mongoAdmin.activeTab = "browse"; }, async saveMongoEditedDocument() { if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection || !this.mongoAdmin.editor.filterText) { return; } this.mongoAdmin.editor.saving = true; this.mongoAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mongo-admin/documents/replace", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mongoAdmin.selectedDatabase, collection: this.mongoAdmin.selectedCollection, filter: this.mongoAdmin.editor.filterText, document: this.mongoAdmin.editor.documentText, upsert: this.mongoAdmin.editor.upsert, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.setMongoAdminMessage(`已保存 ${payload.modifiedCount || payload.matchedCount || 0} 条`, "ok"); await Promise.allSettled([ this.refreshMongoCollectionDetail({ silent: true }), this.runMongoQuery({ silent: true }), this.refreshMongoAdminOverview({ preserveSelection: true, silent: true }), ]); } catch (error) { this.mongoAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mongoAdmin.editor.saving = false; } }, async deleteMongoDocument(item) { if (!item?.idFilterText || !this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { return; } if (!window.confirm(`删除 ${item.idText || "当前文档"}?`)) { return; } this.mongoAdmin.querying = true; this.mongoAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mongo-admin/documents/delete", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mongoAdmin.selectedDatabase, collection: this.mongoAdmin.selectedCollection, filter: item.idFilterText, many: false, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.setMongoAdminMessage("已删除文档", "ok"); await Promise.allSettled([ this.refreshMongoCollectionDetail({ silent: true }), this.runMongoQuery({ silent: true }), this.refreshMongoAdminOverview({ preserveSelection: true, silent: true }), ]); } catch (error) { this.mongoAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mongoAdmin.querying = false; } }, async insertMongoDocument() { if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { return; } this.mongoAdmin.createDocument.saving = true; this.mongoAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mongo-admin/documents/insert", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mongoAdmin.selectedDatabase, collection: this.mongoAdmin.selectedCollection, document: this.mongoAdmin.createDocument.documentText, ordered: this.mongoAdmin.createDocument.ordered, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.mongoAdmin.createDocument.documentText = "{}"; this.mongoAdmin.createDocument.ordered = true; this.setMongoAdminMessage(`已写入 ${payload.insertedCount || 0} 条`, "ok"); await Promise.allSettled([ this.refreshMongoCollectionDetail({ silent: true }), this.runMongoQuery({ resetPage: true, silent: true }), this.refreshMongoAdminOverview({ preserveSelection: true, silent: true }), ]); } catch (error) { this.mongoAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mongoAdmin.createDocument.saving = false; } }, async runMongoUpdateDocuments() { if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { return; } const allowEmptyFilter = this.mongoAdmin.updateOp.many && this.isMongoEmptyFilterText(this.mongoAdmin.updateOp.filterText); if (allowEmptyFilter && !window.confirm("当前条件为空,将更新整表文档。继续?")) { return; } this.mongoAdmin.updateOp.running = true; this.mongoAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mongo-admin/documents/update", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mongoAdmin.selectedDatabase, collection: this.mongoAdmin.selectedCollection, filter: this.mongoAdmin.updateOp.filterText, update: this.mongoAdmin.updateOp.updateText, many: this.mongoAdmin.updateOp.many, upsert: this.mongoAdmin.updateOp.upsert, allowEmptyFilter, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.setMongoAdminMessage( `匹配 ${payload.matchedCount || 0} / 修改 ${payload.modifiedCount || 0}`, "ok", ); await Promise.allSettled([ this.refreshMongoCollectionDetail({ silent: true }), this.runMongoQuery({ silent: true }), this.refreshMongoAdminOverview({ preserveSelection: true, silent: true }), ]); } catch (error) { this.mongoAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mongoAdmin.updateOp.running = false; } }, async runMongoDeleteDocuments() { if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { return; } const allowEmptyFilter = this.mongoAdmin.deleteOp.many && this.isMongoEmptyFilterText(this.mongoAdmin.deleteOp.filterText); if (!window.confirm(this.mongoAdmin.deleteOp.many ? "删除当前条件命中的全部文档?" : "删除当前条件命中的一条文档?")) { return; } if (allowEmptyFilter && !window.confirm("当前条件为空,将删除整表文档。继续?")) { return; } this.mongoAdmin.deleteOp.running = true; this.mongoAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mongo-admin/documents/delete", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mongoAdmin.selectedDatabase, collection: this.mongoAdmin.selectedCollection, filter: this.mongoAdmin.deleteOp.filterText, many: this.mongoAdmin.deleteOp.many, allowEmptyFilter, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.setMongoAdminMessage(`已删除 ${payload.deletedCount || 0} 条`, "ok"); await Promise.allSettled([ this.refreshMongoCollectionDetail({ silent: true }), this.runMongoQuery({ resetPage: true, silent: true }), this.refreshMongoAdminOverview({ preserveSelection: true, silent: true }), ]); } catch (error) { this.mongoAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mongoAdmin.deleteOp.running = false; } }, async runMongoAggregate({ resetPage = false, silent = false } = {}) { if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { return; } if (resetPage) { this.mongoAdmin.aggregate.page = 1; } this.mongoAdmin.aggregate.running = true; if (!silent) { this.mongoAdmin.error = ""; } try { const { response, payload } = await this.requestJson("/api/mongo-admin/aggregate", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mongoAdmin.selectedDatabase, collection: this.mongoAdmin.selectedCollection, pipeline: this.mongoAdmin.aggregate.pipelineText, page: this.mongoAdmin.aggregate.page, pageSize: this.mongoAdmin.aggregate.pageSize, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.mongoAdmin.aggregate.page = Number(payload.page || this.mongoAdmin.aggregate.page); this.mongoAdmin.aggregate.pageSize = Number(payload.pageSize || this.mongoAdmin.aggregate.pageSize); this.mongoAdmin.aggregate.total = Number(payload.total || 0); this.mongoAdmin.aggregate.pageCount = Number(payload.pageCount || 1); this.mongoAdmin.aggregate.items = Array.isArray(payload.items) ? payload.items : []; this.mongoAdmin.aggregate.pipelineText = payload.pipelineText || this.mongoAdmin.aggregate.pipelineText; if (!silent) { this.setMongoAdminMessage(`聚合返回 ${this.mongoAdmin.aggregate.items.length} 条`, "neutral"); } } catch (error) { this.mongoAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mongoAdmin.aggregate.running = false; } }, async changeMongoAggregatePage(nextPage) { const page = Math.max(1, Math.min(Number(nextPage || 1), this.mongoAdmin.aggregate.pageCount || 1)); if (page === this.mongoAdmin.aggregate.page) { return; } this.mongoAdmin.aggregate.page = page; await this.runMongoAggregate({ silent: true }); }, async runMongoDistinct() { if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection || !this.mongoAdmin.distinct.field.trim()) { return; } this.mongoAdmin.distinct.running = true; this.mongoAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mongo-admin/distinct", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mongoAdmin.selectedDatabase, collection: this.mongoAdmin.selectedCollection, field: this.mongoAdmin.distinct.field, filter: this.mongoAdmin.distinct.filterText, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.mongoAdmin.distinct.count = Number(payload.count || 0); this.mongoAdmin.distinct.values = Array.isArray(payload.values) ? payload.values : []; this.mongoAdmin.distinct.valuesText = payload.valuesText || "[]"; this.setMongoAdminMessage(`去重返回 ${this.mongoAdmin.distinct.count} 项`, "neutral"); } catch (error) { this.mongoAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mongoAdmin.distinct.running = false; } }, async createMongoIndex() { if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { return; } this.mongoAdmin.indexForm.saving = true; this.mongoAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mongo-admin/indexes/create", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mongoAdmin.selectedDatabase, collection: this.mongoAdmin.selectedCollection, keys: this.mongoAdmin.indexForm.keysText, options: this.mongoAdmin.indexForm.optionsText, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.setMongoAdminMessage(`已创建索引 ${payload.indexName || ""}`.trim(), "ok"); await Promise.allSettled([ this.refreshMongoCollectionDetail({ silent: true }), this.refreshMongoAdminOverview({ preserveSelection: true, silent: true }), ]); } catch (error) { this.mongoAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mongoAdmin.indexForm.saving = false; } }, async dropMongoIndex(indexName) { if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection || !indexName) { return; } if (!window.confirm(`删除索引 ${indexName}?`)) { return; } this.mongoAdmin.indexForm.saving = true; this.mongoAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mongo-admin/indexes/drop", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mongoAdmin.selectedDatabase, collection: this.mongoAdmin.selectedCollection, indexName, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.setMongoAdminMessage(`已删除索引 ${indexName}`, "ok"); await Promise.allSettled([ this.refreshMongoCollectionDetail({ silent: true }), this.refreshMongoAdminOverview({ preserveSelection: true, silent: true }), ]); } catch (error) { this.mongoAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mongoAdmin.indexForm.saving = false; } }, async dropMongoSecondaryIndexes() { if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { return; } if (!window.confirm(`删除表 ${this.mongoAdmin.selectedCollection} 的全部二级索引?`)) { return; } this.mongoAdmin.indexForm.dropAllSaving = true; this.mongoAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mongo-admin/indexes/drop-secondary", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mongoAdmin.selectedDatabase, collection: this.mongoAdmin.selectedCollection, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.setMongoAdminMessage(`已删除 ${payload.droppedCount || 0} 个二级索引`, "ok"); await Promise.allSettled([ this.refreshMongoCollectionDetail({ silent: true }), this.refreshMongoAdminOverview({ preserveSelection: true, silent: true }), ]); } catch (error) { this.mongoAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mongoAdmin.indexForm.dropAllSaving = false; } }, async createMongoCollection() { const databaseName = (this.mongoAdmin.collectionOps.createDatabaseName || this.mongoAdmin.selectedDatabase || "").trim(); const collectionName = (this.mongoAdmin.collectionOps.createName || "").trim(); if (!databaseName || !collectionName) { return; } this.mongoAdmin.collectionOps.running = true; this.mongoAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mongo-admin/collections/create", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: databaseName, collection: collectionName, options: this.mongoAdmin.collectionOps.createOptionsText, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.mongoAdmin.collectionOps.createName = ""; this.mongoAdmin.collectionOps.createOptionsText = "{}"; this.setMongoAdminMessage(`已创建表 ${collectionName}`, "ok"); await this.refreshMongoAdminOverview({ preserveSelection: false, silent: true }); await this.selectMongoCollection(databaseName, collectionName); } catch (error) { this.mongoAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mongoAdmin.collectionOps.running = false; } }, async renameMongoCollection() { if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection || !this.mongoAdmin.collectionOps.renameName.trim()) { return; } const targetName = this.mongoAdmin.collectionOps.renameName.trim(); if (!window.confirm(`重命名表 ${this.mongoAdmin.selectedCollection} -> ${targetName}?`)) { return; } this.mongoAdmin.collectionOps.running = true; this.mongoAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mongo-admin/collections/rename", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mongoAdmin.selectedDatabase, collection: this.mongoAdmin.selectedCollection, targetCollection: targetName, dropTarget: this.mongoAdmin.collectionOps.renameDropTarget, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.setMongoAdminMessage(`已重命名为 ${targetName}`, "ok"); await this.refreshMongoAdminOverview({ preserveSelection: false, silent: true }); await this.selectMongoCollection(this.mongoAdmin.selectedDatabase, targetName); } catch (error) { this.mongoAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mongoAdmin.collectionOps.running = false; this.mongoAdmin.collectionOps.renameDropTarget = false; } }, async dropMongoCollection() { if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { return; } if (!window.confirm(`删除表 ${this.mongoAdmin.selectedCollection}?`)) { return; } this.mongoAdmin.collectionOps.dropRunning = true; this.mongoAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mongo-admin/collections/drop", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mongoAdmin.selectedDatabase, collection: this.mongoAdmin.selectedCollection, confirmName: this.mongoAdmin.selectedCollection, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.setMongoAdminMessage("已删除表", "ok"); await this.refreshMongoAdminOverview({ preserveSelection: false, silent: true }); } catch (error) { this.mongoAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mongoAdmin.collectionOps.dropRunning = false; } }, async dropMongoDatabase() { if (!this.mongoAdmin.selectedDatabase) { return; } if (!window.confirm(`删除库 ${this.mongoAdmin.selectedDatabase}?`)) { return; } this.mongoAdmin.collectionOps.databaseDropRunning = true; this.mongoAdmin.error = ""; try { const { response, payload } = await this.requestJson("/api/mongo-admin/databases/drop", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ database: this.mongoAdmin.selectedDatabase, confirmName: this.mongoAdmin.selectedDatabase, }), }); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.setMongoAdminMessage("已删除库", "ok"); await this.refreshMongoAdminOverview({ preserveSelection: false, silent: true }); } catch (error) { this.mongoAdmin.error = error instanceof Error ? error.message : String(error); } finally { this.mongoAdmin.collectionOps.databaseDropRunning = 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");