From f9c2fb27d3f2cf0f21caf3ef6b3ce185eb261ac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Mon, 27 Jul 2026 12:16:39 +0800 Subject: [PATCH] feat(nacos): add Nacos dashboard metrics --- Cargo.lock | 22 + .../src/components/chart/MetricLineChart.vue | 3 +- .../connection/ConnectionDialog.vue | 69 +- .../__tests__/DataGridClipboardRegion.spec.ts | 5 +- .../src/components/layout/AppTabBar.vue | 6 +- .../src/components/layout/ContentArea.vue | 7 + .../src/components/nacos/NacosDashboard.vue | 467 ++++++++++ .../sidebar/SidebarTreeRuntimeHost.vue | 6 +- apps/desktop/src/i18n/locales/en.ts | 82 +- apps/desktop/src/i18n/locales/es.ts | 82 +- apps/desktop/src/i18n/locales/it.ts | 82 +- apps/desktop/src/i18n/locales/ja.ts | 82 +- apps/desktop/src/i18n/locales/pt-BR.ts | 82 +- apps/desktop/src/i18n/locales/zh-CN.ts | 82 +- apps/desktop/src/i18n/locales/zh-TW.ts | 82 +- .../lib/__tests__/nacos/nacosAdmin.spec.ts | 31 + .../__tests__/nacos/nacosDashboard.spec.ts | 144 +++ apps/desktop/src/lib/backend/api.ts | 1 + apps/desktop/src/lib/backend/http.ts | 6 + apps/desktop/src/lib/backend/nacos-tauri.ts | 6 + apps/desktop/src/lib/nacos/nacosAdmin.ts | 24 +- apps/desktop/src/lib/nacos/nacosDashboard.ts | 109 +++ .../__tests__/queryStore.switchTab.spec.ts | 17 + apps/desktop/src/stores/queryStore.ts | 26 + apps/desktop/src/types/database.ts | 2 +- apps/desktop/src/types/nacos.ts | 121 +++ crates/dbx-core/Cargo.toml | 1 + crates/dbx-core/src/nacos/batch.rs | 4 + crates/dbx-core/src/nacos/config.rs | 111 +++ crates/dbx-core/src/nacos/http.rs | 504 ++++++++++- crates/dbx-core/src/nacos/mod.rs | 3 + crates/dbx-core/src/nacos/port.rs | 1 + crates/dbx-core/src/nacos/prometheus.rs | 820 ++++++++++++++++++ crates/dbx-core/src/nacos/search.rs | 3 + crates/dbx-core/src/nacos/service.rs | 9 + crates/dbx-core/src/nacos/types.rs | 169 ++++ crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/nacos.rs | 17 + docs/lib/agentRegistry.test.ts | 5 +- src-tauri/src/commands/nacos_cmd.rs | 9 + src-tauri/src/lib.rs | 1 + 41 files changed, 3268 insertions(+), 36 deletions(-) create mode 100644 apps/desktop/src/components/nacos/NacosDashboard.vue create mode 100644 apps/desktop/src/lib/__tests__/nacos/nacosDashboard.spec.ts create mode 100644 apps/desktop/src/lib/nacos/nacosDashboard.ts create mode 100644 crates/dbx-core/src/nacos/prometheus.rs diff --git a/Cargo.lock b/Cargo.lock index 4cb8d0eaa..f8ee5c9a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1904,6 +1904,7 @@ dependencies = [ "pageant", "percent-encoding", "portpicker", + "prometheus-parse", "quick-xml 0.37.5", "rayon", "redis", @@ -3902,6 +3903,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -6039,6 +6049,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prometheus-parse" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "811031bea65e5a401fb2e1f37d802cca6601e204ac463809a3189352d13b78a5" +dependencies = [ + "chrono", + "itertools", + "once_cell", + "regex", +] + [[package]] name = "psm" version = "0.1.31" diff --git a/apps/desktop/src/components/chart/MetricLineChart.vue b/apps/desktop/src/components/chart/MetricLineChart.vue index cd6d78599..cee303dd1 100644 --- a/apps/desktop/src/components/chart/MetricLineChart.vue +++ b/apps/desktop/src/components/chart/MetricLineChart.vue @@ -13,7 +13,7 @@ use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent interface Series { name: string; - data: number[]; + data: Array; color?: string; } @@ -56,6 +56,7 @@ const chartOption = computed(() => { name: s.name, type: "line", data: s.data, + connectNulls: false, smooth: true, showSymbol: false, lineStyle: s.color ? { color: s.color, width: 2 } : { width: 2 }, diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 6b6d4439b..53bcb489d 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -17,7 +17,7 @@ import { Switch } from "@/components/ui/switch"; import type { ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType, HttpTunnelConfig, IdentifierCase, JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database"; import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb"; import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq"; -import type { NacosAdminConfig, NacosAuthConfig, NacosImplementation, NacosRNacosConsoleAuth, NacosVersionMode } from "@/types/nacos"; +import type { NacosAdminConfig, NacosAuthConfig, NacosImplementation, NacosMetricsMode, NacosRNacosConsoleAuth, NacosVersionMode } from "@/types/nacos"; import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore"; import { useTunnelProfileStore } from "@/stores/tunnelProfileStore"; import { detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles"; @@ -57,7 +57,7 @@ import { normalizeRabbitmqAddresses } from "@/lib/connection/rabbitmqAddresses"; import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth"; import { driverInstallProgressChannel, driverInstallProgressPercent, isDriverInstallProgressForOperation, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi"; import { requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityConfig, sqlServerUsesLegacyCompatibility, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility"; -import { normalizeNacosEndpoint } from "@/lib/nacos/nacosAdmin"; +import { nacosMetricsCandidates, normalizeNacosEndpoint, normalizeNacosMetricsUrl } from "@/lib/nacos/nacosAdmin"; import { ArrowLeft, ArrowDown, @@ -604,6 +604,8 @@ const nacosAuthKind = ref("none"); const nacosUsername = ref("nacos"); const nacosPassword = ref(""); const nacosTlsSkipVerify = ref(false); +const nacosMetricsMode = ref("auto"); +const nacosMetricsUrl = ref(""); const nacosPageSize = ref(20); const nacosPrimaryAddressLabel = computed(() => { if (nacosImplementation.value === "rnacos") return t("connection.nacosPrimaryAddressRNacos"); @@ -612,8 +614,7 @@ const nacosPrimaryAddressLabel = computed(() => { return t("connection.nacosPrimaryAddressAuto"); }); const nacosPrimaryAddressPlaceholder = computed(() => { - if (nacosImplementation.value === "rnacos" || nacosVersionMode.value === "v2") return "http://127.0.0.1:8848/nacos"; - return "http://127.0.0.1:8080"; + return "http://127.0.0.1:8848/nacos"; }); const nacosNormalizedPreview = computed(() => { if (!nacosServerAddr.value.trim()) return ""; @@ -630,7 +631,7 @@ const nacosNormalizedPreview = computed(() => { }); const nacosEffectiveContextPath = computed(() => { if (!nacosServerAddr.value.trim()) { - return nacosContextPathCustomized.value ? nacosContextPath.value.trim() || "/" : nacosImplementation.value === "rnacos" || nacosVersionMode.value === "v2" ? "/nacos" : "/"; + return nacosContextPathCustomized.value ? nacosContextPath.value.trim() || "/" : "/nacos"; } try { const normalized = normalizeNacosEndpoint(nacosServerAddr.value, { @@ -650,6 +651,28 @@ const nacosContextPathInput = computed({ nacosContextPath.value = value; }, }); +const nacosMetricsAutoPreview = computed(() => { + if (!nacosNormalizedPreview.value) return ""; + try { + const normalized = normalizeNacosEndpoint(nacosServerAddr.value, { + implementation: nacosImplementation.value, + versionMode: nacosVersionMode.value, + contextPath: nacosContextPathCustomized.value ? nacosContextPath.value : undefined, + }); + return nacosMetricsCandidates(normalized.serverAddr, normalized.contextPath, nacosImplementation.value).join(" · "); + } catch { + return ""; + } +}); +const nacosMetricsUrlError = computed(() => { + if (nacosMetricsMode.value !== "custom") return ""; + try { + normalizeNacosMetricsUrl(nacosMetricsUrl.value); + return ""; + } catch { + return t("connection.nacosMetricsUrlInvalid"); + } +}); function resetNacosContextPathCustomization() { nacosContextPathCustomized.value = false; @@ -1090,6 +1113,8 @@ function resetNacosFields(config?: Partial) { nacosConsoleUsername.value = consoleAuth.kind === "usernamePassword" ? consoleAuth.username : ""; nacosConsolePassword.value = consoleAuth.kind === "usernamePassword" ? consoleAuth.password : ""; nacosTlsSkipVerify.value = !!config?.tlsSkipVerify; + nacosMetricsMode.value = config?.metricsMode || "auto"; + nacosMetricsUrl.value = config?.metricsUrl || ""; nacosPageSize.value = Number(config?.pageSize) > 0 ? Number(config?.pageSize) : 20; const auth = (config?.auth || { kind: "none" }) as NacosAuthConfig; nacosAuthKind.value = auth.kind || "none"; @@ -1298,6 +1323,14 @@ function buildNacosAdminConfig(): NacosAdminConfig { throw new Error(t("connection.nacosRNacosConsoleUrlRequired")); } let rnacosConsoleAuth: NacosRNacosConsoleAuth | undefined; + let metricsUrl: string | undefined; + if (nacosMetricsMode.value === "custom") { + try { + metricsUrl = normalizeNacosMetricsUrl(nacosMetricsUrl.value); + } catch { + throw new Error(t("connection.nacosMetricsUrlInvalid")); + } + } if (rnacosConsoleConfigured) { if (nacosConsoleAuthKind.value === "inherit") { if (nacosAuthKind.value !== "usernamePassword") throw new Error(t("connection.nacosConsoleAuthSeparateRequired")); @@ -1321,6 +1354,8 @@ function buildNacosAdminConfig(): NacosAdminConfig { rnacosConsoleAuth, auth: buildNacosAuth(), tlsSkipVerify: nacosTlsSkipVerify.value || undefined, + metricsMode: nacosMetricsMode.value, + metricsUrl, pageSize: Number(nacosPageSize.value) > 0 ? Number(nacosPageSize.value) : 20, }; } @@ -5312,6 +5347,30 @@ function openExternalUrl(url: string) { +
+ + +
+
+ +

{{ t("connection.nacosMetricsAutoHint", { addresses: nacosMetricsAutoPreview }) }}

+
+
+ +
+ +

{{ nacosMetricsUrlError }}

+
+
+ + diff --git a/apps/desktop/src/components/nacos/NacosDashboard.vue b/apps/desktop/src/components/nacos/NacosDashboard.vue new file mode 100644 index 000000000..fa97bfca5 --- /dev/null +++ b/apps/desktop/src/components/nacos/NacosDashboard.vue @@ -0,0 +1,467 @@ + + + diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 0453a4b5d..531dd2a77 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -1057,7 +1057,9 @@ async function openServerDashboard() { try { await connectionStore.ensureConnected(node.connectionId); connectionStore.activeConnectionId = node.connectionId; - if (currentDatabaseType() === "postgres") { + if (currentDatabaseType() === "nacos") { + queryStore.openNacosDashboard(node.connectionId); + } else if (currentDatabaseType() === "postgres") { queryStore.openPostgresDashboard(node.connectionId); } else { queryStore.openMysqlDashboard(node.connectionId); @@ -3554,7 +3556,7 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean if (node.connectionId && connectionSupportsProcessList(connectionStore.getConfig(node.connectionId))) { items.push({ label: t("contextMenu.processList"), action: openProcessList, icon: Activity }); } - if (node.connectionId && (connectionSupportsServerDashboard(connectionStore.getConfig(node.connectionId)) || connectionSupportsPgServerDashboard(connectionStore.getConfig(node.connectionId)))) { + if (node.connectionId && (currentDatabaseType() === "nacos" || connectionSupportsServerDashboard(connectionStore.getConfig(node.connectionId)) || connectionSupportsPgServerDashboard(connectionStore.getConfig(node.connectionId)))) { items.push({ label: t("contextMenu.serverDashboard"), action: openServerDashboard, icon: Gauge }); } if (currentDatabaseType() === "dameng") { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index ca8e7a1d5..3c22ba9eb 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -341,9 +341,9 @@ export default { nacosVersionAuto: "Auto detect", nacosPrimaryAddressRNacos: "Nacos-compatible API address", nacosPrimaryAddressV2: "Service address (API and console shared)", - nacosPrimaryAddressV3: "Admin API / console address", + nacosPrimaryAddressV3: "Server / Admin API address", nacosPrimaryAddressAuto: "Nacos management address", - nacosPrimaryAddressHint: "Paste a full browser or API URL. Known Nacos 3 console routes are normalized; custom proxy prefixes are preserved.", + nacosPrimaryAddressHint: "For Nacos 3, use the Server / Admin API address (normally http://host:8848/nacos), not the port 8080 console; custom proxy prefixes are preserved.", nacosRequestsUse: "Requests will use {address}/…", nacosNamespace: "Namespace", nacosContextPath: "Context Path", @@ -373,6 +373,13 @@ export default { nacosTls: "TLS", nacosTlsSkipVerify: "Skip certificate verification", nacosPageSize: "Page Size", + nacosMetrics: "Prometheus metrics", + nacosMetricsAuto: "Auto detect", + nacosMetricsDisabled: "Disabled", + nacosMetricsCustom: "Custom URL", + nacosMetricsAutoHint: "Tried in order: {addresses}", + nacosMetricsUrl: "Metrics URL", + nacosMetricsUrlInvalid: "Enter an absolute HTTP or HTTPS Prometheus metrics URL without credentials or a fragment.", kafkaKerberosPrincipal: "Principal", kafkaKerberosKeytab: "Keytab", kafkaKerberosServiceName: "Service Name", @@ -5496,6 +5503,77 @@ export default { presetSchemaDesc: "Current topic schema definition", }, nacos: { + dashboard: "Dashboard", + dashboardTitle: "Operations Dashboard", + dashboardUpdatedAt: "Updated {time}", + dashboardAutoRefresh: "Auto refresh", + dashboardOff: "Off", + dashboardNamespaces: "Namespaces", + dashboardConfigs: "Configs", + dashboardServices: "Services", + dashboardInstances: "Instances", + dashboardClients: "Clients", + dashboardConnections: "{count} connections", + dashboardNodes: "Healthy nodes", + dashboardCpu: "CPU", + dashboardMemory: "Memory", + dashboardLoad: "Load {value}", + dashboardCountsChart: "Service discovery", + dashboardResourcesChart: "Resource utilization", + dashboardClusterNodes: "Cluster nodes", + dashboardSite: "Site", + dashboardLastRefresh: "Last refresh", + dashboardNoNodes: "Cluster node details are unavailable", + dashboardPartial: "{count} dashboard sources are unavailable", + dashboardMemoryUsed: "Heap / memory used", + dashboardMemoryMax: "Maximum {value}", + dashboardRss: "RSS memory", + dashboardVms: "Virtual memory", + dashboardJvmThreads: "JVM daemon threads", + dashboardGcPauses: "GC pauses", + dashboardTrafficSection: "Request traffic", + dashboardRequestRateChart: "HTTP / gRPC requests per second", + dashboardLatencyChart: "Request latency", + dashboardHttpQps: "HTTP QPS", + dashboardGrpcQps: "gRPC QPS", + dashboardHttpAverage: "HTTP average", + dashboardHttpP50: "HTTP P50", + dashboardHttpP95: "HTTP P95", + dashboardHttpP99: "HTTP P99", + dashboardGrpcAverage: "gRPC average", + dashboardGrpcP50: "gRPC P50", + dashboardGrpcP95: "gRPC P95", + dashboardGrpcP99: "gRPC P99", + dashboardReliabilitySection: "Reliability", + dashboardErrorRateChart: "Request error rate", + dashboardExecutorChart: "gRPC executor", + dashboardHttpErrors: "HTTP errors", + dashboardGrpcErrors: "gRPC errors", + dashboardExecutorPool: "Pool size", + dashboardExecutorActive: "Active", + dashboardExecutorQueue: "Queued", + dashboardConfigSection: "Configuration center", + dashboardConfigRateChart: "Configuration operations per second", + dashboardConfigStateChart: "Listeners and pending work", + dashboardConfigGets: "Gets", + dashboardConfigPublishes: "Publishes", + dashboardLongPolling: "Long polling", + dashboardListenerClients: "Listener clients", + dashboardListenerKeys: "Listener keys", + dashboardNotifyTasks: "Notify tasks", + dashboardNotifyClientTasks: "Client notify tasks", + dashboardDumpTasks: "Dump tasks", + dashboardNamingSection: "Service discovery", + dashboardNamingStateChart: "Services, instances and clients", + dashboardPushRateChart: "Pushes per second", + dashboardPushDetailChart: "Push latency and pending work", + dashboardPushAverage: "Average latency", + dashboardPushMaximum: "Maximum latency", + dashboardPushPending: "Pending tasks", + dashboardSubscribers: "Subscribers", + dashboardPushes: "Pushes", + dashboardFailedPushes: "Failed pushes", + dashboardEmptyPushes: "Empty pushes", configs: "Configs", services: "Services", raw: "Raw", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 23cd847a2..63b0faf30 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -328,9 +328,9 @@ export default withEnglishFallback({ nacosVersionAuto: "Detectar automáticamente", nacosPrimaryAddressRNacos: "Dirección de API compatible con Nacos", nacosPrimaryAddressV2: "Dirección del servicio (API y consola compartidas)", - nacosPrimaryAddressV3: "Dirección de API de administración / consola", + nacosPrimaryAddressV3: "Dirección de Server / Admin API", nacosPrimaryAddressAuto: "Dirección de administración de Nacos", - nacosPrimaryAddressHint: "Pegue una URL completa del navegador o de la API. Las rutas conocidas de la consola de Nacos 3 se normalizan; se conservan los prefijos de proxy personalizados.", + nacosPrimaryAddressHint: "Para Nacos 3, use la dirección de Server / Admin API (normalmente http://host:8848/nacos), no la consola del puerto 8080; se conservan los prefijos proxy personalizados.", nacosRequestsUse: "Las solicitudes usarán {address}/…", nacosNamespace: "Namespace", nacosContextPath: "Ruta de contexto", @@ -360,6 +360,13 @@ export default withEnglishFallback({ nacosTls: "TLS", nacosTlsSkipVerify: "Omitir verificación de certificado", nacosPageSize: "Tamaño de página", + nacosMetrics: "Métricas Prometheus", + nacosMetricsAuto: "Detección automática", + nacosMetricsDisabled: "Desactivado", + nacosMetricsCustom: "URL personalizada", + nacosMetricsAutoHint: "Se probarán en orden: {addresses}", + nacosMetricsUrl: "URL de métricas", + nacosMetricsUrlInvalid: "Introduce una URL HTTP o HTTPS absoluta sin credenciales ni fragmento.", searchDatabasePlaceholder: "Buscar tipos de bases de datos", searchResults: "Resultados de búsqueda", databaseCategories: "Categorías", @@ -4278,6 +4285,77 @@ export default withEnglishFallback({ runInBackground: "Ejecutar en segundo plano", }, nacos: { + dashboard: "Panel", + dashboardTitle: "Panel de operaciones", + dashboardUpdatedAt: "Actualizado {time}", + dashboardAutoRefresh: "Actualización automática", + dashboardOff: "Desactivada", + dashboardNamespaces: "Espacios de nombres", + dashboardConfigs: "Configuraciones", + dashboardServices: "Servicios", + dashboardInstances: "Instancias", + dashboardClients: "Clientes", + dashboardConnections: "{count} conexiones", + dashboardNodes: "Nodos saludables", + dashboardCpu: "CPU", + dashboardMemory: "Memoria", + dashboardLoad: "Carga {value}", + dashboardCountsChart: "Descubrimiento de servicios", + dashboardResourcesChart: "Uso de recursos", + dashboardClusterNodes: "Nodos del clúster", + dashboardSite: "Sitio", + dashboardLastRefresh: "Última actualización", + dashboardNoNodes: "Los detalles de los nodos no están disponibles", + dashboardPartial: "{count} fuentes de datos no están disponibles", + dashboardMemoryUsed: "Heap / memoria usada", + dashboardMemoryMax: "Máximo {value}", + dashboardRss: "Memoria RSS", + dashboardVms: "Memoria virtual", + dashboardJvmThreads: "Hilos daemon JVM", + dashboardGcPauses: "Pausas GC", + dashboardTrafficSection: "Tráfico de solicitudes", + dashboardRequestRateChart: "Solicitudes HTTP / gRPC por segundo", + dashboardLatencyChart: "Latencia de solicitudes", + dashboardHttpQps: "QPS HTTP", + dashboardGrpcQps: "QPS gRPC", + dashboardHttpAverage: "Media HTTP", + dashboardHttpP50: "HTTP P50", + dashboardHttpP95: "HTTP P95", + dashboardHttpP99: "HTTP P99", + dashboardGrpcAverage: "Media gRPC", + dashboardGrpcP50: "gRPC P50", + dashboardGrpcP95: "gRPC P95", + dashboardGrpcP99: "gRPC P99", + dashboardReliabilitySection: "Fiabilidad", + dashboardErrorRateChart: "Tasa de errores", + dashboardExecutorChart: "Ejecutor gRPC", + dashboardHttpErrors: "Errores HTTP", + dashboardGrpcErrors: "Errores gRPC", + dashboardExecutorPool: "Tamaño del pool", + dashboardExecutorActive: "Activos", + dashboardExecutorQueue: "En cola", + dashboardConfigSection: "Centro de configuración", + dashboardConfigRateChart: "Operaciones de configuración por segundo", + dashboardConfigStateChart: "Listeners y trabajo pendiente", + dashboardConfigGets: "Consultas", + dashboardConfigPublishes: "Publicaciones", + dashboardLongPolling: "Sondeo largo", + dashboardListenerClients: "Clientes listener", + dashboardListenerKeys: "Claves listener", + dashboardNotifyTasks: "Tareas de notificación", + dashboardNotifyClientTasks: "Notificaciones a clientes", + dashboardDumpTasks: "Tareas dump", + dashboardNamingSection: "Descubrimiento de servicios", + dashboardNamingStateChart: "Servicios, instancias y clientes", + dashboardPushRateChart: "Envíos por segundo", + dashboardPushDetailChart: "Latencia y tareas pendientes de envío", + dashboardPushAverage: "Latencia media", + dashboardPushMaximum: "Latencia máxima", + dashboardPushPending: "Tareas pendientes", + dashboardSubscribers: "Suscriptores", + dashboardPushes: "Envíos", + dashboardFailedPushes: "Envíos fallidos", + dashboardEmptyPushes: "Envíos vacíos", configs: "Configuraciones", services: "Servicios", raw: "Raw", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 30e708e4c..3e04e5e67 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -327,9 +327,9 @@ export default withEnglishFallback({ nacosVersionAuto: "Rileva automaticamente", nacosPrimaryAddressRNacos: "Indirizzo API compatibile con Nacos", nacosPrimaryAddressV2: "Indirizzo del servizio (API e console condivise)", - nacosPrimaryAddressV3: "Indirizzo API di amministrazione / console", + nacosPrimaryAddressV3: "Indirizzo Server / Admin API", nacosPrimaryAddressAuto: "Indirizzo di amministrazione Nacos", - nacosPrimaryAddressHint: "Incolla un URL completo del browser o dell'API. I percorsi noti della console Nacos 3 vengono normalizzati; i prefissi proxy personalizzati vengono mantenuti.", + nacosPrimaryAddressHint: "Per Nacos 3 usa l'indirizzo Server / Admin API (normalmente http://host:8848/nacos), non la console sulla porta 8080; i prefissi proxy personalizzati vengono mantenuti.", nacosRequestsUse: "Le richieste useranno {address}/…", nacosNamespace: "Namespace", nacosContextPath: "Percorso di contesto", @@ -359,6 +359,13 @@ export default withEnglishFallback({ nacosTls: "TLS", nacosTlsSkipVerify: "Salta verifica certificato", nacosPageSize: "Dimensione pagina", + nacosMetrics: "Metriche Prometheus", + nacosMetricsAuto: "Rilevamento automatico", + nacosMetricsDisabled: "Disabilitato", + nacosMetricsCustom: "URL personalizzato", + nacosMetricsAutoHint: "Verranno provati in ordine: {addresses}", + nacosMetricsUrl: "URL metriche", + nacosMetricsUrlInvalid: "Inserisci un URL HTTP o HTTPS assoluto senza credenziali o frammento.", searchDatabasePlaceholder: "Cerca tipi di database", searchResults: "Risultati della ricerca", databaseCategories: "Categorie", @@ -4276,6 +4283,77 @@ export default withEnglishFallback({ runInBackground: "Esegui in background", }, nacos: { + dashboard: "Dashboard", + dashboardTitle: "Dashboard operativa", + dashboardUpdatedAt: "Aggiornato {time}", + dashboardAutoRefresh: "Aggiornamento automatico", + dashboardOff: "Disattivato", + dashboardNamespaces: "Namespace", + dashboardConfigs: "Configurazioni", + dashboardServices: "Servizi", + dashboardInstances: "Istanze", + dashboardClients: "Client", + dashboardConnections: "{count} connessioni", + dashboardNodes: "Nodi integri", + dashboardCpu: "CPU", + dashboardMemory: "Memoria", + dashboardLoad: "Carico {value}", + dashboardCountsChart: "Rilevamento servizi", + dashboardResourcesChart: "Utilizzo risorse", + dashboardClusterNodes: "Nodi del cluster", + dashboardSite: "Sito", + dashboardLastRefresh: "Ultimo aggiornamento", + dashboardNoNodes: "I dettagli dei nodi non sono disponibili", + dashboardPartial: "{count} origini dati non sono disponibili", + dashboardMemoryUsed: "Heap / memoria usata", + dashboardMemoryMax: "Massimo {value}", + dashboardRss: "Memoria RSS", + dashboardVms: "Memoria virtuale", + dashboardJvmThreads: "Thread daemon JVM", + dashboardGcPauses: "Pause GC", + dashboardTrafficSection: "Traffico richieste", + dashboardRequestRateChart: "Richieste HTTP / gRPC al secondo", + dashboardLatencyChart: "Latenza richieste", + dashboardHttpQps: "QPS HTTP", + dashboardGrpcQps: "QPS gRPC", + dashboardHttpAverage: "Media HTTP", + dashboardHttpP50: "HTTP P50", + dashboardHttpP95: "HTTP P95", + dashboardHttpP99: "HTTP P99", + dashboardGrpcAverage: "Media gRPC", + dashboardGrpcP50: "gRPC P50", + dashboardGrpcP95: "gRPC P95", + dashboardGrpcP99: "gRPC P99", + dashboardReliabilitySection: "Affidabilità", + dashboardErrorRateChart: "Tasso di errore", + dashboardExecutorChart: "Executor gRPC", + dashboardHttpErrors: "Errori HTTP", + dashboardGrpcErrors: "Errori gRPC", + dashboardExecutorPool: "Dimensione pool", + dashboardExecutorActive: "Attivi", + dashboardExecutorQueue: "In coda", + dashboardConfigSection: "Centro configurazioni", + dashboardConfigRateChart: "Operazioni di configurazione al secondo", + dashboardConfigStateChart: "Listener e lavoro in attesa", + dashboardConfigGets: "Letture", + dashboardConfigPublishes: "Pubblicazioni", + dashboardLongPolling: "Long polling", + dashboardListenerClients: "Client listener", + dashboardListenerKeys: "Chiavi listener", + dashboardNotifyTasks: "Attività di notifica", + dashboardNotifyClientTasks: "Notifiche client", + dashboardDumpTasks: "Attività dump", + dashboardNamingSection: "Service discovery", + dashboardNamingStateChart: "Servizi, istanze e client", + dashboardPushRateChart: "Push al secondo", + dashboardPushDetailChart: "Latenza push e attività in attesa", + dashboardPushAverage: "Latenza media", + dashboardPushMaximum: "Latenza massima", + dashboardPushPending: "Attività in attesa", + dashboardSubscribers: "Sottoscrittori", + dashboardPushes: "Push", + dashboardFailedPushes: "Push falliti", + dashboardEmptyPushes: "Push vuoti", configs: "Configurazioni", services: "Servizi", raw: "Raw", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 08d934cd4..e0a0ee3b6 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -321,9 +321,9 @@ export default withEnglishFallback({ nacosVersionAuto: "自動検出", nacosPrimaryAddressRNacos: "Nacos 互換 API アドレス", nacosPrimaryAddressV2: "サービスアドレス(API とコンソールで共有)", - nacosPrimaryAddressV3: "管理 API / コンソールアドレス", + nacosPrimaryAddressV3: "Server / Admin API アドレス", nacosPrimaryAddressAuto: "Nacos 管理アドレス", - nacosPrimaryAddressHint: "完全なブラウザーまたは API URL を貼り付けてください。既知の Nacos 3 コンソールルートは正規化され、カスタムプロキシプレフィックスは保持されます。", + nacosPrimaryAddressHint: "Nacos 3 では、8080 のコンソールではなく Server / Admin API アドレス(通常 http://host:8848/nacos)を指定してください。カスタムプロキシプレフィックスは保持されます。", nacosRequestsUse: "リクエストには {address}/… を使用します", nacosNamespace: "名前空間", nacosContextPath: "コンテキストパス", @@ -353,6 +353,13 @@ export default withEnglishFallback({ nacosTls: "TLS", nacosTlsSkipVerify: "証明書検証をスキップ", nacosPageSize: "ページサイズ", + nacosMetrics: "Prometheus メトリクス", + nacosMetricsAuto: "自動検出", + nacosMetricsDisabled: "無効", + nacosMetricsCustom: "カスタム URL", + nacosMetricsAutoHint: "次の順序で試行します:{addresses}", + nacosMetricsUrl: "メトリクス URL", + nacosMetricsUrlInvalid: "認証情報やフラグメントを含まない HTTP または HTTPS の絶対 URL を入力してください。", searchDatabasePlaceholder: "データベースタイプを検索", searchResults: "検索結果", databaseCategories: "カテゴリ", @@ -4276,6 +4283,77 @@ export default withEnglishFallback({ exportCancelled: "エクスポートがキャンセルされました", }, nacos: { + dashboard: "ダッシュボード", + dashboardTitle: "運用ダッシュボード", + dashboardUpdatedAt: "更新 {time}", + dashboardAutoRefresh: "自動更新", + dashboardOff: "オフ", + dashboardNamespaces: "名前空間", + dashboardConfigs: "設定数", + dashboardServices: "サービス数", + dashboardInstances: "インスタンス数", + dashboardClients: "クライアント", + dashboardConnections: "{count} 接続", + dashboardNodes: "正常ノード", + dashboardCpu: "CPU", + dashboardMemory: "メモリ", + dashboardLoad: "負荷 {value}", + dashboardCountsChart: "サービスディスカバリ", + dashboardResourcesChart: "リソース使用率", + dashboardClusterNodes: "クラスターノード", + dashboardSite: "サイト", + dashboardLastRefresh: "最終更新", + dashboardNoNodes: "クラスターノードの詳細を取得できません", + dashboardPartial: "{count} 件のデータソースを利用できません", + dashboardMemoryUsed: "ヒープ / メモリ使用量", + dashboardMemoryMax: "上限 {value}", + dashboardRss: "RSS メモリ", + dashboardVms: "仮想メモリ", + dashboardJvmThreads: "JVM デーモンスレッド", + dashboardGcPauses: "GC 一時停止回数", + dashboardTrafficSection: "リクエストトラフィック", + dashboardRequestRateChart: "HTTP / gRPC リクエスト/秒", + dashboardLatencyChart: "リクエスト遅延", + dashboardHttpQps: "HTTP QPS", + dashboardGrpcQps: "gRPC QPS", + dashboardHttpAverage: "HTTP 平均", + dashboardHttpP50: "HTTP P50", + dashboardHttpP95: "HTTP P95", + dashboardHttpP99: "HTTP P99", + dashboardGrpcAverage: "gRPC 平均", + dashboardGrpcP50: "gRPC P50", + dashboardGrpcP95: "gRPC P95", + dashboardGrpcP99: "gRPC P99", + dashboardReliabilitySection: "信頼性", + dashboardErrorRateChart: "リクエストエラー率", + dashboardExecutorChart: "gRPC エグゼキューター", + dashboardHttpErrors: "HTTP エラー", + dashboardGrpcErrors: "gRPC エラー", + dashboardExecutorPool: "プールサイズ", + dashboardExecutorActive: "アクティブ", + dashboardExecutorQueue: "待機中", + dashboardConfigSection: "設定センター", + dashboardConfigRateChart: "設定操作/秒", + dashboardConfigStateChart: "リスナーと保留タスク", + dashboardConfigGets: "取得", + dashboardConfigPublishes: "公開", + dashboardLongPolling: "ロングポーリング", + dashboardListenerClients: "リスナークライアント", + dashboardListenerKeys: "リスナーキー", + dashboardNotifyTasks: "通知タスク", + dashboardNotifyClientTasks: "クライアント通知タスク", + dashboardDumpTasks: "ダンプタスク", + dashboardNamingSection: "サービスディスカバリ", + dashboardNamingStateChart: "サービス、インスタンス、クライアント", + dashboardPushRateChart: "プッシュ/秒", + dashboardPushDetailChart: "プッシュ遅延と保留タスク", + dashboardPushAverage: "平均遅延", + dashboardPushMaximum: "最大遅延", + dashboardPushPending: "保留タスク", + dashboardSubscribers: "サブスクライバー", + dashboardPushes: "プッシュ", + dashboardFailedPushes: "失敗プッシュ", + dashboardEmptyPushes: "空プッシュ", configs: "設定", services: "サービス", raw: "Raw", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 2d5fa67f8..d6cab02fd 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -328,9 +328,9 @@ export default withEnglishFallback({ nacosVersionAuto: "Detectar automaticamente", nacosPrimaryAddressRNacos: "Endereço de API compatível com Nacos", nacosPrimaryAddressV2: "Endereço do serviço (API e console compartilhados)", - nacosPrimaryAddressV3: "Endereço de API administrativa / console", + nacosPrimaryAddressV3: "Endereço de Server / Admin API", nacosPrimaryAddressAuto: "Endereço de administração do Nacos", - nacosPrimaryAddressHint: "Cole uma URL completa do navegador ou da API. As rotas conhecidas do console do Nacos 3 são normalizadas; prefixos de proxy personalizados são preservados.", + nacosPrimaryAddressHint: "No Nacos 3, use o endereço de Server / Admin API (normalmente http://host:8848/nacos), não o console na porta 8080; prefixos de proxy personalizados são preservados.", nacosRequestsUse: "As solicitações usarão {address}/…", nacosNamespace: "Namespace", nacosContextPath: "Caminho de contexto", @@ -360,6 +360,13 @@ export default withEnglishFallback({ nacosTls: "TLS", nacosTlsSkipVerify: "Ignorar verificação do certificado", nacosPageSize: "Tamanho da página", + nacosMetrics: "Métricas Prometheus", + nacosMetricsAuto: "Detecção automática", + nacosMetricsDisabled: "Desativado", + nacosMetricsCustom: "URL personalizada", + nacosMetricsAutoHint: "Tentativas em ordem: {addresses}", + nacosMetricsUrl: "URL de métricas", + nacosMetricsUrlInvalid: "Informe uma URL HTTP ou HTTPS absoluta, sem credenciais ou fragmento.", searchDatabasePlaceholder: "Pesquisar tipos de banco de dados", searchResults: "Resultados da busca", databaseCategories: "Categorias", @@ -4278,6 +4285,77 @@ export default withEnglishFallback({ exportCancelled: "Exportação cancelada", }, nacos: { + dashboard: "Painel", + dashboardTitle: "Painel de operações", + dashboardUpdatedAt: "Atualizado {time}", + dashboardAutoRefresh: "Atualização automática", + dashboardOff: "Desativada", + dashboardNamespaces: "Namespaces", + dashboardConfigs: "Configurações", + dashboardServices: "Serviços", + dashboardInstances: "Instâncias", + dashboardClients: "Clientes", + dashboardConnections: "{count} conexões", + dashboardNodes: "Nós saudáveis", + dashboardCpu: "CPU", + dashboardMemory: "Memória", + dashboardLoad: "Carga {value}", + dashboardCountsChart: "Descoberta de serviços", + dashboardResourcesChart: "Uso de recursos", + dashboardClusterNodes: "Nós do cluster", + dashboardSite: "Site", + dashboardLastRefresh: "Última atualização", + dashboardNoNodes: "Os detalhes dos nós não estão disponíveis", + dashboardPartial: "{count} fontes de dados não estão disponíveis", + dashboardMemoryUsed: "Heap / memória usada", + dashboardMemoryMax: "Máximo {value}", + dashboardRss: "Memória RSS", + dashboardVms: "Memória virtual", + dashboardJvmThreads: "Threads daemon da JVM", + dashboardGcPauses: "Pausas de GC", + dashboardTrafficSection: "Tráfego de requisições", + dashboardRequestRateChart: "Requisições HTTP / gRPC por segundo", + dashboardLatencyChart: "Latência das requisições", + dashboardHttpQps: "QPS HTTP", + dashboardGrpcQps: "QPS gRPC", + dashboardHttpAverage: "Média HTTP", + dashboardHttpP50: "HTTP P50", + dashboardHttpP95: "HTTP P95", + dashboardHttpP99: "HTTP P99", + dashboardGrpcAverage: "Média gRPC", + dashboardGrpcP50: "gRPC P50", + dashboardGrpcP95: "gRPC P95", + dashboardGrpcP99: "gRPC P99", + dashboardReliabilitySection: "Confiabilidade", + dashboardErrorRateChart: "Taxa de erros", + dashboardExecutorChart: "Executor gRPC", + dashboardHttpErrors: "Erros HTTP", + dashboardGrpcErrors: "Erros gRPC", + dashboardExecutorPool: "Tamanho do pool", + dashboardExecutorActive: "Ativos", + dashboardExecutorQueue: "Na fila", + dashboardConfigSection: "Central de configuração", + dashboardConfigRateChart: "Operações de configuração por segundo", + dashboardConfigStateChart: "Listeners e trabalho pendente", + dashboardConfigGets: "Consultas", + dashboardConfigPublishes: "Publicações", + dashboardLongPolling: "Long polling", + dashboardListenerClients: "Clientes listener", + dashboardListenerKeys: "Chaves listener", + dashboardNotifyTasks: "Tarefas de notificação", + dashboardNotifyClientTasks: "Notificações de clientes", + dashboardDumpTasks: "Tarefas dump", + dashboardNamingSection: "Descoberta de serviços", + dashboardNamingStateChart: "Serviços, instâncias e clientes", + dashboardPushRateChart: "Pushes por segundo", + dashboardPushDetailChart: "Latência e tarefas pendentes de push", + dashboardPushAverage: "Latência média", + dashboardPushMaximum: "Latência máxima", + dashboardPushPending: "Tarefas pendentes", + dashboardSubscribers: "Assinantes", + dashboardPushes: "Pushes", + dashboardFailedPushes: "Pushes com falha", + dashboardEmptyPushes: "Pushes vazios", configs: "Configurações", services: "Serviços", raw: "Raw", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index aa366ff41..7471c0db7 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -343,9 +343,9 @@ export default withEnglishFallback({ nacosVersionAuto: "自动检测", nacosPrimaryAddressRNacos: "兼容 Nacos 的 API 地址", nacosPrimaryAddressV2: "服务地址(API 与控制台共用)", - nacosPrimaryAddressV3: "管理 API / 控制台地址", + nacosPrimaryAddressV3: "Server / Admin API 地址", nacosPrimaryAddressAuto: "Nacos 管理地址", - nacosPrimaryAddressHint: "可粘贴完整的浏览器或 API URL。已知的 Nacos 3 控制台路径会自动规范化;自定义代理前缀会被保留。", + nacosPrimaryAddressHint: "Nacos 3 请填写 Server / Admin API 地址(默认 http://主机:8848/nacos),不要填写 8080 控制台地址;自定义代理前缀会被保留。", nacosRequestsUse: "请求将使用 {address}/…", nacosNamespace: "命名空间", nacosContextPath: "上下文路径", @@ -374,6 +374,13 @@ export default withEnglishFallback({ nacosTls: "TLS", nacosTlsSkipVerify: "跳过证书验证", nacosPageSize: "分页大小", + nacosMetrics: "Prometheus 指标", + nacosMetricsAuto: "自动探测", + nacosMetricsDisabled: "关闭", + nacosMetricsCustom: "自定义地址", + nacosMetricsAutoHint: "将按顺序尝试:{addresses}", + nacosMetricsUrl: "指标地址", + nacosMetricsUrlInvalid: "请输入不含凭据和片段的 HTTP 或 HTTPS Prometheus 指标绝对地址。", kafkaKerberosPrincipal: "Principal", kafkaKerberosKeytab: "Keytab", kafkaKerberosServiceName: "服务名", @@ -5494,6 +5501,77 @@ export default withEnglishFallback({ presetSchemaDesc: "查看主题当前 schema 定义", }, nacos: { + dashboard: "大盘", + dashboardTitle: "运行大盘", + dashboardUpdatedAt: "更新于 {time}", + dashboardAutoRefresh: "自动刷新", + dashboardOff: "关闭", + dashboardNamespaces: "命名空间", + dashboardConfigs: "配置数", + dashboardServices: "服务数", + dashboardInstances: "实例数", + dashboardClients: "客户端", + dashboardConnections: "{count} 个连接", + dashboardNodes: "健康节点", + dashboardCpu: "CPU", + dashboardMemory: "内存", + dashboardLoad: "负载 {value}", + dashboardCountsChart: "服务发现指标", + dashboardResourcesChart: "资源使用率", + dashboardClusterNodes: "集群节点", + dashboardSite: "站点", + dashboardLastRefresh: "最后刷新", + dashboardNoNodes: "当前无法获取集群节点明细", + dashboardPartial: "有 {count} 项大盘数据源不可用", + dashboardMemoryUsed: "堆 / 内存已用", + dashboardMemoryMax: "上限 {value}", + dashboardRss: "RSS 内存", + dashboardVms: "虚拟内存", + dashboardJvmThreads: "JVM 守护线程", + dashboardGcPauses: "GC 暂停次数", + dashboardTrafficSection: "请求流量", + dashboardRequestRateChart: "HTTP / gRPC 每秒请求", + dashboardLatencyChart: "请求延迟", + dashboardHttpQps: "HTTP QPS", + dashboardGrpcQps: "gRPC QPS", + dashboardHttpAverage: "HTTP 平均", + dashboardHttpP50: "HTTP P50", + dashboardHttpP95: "HTTP P95", + dashboardHttpP99: "HTTP P99", + dashboardGrpcAverage: "gRPC 平均", + dashboardGrpcP50: "gRPC P50", + dashboardGrpcP95: "gRPC P95", + dashboardGrpcP99: "gRPC P99", + dashboardReliabilitySection: "稳定性", + dashboardErrorRateChart: "请求错误率", + dashboardExecutorChart: "gRPC 执行器", + dashboardHttpErrors: "HTTP 错误", + dashboardGrpcErrors: "gRPC 错误", + dashboardExecutorPool: "线程池", + dashboardExecutorActive: "活跃线程", + dashboardExecutorQueue: "排队任务", + dashboardConfigSection: "配置中心", + dashboardConfigRateChart: "配置操作速率", + dashboardConfigStateChart: "监听与积压", + dashboardConfigGets: "查询配置", + dashboardConfigPublishes: "发布配置", + dashboardLongPolling: "长轮询", + dashboardListenerClients: "监听客户端", + dashboardListenerKeys: "监听键", + dashboardNotifyTasks: "通知任务", + dashboardNotifyClientTasks: "客户端通知任务", + dashboardDumpTasks: "Dump 任务", + dashboardNamingSection: "服务发现", + dashboardNamingStateChart: "服务、实例与客户端", + dashboardPushRateChart: "每秒推送", + dashboardPushDetailChart: "推送延迟与积压", + dashboardPushAverage: "平均延迟", + dashboardPushMaximum: "最大延迟", + dashboardPushPending: "待处理任务", + dashboardSubscribers: "订阅者", + dashboardPushes: "推送", + dashboardFailedPushes: "失败推送", + dashboardEmptyPushes: "空推送", configs: "配置", services: "服务", raw: "Raw", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 4daecd3a0..1326c97ee 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -328,9 +328,9 @@ export default withEnglishFallback({ nacosVersionAuto: "自動偵測", nacosPrimaryAddressRNacos: "相容 Nacos 的 API 位址", nacosPrimaryAddressV2: "服務位址(API 與主控台共用)", - nacosPrimaryAddressV3: "管理 API / 主控台位址", + nacosPrimaryAddressV3: "Server / Admin API 位址", nacosPrimaryAddressAuto: "Nacos 管理位址", - nacosPrimaryAddressHint: "可貼上完整的瀏覽器或 API URL。已知的 Nacos 3 主控台路徑會自動正規化;自訂代理前綴會被保留。", + nacosPrimaryAddressHint: "Nacos 3 請填寫 Server / Admin API 位址(預設 http://主機:8848/nacos),不要填寫 8080 主控台位址;自訂代理前綴會被保留。", nacosRequestsUse: "請求將使用 {address}/…", nacosNamespace: "命名空間", nacosContextPath: "上下文路徑", @@ -359,6 +359,13 @@ export default withEnglishFallback({ nacosTls: "TLS", nacosTlsSkipVerify: "跳過憑證驗證", nacosPageSize: "分頁大小", + nacosMetrics: "Prometheus 指標", + nacosMetricsAuto: "自動偵測", + nacosMetricsDisabled: "關閉", + nacosMetricsCustom: "自訂位址", + nacosMetricsAutoHint: "將依序嘗試:{addresses}", + nacosMetricsUrl: "指標位址", + nacosMetricsUrlInvalid: "請輸入不含認證資訊與片段的 HTTP 或 HTTPS Prometheus 指標絕對位址。", searchDatabasePlaceholder: "搜尋資料庫類型", searchResults: "搜尋結果", databaseCategories: "分類", @@ -4087,6 +4094,77 @@ export default withEnglishFallback({ runInBackground: "背景執行", }, nacos: { + dashboard: "儀表板", + dashboardTitle: "運行儀表板", + dashboardUpdatedAt: "更新於 {time}", + dashboardAutoRefresh: "自動重新整理", + dashboardOff: "關閉", + dashboardNamespaces: "命名空間", + dashboardConfigs: "設定數", + dashboardServices: "服務數", + dashboardInstances: "實例數", + dashboardClients: "用戶端", + dashboardConnections: "{count} 個連線", + dashboardNodes: "健康節點", + dashboardCpu: "CPU", + dashboardMemory: "記憶體", + dashboardLoad: "負載 {value}", + dashboardCountsChart: "服務發現指標", + dashboardResourcesChart: "資源使用率", + dashboardClusterNodes: "叢集節點", + dashboardSite: "站點", + dashboardLastRefresh: "最後重新整理", + dashboardNoNodes: "目前無法取得叢集節點明細", + dashboardPartial: "有 {count} 項儀表板資料來源無法使用", + dashboardMemoryUsed: "堆積 / 記憶體已用", + dashboardMemoryMax: "上限 {value}", + dashboardRss: "RSS 記憶體", + dashboardVms: "虛擬記憶體", + dashboardJvmThreads: "JVM 守護執行緒", + dashboardGcPauses: "GC 暫停次數", + dashboardTrafficSection: "請求流量", + dashboardRequestRateChart: "HTTP / gRPC 每秒請求", + dashboardLatencyChart: "請求延遲", + dashboardHttpQps: "HTTP QPS", + dashboardGrpcQps: "gRPC QPS", + dashboardHttpAverage: "HTTP 平均", + dashboardHttpP50: "HTTP P50", + dashboardHttpP95: "HTTP P95", + dashboardHttpP99: "HTTP P99", + dashboardGrpcAverage: "gRPC 平均", + dashboardGrpcP50: "gRPC P50", + dashboardGrpcP95: "gRPC P95", + dashboardGrpcP99: "gRPC P99", + dashboardReliabilitySection: "穩定性", + dashboardErrorRateChart: "請求錯誤率", + dashboardExecutorChart: "gRPC 執行器", + dashboardHttpErrors: "HTTP 錯誤", + dashboardGrpcErrors: "gRPC 錯誤", + dashboardExecutorPool: "執行緒池", + dashboardExecutorActive: "活躍執行緒", + dashboardExecutorQueue: "排隊任務", + dashboardConfigSection: "設定中心", + dashboardConfigRateChart: "設定操作速率", + dashboardConfigStateChart: "監聽與積壓", + dashboardConfigGets: "查詢設定", + dashboardConfigPublishes: "發布設定", + dashboardLongPolling: "長輪詢", + dashboardListenerClients: "監聽用戶端", + dashboardListenerKeys: "監聽鍵", + dashboardNotifyTasks: "通知任務", + dashboardNotifyClientTasks: "用戶端通知任務", + dashboardDumpTasks: "Dump 任務", + dashboardNamingSection: "服務發現", + dashboardNamingStateChart: "服務、實例與用戶端", + dashboardPushRateChart: "每秒推送", + dashboardPushDetailChart: "推送延遲與積壓", + dashboardPushAverage: "平均延遲", + dashboardPushMaximum: "最大延遲", + dashboardPushPending: "待處理任務", + dashboardSubscribers: "訂閱者", + dashboardPushes: "推送", + dashboardFailedPushes: "失敗推送", + dashboardEmptyPushes: "空推送", configs: "配置", services: "服務", raw: "Raw", diff --git a/apps/desktop/src/lib/__tests__/nacos/nacosAdmin.spec.ts b/apps/desktop/src/lib/__tests__/nacos/nacosAdmin.spec.ts index 08d988ea9..9e570c1aa 100644 --- a/apps/desktop/src/lib/__tests__/nacos/nacosAdmin.spec.ts +++ b/apps/desktop/src/lib/__tests__/nacos/nacosAdmin.spec.ts @@ -18,6 +18,8 @@ import { isNacosConfigSaveSnapshotCurrent, isNacosConfigDeleteSnapshotInScope, nacosConfigFileExtension, + nacosMetricsCandidates, + normalizeNacosMetricsUrl, parseNacosRawBody, parseNacosRawQuery, normalizeNacosEndpoint, @@ -41,12 +43,41 @@ describe("nacosAdmin helpers", () => { contextPath: "/gateway", detectedVersion: "v3", }); + expect(normalizeNacosEndpoint("http://127.0.0.1:8848", { implementation: "nacos", versionMode: "v3" })).toMatchObject({ + serverAddr: "http://127.0.0.1:8848", + contextPath: "/nacos", + detectedVersion: "v3", + }); + expect(normalizeNacosEndpoint("http://127.0.0.1:8848", { implementation: "nacos", versionMode: "v3", contextPath: "/" })).toMatchObject({ + serverAddr: "http://127.0.0.1:8848", + contextPath: "/", + detectedVersion: "v3", + }); + const savedAutoRootEndpoint = normalizeNacosEndpoint("http://127.0.0.1:8080", { implementation: "nacos", versionMode: "auto" }); + expect(savedAutoRootEndpoint).toMatchObject({ serverAddr: "http://127.0.0.1:8080", contextPath: "" }); + expect( + normalizeNacosEndpoint(savedAutoRootEndpoint.serverAddr, { + implementation: "nacos", + versionMode: "auto", + contextPath: savedAutoRootEndpoint.contextPath || undefined, + }), + ).toMatchObject({ serverAddr: "http://127.0.0.1:8080", contextPath: "" }); expect(normalizeNacosEndpoint("http://rnacos.example:8848/nacos", { implementation: "rnacos" })).toMatchObject({ serverAddr: "http://rnacos.example:8848", contextPath: "/nacos", }); expect(() => normalizeNacosEndpoint("http://user:secret@nacos.example", { implementation: "nacos" })).toThrow(/embedded credentials/i); }); + + it("derives and validates Prometheus endpoints", () => { + expect(nacosMetricsCandidates("http://127.0.0.1:8818", "/nacos", "nacos")).toEqual(["http://127.0.0.1:8818/nacos/actuator/prometheus", "http://127.0.0.1:8818/actuator/prometheus"]); + expect(nacosMetricsCandidates("http://127.0.0.1:3848", "/nacos", "rnacos")).toEqual(["http://127.0.0.1:3848/metrics", "http://127.0.0.1:3848/nacos/metrics", "http://127.0.0.1:3848/rnacos/metrics"]); + expect(normalizeNacosMetricsUrl("http://localhost:8818/metrics?node=a")).toBe("http://localhost:8818/metrics?node=a"); + expect(() => normalizeNacosMetricsUrl("file:///tmp/metrics")).toThrow(/HTTP or HTTPS/); + expect(() => normalizeNacosMetricsUrl("http://user:secret@localhost/metrics")).toThrow(/credentials/); + expect(() => normalizeNacosMetricsUrl("http://localhost/metrics#fragment")).toThrow(/fragment/); + expect(() => normalizeNacosMetricsUrl("http://localhost/metrics#")).toThrow(/fragment/); + }); it("parses raw query and body text", () => { expect(parseNacosRawQuery("?dataId=a&group=DEFAULT_GROUP")).toEqual({ dataId: "a", group: "DEFAULT_GROUP" }); expect(parseNacosRawQuery("")).toBeUndefined(); diff --git a/apps/desktop/src/lib/__tests__/nacos/nacosDashboard.spec.ts b/apps/desktop/src/lib/__tests__/nacos/nacosDashboard.spec.ts new file mode 100644 index 000000000..15f285ee1 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/nacos/nacosDashboard.spec.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { appendDashboardSample, averageDurationMsSeries, counterRateSeries, dashboardMetric, dashboardNamespaceLabel, dashboardSeries, errorRateSeries, formatDashboardPercent, isHealthyNacosNode, ratioPercent, type NacosDashboardSample } from "@/lib/nacos/nacosDashboard"; + +function sample(at: number, metrics: NacosDashboardSample["snapshot"]["metrics"]): NacosDashboardSample { + return { + at, + snapshot: { + namespace: "", + metrics, + nodes: [], + warnings: [], + }, + }; +} + +describe("nacosDashboard helpers", () => { + it("reads metric series and leaves gaps for missing values", () => { + const samples = [sample(1, { serviceCount: 2 }), sample(2, { serviceCount: 5 }), sample(3, undefined)]; + expect(dashboardMetric(samples[1], "serviceCount")).toBe(5); + expect(dashboardSeries(samples, "serviceCount")).toEqual([2, 5, null]); + }); + + it("calculates rates and leaves gaps after counter resets", () => { + const samples = [ + { ...sample(1_000, {}), snapshot: { ...sample(1_000, {}).snapshot, prometheus: { source: { kind: "nacos" as const, endpoint: "http://metrics" }, resource: {}, traffic: { httpRequestsTotal: 10 }, config: {}, naming: {} } } }, + { ...sample(3_000, {}), snapshot: { ...sample(3_000, {}).snapshot, prometheus: { source: { kind: "nacos" as const, endpoint: "http://metrics" }, resource: {}, traffic: { httpRequestsTotal: 20 }, config: {}, naming: {} } } }, + { ...sample(5_000, {}), snapshot: { ...sample(5_000, {}).snapshot, prometheus: { source: { kind: "nacos" as const, endpoint: "http://metrics" }, resource: {}, traffic: { httpRequestsTotal: 2 }, config: {}, naming: {} } } }, + ]; + expect(counterRateSeries(samples, (item) => item.snapshot.prometheus?.traffic.httpRequestsTotal)).toEqual([null, 5, null]); + }); + + it("leaves counter-derived gaps when the Prometheus source changes", () => { + const samples: NacosDashboardSample[] = [ + { + ...sample(1_000, {}), + snapshot: { + ...sample(1_000, {}).snapshot, + prometheus: { source: { kind: "nacos", endpoint: "http://metrics-a" }, resource: {}, traffic: { httpRequestsTotal: 10, httpErrorsTotal: 1, httpDurationSecondsTotal: 1, httpDurationCount: 10 }, config: {}, naming: {} }, + }, + }, + { + ...sample(2_000, {}), + snapshot: { + ...sample(2_000, {}).snapshot, + prometheus: { source: { kind: "nacos", endpoint: "http://metrics-b" }, resource: {}, traffic: { httpRequestsTotal: 100, httpErrorsTotal: 20, httpDurationSecondsTotal: 50, httpDurationCount: 100 }, config: {}, naming: {} }, + }, + }, + ]; + expect(counterRateSeries(samples, (item) => item.snapshot.prometheus?.traffic.httpRequestsTotal)).toEqual([null, null]); + expect( + averageDurationMsSeries( + samples, + (item) => item.snapshot.prometheus?.traffic.httpDurationSecondsTotal, + (item) => item.snapshot.prometheus?.traffic.httpDurationCount, + ), + ).toEqual([null, null]); + expect( + errorRateSeries( + samples, + (item) => item.snapshot.prometheus?.traffic.httpErrorsTotal, + (item) => item.snapshot.prometheus?.traffic.httpRequestsTotal, + ), + ).toEqual([null, null]); + }); + + it("uses the private source fingerprint when redacted endpoints match", () => { + const samples: NacosDashboardSample[] = [ + { + ...sample(1_000, {}), + snapshot: { + ...sample(1_000, {}).snapshot, + prometheus: { source: { kind: "nacos", endpoint: "http://metrics", fingerprint: "source-a" }, resource: {}, traffic: { httpRequestsTotal: 10 }, config: {}, naming: {} }, + }, + }, + { + ...sample(2_000, {}), + snapshot: { + ...sample(2_000, {}).snapshot, + prometheus: { source: { kind: "nacos", endpoint: "http://metrics", fingerprint: "source-b" }, resource: {}, traffic: { httpRequestsTotal: 20 }, config: {}, naming: {} }, + }, + }, + ]; + expect(counterRateSeries(samples, (item) => item.snapshot.prometheus?.traffic.httpRequestsTotal)).toEqual([null, null]); + }); + + it("calculates average latency and error percentage from counter deltas", () => { + const samples: NacosDashboardSample[] = [ + { + ...sample(1_000, {}), + snapshot: { + ...sample(1_000, {}).snapshot, + prometheus: { source: { kind: "nacos", endpoint: "http://metrics" }, resource: {}, traffic: { httpRequestsTotal: 100, httpErrorsTotal: 2, httpDurationSecondsTotal: 10, httpDurationCount: 100 }, config: {}, naming: {} }, + }, + }, + { + ...sample(2_000, {}), + snapshot: { + ...sample(2_000, {}).snapshot, + prometheus: { source: { kind: "nacos", endpoint: "http://metrics" }, resource: {}, traffic: { httpRequestsTotal: 120, httpErrorsTotal: 4, httpDurationSecondsTotal: 14, httpDurationCount: 120 }, config: {}, naming: {} }, + }, + }, + ]; + expect( + averageDurationMsSeries( + samples, + (item) => item.snapshot.prometheus?.traffic.httpDurationSecondsTotal, + (item) => item.snapshot.prometheus?.traffic.httpDurationCount, + ), + ).toEqual([null, 200]); + expect( + errorRateSeries( + samples, + (item) => item.snapshot.prometheus?.traffic.httpErrorsTotal, + (item) => item.snapshot.prometheus?.traffic.httpRequestsTotal, + ), + ).toEqual([null, 10]); + }); + + it("keeps the newest samples within the requested limit", () => { + let samples: NacosDashboardSample[] = []; + for (let at = 1; at <= 4; at++) samples = appendDashboardSample(samples, sample(at, {}), 3); + expect(samples.map((item) => item.at)).toEqual([2, 3, 4]); + }); + + it("normalizes ratio and percentage resource values", () => { + expect(ratioPercent(0.25)).toBe(25); + expect(ratioPercent(25)).toBe(25); + expect(formatDashboardPercent(0.125)).toBe("12.5%"); + expect(formatDashboardPercent(undefined)).toBe("—"); + }); + + it("prefers the dashboard response namespace over the requested fallback", () => { + expect(dashboardNamespaceLabel("prod", undefined)).toBe("prod"); + expect(dashboardNamespaceLabel("", "dev")).toBe("public"); + expect(dashboardNamespaceLabel(undefined, "dev")).toBe("dev"); + expect(dashboardNamespaceLabel(undefined, undefined)).toBe("public"); + }); + + it("derives node health from explicit state when alive is absent", () => { + expect(isHealthyNacosNode({ state: "UP" })).toBe(true); + expect(isHealthyNacosNode({ state: "DOWN" })).toBe(false); + expect(isHealthyNacosNode({ alive: false, state: "UP" })).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index cd3e306d8..3eaf7ae6b 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -345,6 +345,7 @@ export const nacosLoginRNacosConsole = forward("nacosLoginRNacosConsole"); export const nacosListServices = forward("nacosListServices"); export const nacosListInstances = forward("nacosListInstances"); export const nacosUpdateInstance = forward("nacosUpdateInstance"); +export const nacosGetDashboard = forward("nacosGetDashboard"); export const nacosRawRequest = forward("nacosRawRequest"); // Data Transfer diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 0938a8850..1a3f6297f 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -166,6 +166,8 @@ import type { NacosInstanceInfo, NacosInstanceQuery, NacosInstanceUpdate, + NacosDashboardQuery, + NacosDashboardSnapshot, NacosNamespaceCreate, NacosNamespaceInfo, NacosNamespaceUpdate, @@ -2256,6 +2258,10 @@ export async function nacosUpdateInstance(connectionId: string, req: NacosInstan return post("/api/nacos/instances/update", { connectionId, req }); } +export async function nacosGetDashboard(connectionId: string, query: NacosDashboardQuery): Promise { + return post("/api/nacos/dashboard", { connectionId, query }); +} + export async function nacosRawRequest(connectionId: string, req: NacosRawRequest): Promise { return post("/api/nacos/raw", { connectionId, req }); } diff --git a/apps/desktop/src/lib/backend/nacos-tauri.ts b/apps/desktop/src/lib/backend/nacos-tauri.ts index d5db2efe6..5b1107b7a 100644 --- a/apps/desktop/src/lib/backend/nacos-tauri.ts +++ b/apps/desktop/src/lib/backend/nacos-tauri.ts @@ -17,6 +17,8 @@ import type { NacosConfigRollbackRequest, NacosConfigUpsert, NacosConnectionInfo, + NacosDashboardQuery, + NacosDashboardSnapshot, NacosRNacosConsoleCaptcha, NacosInstanceInfo, NacosInstanceQuery, @@ -127,6 +129,10 @@ export async function nacosUpdateInstance(connectionId: string, req: NacosInstan return invoke("nacos_update_instance", { connectionId, req }); } +export async function nacosGetDashboard(connectionId: string, query: NacosDashboardQuery): Promise { + return invoke("nacos_get_dashboard", { connectionId, query }); +} + export async function nacosRawRequest(connectionId: string, req: NacosRawRequest): Promise { return invoke("nacos_raw_request", { connectionId, req }); } diff --git a/apps/desktop/src/lib/nacos/nacosAdmin.ts b/apps/desktop/src/lib/nacos/nacosAdmin.ts index b4e566c3b..20a9ebfdc 100644 --- a/apps/desktop/src/lib/nacos/nacosAdmin.ts +++ b/apps/desktop/src/lib/nacos/nacosAdmin.ts @@ -91,9 +91,10 @@ export function normalizeNacosEndpoint(input: string, options: NacosEndpointNorm contextPath = hasNacosSuffix ? rawPath : options.contextPath?.trim() || "/nacos"; } else if (detectedVersion === "v3" || hasNacos3UiSuffix) { contextPath = rawPath.replace(/\/(?:next(?:\/index\.html)?|index\.html)$/i, ""); + if (!contextPath) contextPath = options.contextPath?.trim() || "/nacos"; if (hasNacos3UiSuffix) warnings.push("The Nacos 3 console route was removed from the API context."); } else if (!contextPath) { - contextPath = options.contextPath?.trim() || (detectedVersion === "v2" ? "/nacos" : ""); + contextPath = options.contextPath?.trim() || (versionMode === "v2" ? "/nacos" : ""); } url.pathname = "/"; url.search = ""; @@ -107,6 +108,27 @@ export function normalizeNacosEndpoint(input: string, options: NacosEndpointNorm }; } +export function normalizeNacosMetricsUrl(input: string): string { + const value = input.trim(); + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("Nacos Prometheus metrics URL must be a valid absolute URL"); + } + if (!["http:", "https:"].includes(url.protocol)) throw new Error("Nacos Prometheus metrics URL must use HTTP or HTTPS"); + if (url.username || url.password) throw new Error("Nacos Prometheus metrics URL must not contain embedded credentials"); + if (value.includes("#")) throw new Error("Nacos Prometheus metrics URL must not contain a fragment"); + return url.toString(); +} + +export function nacosMetricsCandidates(serverAddr: string, contextPath: string, implementation: NacosImplementation): string[] { + const base = serverAddr.replace(/\/+$/, ""); + const context = contextPath.replace(/\/+$/, ""); + const raw = implementation === "rnacos" ? [`${base}/metrics`, `${base}${context}/metrics`, `${base}/rnacos/metrics`] : [`${base}${context}/actuator/prometheus`, `${base}/nacos/actuator/prometheus`, `${base}/actuator/prometheus`]; + return [...new Set(raw.map((value) => new URL(value).toString()))]; +} + /** * r-nacos exposes its Nacos-compatible OpenAPI on the service port (8848) at * `/nacos`; `/rnacos` on 10848 is the separate web console and rejects these diff --git a/apps/desktop/src/lib/nacos/nacosDashboard.ts b/apps/desktop/src/lib/nacos/nacosDashboard.ts new file mode 100644 index 000000000..0f7af5596 --- /dev/null +++ b/apps/desktop/src/lib/nacos/nacosDashboard.ts @@ -0,0 +1,109 @@ +import type { NacosDashboardMetrics, NacosDashboardSnapshot } from "@/types/nacos"; + +export const MAX_NACOS_DASHBOARD_SAMPLES = 60; + +export interface NacosDashboardSample { + at: number; + snapshot: NacosDashboardSnapshot; +} + +export type NacosDashboardMetricKey = keyof NacosDashboardMetrics; + +export type NullableMetric = number | null; +export type NacosMetricSelector = (sample: NacosDashboardSample) => number | undefined; + +export function dashboardNamespaceLabel(snapshotNamespace: string | undefined, requestedNamespace: string | undefined): string { + return (snapshotNamespace !== undefined ? snapshotNamespace : requestedNamespace) || "public"; +} + +export function hasContinuousPrometheusSource(previous: NacosDashboardSample, current: NacosDashboardSample): boolean { + const previousSource = previous.snapshot.prometheus?.source; + const currentSource = current.snapshot.prometheus?.source; + const previousIdentity = previousSource?.fingerprint ?? previousSource?.endpoint; + const currentIdentity = currentSource?.fingerprint ?? currentSource?.endpoint; + return !!previousSource && !!currentSource && previousSource.kind === currentSource.kind && previousIdentity === currentIdentity; +} + +export function finiteMetric(value: number | undefined): NullableMetric { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +export function dashboardMetric(sample: NacosDashboardSample | undefined, key: NacosDashboardMetricKey): NullableMetric { + const value = sample?.snapshot.metrics?.[key]; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +export function dashboardSeries(samples: readonly NacosDashboardSample[], key: NacosDashboardMetricKey): NullableMetric[] { + return samples.map((sample) => dashboardMetric(sample, key)); +} + +export function gaugeSeries(samples: readonly NacosDashboardSample[], selector: NacosMetricSelector): NullableMetric[] { + return samples.map((sample) => finiteMetric(selector(sample))); +} + +export function counterRateSeries(samples: readonly NacosDashboardSample[], selector: NacosMetricSelector): NullableMetric[] { + return samples.map((sample, index) => { + if (index === 0) return null; + const previousSample = samples[index - 1]; + if (!hasContinuousPrometheusSource(previousSample, sample)) return null; + const previous = finiteMetric(selector(previousSample)); + const current = finiteMetric(selector(sample)); + const elapsedSeconds = (sample.at - samples[index - 1].at) / 1_000; + if (previous === null || current === null || current < previous || elapsedSeconds <= 0) return null; + return (current - previous) / elapsedSeconds; + }); +} + +export function averageDurationMsSeries(samples: readonly NacosDashboardSample[], sumSelector: NacosMetricSelector, countSelector: NacosMetricSelector): NullableMetric[] { + return samples.map((sample, index) => { + if (index === 0) return null; + const previousSample = samples[index - 1]; + if (!hasContinuousPrometheusSource(previousSample, sample)) return null; + const previousSum = finiteMetric(sumSelector(previousSample)); + const currentSum = finiteMetric(sumSelector(sample)); + const previousCount = finiteMetric(countSelector(samples[index - 1])); + const currentCount = finiteMetric(countSelector(sample)); + if (previousSum === null || currentSum === null || previousCount === null || currentCount === null) return null; + const sumDelta = currentSum - previousSum; + const countDelta = currentCount - previousCount; + if (sumDelta < 0 || countDelta <= 0) return null; + return (sumDelta / countDelta) * 1_000; + }); +} + +export function errorRateSeries(samples: readonly NacosDashboardSample[], errorSelector: NacosMetricSelector, requestSelector: NacosMetricSelector): NullableMetric[] { + return samples.map((sample, index) => { + if (index === 0) return null; + const previousSample = samples[index - 1]; + if (!hasContinuousPrometheusSource(previousSample, sample)) return null; + const previousErrors = finiteMetric(errorSelector(previousSample)); + const currentErrors = finiteMetric(errorSelector(sample)); + const previousRequests = finiteMetric(requestSelector(samples[index - 1])); + const currentRequests = finiteMetric(requestSelector(sample)); + if (previousErrors === null || currentErrors === null || previousRequests === null || currentRequests === null) return null; + const errorDelta = currentErrors - previousErrors; + const requestDelta = currentRequests - previousRequests; + if (errorDelta < 0 || requestDelta <= 0) return null; + return Math.min(100, Math.max(0, (errorDelta / requestDelta) * 100)); + }); +} + +export function appendDashboardSample(samples: readonly NacosDashboardSample[], sample: NacosDashboardSample, maxSamples = MAX_NACOS_DASHBOARD_SAMPLES): NacosDashboardSample[] { + const next = [...samples, sample]; + return next.length > maxSamples ? next.slice(next.length - maxSamples) : next; +} + +export function ratioPercent(value: number | undefined): number | null { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null; + return Math.min(100, value <= 1 ? value * 100 : value); +} + +export function formatDashboardPercent(value: number | undefined): string { + const percent = ratioPercent(value); + return percent === null ? "—" : `${percent.toFixed(1)}%`; +} + +export function isHealthyNacosNode(node: { alive?: boolean; state?: string }): boolean { + if (typeof node.alive === "boolean") return node.alive; + return ["UP", "ONLINE", "HEALTHY"].includes(node.state?.trim().toUpperCase() ?? ""); +} diff --git a/apps/desktop/src/stores/__tests__/queryStore.switchTab.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.switchTab.spec.ts index e2c1fd1fb..79577a177 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.switchTab.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.switchTab.spec.ts @@ -5,6 +5,11 @@ describe("queryStore switchTab", () => { beforeEach(() => { vi.resetModules(); vi.unstubAllGlobals(); + vi.stubGlobal("localStorage", { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + }); setActivePinia(createPinia()); }); @@ -87,4 +92,16 @@ describe("queryStore switchTab", () => { queryStore.updateDataGridLocalColumnFilters(tabId, {}); expect(tab.result.local_column_filters).toBeUndefined(); }); + + it("opens one reusable Nacos dashboard tab per connection", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const queryStore = useQueryStore(); + + const tabId = queryStore.openNacosDashboard("nacos-1"); + const reopenedTabId = queryStore.openNacosDashboard("nacos-1"); + + expect(reopenedTabId).toBe(tabId); + expect(queryStore.tabs.filter((tab) => tab.mode === "nacos-dashboard")).toHaveLength(1); + expect(queryStore.activeTabId).toBe(tabId); + }); }); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index efc815e28..2f6398d70 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1320,6 +1320,31 @@ export const useQueryStore = defineStore("query", () => { return id; } + function openNacosDashboard(connectionId: string) { + const existing = tabs.value.find((tab) => tab.mode === "nacos-dashboard" && tab.connectionId === connectionId); + if (existing) { + switchTab(existing.id); + return existing.id; + } + + const conn = useConnectionStore().getConfig(connectionId); + const id = uuid(); + const tab: QueryTab = { + id, + title: conn?.name ? `${conn.name} - ${t("serverDashboard.title")}` : t("serverDashboard.title"), + connectionId, + database: conn?.database || "", + sql: "", + isExecuting: false, + isCancelling: false, + isExplaining: false, + mode: "nacos-dashboard", + }; + tabs.value.push(tab); + activeTabId.value = id; + return id; + } + function openDamengJobAdmin(connectionId: string) { const existing = tabs.value.find((tab) => tab.mode === "dameng-jobs" && tab.connectionId === connectionId); if (existing) { @@ -4644,6 +4669,7 @@ export const useQueryStore = defineStore("query", () => { openProcessList, openMysqlDashboard, openPostgresDashboard, + openNacosDashboard, openDamengJobAdmin, openMqAdmin, openNacosAdmin, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index cc9442be6..25aa86c1e 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -898,7 +898,7 @@ export interface QueryTab { explainClientSessionId?: string; /** Invalidates tab-scoped completion metadata after session context changes. */ completionContextVersion?: number; - mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "zookeeper" | "mq" | "nacos" | "objects" | "structure" | "users" | "dameng-jobs" | "processlist" | "mysql-dashboard" | "postgres-dashboard"; + mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "zookeeper" | "mq" | "nacos" | "nacos-dashboard" | "objects" | "structure" | "users" | "dameng-jobs" | "processlist" | "mysql-dashboard" | "postgres-dashboard"; mqTenant?: string; mqInitialTab?: "topics"; nacosNamespace?: string; diff --git a/apps/desktop/src/types/nacos.ts b/apps/desktop/src/types/nacos.ts index ac049c6ca..15ebd2be0 100644 --- a/apps/desktop/src/types/nacos.ts +++ b/apps/desktop/src/types/nacos.ts @@ -51,6 +51,7 @@ export interface NacosAuthConfig { export type NacosImplementation = "nacos" | "rnacos"; export type NacosVersionMode = "auto" | "v2" | "v3"; +export type NacosMetricsMode = "auto" | "disabled" | "custom"; export type NacosRNacosConsoleAuth = { kind: "inherit" } | { kind: "usernamePassword"; username: string; password: string }; export interface NacosAdminConfig { @@ -65,6 +66,8 @@ export interface NacosAdminConfig { rnacosConsoleAuth?: NacosRNacosConsoleAuth; auth?: NacosAuthConfig; tlsSkipVerify?: boolean; + metricsMode?: NacosMetricsMode; + metricsUrl?: string; pageSize?: number; } @@ -344,6 +347,124 @@ export interface NacosInstanceUpdate { metadata?: unknown; } +export interface NacosDashboardQuery { + namespace?: string; +} + +export interface NacosDashboardMetrics { + status?: string; + serviceCount?: number; + instanceCount?: number; + subscribeCount?: number; + raftNotifyTaskCount?: number; + responsibleServiceCount?: number; + responsibleInstanceCount?: number; + clientCount?: number; + connectionBasedClientCount?: number; + ephemeralIpPortClientCount?: number; + persistentIpPortClientCount?: number; + responsibleClientCount?: number; + cpu?: number; + load?: number; + mem?: number; +} + +export interface NacosPrometheusSource { + kind: NacosImplementation; + endpoint: string; + fingerprint?: string; +} + +export interface NacosPrometheusResourceMetrics { + cpuRatio?: number; + memoryRatio?: number; + memoryUsedBytes?: number; + memoryMaxBytes?: number; + rssBytes?: number; + vmsBytes?: number; + systemTotalMemoryBytes?: number; + load1m?: number; + jvmDaemonThreads?: number; + gcPauseCount?: number; +} + +export interface NacosPrometheusTrafficMetrics { + httpRequestsTotal?: number; + grpcRequestsTotal?: number; + httpErrorsTotal?: number; + grpcErrorsTotal?: number; + httpDurationSecondsTotal?: number; + httpDurationCount?: number; + grpcDurationSecondsTotal?: number; + grpcDurationCount?: number; + httpP50Ms?: number; + httpP95Ms?: number; + httpP99Ms?: number; + grpcP50Ms?: number; + grpcP95Ms?: number; + grpcP99Ms?: number; + executorPoolSize?: number; + executorActiveCount?: number; + executorQueuedTasks?: number; +} + +export interface NacosPrometheusConfigMetrics { + configCount?: number; + getConfigTotal?: number; + publishTotal?: number; + longPolling?: number; + listenerClients?: number; + listenerKeys?: number; + notifyTasks?: number; + notifyClientTasks?: number; + dumpTasks?: number; + subscriberCount?: number; +} + +export interface NacosPrometheusNamingMetrics { + serviceCount?: number; + instanceCount?: number; + subscriberCount?: number; + connectionCount?: number; + totalPush?: number; + failedPush?: number; + emptyPush?: number; + pushPendingTasks?: number; + avgPushCostMs?: number; + maxPushCostMs?: number; + leaderStatus?: number; +} + +export interface NacosPrometheusSnapshot { + source: NacosPrometheusSource; + resource: NacosPrometheusResourceMetrics; + traffic: NacosPrometheusTrafficMetrics; + config: NacosPrometheusConfigMetrics; + naming: NacosPrometheusNamingMetrics; +} + +export interface NacosClusterNode { + address: string; + ip?: string; + port?: number; + state?: string; + alive?: boolean; + site?: string; + weight?: number; + lastRefreshTime?: string; +} + +export interface NacosDashboardSnapshot { + namespace: string; + namespaceCount?: number; + configCount?: number; + serviceCount?: number; + metrics?: NacosDashboardMetrics; + prometheus?: NacosPrometheusSnapshot; + nodes: NacosClusterNode[]; + warnings: string[]; +} + export interface NacosRawRequest { method: string; path: string; diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml index d05abf5b4..29a4cf453 100644 --- a/crates/dbx-core/Cargo.toml +++ b/crates/dbx-core/Cargo.toml @@ -70,6 +70,7 @@ rustls-pemfile = "2.2" duckdb = { version = "1.3.2", optional = true } tiberius = { version = "0.12.3", default-features = false, features = ["tds73", "chrono", "rust_decimal", "rustls", "sql-browser-tokio"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "socks"] } +prometheus-parse = "=0.2.5" futures = "0.3" iana-time-zone = "0.1" mongodb = "3.2.5" diff --git a/crates/dbx-core/src/nacos/batch.rs b/crates/dbx-core/src/nacos/batch.rs index 4f5f8f17b..6f92fc890 100644 --- a/crates/dbx-core/src/nacos/batch.rs +++ b/crates/dbx-core/src/nacos/batch.rs @@ -626,6 +626,10 @@ mod tests { Err("unused".to_string()) } + async fn get_dashboard(&self, _: NacosDashboardQuery) -> Result { + Err("unused".to_string()) + } + async fn raw_request(&self, _: NacosRawRequest) -> Result { Err("unused".to_string()) } diff --git a/crates/dbx-core/src/nacos/config.rs b/crates/dbx-core/src/nacos/config.rs index fc314b5ae..75724d36c 100644 --- a/crates/dbx-core/src/nacos/config.rs +++ b/crates/dbx-core/src/nacos/config.rs @@ -32,6 +32,15 @@ pub enum NacosVersionMode { V3, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub enum NacosMetricsMode { + #[default] + Auto, + Disabled, + Custom, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum NacosRNacosConsoleAuth { @@ -73,6 +82,10 @@ pub struct NacosAdminConfig { pub auth: NacosAuthConfig, #[serde(default)] pub tls_skip_verify: bool, + #[serde(default)] + pub metrics_mode: NacosMetricsMode, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub metrics_url: String, #[serde(default = "default_page_size")] pub page_size: u32, #[serde(skip)] @@ -113,6 +126,8 @@ impl NacosAdminConfig { NacosAuthConfig::UsernamePassword { username: cfg.username.clone(), password: cfg.password.clone() } }, tls_skip_verify: false, + metrics_mode: NacosMetricsMode::Auto, + metrics_url: String::new(), page_size: default_page_size(), connect_override: None, } @@ -130,7 +145,19 @@ impl NacosAdminConfig { } else { self.display_server_addr = normalize_endpoint_url(&self.display_server_addr, "Nacos display address")?; } + let context_path_is_explicit_root = self.context_path.trim() == "/"; self.context_path = normalize_context_path(&self.context_path); + // Nacos 3 separates the console (normally :8080) from the server-side + // Admin API (normally :8848/nacos). Older DBX connection records did + // not persist the default server context, so repair only explicit + // Nacos 3 profiles here while preserving custom contexts. + if self.context_path.is_empty() + && !context_path_is_explicit_root + && matches!(self.implementation, Some(NacosImplementation::Nacos)) + && matches!(self.version_mode, Some(NacosVersionMode::V3)) + { + self.context_path = "/nacos".to_string(); + } self.rnacos_console_addr = if self.rnacos_console_addr.trim().is_empty() { String::new() } else { @@ -144,6 +171,10 @@ impl NacosAdminConfig { return Err("r-nacos console username is empty".to_string()); } } + self.metrics_url = match self.metrics_mode { + NacosMetricsMode::Custom => normalize_metrics_url(&self.metrics_url)?, + NacosMetricsMode::Auto | NacosMetricsMode::Disabled => String::new(), + }; if self.page_size == 0 { self.page_size = default_page_size(); } @@ -206,6 +237,24 @@ impl NacosAdminConfig { } } +fn normalize_metrics_url(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err("Nacos Prometheus metrics URL is empty".to_string()); + } + let url = reqwest::Url::parse(value).map_err(|e| format!("Nacos Prometheus metrics URL is invalid: {e}"))?; + if !matches!(url.scheme(), "http" | "https") { + return Err("Nacos Prometheus metrics URL must use http or https".to_string()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("Nacos Prometheus metrics URL must not contain embedded credentials".to_string()); + } + if url.fragment().is_some() { + return Err("Nacos Prometheus metrics URL must not contain a fragment".to_string()); + } + Ok(url.to_string()) +} + fn normalize_endpoint_url(value: &str, label: &str) -> Result { let mut url = reqwest::Url::parse(value.trim()).map_err(|e| format!("{label} is invalid: {e}"))?; if !url.username().is_empty() || url.password().is_some() { @@ -336,6 +385,41 @@ mod tests { assert!(err.contains("must not contain embedded credentials")); } + #[test] + fn defaults_prometheus_metrics_to_auto_and_validates_custom_urls() { + let parsed = NacosAdminConfig::from_connection(&connection_with_external(serde_json::json!({ + "serverAddr": "http://127.0.0.1:8818" + }))) + .unwrap(); + assert_eq!(parsed.metrics_mode, NacosMetricsMode::Auto); + assert!(parsed.metrics_url.is_empty()); + + let parsed = NacosAdminConfig::from_connection(&connection_with_external(serde_json::json!({ + "serverAddr": "http://127.0.0.1:8848", + "metricsMode": "custom", + "metricsUrl": "http://127.0.0.1:8818/nacos/actuator/prometheus?node=a" + }))) + .unwrap(); + assert_eq!(parsed.metrics_mode, NacosMetricsMode::Custom); + assert_eq!(parsed.metrics_url, "http://127.0.0.1:8818/nacos/actuator/prometheus?node=a"); + + for metrics_url in [ + "", + "file:///tmp/metrics", + "http://user:secret@127.0.0.1:8818/metrics", + "http://127.0.0.1:8818/metrics#private", + "http://127.0.0.1:8818/metrics#", + ] { + let error = NacosAdminConfig::from_connection(&connection_with_external(serde_json::json!({ + "serverAddr": "http://127.0.0.1:8848", + "metricsMode": "custom", + "metricsUrl": metrics_url + }))) + .unwrap_err(); + assert!(error.contains("Prometheus metrics URL")); + } + } + #[test] fn missing_external_context_path_defaults_to_root() { let cfg = connection_with_external(serde_json::json!({ @@ -347,6 +431,33 @@ mod tests { assert_eq!(parsed.context_path, ""); } + #[test] + fn explicit_nacos_v3_defaults_to_server_admin_context() { + let cfg = connection_with_external(serde_json::json!({ + "implementation": "nacos", + "versionMode": "v3", + "serverAddr": "http://127.0.0.1:8848", + "auth": { "kind": "none" } + })); + + let parsed = NacosAdminConfig::from_connection(&cfg).unwrap(); + assert_eq!(parsed.context_path, "/nacos"); + } + + #[test] + fn explicit_nacos_v3_root_context_is_preserved() { + let cfg = connection_with_external(serde_json::json!({ + "implementation": "nacos", + "versionMode": "v3", + "serverAddr": "http://127.0.0.1:8848", + "contextPath": "/", + "auth": { "kind": "none" } + })); + + let parsed = NacosAdminConfig::from_connection(&cfg).unwrap(); + assert_eq!(parsed.context_path, ""); + } + #[test] fn falls_back_to_connection_fields() { let mut cfg = connection_with_external(serde_json::Value::Null); diff --git a/crates/dbx-core/src/nacos/http.rs b/crates/dbx-core/src/nacos/http.rs index 004e0df22..b5b0e386b 100644 --- a/crates/dbx-core/src/nacos/http.rs +++ b/crates/dbx-core/src/nacos/http.rs @@ -597,7 +597,7 @@ impl NacosOpenApiAdmin { fn api_path_allowed(&self, path: &str) -> bool { match self.cfg.version_mode.as_ref() { Some(NacosVersionMode::V2) => !path.starts_with("/v3/"), - Some(NacosVersionMode::V3) => !path.starts_with("/v1/"), + Some(NacosVersionMode::V3) => !path.starts_with("/v1/") && !path.starts_with("/v2/"), Some(NacosVersionMode::Auto) | None => true, } } @@ -834,6 +834,39 @@ impl NacosOpenApiAdmin { instances.retain(|instance| seen.insert((instance.ip.clone(), instance.port, instance.cluster_name.clone()))); Ok(instances) } + + async fn get_dashboard_nodes(&self) -> Result, String> { + // r-nacos does not implement the official Nacos cluster-node Admin + // APIs. An empty list represents this unsupported optional capability + // without turning every dashboard refresh into a false warning. + if self.is_explicit_rnacos() { + return Ok(Vec::new()); + } + + self.get_json_from_candidates( + "load Nacos cluster nodes", + vec![ + ("/v3/admin/core/cluster/node/list", Vec::new()), + ("/v2/core/cluster/node/list", Vec::new()), + ("/v1/core/cluster/nodes", Vec::new()), + ("/v1/ns/operator/servers", Vec::new()), + ], + ) + .await + .map(parse_cluster_nodes) + } + + fn dashboard_warning(&self, error: String) -> String { + if matches!(self.cfg.version_mode, Some(NacosVersionMode::V3)) + && (error.contains("[contextPathMismatch]") || error.contains("[apiVersionMismatch]")) + { + return format!( + "{error} Check the connection address: Nacos 3 dashboard APIs use the Server / Admin API endpoint \ + (normally http://host:8848/nacos), not the port 8080 Console." + ); + } + error + } } fn qualified_nacos_service_name(service_name: &str, group_name: Option<&str>) -> String { @@ -1570,6 +1603,102 @@ impl NacosAdmin for NacosOpenApiAdmin { .await } + async fn get_dashboard(&self, query: NacosDashboardQuery) -> Result { + let namespace = self.namespace(query.namespace.as_deref()); + let metrics_future = self.get_json_from_candidates( + "load Nacos dashboard metrics", + vec![ + ("/v3/admin/ns/ops/metrics", vec![("onlyStatus".to_string(), "false".to_string())]), + ("/v2/ns/operator/metrics", vec![("onlyStatus".to_string(), "false".to_string())]), + ("/v1/ns/operator/metrics", Vec::new()), + ], + ); + let nodes_future = self.get_dashboard_nodes(); + let namespaces_future = self.list_namespaces(); + let configs_future = self.list_configs(NacosConfigQuery { + namespace: Some(namespace.clone()), + group: None, + data_id: None, + app_name: None, + search: None, + page_no: Some(1), + page_size: Some(1), + }); + let services_future = self.list_services(NacosServiceQuery { + namespace: Some(namespace.clone()), + group_name: None, + service_name: None, + page_no: Some(1), + page_size: Some(1), + }); + let prometheus_future = crate::nacos::prometheus::scrape(&self.http, &self.cfg); + + let (metrics_result, nodes_result, namespaces_result, configs_result, services_result, prometheus_result) = tokio::join!( + metrics_future, + nodes_future, + namespaces_future, + configs_future, + services_future, + prometheus_future + ); + let mut warnings = Vec::new(); + + let mut metrics = match metrics_result { + Ok(value) => Some(parse_dashboard_metrics(value)), + Err(error) => { + warnings.push(self.dashboard_warning(error)); + None + } + }; + let nodes = match nodes_result { + Ok(value) => value, + Err(error) => { + warnings.push(self.dashboard_warning(error)); + Vec::new() + } + }; + let namespace_count = match namespaces_result { + Ok(items) => Some(items.len() as u64), + Err(error) => { + warnings.push(error); + None + } + }; + let config_count = match configs_result { + Ok(result) => Some(result.total_count), + Err(error) => { + warnings.push(error); + None + } + }; + let service_count = match services_result { + Ok(result) => Some(result.total_count), + Err(error) => { + warnings.push(error); + None + } + }; + let prometheus = match prometheus_result { + Ok(value) => value, + Err(error) => { + warnings.push(error); + None + } + }; + merge_prometheus_dashboard(&mut metrics, prometheus.as_ref()); + + Ok(NacosDashboardSnapshot { + namespace, + namespace_count, + config_count, + service_count, + metrics, + prometheus, + nodes, + warnings, + }) + } + async fn raw_request(&self, req: NacosRawRequest) -> Result { validate_raw_api_path(&req.path)?; let method = reqwest::Method::from_bytes(req.method.to_ascii_uppercase().as_bytes()) @@ -1660,6 +1789,107 @@ fn parse_namespaces(value: Value) -> Vec { namespaces } +fn parse_dashboard_metrics(value: Value) -> NacosDashboardMetrics { + let data = value.get("data").unwrap_or(&value); + NacosDashboardMetrics { + status: optional_string_field(data, &["status"]) + .or_else(|| data.as_str().map(str::to_string)) + .filter(|value| !value.trim().is_empty()), + service_count: optional_u64_field(data, &["serviceCount"]), + instance_count: optional_u64_field(data, &["instanceCount"]), + subscribe_count: optional_u64_field(data, &["subscribeCount"]), + raft_notify_task_count: optional_u64_field(data, &["raftNotifyTaskCount"]), + responsible_service_count: optional_u64_field(data, &["responsibleServiceCount"]), + responsible_instance_count: optional_u64_field(data, &["responsibleInstanceCount"]), + client_count: optional_u64_field(data, &["clientCount"]), + connection_based_client_count: optional_u64_field(data, &["connectionBasedClientCount"]), + ephemeral_ip_port_client_count: optional_u64_field(data, &["ephemeralIpPortClientCount"]), + persistent_ip_port_client_count: optional_u64_field(data, &["persistentIpPortClientCount"]), + responsible_client_count: optional_u64_field(data, &["responsibleClientCount"]), + cpu: optional_f64_field(data, &["cpu"]), + load: optional_f64_field(data, &["load"]), + mem: optional_f64_field(data, &["mem"]), + } +} + +fn merge_prometheus_dashboard( + metrics: &mut Option, + prometheus: Option<&NacosPrometheusSnapshot>, +) { + let Some(prometheus) = prometheus else { + return; + }; + let metrics = metrics.get_or_insert_with(NacosDashboardMetrics::default); + if let Some(value) = finite_u64(prometheus.naming.instance_count) { + metrics.instance_count = Some(value); + } + if let Some(value) = finite_u64(prometheus.naming.subscriber_count) { + metrics.subscribe_count = Some(value); + } + if let Some(value) = finite_u64(prometheus.naming.connection_count) { + metrics.client_count = Some(value); + metrics.connection_based_client_count = Some(value); + } + if let Some(value) = prometheus.resource.cpu_ratio { + metrics.cpu = Some(value); + } + if let Some(value) = prometheus.resource.memory_ratio { + metrics.mem = Some(value); + } + if let Some(value) = prometheus.resource.load_1m { + metrics.load = Some(value); + } +} + +fn finite_u64(value: Option) -> Option { + value.filter(|value| value.is_finite() && *value >= 0.0 && *value <= u64::MAX as f64).map(|value| value as u64) +} + +fn parse_cluster_nodes(value: Value) -> Vec { + let data = value.get("data").unwrap_or(&value); + let items = data + .as_array() + .cloned() + .or_else(|| data.get("servers").and_then(Value::as_array).cloned()) + .or_else(|| data.get("members").and_then(Value::as_array).cloned()) + .or_else(|| data.get("nodes").and_then(Value::as_array).cloned()) + .or_else(|| data.get("pageItems").and_then(Value::as_array).cloned()) + .or_else(|| value.get("servers").and_then(Value::as_array).cloned()) + .unwrap_or_default(); + items + .into_iter() + .map(|item| { + let ip = optional_string_field(&item, &["ip"]); + let port = optional_u64_field(&item, &["port", "servePort"]).and_then(|port| u16::try_from(port).ok()); + let address = optional_string_field(&item, &["address", "key"]) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| match (&ip, port) { + (Some(ip), Some(port)) => format!("{ip}:{port}"), + (Some(ip), None) => ip.clone(), + _ => "-".to_string(), + }); + let state = optional_string_field(&item, &["state", "status"]); + let alive = optional_bool_field(&item, &["alive", "healthy"]).or_else(|| { + state.as_ref().map(|state| matches!(state.to_ascii_uppercase().as_str(), "UP" | "ONLINE" | "HEALTHY")) + }); + NacosClusterNode { + address, + ip, + port, + state, + alive, + site: optional_string_field(&item, &["site"]), + weight: optional_f64_field(&item, &["weight", "adWeight"]), + last_refresh_time: optional_string_field(&item, &["lastRefreshTime", "lastRefTimeStr", "lastRefTime"]) + .or_else(|| { + item.get("extendInfo") + .and_then(|extend_info| optional_string_field(extend_info, &["lastRefreshTime"])) + }), + } + }) + .collect() +} + fn normalize_api_path(path: &str) -> String { let trimmed = path.trim(); if trimmed.starts_with('/') { @@ -1759,10 +1989,34 @@ async fn error_for_status(resp: reqwest::Response, path: &str) -> Result String { + let detail = detail.trim(); + if detail.is_empty() { + return String::new(); + } + if detail.to_ascii_lowercase().contains(" String { format!("{NACOS_ERROR_PREFIX}[{kind}]: {message}") } @@ -2188,7 +2442,29 @@ fn normalize_config_format(value: String) -> String { } fn optional_u64_field(value: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| value.get(*key)).and_then(Value::as_u64) + keys.iter().find_map(|key| value.get(*key)).and_then(|value| { + value + .as_u64() + .or_else(|| value.as_i64().and_then(|value| u64::try_from(value).ok())) + .or_else(|| value.as_str().and_then(|value| value.trim().parse().ok())) + }) +} + +fn optional_f64_field(value: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| value.get(*key)) + .and_then(|value| value.as_f64().or_else(|| value.as_str().and_then(|value| value.trim().parse().ok()))) + .filter(|value| value.is_finite()) +} + +fn optional_bool_field(value: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| value.get(*key)).and_then(|value| { + value.as_bool().or_else(|| match value.as_str()?.trim().to_ascii_lowercase().as_str() { + "true" | "up" | "online" | "healthy" => Some(true), + "false" | "down" | "offline" | "unhealthy" => Some(false), + _ => None, + }) + }) } fn optional_i64_field(value: &Value, keys: &[&str]) -> Option { @@ -2311,6 +2587,8 @@ mod tests { rnacos_console_auth: Default::default(), auth: NacosAuthConfig::None, tls_skip_verify: false, + metrics_mode: Default::default(), + metrics_url: String::new(), page_size: 100, connect_override: None, } @@ -2334,6 +2612,124 @@ mod tests { server.await.unwrap(); } + #[test] + fn explicit_v3_rejects_legacy_admin_api_paths() { + let mut config = test_admin_config("http://127.0.0.1:8848".to_string()); + config.version_mode = Some(NacosVersionMode::V3); + let admin = NacosOpenApiAdmin::new(config).unwrap(); + + assert!(admin.api_path_allowed("/v3/admin/ns/ops/metrics")); + assert!(admin.api_path_allowed("/health")); + assert!(!admin.api_path_allowed("/v2/ns/operator/metrics")); + assert!(!admin.api_path_allowed("/v1/ns/operator/metrics")); + } + + #[test] + fn compacts_html_error_pages_in_admin_warnings() { + assert_eq!( + compact_response_detail("

HTTP Status 404

"), + "HTML error page" + ); + assert!(compact_response_detail(&"x".repeat(600)).ends_with('…')); + } + + #[test] + fn gives_nacos_v3_dashboard_endpoint_guidance() { + let mut config = test_admin_config("http://127.0.0.1:8080".to_string()); + config.version_mode = Some(NacosVersionMode::V3); + let admin = NacosOpenApiAdmin::new(config).unwrap(); + + let warning = + admin.dashboard_warning("NACOS_ERROR[contextPathMismatch]: No static resource v3/admin".to_string()); + assert!(warning.contains("http://host:8848/nacos")); + assert!(warning.contains("not the port 8080 Console")); + } + + #[test] + fn prometheus_metrics_preserve_namespace_dashboard_values() { + let mut metrics = Some(NacosDashboardMetrics { + service_count: Some(1), + instance_count: Some(2), + cpu: Some(0.1), + ..Default::default() + }); + let config_count = Some(3); + let service_count = Some(1); + let prometheus = NacosPrometheusSnapshot { + resource: NacosPrometheusResourceMetrics { cpu_ratio: Some(0.5), ..Default::default() }, + config: NacosPrometheusConfigMetrics { config_count: Some(7.0), ..Default::default() }, + naming: NacosPrometheusNamingMetrics { + service_count: Some(8.0), + instance_count: Some(9.0), + ..Default::default() + }, + ..Default::default() + }; + + merge_prometheus_dashboard(&mut metrics, Some(&prometheus)); + + let metrics = metrics.unwrap(); + assert_eq!(config_count, Some(3)); + assert_eq!(service_count, Some(1)); + assert_eq!(metrics.service_count, Some(1)); + assert_eq!(metrics.instance_count, Some(9)); + assert_eq!(metrics.cpu, Some(0.5)); + } + + #[tokio::test] + async fn nacos_v2_dashboard_uses_core_cluster_node_api() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + assert_eq!(read_request_target(&mut socket).await, "/nacos/v2/core/cluster/node/list"); + write_json_response(&mut socket, r#"{"code":0,"data":[{"address":"127.0.0.1:8848","state":"UP"}]}"#).await; + }); + let mut config = test_admin_config(format!("http://{address}")); + config.context_path = "/nacos".to_string(); + config.version_mode = Some(NacosVersionMode::V2); + let admin = NacosOpenApiAdmin::new(config).unwrap(); + + let nodes = admin.get_dashboard_nodes().await.unwrap(); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].address, "127.0.0.1:8848"); + server.await.unwrap(); + } + + #[tokio::test] + async fn nacos_v2_dashboard_falls_back_to_legacy_core_cluster_nodes_api() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + assert_eq!(read_request_target(&mut socket).await, "/nacos/v2/core/cluster/node/list"); + write_not_found_response(&mut socket).await; + + let (mut socket, _) = listener.accept().await.unwrap(); + assert_eq!(read_request_target(&mut socket).await, "/nacos/v1/core/cluster/nodes"); + write_json_response(&mut socket, r#"{"code":200,"data":[{"address":"127.0.0.1:8848","state":"UP"}]}"#) + .await; + }); + let mut config = test_admin_config(format!("http://{address}")); + config.context_path = "/nacos".to_string(); + config.version_mode = Some(NacosVersionMode::V2); + let admin = NacosOpenApiAdmin::new(config).unwrap(); + + let nodes = admin.get_dashboard_nodes().await.unwrap(); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].address, "127.0.0.1:8848"); + server.await.unwrap(); + } + + #[tokio::test] + async fn rnacos_dashboard_skips_unsupported_cluster_node_api() { + let mut config = test_admin_config("http://127.0.0.1:1".to_string()); + config.implementation = Some(NacosImplementation::RNacos); + let admin = NacosOpenApiAdmin::new(config).unwrap(); + + assert!(admin.get_dashboard_nodes().await.unwrap().is_empty()); + } + #[tokio::test] async fn version_mode_v3_uses_only_v3_config_paths() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -3638,6 +4034,53 @@ mod tests { server.await.unwrap(); } + #[tokio::test] + async fn dashboard_combines_metrics_nodes_and_namespace_totals() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let mut paths = HashSet::new(); + for _ in 0..6 { + let (mut socket, _) = listener.accept().await.unwrap(); + let target = read_request_target(&mut socket).await; + let url = reqwest::Url::parse(&format!("http://localhost{target}")).unwrap(); + paths.insert(url.path().to_string()); + let body = match url.path() { + "/v3/admin/ns/ops/metrics" => { + r#"{"code":0,"data":{"status":"UP","serviceCount":8,"instanceCount":13,"clientCount":5}}"# + } + "/v3/admin/core/cluster/node/list" => { + r#"{"code":0,"data":[{"address":"127.0.0.1:8848","ip":"127.0.0.1","port":8848,"state":"UP"}]}"# + } + "/v3/console/core/namespace/list" => { + r#"{"code":0,"data":{"pageItems":[{"namespaceId":"dev","namespaceName":"Development"}]}}"# + } + "/v3/console/cs/config/list" => r#"{"code":0,"data":{"totalCount":21,"pageItems":[]}}"#, + "/v3/console/ns/service/list" => r#"{"code":0,"data":{"count":3,"serviceList":[]}}"#, + "/actuator/prometheus" => { + "# TYPE system_cpu_usage gauge\nsystem_cpu_usage 0.25\n# TYPE nacos_monitor gauge\nnacos_monitor{module=\"config\",name=\"configCount\"} 12\nnacos_monitor{module=\"naming\",name=\"serviceCount\"} 4\nnacos_monitor{module=\"naming\",name=\"ipCount\"} 14\n" + } + path => panic!("unexpected dashboard request path: {path}"), + }; + write_json_response(&mut socket, body).await; + } + paths + }); + + let admin = NacosOpenApiAdmin::new(test_admin_config(format!("http://{address}"))).unwrap(); + let snapshot = admin.get_dashboard(NacosDashboardQuery { namespace: Some("dev".to_string()) }).await.unwrap(); + + assert_eq!(snapshot.namespace, "dev"); + assert_eq!(snapshot.namespace_count, Some(2)); + assert_eq!(snapshot.config_count, Some(21)); + assert_eq!(snapshot.service_count, Some(3)); + assert_eq!(snapshot.metrics.as_ref().and_then(|metrics| metrics.instance_count), Some(14)); + assert_eq!(snapshot.prometheus.as_ref().and_then(|metrics| metrics.resource.cpu_ratio), Some(0.25)); + assert_eq!(snapshot.nodes.len(), 1); + assert!(snapshot.warnings.is_empty()); + assert_eq!(server.await.unwrap().len(), 6); + } + #[test] fn parses_namespace_list_shape() { let parsed = parse_namespaces(serde_json::json!({ @@ -3667,6 +4110,61 @@ mod tests { assert_eq!(parsed[1].namespace_show_name, "Development"); } + #[test] + fn parses_v3_dashboard_metrics_shape() { + let parsed = parse_dashboard_metrics(serde_json::json!({ + "code": 0, + "data": { + "status": "UP", + "serviceCount": 12, + "instanceCount": 34, + "clientCount": "5", + "cpu": 0.25, + "mem": "0.5" + } + })); + + assert_eq!(parsed.status.as_deref(), Some("UP")); + assert_eq!(parsed.service_count, Some(12)); + assert_eq!(parsed.instance_count, Some(34)); + assert_eq!(parsed.client_count, Some(5)); + assert_eq!(parsed.cpu, Some(0.25)); + assert_eq!(parsed.mem, Some(0.5)); + + let status_only = parse_dashboard_metrics(serde_json::json!({ "code": 0, "data": "UP" })); + assert_eq!(status_only.status.as_deref(), Some("UP")); + } + + #[test] + fn parses_v1_and_v3_cluster_node_shapes() { + let v1 = parse_cluster_nodes(serde_json::json!({ + "servers": [{ + "ip": "192.0.2.1", + "servePort": 8848, + "alive": true, + "site": "unknown", + "lastRefTimeStr": "2026-07-26 10:00:00" + }] + })); + assert_eq!(v1[0].address, "192.0.2.1:8848"); + assert_eq!(v1[0].alive, Some(true)); + assert_eq!(v1[0].last_refresh_time.as_deref(), Some("2026-07-26 10:00:00")); + + let v3 = parse_cluster_nodes(serde_json::json!({ + "code": 0, + "data": [{ + "address": "192.0.2.2:8848", + "ip": "192.0.2.2", + "port": 8848, + "state": "UP", + "extendInfo": { "lastRefreshTime": 1785031200000_u64 } + }] + })); + assert_eq!(v3[0].address, "192.0.2.2:8848"); + assert_eq!(v3[0].alive, Some(true)); + assert_eq!(v3[0].last_refresh_time.as_deref(), Some("1785031200000")); + } + #[test] fn validates_raw_api_paths() { for path in ["/v1/cs/configs", "/v2/console/example", "/v3/console/server/state"] { diff --git a/crates/dbx-core/src/nacos/mod.rs b/crates/dbx-core/src/nacos/mod.rs index 49fe415f7..857ae758e 100644 --- a/crates/dbx-core/src/nacos/mod.rs +++ b/crates/dbx-core/src/nacos/mod.rs @@ -11,6 +11,7 @@ pub mod batch; pub mod config; pub mod http; pub mod port; +mod prometheus; pub mod search; pub mod service; pub mod types; @@ -183,6 +184,8 @@ mod tests { }, auth: NacosAuthConfig::None, tls_skip_verify: false, + metrics_mode: Default::default(), + metrics_url: String::new(), page_size: 20, connect_override: None, } diff --git a/crates/dbx-core/src/nacos/port.rs b/crates/dbx-core/src/nacos/port.rs index 084075b4d..31baf07b9 100644 --- a/crates/dbx-core/src/nacos/port.rs +++ b/crates/dbx-core/src/nacos/port.rs @@ -31,5 +31,6 @@ pub trait NacosAdmin: Send + Sync { async fn list_services(&self, query: NacosServiceQuery) -> Result; async fn list_instances(&self, query: NacosInstanceQuery) -> Result, String>; async fn update_instance(&self, req: NacosInstanceUpdate) -> Result<(), String>; + async fn get_dashboard(&self, query: NacosDashboardQuery) -> Result; async fn raw_request(&self, req: NacosRawRequest) -> Result; } diff --git a/crates/dbx-core/src/nacos/prometheus.rs b/crates/dbx-core/src/nacos/prometheus.rs new file mode 100644 index 000000000..ae9264d60 --- /dev/null +++ b/crates/dbx-core/src/nacos/prometheus.rs @@ -0,0 +1,820 @@ +use std::collections::{HashMap, HashSet}; +use std::io; +use std::time::Duration; + +use futures::StreamExt; +use prometheus_parse::{Labels, Sample, Scrape, Value}; +use sha2::{Digest, Sha256}; + +use crate::nacos::config::{NacosAdminConfig, NacosImplementation, NacosMetricsMode}; +use crate::nacos::types::{ + NacosPrometheusConfigMetrics, NacosPrometheusNamingMetrics, NacosPrometheusResourceMetrics, + NacosPrometheusSnapshot, NacosPrometheusSource, NacosPrometheusTrafficMetrics, +}; + +const SCRAPE_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_SCRAPE_BYTES: usize = 4 * 1024 * 1024; + +pub(crate) fn endpoint_candidates(cfg: &NacosAdminConfig) -> Result, String> { + match cfg.metrics_mode { + NacosMetricsMode::Disabled => return Ok(Vec::new()), + NacosMetricsMode::Custom => return Ok(vec![cfg.metrics_url.clone()]), + NacosMetricsMode::Auto => {} + } + + let base = cfg.server_addr.trim_end_matches('/'); + let context = cfg.context_path.trim_end_matches('/'); + let raw = match cfg.implementation.as_ref().unwrap_or(&NacosImplementation::Nacos) { + NacosImplementation::Nacos => vec![ + format!("{base}{context}/actuator/prometheus"), + format!("{base}/nacos/actuator/prometheus"), + format!("{base}/actuator/prometheus"), + ], + NacosImplementation::RNacos => { + vec![format!("{base}/metrics"), format!("{base}{context}/metrics"), format!("{base}/rnacos/metrics")] + } + }; + + let mut seen = HashSet::new(); + raw.into_iter() + .map(|candidate| { + reqwest::Url::parse(&candidate) + .map(|url| url.to_string()) + .map_err(|error| format!("Nacos Prometheus metrics URL is invalid: {error}")) + }) + .filter(|candidate| match candidate { + Ok(value) => seen.insert(value.clone()), + Err(_) => true, + }) + .collect() +} + +pub(crate) async fn scrape( + client: &reqwest::Client, + cfg: &NacosAdminConfig, +) -> Result, String> { + scrape_with_timeout(client, cfg, SCRAPE_TIMEOUT).await +} + +async fn scrape_with_timeout( + client: &reqwest::Client, + cfg: &NacosAdminConfig, + timeout: Duration, +) -> Result, String> { + let candidates = endpoint_candidates(cfg)?; + if candidates.is_empty() { + return Ok(None); + } + let candidate_summary = + candidates.iter().map(|candidate| redact_endpoint(candidate)).collect::>().join(", "); + + tokio::time::timeout(timeout, async { + let mut errors = Vec::new(); + for endpoint in candidates { + match scrape_endpoint(client, cfg, &endpoint).await { + Ok(snapshot) => return Ok(Some(snapshot)), + Err(error) => errors.push(format!("{}: {error}", redact_endpoint(&endpoint))), + } + } + Err(format!("Prometheus metrics unavailable: {}", errors.join("; "))) + }) + .await + .map_err(|_| { + format!("Prometheus metrics unavailable: scrape timed out after {timeout:?} while trying {candidate_summary}") + })? +} + +async fn scrape_endpoint( + client: &reqwest::Client, + cfg: &NacosAdminConfig, + endpoint: &str, +) -> Result { + let response = client + .get(endpoint) + .timeout(SCRAPE_TIMEOUT) + .send() + .await + .map_err(|error| format!("request failed: {}", error.without_url()))?; + if !response.status().is_success() { + return Err(format!("returned HTTP {}", response.status())); + } + + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| format!("failed to read response: {}", error.without_url()))?; + if bytes.len().saturating_add(chunk.len()) > MAX_SCRAPE_BYTES { + return Err(format!("response exceeds {MAX_SCRAPE_BYTES} bytes")); + } + bytes.extend_from_slice(&chunk); + } + let body = String::from_utf8(bytes).map_err(|_| "response is not valid UTF-8".to_string())?; + parse_scrape(&body, cfg.implementation.as_ref().unwrap_or(&NacosImplementation::Nacos), endpoint) +} + +pub(crate) fn parse_scrape( + body: &str, + implementation: &NacosImplementation, + endpoint: &str, +) -> Result { + let lines = body.lines().map(|line| Ok::<_, io::Error>(line.to_string())); + let scrape = Scrape::parse(lines).map_err(|error| format!("failed to parse metrics: {error}"))?; + if scrape.samples.is_empty() { + return Err("response contains no Prometheus samples".to_string()); + } + let source = NacosPrometheusSource { + kind: match implementation { + NacosImplementation::Nacos => "nacos", + NacosImplementation::RNacos => "rnacos", + } + .to_string(), + endpoint: redact_endpoint(endpoint), + fingerprint: Some(endpoint_fingerprint(endpoint)), + }; + Ok(match implementation { + NacosImplementation::Nacos => normalize_nacos(source, &scrape.samples), + NacosImplementation::RNacos => normalize_rnacos(source, &scrape.samples), + }) +} + +fn normalize_nacos(source: NacosPrometheusSource, samples: &[Sample]) -> NacosPrometheusSnapshot { + let heap_used = scalar_sum_filtered(samples, "jvm_memory_used_bytes", |labels| label_eq(labels, "area", "heap")); + let heap_max = scalar_sum_filtered(samples, "jvm_memory_max_bytes", |labels| label_eq(labels, "area", "heap")); + let memory_ratio = ratio(heap_used, heap_max); + let http_count = scalar_sum(samples, "http_server_requests_seconds_count"); + let grpc_count = scalar_sum_any(samples, &["grpc_server_requests_seconds_count", "grpc_server_requests_count"]); + + NacosPrometheusSnapshot { + source, + resource: NacosPrometheusResourceMetrics { + cpu_ratio: scalar_max_any(samples, &["system_cpu_usage", "process_cpu_usage"]).and_then(normalize_ratio), + memory_ratio, + memory_used_bytes: heap_used, + memory_max_bytes: heap_max, + rss_bytes: None, + vms_bytes: None, + system_total_memory_bytes: None, + load_1m: scalar_max(samples, "system_load_average_1m"), + jvm_daemon_threads: scalar_sum(samples, "jvm_threads_daemon"), + gc_pause_count: scalar_sum_any(samples, &["jvm_gc_pause_seconds_count", "jvm_gc_pause_count"]), + }, + traffic: NacosPrometheusTrafficMetrics { + http_requests_total: http_count, + grpc_requests_total: grpc_count, + http_errors_total: scalar_sum_filtered(samples, "http_server_requests_seconds_count", is_error_labels), + grpc_errors_total: scalar_sum_filtered_any( + samples, + &["grpc_server_requests_seconds_count", "grpc_server_requests_count"], + is_grpc_error_labels, + ), + http_duration_seconds_total: scalar_sum(samples, "http_server_requests_seconds_sum"), + http_duration_count: http_count, + grpc_duration_seconds_total: scalar_sum_any( + samples, + &["grpc_server_requests_seconds_sum", "grpc_server_requests_sum"], + ), + grpc_duration_count: grpc_count, + http_p50_ms: quantile_ms(samples, "http_server_requests_seconds", 0.5, 1_000.0), + http_p95_ms: quantile_ms(samples, "http_server_requests_seconds", 0.95, 1_000.0), + http_p99_ms: quantile_ms(samples, "http_server_requests_seconds", 0.99, 1_000.0), + grpc_p50_ms: quantile_ms_any( + samples, + &["grpc_server_requests_seconds", "grpc_server_requests"], + 0.5, + 1_000.0, + ), + grpc_p95_ms: quantile_ms_any( + samples, + &["grpc_server_requests_seconds", "grpc_server_requests"], + 0.95, + 1_000.0, + ), + grpc_p99_ms: quantile_ms_any( + samples, + &["grpc_server_requests_seconds", "grpc_server_requests"], + 0.99, + 1_000.0, + ), + executor_pool_size: scalar_max_filtered(samples, "grpc_server_executor", |labels| { + label_in(labels, "name", &["poolSize", "pool_size"]) + }), + executor_active_count: scalar_max_filtered(samples, "grpc_server_executor", |labels| { + label_in(labels, "name", &["activeCount", "active_count"]) + }), + executor_queued_tasks: scalar_max_filtered(samples, "grpc_server_executor", |labels| { + label_in(labels, "name", &["queuedTasks", "queueSize", "queued_tasks"]) + }), + }, + config: NacosPrometheusConfigMetrics { + config_count: monitor(samples, "config", &["configCount"]), + get_config_total: monitor(samples, "config", &["getConfig", "getConfigCount"]), + publish_total: monitor(samples, "config", &["publish", "publishCount"]), + long_polling: monitor(samples, "config", &["longPolling"]), + listener_clients: None, + listener_keys: None, + notify_tasks: monitor(samples, "config", &["notifyTask"]), + notify_client_tasks: monitor(samples, "config", &["notifyClientTask"]), + dump_tasks: monitor(samples, "config", &["dumpTask"]), + subscriber_count: scalar_sum(samples, "nacos_config_subscriber"), + }, + naming: NacosPrometheusNamingMetrics { + service_count: monitor(samples, "naming", &["serviceCount"]), + instance_count: monitor(samples, "naming", &["ipCount", "instanceCount"]), + subscriber_count: monitor(samples, "naming", &["subscriberCount"]) + .or_else(|| scalar_sum(samples, "nacos_naming_subscriber")), + connection_count: monitor(samples, "core", &["longConnection"]), + total_push: monitor(samples, "naming", &["totalPush"]), + failed_push: monitor(samples, "naming", &["failedPush"]), + empty_push: monitor(samples, "naming", &["emptyPush"]), + push_pending_tasks: monitor( + samples, + "naming", + &["pushPendingTaskCount", "pushPendingTask", "pushPendingTasks"], + ), + avg_push_cost_ms: monitor(samples, "naming", &["avgPushCost"]), + max_push_cost_ms: monitor(samples, "naming", &["maxPushCost"]), + leader_status: monitor(samples, "naming", &["leaderStatus"]), + }, + } +} + +fn normalize_rnacos(source: NacosPrometheusSource, samples: &[Sample]) -> NacosPrometheusSnapshot { + let mib = 1024.0 * 1024.0; + let http_count = scalar_sum(samples, "http_request_total_count"); + let grpc_count = scalar_sum(samples, "grpc_request_total_count"); + let rss_mib = scalar_sum(samples, "app_rss_memory"); + let system_memory_mib = scalar_sum(samples, "sys_total_memory"); + NacosPrometheusSnapshot { + source, + resource: NacosPrometheusResourceMetrics { + cpu_ratio: scalar_max(samples, "app_cpu_usage").and_then(normalize_percentage), + memory_ratio: scalar_max(samples, "app_memory_usage") + .and_then(normalize_percentage) + .or_else(|| ratio(rss_mib, system_memory_mib)), + memory_used_bytes: rss_mib.map(|value| value * mib), + memory_max_bytes: system_memory_mib.map(|value| value * mib), + rss_bytes: rss_mib.map(|value| value * mib), + vms_bytes: scalar_sum(samples, "app_vms_memory").map(|value| value * mib), + system_total_memory_bytes: system_memory_mib.map(|value| value * mib), + load_1m: None, + jvm_daemon_threads: None, + gc_pause_count: None, + }, + traffic: NacosPrometheusTrafficMetrics { + http_requests_total: http_count, + grpc_requests_total: grpc_count, + http_errors_total: None, + grpc_errors_total: None, + http_duration_seconds_total: scalar_sum(samples, "http_request_handle_rt_histogram_sum") + .or_else(|| scalar_sum(samples, "http_request_handle_rt_summary_sum")) + .map(|value| value / 1_000.0), + http_duration_count: scalar_sum(samples, "http_request_handle_rt_histogram_count") + .or_else(|| scalar_sum(samples, "http_request_handle_rt_summary_count")) + .or(http_count), + grpc_duration_seconds_total: scalar_sum(samples, "grpc_request_handle_rt_histogram_sum") + .or_else(|| scalar_sum(samples, "grpc_request_handle_rt_summary_sum")) + .map(|value| value / 1_000.0), + grpc_duration_count: scalar_sum(samples, "grpc_request_handle_rt_histogram_count") + .or_else(|| scalar_sum(samples, "grpc_request_handle_rt_summary_count")) + .or(grpc_count), + http_p50_ms: quantile_ms_any( + samples, + &["http_request_handle_rt_summary", "http_request_handle_rt_histogram"], + 0.5, + 1.0, + ), + http_p95_ms: quantile_ms_any( + samples, + &["http_request_handle_rt_summary", "http_request_handle_rt_histogram"], + 0.95, + 1.0, + ), + http_p99_ms: quantile_ms_any( + samples, + &["http_request_handle_rt_summary", "http_request_handle_rt_histogram"], + 0.99, + 1.0, + ), + grpc_p50_ms: quantile_ms_any( + samples, + &["grpc_request_handle_rt_summary", "grpc_request_handle_rt_histogram"], + 0.5, + 1.0, + ), + grpc_p95_ms: quantile_ms_any( + samples, + &["grpc_request_handle_rt_summary", "grpc_request_handle_rt_histogram"], + 0.95, + 1.0, + ), + grpc_p99_ms: quantile_ms_any( + samples, + &["grpc_request_handle_rt_summary", "grpc_request_handle_rt_histogram"], + 0.99, + 1.0, + ), + executor_pool_size: None, + executor_active_count: None, + executor_queued_tasks: None, + }, + config: NacosPrometheusConfigMetrics { + config_count: scalar_sum(samples, "config_data_size") + .or_else(|| scalar_sum(samples, "config_index_config_size")), + get_config_total: None, + publish_total: None, + long_polling: None, + listener_clients: scalar_sum(samples, "config_listener_client_size"), + listener_keys: scalar_sum(samples, "config_listener_key_size"), + notify_tasks: None, + notify_client_tasks: None, + dump_tasks: None, + subscriber_count: scalar_sum(samples, "config_subscriber_client_size") + .or_else(|| scalar_sum(samples, "config_subscriber_client_value_size")), + }, + naming: NacosPrometheusNamingMetrics { + service_count: scalar_sum(samples, "naming_service_size") + .or_else(|| scalar_sum(samples, "naming_index_service_size")), + instance_count: scalar_sum(samples, "naming_instance_size"), + subscriber_count: scalar_sum(samples, "naming_subscriber_client_size") + .or_else(|| scalar_sum(samples, "naming_subscriber_client_value_size")), + connection_count: scalar_sum(samples, "grpc_conn_size"), + total_push: None, + failed_push: None, + empty_push: None, + push_pending_tasks: None, + avg_push_cost_ms: None, + max_push_cost_ms: None, + leader_status: None, + }, + } +} + +fn scalar_value(sample: &Sample) -> Option { + let value = match sample.value { + Value::Counter(value) | Value::Gauge(value) | Value::Untyped(value) => value, + Value::Histogram(_) | Value::Summary(_) => return None, + }; + value.is_finite().then_some(value) +} + +fn scalar_sum(samples: &[Sample], metric: &str) -> Option { + scalar_sum_filtered(samples, metric, |_| true) +} + +fn scalar_sum_any(samples: &[Sample], metrics: &[&str]) -> Option { + metrics.iter().find_map(|metric| scalar_sum(samples, metric)) +} + +fn scalar_sum_filtered(samples: &[Sample], metric: &str, predicate: impl Fn(&Labels) -> bool) -> Option { + let values = + samples.iter().filter(|sample| sample.metric == metric && predicate(&sample.labels)).filter_map(scalar_value); + sum_non_empty(values) +} + +fn scalar_sum_filtered_any( + samples: &[Sample], + metrics: &[&str], + predicate: impl Fn(&Labels) -> bool + Copy, +) -> Option { + metrics.iter().find_map(|metric| scalar_sum_filtered(samples, metric, predicate)) +} + +fn scalar_max(samples: &[Sample], metric: &str) -> Option { + scalar_max_filtered(samples, metric, |_| true) +} + +fn scalar_max_any(samples: &[Sample], metrics: &[&str]) -> Option { + metrics.iter().find_map(|metric| scalar_max(samples, metric)) +} + +fn scalar_max_filtered(samples: &[Sample], metric: &str, predicate: impl Fn(&Labels) -> bool) -> Option { + samples + .iter() + .filter(|sample| sample.metric == metric && predicate(&sample.labels)) + .filter_map(scalar_value) + .reduce(f64::max) +} + +fn sum_non_empty(values: impl Iterator) -> Option { + let mut found = false; + let sum = values.fold(0.0, |sum, value| { + found = true; + sum + value + }); + found.then_some(sum) +} + +fn monitor(samples: &[Sample], module: &str, names: &[&str]) -> Option { + names.iter().find_map(|name| { + scalar_sum_filtered(samples, "nacos_monitor", |labels| { + label_eq(labels, "module", module) && label_eq(labels, "name", name) + }) + }) +} + +fn label_eq(labels: &Labels, key: &str, value: &str) -> bool { + labels.get(key).is_some_and(|current| current.eq_ignore_ascii_case(value)) +} + +fn label_in(labels: &Labels, key: &str, values: &[&str]) -> bool { + values.iter().any(|value| label_eq(labels, key, value)) +} + +fn is_error_labels(labels: &Labels) -> bool { + if let Some(status) = labels.get("status").or_else(|| labels.get("code")) { + return !status.starts_with('2') && status != "0"; + } + labels.get("outcome").is_some_and(|outcome| !matches!(outcome.to_ascii_uppercase().as_str(), "SUCCESS" | "UNKNOWN")) +} + +fn is_grpc_error_labels(labels: &Labels) -> bool { + if labels.get("success").is_some_and(|value| value.eq_ignore_ascii_case("false")) { + return true; + } + labels + .get("errorCode") + .or_else(|| labels.get("error_code")) + .or_else(|| labels.get("code")) + .is_some_and(|value| value != "0" && !value.eq_ignore_ascii_case("OK")) + || labels.get("result").is_some_and(|value| !matches!(value.to_ascii_uppercase().as_str(), "SUCCESS" | "OK")) +} + +fn ratio(numerator: Option, denominator: Option) -> Option { + match (numerator, denominator) { + (Some(numerator), Some(denominator)) if denominator > 0.0 => normalize_ratio(numerator / denominator), + _ => None, + } +} + +fn normalize_ratio(value: f64) -> Option { + if !value.is_finite() || value < 0.0 { + return None; + } + Some((if value > 1.0 { value / 100.0 } else { value }).clamp(0.0, 1.0)) +} + +fn normalize_percentage(value: f64) -> Option { + if !value.is_finite() || value < 0.0 { + return None; + } + Some((value / 100.0).clamp(0.0, 1.0)) +} + +fn endpoint_fingerprint(endpoint: &str) -> String { + format!("{:x}", Sha256::digest(endpoint.as_bytes())) +} + +fn redact_endpoint(endpoint: &str) -> String { + let Ok(mut url) = reqwest::Url::parse(endpoint) else { + return endpoint.split_once('?').map_or(endpoint, |(base, _)| base).to_string(); + }; + url.set_query(None); + url.set_fragment(None); + url.to_string() +} + +fn quantile_ms(samples: &[Sample], metric: &str, quantile: f64, multiplier: f64) -> Option { + let mut histogram_buckets = HashMap::::new(); + let mut summary_quantiles = Vec::new(); + for sample in samples.iter().filter(|sample| sample.metric == metric) { + match &sample.value { + Value::Histogram(values) => { + for value in values { + if value.less_than.is_nan() || !value.count.is_finite() { + continue; + } + histogram_buckets + .entry(value.less_than.to_bits()) + .and_modify(|(_, count)| *count += value.count) + .or_insert((value.less_than, value.count)); + } + } + Value::Summary(values) => summary_quantiles.push(values), + _ => {} + } + } + + if !histogram_buckets.is_empty() { + return histogram_quantile(histogram_buckets.into_values(), quantile).map(|value| value * multiplier); + } + // Prometheus summaries are already-calculated client-side quantiles and + // cannot be aggregated across label sets without changing their meaning. + let [values] = summary_quantiles.as_slice() else { + return None; + }; + nearest_quantile(values.iter().map(|value| (value.quantile, value.count)), quantile).map(|value| value * multiplier) +} + +fn quantile_ms_any(samples: &[Sample], metrics: &[&str], quantile: f64, multiplier: f64) -> Option { + metrics.iter().find_map(|metric| quantile_ms(samples, metric, quantile, multiplier)) +} + +fn nearest_quantile(values: impl Iterator, target: f64) -> Option { + values + .filter(|(quantile, value)| quantile.is_finite() && value.is_finite()) + .min_by(|left, right| { + (left.0 - target).abs().partial_cmp(&(right.0 - target).abs()).unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(_, value)| value) +} + +fn histogram_quantile(values: impl Iterator, target: f64) -> Option { + let mut buckets = + values.filter(|(boundary, count)| !boundary.is_nan() && count.is_finite() && *count >= 0.0).collect::>(); + buckets.sort_by(|left, right| left.0.partial_cmp(&right.0).unwrap_or(std::cmp::Ordering::Equal)); + let mut previous_count = 0.0; + for (_, count) in &mut buckets { + *count = count.max(previous_count); + previous_count = *count; + } + let total = buckets.last()?.1; + if total <= 0.0 { + return None; + } + let threshold = total * target.clamp(0.0, 1.0); + let index = buckets.iter().position(|(_, count)| *count >= threshold)?; + let (upper_bound, upper_count) = buckets[index]; + if upper_bound.is_infinite() { + return (index > 0).then_some(buckets[index - 1].0).filter(|boundary| boundary.is_finite()); + } + let (lower_bound, lower_count) = if index == 0 { (0.0, 0.0) } else { buckets[index - 1] }; + if upper_count <= lower_count { + return Some(upper_bound); + } + Some(lower_bound + (upper_bound - lower_bound) * ((threshold - lower_count) / (upper_count - lower_count))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derives_and_deduplicates_nacos_endpoints() { + let cfg = test_config(NacosImplementation::Nacos, "http://127.0.0.1:8818", "/nacos"); + assert_eq!( + endpoint_candidates(&cfg).unwrap(), + vec!["http://127.0.0.1:8818/nacos/actuator/prometheus", "http://127.0.0.1:8818/actuator/prometheus",] + ); + } + + #[test] + fn derives_rnacos_endpoints() { + let cfg = test_config(NacosImplementation::RNacos, "http://127.0.0.1:3848", "/nacos"); + assert_eq!( + endpoint_candidates(&cfg).unwrap(), + vec![ + "http://127.0.0.1:3848/metrics", + "http://127.0.0.1:3848/nacos/metrics", + "http://127.0.0.1:3848/rnacos/metrics", + ] + ); + } + + #[test] + fn disabled_mode_skips_scraping() { + let mut cfg = test_config(NacosImplementation::Nacos, "http://127.0.0.1:8818", "/nacos"); + cfg.metrics_mode = NacosMetricsMode::Disabled; + assert!(endpoint_candidates(&cfg).unwrap().is_empty()); + } + + #[test] + fn parses_nacos_metrics_and_labels() { + let body = r#" +# TYPE system_cpu_usage gauge +system_cpu_usage 0.25 +# TYPE jvm_memory_used_bytes gauge +jvm_memory_used_bytes{area="heap",id="a"} 100 +jvm_memory_used_bytes{area="heap",id="b"} 50 +# TYPE jvm_memory_max_bytes gauge +jvm_memory_max_bytes{area="heap",id="a"} 400 +# TYPE http_server_requests_seconds counter +http_server_requests_seconds_count{status="200"} 90 +http_server_requests_seconds_count{status="500"} 10 +http_server_requests_seconds_sum 25 +# TYPE nacos_monitor gauge +nacos_monitor{module="naming",name="serviceCount"} 12 +nacos_monitor{module="core",name="longConnection"} 8 +nacos_monitor{module="naming",name="pushPendingTaskCount"} 3 +# TYPE nacos_naming_subscriber gauge +nacos_naming_subscriber{version="v1"} 2 +nacos_naming_subscriber{version="v2"} 4 +"#; + let parsed = parse_scrape(body, &NacosImplementation::Nacos, "http://localhost/metrics").unwrap(); + assert_eq!(parsed.resource.cpu_ratio, Some(0.25)); + assert_eq!(parsed.resource.memory_used_bytes, Some(150.0)); + assert_eq!(parsed.resource.memory_ratio, Some(0.375)); + assert_eq!(parsed.traffic.http_requests_total, Some(100.0)); + assert_eq!(parsed.traffic.http_errors_total, Some(10.0)); + assert_eq!(parsed.naming.service_count, Some(12.0)); + assert_eq!(parsed.naming.connection_count, Some(8.0)); + assert_eq!(parsed.naming.subscriber_count, Some(6.0)); + assert_eq!(parsed.naming.push_pending_tasks, Some(3.0)); + } + + #[test] + fn aggregates_histogram_buckets_across_label_sets() { + let body = r#" +# TYPE http_server_requests_seconds histogram +http_server_requests_seconds_bucket{route="a",le="1"} 50 +http_server_requests_seconds_bucket{route="a",le="10"} 50 +http_server_requests_seconds_bucket{route="a",le="+Inf"} 50 +http_server_requests_seconds_sum{route="a"} 25 +http_server_requests_seconds_count{route="a"} 50 +http_server_requests_seconds_bucket{route="b",le="1"} 0 +http_server_requests_seconds_bucket{route="b",le="10"} 50 +http_server_requests_seconds_bucket{route="b",le="+Inf"} 50 +http_server_requests_seconds_sum{route="b"} 250 +http_server_requests_seconds_count{route="b"} 50 +"#; + let parsed = parse_scrape(body, &NacosImplementation::Nacos, "http://localhost/metrics").unwrap(); + assert_eq!(parsed.traffic.http_p50_ms, Some(1_000.0)); + } + + #[test] + fn redacts_endpoint_query_from_snapshot_source() { + let parsed = parse_scrape( + "# TYPE system_cpu_usage gauge\nsystem_cpu_usage 0.25\n", + &NacosImplementation::Nacos, + "http://localhost/metrics?token=secret&node=a", + ) + .unwrap(); + let other_source = parse_scrape( + "# TYPE system_cpu_usage gauge\nsystem_cpu_usage 0.25\n", + &NacosImplementation::Nacos, + "http://localhost/metrics?token=secret&node=b", + ) + .unwrap(); + assert_eq!(parsed.source.endpoint, "http://localhost/metrics"); + assert_ne!(parsed.source.fingerprint, other_source.source.fingerprint); + assert!(!parsed.source.fingerprint.as_deref().unwrap().contains("secret")); + } + + #[test] + fn parses_rnacos_units_counts_and_summary() { + let body = r#" +# TYPE app_cpu_usage gauge +app_cpu_usage 12.5 +# TYPE app_rss_memory gauge +app_rss_memory 10 +# TYPE sys_total_memory gauge +sys_total_memory 100 +# TYPE naming_service_size gauge +naming_service_size 4 +# TYPE grpc_conn_size gauge +grpc_conn_size 3 +# TYPE http_request_total_count counter +http_request_total_count 20 +# TYPE http_request_handle_rt_summary summary +http_request_handle_rt_summary{quantile="0.5"} 5 +http_request_handle_rt_summary{quantile="0.95"} 15 +http_request_handle_rt_summary_sum 100 +http_request_handle_rt_summary_count 20 +"#; + let parsed = parse_scrape(body, &NacosImplementation::RNacos, "http://localhost/metrics").unwrap(); + assert_eq!(parsed.resource.cpu_ratio, Some(0.125)); + assert_eq!(parsed.resource.rss_bytes, Some(10.0 * 1024.0 * 1024.0)); + assert_eq!(parsed.resource.memory_ratio, Some(0.1)); + assert_eq!(parsed.naming.service_count, Some(4.0)); + assert_eq!(parsed.naming.connection_count, Some(3.0)); + assert_eq!(parsed.traffic.http_p95_ms, Some(15.0)); + assert_eq!(parsed.traffic.http_duration_seconds_total, Some(0.1)); + } + + #[test] + fn converts_low_rnacos_percentages_to_ratios() { + let body = r#" +# TYPE app_cpu_usage gauge +app_cpu_usage 0.5 +# TYPE app_memory_usage gauge +app_memory_usage 1 +"#; + let parsed = parse_scrape(body, &NacosImplementation::RNacos, "http://localhost/metrics").unwrap(); + assert_eq!(parsed.resource.cpu_ratio, Some(0.005)); + assert_eq!(parsed.resource.memory_ratio, Some(0.01)); + } + + #[test] + fn rejects_responses_without_samples() { + assert!(parse_scrape("not metrics", &NacosImplementation::Nacos, "http://localhost/metrics") + .unwrap_err() + .contains("no Prometheus samples")); + } + + #[tokio::test] + async fn falls_back_after_a_missing_auto_endpoint() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + for index in 0..2 { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = vec![0_u8; 2048]; + let _ = tokio::io::AsyncReadExt::read(&mut socket, &mut request).await.unwrap(); + if index == 0 { + let body = "not found"; + let response = format!( + "HTTP/1.1 404 Not Found\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + tokio::io::AsyncWriteExt::write_all(&mut socket, response.as_bytes()).await.unwrap(); + } else { + let body = "# TYPE system_cpu_usage gauge\nsystem_cpu_usage 0.4\n"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + tokio::io::AsyncWriteExt::write_all(&mut socket, response.as_bytes()).await.unwrap(); + } + } + }); + let cfg = test_config(NacosImplementation::Nacos, &format!("http://{address}"), ""); + let client = reqwest::Client::new(); + let parsed = scrape(&client, &cfg).await.unwrap().unwrap(); + assert_eq!(parsed.resource.cpu_ratio, Some(0.4)); + assert!(parsed.source.endpoint.ends_with("/nacos/actuator/prometheus")); + server.await.unwrap(); + } + + #[tokio::test] + async fn rejects_oversized_scrapes() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = vec![0_u8; 2048]; + let _ = tokio::io::AsyncReadExt::read(&mut socket, &mut request).await.unwrap(); + let body = vec![b'x'; MAX_SCRAPE_BYTES + 1]; + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = tokio::io::AsyncWriteExt::write_all(&mut socket, header.as_bytes()).await; + let _ = tokio::io::AsyncWriteExt::write_all(&mut socket, &body).await; + }); + let mut cfg = test_config(NacosImplementation::Nacos, "http://127.0.0.1:8848", ""); + cfg.metrics_mode = NacosMetricsMode::Custom; + cfg.metrics_url = format!("http://{address}/metrics"); + let error = scrape(&reqwest::Client::new(), &cfg).await.unwrap_err(); + assert!(error.contains("exceeds")); + server.await.unwrap(); + } + + #[tokio::test] + async fn redacts_endpoint_query_from_scrape_errors() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = vec![0_u8; 2048]; + let _ = tokio::io::AsyncReadExt::read(&mut socket, &mut request).await.unwrap(); + let response = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + tokio::io::AsyncWriteExt::write_all(&mut socket, response.as_bytes()).await.unwrap(); + }); + let mut cfg = test_config(NacosImplementation::Nacos, "http://127.0.0.1:8848", ""); + cfg.metrics_mode = NacosMetricsMode::Custom; + cfg.metrics_url = format!("http://{address}/metrics?token=secret"); + let error = scrape(&reqwest::Client::new(), &cfg).await.unwrap_err(); + assert!(error.contains(&format!("http://{address}/metrics"))); + assert!(!error.contains("secret")); + server.await.unwrap(); + } + + #[tokio::test] + async fn times_out_without_exposing_endpoint_query() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = vec![0_u8; 2048]; + let _ = tokio::io::AsyncReadExt::read(&mut socket, &mut request).await.unwrap(); + std::future::pending::<()>().await; + }); + let mut cfg = test_config(NacosImplementation::Nacos, "http://127.0.0.1:8848", ""); + cfg.metrics_mode = NacosMetricsMode::Custom; + cfg.metrics_url = format!("http://{address}/metrics?token=secret"); + let error = scrape_with_timeout(&reqwest::Client::new(), &cfg, Duration::from_millis(25)).await.unwrap_err(); + assert!(error.contains("timed out")); + assert!(error.contains(&format!("http://{address}/metrics"))); + assert!(!error.contains("secret")); + server.abort(); + } + + fn test_config(implementation: NacosImplementation, server_addr: &str, context_path: &str) -> NacosAdminConfig { + NacosAdminConfig { + implementation: Some(implementation), + version_mode: None, + server_addr: server_addr.to_string(), + display_server_addr: server_addr.to_string(), + namespace: String::new(), + context_path: context_path.to_string(), + rnacos_console_addr: String::new(), + rnacos_history_enabled: None, + rnacos_console_auth: Default::default(), + auth: Default::default(), + tls_skip_verify: false, + metrics_mode: NacosMetricsMode::Auto, + metrics_url: String::new(), + page_size: 20, + connect_override: None, + } + } +} diff --git a/crates/dbx-core/src/nacos/search.rs b/crates/dbx-core/src/nacos/search.rs index 457026330..ec4218628 100644 --- a/crates/dbx-core/src/nacos/search.rs +++ b/crates/dbx-core/src/nacos/search.rs @@ -699,6 +699,9 @@ mod tests { async fn update_instance(&self, _: NacosInstanceUpdate) -> Result<(), String> { Err("unused".to_string()) } + async fn get_dashboard(&self, _: NacosDashboardQuery) -> Result { + Err("unused".to_string()) + } async fn raw_request(&self, _: NacosRawRequest) -> Result { Err("unused".to_string()) } diff --git a/crates/dbx-core/src/nacos/service.rs b/crates/dbx-core/src/nacos/service.rs index 7132e5fff..c747d371f 100644 --- a/crates/dbx-core/src/nacos/service.rs +++ b/crates/dbx-core/src/nacos/service.rs @@ -162,6 +162,15 @@ pub async fn nacos_update_instance_core( admin.update_instance(req).await } +pub async fn nacos_get_dashboard_core( + state: &AppState, + conn_id: &str, + query: NacosDashboardQuery, +) -> Result { + let admin = get_admin(state, conn_id).await?; + admin.get_dashboard(query).await +} + pub async fn nacos_raw_request_core( state: &AppState, conn_id: &str, diff --git a/crates/dbx-core/src/nacos/types.rs b/crates/dbx-core/src/nacos/types.rs index 8333fb29a..62c46ed9d 100644 --- a/crates/dbx-core/src/nacos/types.rs +++ b/crates/dbx-core/src/nacos/types.rs @@ -496,6 +496,175 @@ pub struct NacosInstanceUpdate { pub metadata: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NacosDashboardQuery { + #[serde(default)] + pub namespace: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct NacosDashboardMetrics { + #[serde(default)] + pub status: Option, + #[serde(default)] + pub service_count: Option, + #[serde(default)] + pub instance_count: Option, + #[serde(default)] + pub subscribe_count: Option, + #[serde(default)] + pub raft_notify_task_count: Option, + #[serde(default)] + pub responsible_service_count: Option, + #[serde(default)] + pub responsible_instance_count: Option, + #[serde(default)] + pub client_count: Option, + #[serde(default)] + pub connection_based_client_count: Option, + #[serde(default)] + pub ephemeral_ip_port_client_count: Option, + #[serde(default)] + pub persistent_ip_port_client_count: Option, + #[serde(default)] + pub responsible_client_count: Option, + #[serde(default)] + pub cpu: Option, + #[serde(default)] + pub load: Option, + #[serde(default)] + pub mem: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct NacosPrometheusSource { + pub kind: String, + pub endpoint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct NacosPrometheusResourceMetrics { + pub cpu_ratio: Option, + pub memory_ratio: Option, + pub memory_used_bytes: Option, + pub memory_max_bytes: Option, + pub rss_bytes: Option, + pub vms_bytes: Option, + pub system_total_memory_bytes: Option, + pub load_1m: Option, + pub jvm_daemon_threads: Option, + pub gc_pause_count: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct NacosPrometheusTrafficMetrics { + pub http_requests_total: Option, + pub grpc_requests_total: Option, + pub http_errors_total: Option, + pub grpc_errors_total: Option, + pub http_duration_seconds_total: Option, + pub http_duration_count: Option, + pub grpc_duration_seconds_total: Option, + pub grpc_duration_count: Option, + pub http_p50_ms: Option, + pub http_p95_ms: Option, + pub http_p99_ms: Option, + pub grpc_p50_ms: Option, + pub grpc_p95_ms: Option, + pub grpc_p99_ms: Option, + pub executor_pool_size: Option, + pub executor_active_count: Option, + pub executor_queued_tasks: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct NacosPrometheusConfigMetrics { + pub config_count: Option, + pub get_config_total: Option, + pub publish_total: Option, + pub long_polling: Option, + pub listener_clients: Option, + pub listener_keys: Option, + pub notify_tasks: Option, + pub notify_client_tasks: Option, + pub dump_tasks: Option, + pub subscriber_count: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct NacosPrometheusNamingMetrics { + pub service_count: Option, + pub instance_count: Option, + pub subscriber_count: Option, + pub connection_count: Option, + pub total_push: Option, + pub failed_push: Option, + pub empty_push: Option, + pub push_pending_tasks: Option, + pub avg_push_cost_ms: Option, + pub max_push_cost_ms: Option, + pub leader_status: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct NacosPrometheusSnapshot { + pub source: NacosPrometheusSource, + pub resource: NacosPrometheusResourceMetrics, + pub traffic: NacosPrometheusTrafficMetrics, + pub config: NacosPrometheusConfigMetrics, + pub naming: NacosPrometheusNamingMetrics, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct NacosClusterNode { + pub address: String, + #[serde(default)] + pub ip: Option, + #[serde(default)] + pub port: Option, + #[serde(default)] + pub state: Option, + #[serde(default)] + pub alive: Option, + #[serde(default)] + pub site: Option, + #[serde(default)] + pub weight: Option, + #[serde(default)] + pub last_refresh_time: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct NacosDashboardSnapshot { + pub namespace: String, + #[serde(default)] + pub namespace_count: Option, + #[serde(default)] + pub config_count: Option, + #[serde(default)] + pub service_count: Option, + #[serde(default)] + pub metrics: Option, + #[serde(default)] + pub prometheus: Option, + #[serde(default)] + pub nodes: Vec, + #[serde(default)] + pub warnings: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct NacosRawRequest { diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 241709175..e9c5ce3b2 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -473,6 +473,7 @@ async fn main() { .route("/nacos/services/list", post(routes::nacos::list_services)) .route("/nacos/instances/list", post(routes::nacos::list_instances)) .route("/nacos/instances/update", post(routes::nacos::update_instance)) + .route("/nacos/dashboard", post(routes::nacos::get_dashboard)) .route("/nacos/raw", post(routes::nacos::raw_request)) .route("/nacos/configs/search", post(routes::nacos::search_config_content)) .route("/nacos/configs/search/cancel", post(routes::nacos::cancel_operation)) diff --git a/crates/dbx-web/src/routes/nacos.rs b/crates/dbx-web/src/routes/nacos.rs index a370771d8..7ca7e9ff1 100644 --- a/crates/dbx-web/src/routes/nacos.rs +++ b/crates/dbx-web/src/routes/nacos.rs @@ -109,6 +109,13 @@ pub(crate) struct InstanceUpdateReq { req: dbx_core::nacos::NacosInstanceUpdate, } +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DashboardReq { + connection_id: String, + query: dbx_core::nacos::NacosDashboardQuery, +} + #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct RawReq { @@ -322,6 +329,16 @@ pub async fn update_instance( Ok(Json(())) } +pub async fn get_dashboard( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + let result = dbx_core::nacos::service::nacos_get_dashboard_core(&state.app, &req.connection_id, req.query) + .await + .map_err(AppError::from)?; + Ok(Json(result)) +} + pub async fn raw_request( State(state): State>, Json(req): Json, diff --git a/docs/lib/agentRegistry.test.ts b/docs/lib/agentRegistry.test.ts index b67fefdf6..9d05fd91e 100644 --- a/docs/lib/agentRegistry.test.ts +++ b/docs/lib/agentRegistry.test.ts @@ -31,6 +31,7 @@ test("non-release assets retain their official download link", () => { test("catalog falls back from R2 to CNB without using GitHub API", async () => { const requestedUrls: string[] = []; + const accessVersion = driverVersions.access; vi.stubGlobal( "fetch", vi.fn(async (input: string | URL | Request) => { @@ -42,9 +43,9 @@ test("catalog falls back from R2 to CNB without using GitHub API", async () => { return Response.json({ drivers: { access: { - version: "0.1.30", + version: accessVersion, jar: { - url: "https://github.com/t8y2/dbx/releases/download/agents-v0.2.64/dbx-agent-access-0.1.30.jar", + url: `https://github.com/t8y2/dbx/releases/download/agents-v0.2.64/dbx-agent-access-${accessVersion}.jar`, size: 1, }, }, diff --git a/src-tauri/src/commands/nacos_cmd.rs b/src-tauri/src/commands/nacos_cmd.rs index 60dea371f..43af958d3 100644 --- a/src-tauri/src/commands/nacos_cmd.rs +++ b/src-tauri/src/commands/nacos_cmd.rs @@ -147,6 +147,15 @@ pub async fn nacos_update_instance( dbx_core::nacos::service::nacos_update_instance_core(&state, &connection_id, req).await } +#[tauri::command] +pub async fn nacos_get_dashboard( + state: State<'_, Arc>, + connection_id: String, + query: dbx_core::nacos::NacosDashboardQuery, +) -> Result { + dbx_core::nacos::service::nacos_get_dashboard_core(&state, &connection_id, query).await +} + #[tauri::command] pub async fn nacos_raw_request( state: State<'_, Arc>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9ef2aa69c..471f49700 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1569,6 +1569,7 @@ pub fn run() { commands::nacos_cmd::nacos_list_services, commands::nacos_cmd::nacos_list_instances, commands::nacos_cmd::nacos_update_instance, + commands::nacos_cmd::nacos_get_dashboard, commands::nacos_cmd::nacos_raw_request, commands::nacos_cmd::nacos_search_config_content, commands::nacos_cmd::nacos_cancel_operation,