feat(nacos): add Nacos dashboard metrics
This commit is contained in:
parent
98bf3d1dfd
commit
f9c2fb27d3
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent
|
|||
|
||||
interface Series {
|
||||
name: string;
|
||||
data: number[];
|
||||
data: Array<number | null>;
|
||||
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 },
|
||||
|
|
|
|||
|
|
@ -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<NacosAuthKind>("none");
|
|||
const nacosUsername = ref("nacos");
|
||||
const nacosPassword = ref("");
|
||||
const nacosTlsSkipVerify = ref(false);
|
||||
const nacosMetricsMode = ref<NacosMetricsMode>("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<NacosAdminConfig>) {
|
|||
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) {
|
|||
<Label :class="connectionLabelClass">{{ t("connection.nacosNamespace") }}</Label>
|
||||
<Input v-model="nacosNamespace" class="col-span-3" placeholder="public" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.nacosMetrics") }}</Label>
|
||||
<Select v-model="nacosMetricsMode">
|
||||
<SelectTrigger class="col-span-3 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{{ t("connection.nacosMetricsAuto") }}</SelectItem>
|
||||
<SelectItem value="disabled">{{ t("connection.nacosMetricsDisabled") }}</SelectItem>
|
||||
<SelectItem value="custom">{{ t("connection.nacosMetricsCustom") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="nacosMetricsMode === 'auto' && nacosMetricsAutoPreview" class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 m-0 break-all text-xs leading-5 text-muted-foreground">{{ t("connection.nacosMetricsAutoHint", { addresses: nacosMetricsAutoPreview }) }}</p>
|
||||
</div>
|
||||
<div v-if="nacosMetricsMode === 'custom'" class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.nacosMetricsUrl") }}</Label>
|
||||
<div class="col-span-3">
|
||||
<Input v-model="nacosMetricsUrl" :aria-invalid="!!nacosMetricsUrlError" :class="{ 'border-destructive focus-visible:ring-destructive': nacosMetricsUrlError }" placeholder="http://127.0.0.1:8818/nacos/actuator/prometheus" />
|
||||
<p v-if="nacosMetricsUrlError" class="mt-1 text-xs text-destructive">{{ nacosMetricsUrlError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="nacosImplementation === 'rnacos'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.nacosConfigurationHistory") }}</Label>
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@ const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url),
|
|||
|
||||
describe("DataGrid native clipboard regions", () => {
|
||||
it("keeps table info text selection out of grid copy shortcuts", () => {
|
||||
const drawerTag = dataGridSource.match(/<div\s+v-if="showTableInfo"[^>]*>/)?.[0];
|
||||
|
||||
expect(drawerTag).toBeDefined();
|
||||
expect(drawerTag).toContain("data-native-clipboard");
|
||||
expect(dataGridSource).toMatch(/<div\b(?=[^>]*\bv-if="showTableInfo")(?=[^>]*\bdata-native-clipboard)[^>]*>/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -480,7 +480,7 @@ function tabMenuIcon(tab: QueryTab) {
|
|||
if (tab.mode === "structure") return PencilRuler;
|
||||
if (tab.mode === "dameng-jobs") return CalendarClock;
|
||||
if (tab.mode === "processlist") return Activity;
|
||||
if (tab.mode === "mysql-dashboard" || tab.mode === "postgres-dashboard") return Gauge;
|
||||
if (tab.mode === "mysql-dashboard" || tab.mode === "postgres-dashboard" || tab.mode === "nacos-dashboard") return Gauge;
|
||||
return Code2;
|
||||
}
|
||||
|
||||
|
|
@ -623,7 +623,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
|||
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
|
||||
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
|
||||
<Activity v-else-if="tab.mode === 'processlist'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="tab.mode === 'mysql-dashboard' || tab.mode === 'postgres-dashboard'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="tab.mode === 'mysql-dashboard' || tab.mode === 'postgres-dashboard' || tab.mode === 'nacos-dashboard'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<input
|
||||
|
|
@ -816,7 +816,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
|||
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
|
||||
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
|
||||
<Activity v-else-if="tab.mode === 'processlist'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="tab.mode === 'mysql-dashboard' || tab.mode === 'postgres-dashboard'" class="h-3.5 w-3.5" />
|
||||
<Gauge v-else-if="tab.mode === 'mysql-dashboard' || tab.mode === 'postgres-dashboard' || tab.mode === 'nacos-dashboard'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/Vec
|
|||
const ElasticsearchJsonResponsePanel = defineAsyncComponent(() => import("@/components/common/ElasticsearchJsonResponsePanel.vue"));
|
||||
const MqAdminConsole = defineAsyncComponent(() => import("@/components/mq/MqAdminConsole.vue"));
|
||||
const NacosAdminConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAdminConsole.vue"));
|
||||
const NacosDashboard = defineAsyncComponent(() => import("@/components/nacos/NacosDashboard.vue"));
|
||||
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
|
||||
const TableStructureEditor = defineAsyncComponent(() => import("@/components/structure/TableStructureEditor.vue"));
|
||||
const DatabaseUserAdmin = defineAsyncComponent(() => import("@/components/admin/DatabaseUserAdmin.vue"));
|
||||
|
|
@ -1687,6 +1688,12 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'nacos-dashboard'">
|
||||
<div class="min-h-0 flex-1">
|
||||
<NacosDashboard :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'dameng-jobs' && activeConnection">
|
||||
<DamengJobAdmin :key="activeTab.id" :connection="activeConnection" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,467 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { Activity, Boxes, Braces, Cpu, Gauge, HardDrive, Layers3, Loader2, Network, RefreshCw, Server, Users } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import MetricCard from "@/components/common/MetricCard.vue";
|
||||
import MetricLineChart from "@/components/chart/MetricLineChart.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { formatBytes, formatNumber, formatRate } from "@/lib/database/serverMetrics";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import {
|
||||
appendDashboardSample,
|
||||
averageDurationMsSeries,
|
||||
counterRateSeries,
|
||||
dashboardMetric,
|
||||
dashboardNamespaceLabel,
|
||||
dashboardSeries,
|
||||
errorRateSeries,
|
||||
formatDashboardPercent,
|
||||
gaugeSeries,
|
||||
isHealthyNacosNode,
|
||||
ratioPercent,
|
||||
type NacosDashboardSample,
|
||||
type NullableMetric,
|
||||
} from "@/lib/nacos/nacosDashboard";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
namespace?: string;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const loading = ref(false);
|
||||
const fetching = ref(false);
|
||||
const error = ref("");
|
||||
const samples = ref<NacosDashboardSample[]>([]);
|
||||
const autoRefreshInterval = ref(10);
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let activeRequestKey = "";
|
||||
let latestRequestId = 0;
|
||||
|
||||
const latest = computed(() => samples.value[samples.value.length - 1]);
|
||||
const snapshot = computed(() => latest.value?.snapshot);
|
||||
const metrics = computed(() => snapshot.value?.metrics);
|
||||
const prometheus = computed(() => snapshot.value?.prometheus);
|
||||
const namespaceLabel = computed(() => dashboardNamespaceLabel(snapshot.value?.namespace, props.namespace));
|
||||
const healthyNodes = computed(() => snapshot.value?.nodes.filter(isHealthyNacosNode).length ?? 0);
|
||||
const chartLabels = computed(() => samples.value.map((sample) => formatClock(sample.at)));
|
||||
const lastUpdated = computed(() => (latest.value ? formatClock(latest.value.at) : "—"));
|
||||
type ChartSeries = { name: string; data: NullableMetric[]; color?: string };
|
||||
|
||||
function hasSeries(series: readonly ChartSeries[]): boolean {
|
||||
return series.some((item) => item.data.some((value) => value !== null));
|
||||
}
|
||||
|
||||
const countSeries = computed(() => {
|
||||
const series = [
|
||||
{
|
||||
name: t("nacos.dashboardServices"),
|
||||
data: samples.value.map((sample) => sample.snapshot.serviceCount ?? dashboardMetric(sample, "serviceCount")),
|
||||
color: "#3b82f6",
|
||||
},
|
||||
];
|
||||
if (samples.value.some((sample) => typeof sample.snapshot.metrics?.instanceCount === "number")) {
|
||||
series.push({ name: t("nacos.dashboardInstances"), data: dashboardSeries(samples.value, "instanceCount"), color: "#22c55e" });
|
||||
}
|
||||
if (samples.value.some((sample) => typeof sample.snapshot.metrics?.clientCount === "number")) {
|
||||
series.push({ name: t("nacos.dashboardClients"), data: dashboardSeries(samples.value, "clientCount"), color: "#8b5cf6" });
|
||||
}
|
||||
return series;
|
||||
});
|
||||
|
||||
const resourceSeries = computed(() => [
|
||||
{
|
||||
name: t("nacos.dashboardCpu"),
|
||||
data: samples.value.map((sample) => ratioPercent(sample.snapshot.prometheus?.resource.cpuRatio ?? sample.snapshot.metrics?.cpu)),
|
||||
color: "#f59e0b",
|
||||
},
|
||||
{
|
||||
name: t("nacos.dashboardMemory"),
|
||||
data: samples.value.map((sample) => ratioPercent(sample.snapshot.prometheus?.resource.memoryRatio ?? sample.snapshot.metrics?.mem)),
|
||||
color: "#ef4444",
|
||||
},
|
||||
]);
|
||||
const hasResourceMetrics = computed(() => hasSeries(resourceSeries.value));
|
||||
|
||||
const trafficSeries = computed<ChartSeries[]>(() => [
|
||||
{
|
||||
name: t("nacos.dashboardHttpQps"),
|
||||
data: counterRateSeries(samples.value, (sample) => sample.snapshot.prometheus?.traffic.httpRequestsTotal),
|
||||
color: "#3b82f6",
|
||||
},
|
||||
{
|
||||
name: t("nacos.dashboardGrpcQps"),
|
||||
data: counterRateSeries(samples.value, (sample) => sample.snapshot.prometheus?.traffic.grpcRequestsTotal),
|
||||
color: "#8b5cf6",
|
||||
},
|
||||
]);
|
||||
const latencySeries = computed<ChartSeries[]>(() => [
|
||||
{
|
||||
name: t("nacos.dashboardHttpAverage"),
|
||||
data: averageDurationMsSeries(
|
||||
samples.value,
|
||||
(sample) => sample.snapshot.prometheus?.traffic.httpDurationSecondsTotal,
|
||||
(sample) => sample.snapshot.prometheus?.traffic.httpDurationCount,
|
||||
),
|
||||
color: "#3b82f6",
|
||||
},
|
||||
{
|
||||
name: t("nacos.dashboardHttpP50"),
|
||||
data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.traffic.httpP50Ms),
|
||||
color: "#0ea5e9",
|
||||
},
|
||||
{
|
||||
name: t("nacos.dashboardHttpP95"),
|
||||
data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.traffic.httpP95Ms),
|
||||
color: "#06b6d4",
|
||||
},
|
||||
{
|
||||
name: t("nacos.dashboardHttpP99"),
|
||||
data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.traffic.httpP99Ms),
|
||||
color: "#14b8a6",
|
||||
},
|
||||
{
|
||||
name: t("nacos.dashboardGrpcAverage"),
|
||||
data: averageDurationMsSeries(
|
||||
samples.value,
|
||||
(sample) => sample.snapshot.prometheus?.traffic.grpcDurationSecondsTotal,
|
||||
(sample) => sample.snapshot.prometheus?.traffic.grpcDurationCount,
|
||||
),
|
||||
color: "#8b5cf6",
|
||||
},
|
||||
{
|
||||
name: t("nacos.dashboardGrpcP50"),
|
||||
data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.traffic.grpcP50Ms),
|
||||
color: "#a855f7",
|
||||
},
|
||||
{
|
||||
name: t("nacos.dashboardGrpcP95"),
|
||||
data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.traffic.grpcP95Ms),
|
||||
color: "#d946ef",
|
||||
},
|
||||
{
|
||||
name: t("nacos.dashboardGrpcP99"),
|
||||
data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.traffic.grpcP99Ms),
|
||||
color: "#ec4899",
|
||||
},
|
||||
]);
|
||||
const errorRateChartSeries = computed<ChartSeries[]>(() => [
|
||||
{
|
||||
name: t("nacos.dashboardHttpErrors"),
|
||||
data: errorRateSeries(
|
||||
samples.value,
|
||||
(sample) => sample.snapshot.prometheus?.traffic.httpErrorsTotal,
|
||||
(sample) => sample.snapshot.prometheus?.traffic.httpRequestsTotal,
|
||||
),
|
||||
color: "#ef4444",
|
||||
},
|
||||
{
|
||||
name: t("nacos.dashboardGrpcErrors"),
|
||||
data: errorRateSeries(
|
||||
samples.value,
|
||||
(sample) => sample.snapshot.prometheus?.traffic.grpcErrorsTotal,
|
||||
(sample) => sample.snapshot.prometheus?.traffic.grpcRequestsTotal,
|
||||
),
|
||||
color: "#f97316",
|
||||
},
|
||||
]);
|
||||
const executorSeries = computed<ChartSeries[]>(() => [
|
||||
{
|
||||
name: t("nacos.dashboardExecutorPool"),
|
||||
data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.traffic.executorPoolSize),
|
||||
color: "#3b82f6",
|
||||
},
|
||||
{
|
||||
name: t("nacos.dashboardExecutorActive"),
|
||||
data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.traffic.executorActiveCount),
|
||||
color: "#22c55e",
|
||||
},
|
||||
{
|
||||
name: t("nacos.dashboardExecutorQueue"),
|
||||
data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.traffic.executorQueuedTasks),
|
||||
color: "#ef4444",
|
||||
},
|
||||
]);
|
||||
const configRateSeries = computed<ChartSeries[]>(() => [
|
||||
{
|
||||
name: t("nacos.dashboardConfigGets"),
|
||||
data: counterRateSeries(samples.value, (sample) => sample.snapshot.prometheus?.config.getConfigTotal),
|
||||
color: "#3b82f6",
|
||||
},
|
||||
{
|
||||
name: t("nacos.dashboardConfigPublishes"),
|
||||
data: counterRateSeries(samples.value, (sample) => sample.snapshot.prometheus?.config.publishTotal),
|
||||
color: "#22c55e",
|
||||
},
|
||||
]);
|
||||
const configStateSeries = computed<ChartSeries[]>(() => [
|
||||
{ name: t("nacos.dashboardLongPolling"), data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.config.longPolling), color: "#8b5cf6" },
|
||||
{ name: t("nacos.dashboardListenerClients"), data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.config.listenerClients), color: "#06b6d4" },
|
||||
{ name: t("nacos.dashboardListenerKeys"), data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.config.listenerKeys), color: "#3b82f6" },
|
||||
{ name: t("nacos.dashboardNotifyTasks"), data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.config.notifyTasks), color: "#f59e0b" },
|
||||
{ name: t("nacos.dashboardNotifyClientTasks"), data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.config.notifyClientTasks), color: "#ef4444" },
|
||||
{ name: t("nacos.dashboardDumpTasks"), data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.config.dumpTasks), color: "#64748b" },
|
||||
]);
|
||||
const namingStateSeries = computed<ChartSeries[]>(() => [
|
||||
{ name: t("nacos.dashboardServices"), data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.naming.serviceCount), color: "#3b82f6" },
|
||||
{ name: t("nacos.dashboardInstances"), data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.naming.instanceCount), color: "#22c55e" },
|
||||
{ name: t("nacos.dashboardSubscribers"), data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.naming.subscriberCount), color: "#8b5cf6" },
|
||||
{ name: t("nacos.dashboardClients"), data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.naming.connectionCount), color: "#06b6d4" },
|
||||
]);
|
||||
const pushRateSeries = computed<ChartSeries[]>(() => [
|
||||
{ name: t("nacos.dashboardPushes"), data: counterRateSeries(samples.value, (sample) => sample.snapshot.prometheus?.naming.totalPush), color: "#3b82f6" },
|
||||
{ name: t("nacos.dashboardFailedPushes"), data: counterRateSeries(samples.value, (sample) => sample.snapshot.prometheus?.naming.failedPush), color: "#ef4444" },
|
||||
{ name: t("nacos.dashboardEmptyPushes"), data: counterRateSeries(samples.value, (sample) => sample.snapshot.prometheus?.naming.emptyPush), color: "#f59e0b" },
|
||||
]);
|
||||
const pushDetailSeries = computed<ChartSeries[]>(() => [
|
||||
{ name: t("nacos.dashboardPushAverage"), data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.naming.avgPushCostMs), color: "#3b82f6" },
|
||||
{ name: t("nacos.dashboardPushMaximum"), data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.naming.maxPushCostMs), color: "#8b5cf6" },
|
||||
{ name: t("nacos.dashboardPushPending"), data: gaugeSeries(samples.value, (sample) => sample.snapshot.prometheus?.naming.pushPendingTasks), color: "#ef4444" },
|
||||
]);
|
||||
|
||||
const hasTraffic = computed(() => hasSeries(trafficSeries.value));
|
||||
const hasLatency = computed(() => hasSeries(latencySeries.value));
|
||||
const hasReliability = computed(() => hasSeries(errorRateChartSeries.value) || hasSeries(executorSeries.value));
|
||||
const hasConfigPrometheus = computed(() => hasSeries(configRateSeries.value) || hasSeries(configStateSeries.value));
|
||||
const hasNamingPrometheus = computed(() => hasSeries(namingStateSeries.value) || hasSeries(pushRateSeries.value) || hasSeries(pushDetailSeries.value));
|
||||
|
||||
function formatClock(at: number): string {
|
||||
const date = new Date(at);
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function optionalCount(value: number | undefined): string {
|
||||
return typeof value === "number" && Number.isFinite(value) ? formatNumber(value) : "—";
|
||||
}
|
||||
|
||||
function optionalMetric(value: number | undefined): string {
|
||||
return typeof value === "number" && Number.isFinite(value) ? formatRate(value) : "—";
|
||||
}
|
||||
|
||||
function optionalBytes(value: number | undefined): string {
|
||||
return typeof value === "number" && Number.isFinite(value) ? formatBytes(value) : "—";
|
||||
}
|
||||
|
||||
function nodeLastRefresh(value: string | undefined): string {
|
||||
if (!value) return "—";
|
||||
const timestamp = Number(value);
|
||||
if (Number.isFinite(timestamp) && timestamp > 1_000_000_000_000) return new Date(timestamp).toLocaleString();
|
||||
return value;
|
||||
}
|
||||
|
||||
function nodeState(node: { alive?: boolean; state?: string }): string {
|
||||
if (node.state?.trim()) return node.state;
|
||||
if (node.alive === true) return t("nacos.healthy");
|
||||
if (node.alive === false) return t("nacos.unhealthy");
|
||||
return "—";
|
||||
}
|
||||
|
||||
async function fetchSnapshot(options: { silent?: boolean } = {}) {
|
||||
const connectionId = props.connectionId;
|
||||
const namespace = props.namespace || undefined;
|
||||
const requestKey = `${connectionId}\u0000${namespace ?? ""}`;
|
||||
if (fetching.value && activeRequestKey === requestKey) return;
|
||||
const requestId = ++latestRequestId;
|
||||
activeRequestKey = requestKey;
|
||||
fetching.value = true;
|
||||
if (!options.silent) loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
const value = await api.nacosGetDashboard(connectionId, { namespace });
|
||||
if (requestId !== latestRequestId) return;
|
||||
samples.value = appendDashboardSample(samples.value, { at: Date.now(), snapshot: value });
|
||||
} catch (cause: unknown) {
|
||||
if (requestId !== latestRequestId) return;
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
if (requestId === latestRequestId) {
|
||||
loading.value = false;
|
||||
fetching.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh();
|
||||
if (autoRefreshInterval.value <= 0) return;
|
||||
refreshTimer = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
void fetchSnapshot({ silent: true });
|
||||
}, autoRefreshInterval.value * 1000);
|
||||
}
|
||||
|
||||
function onIntervalChange(value: unknown) {
|
||||
autoRefreshInterval.value = Number(value);
|
||||
startAutoRefresh();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.connectionId, props.namespace] as const,
|
||||
async () => {
|
||||
samples.value = [];
|
||||
await fetchSnapshot();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSnapshot();
|
||||
startAutoRefresh();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
latestRequestId += 1;
|
||||
stopAutoRefresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="flex h-10 shrink-0 items-center gap-2 border-b px-3">
|
||||
<Gauge class="h-4 w-4 text-primary" />
|
||||
<span class="text-xs font-medium">{{ t("nacos.dashboardTitle") }}</span>
|
||||
<Badge variant="outline" class="h-5 rounded-md px-1.5 text-[11px]">{{ namespaceLabel }}</Badge>
|
||||
<Badge v-if="metrics?.status" :variant="metrics.status.toUpperCase() === 'UP' ? 'secondary' : 'destructive'" class="h-5 rounded-md px-1.5 text-[11px]">
|
||||
{{ metrics.status }}
|
||||
</Badge>
|
||||
<Badge variant="outline" class="h-5 max-w-48 truncate rounded-md px-1.5 text-[11px]" :title="prometheus?.source.endpoint">
|
||||
{{ prometheus ? "OpenAPI + Prometheus" : "OpenAPI" }}
|
||||
</Badge>
|
||||
<span class="ml-auto text-[11px] text-muted-foreground">{{ t("nacos.dashboardUpdatedAt", { time: lastUpdated }) }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ t("nacos.dashboardAutoRefresh") }}</span>
|
||||
<Select :model-value="String(autoRefreshInterval)" @update:model-value="onIntervalChange">
|
||||
<SelectTrigger class="h-7 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">{{ t("nacos.dashboardOff") }}</SelectItem>
|
||||
<SelectItem value="5">5s</SelectItem>
|
||||
<SelectItem value="10">10s</SelectItem>
|
||||
<SelectItem value="30">30s</SelectItem>
|
||||
<SelectItem value="60">60s</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" :disabled="loading" @click="fetchSnapshot()">
|
||||
<Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" />
|
||||
<RefreshCw v-else class="h-3.5 w-3.5" />
|
||||
{{ t("nacos.refresh") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="shrink-0 border-b bg-destructive/10 px-3 py-2 text-xs text-destructive">{{ error }}</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
<div class="grid shrink-0 grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<MetricCard :label="t('nacos.dashboardNamespaces')" :value="optionalCount(snapshot?.namespaceCount)" :icon="Layers3" />
|
||||
<MetricCard :label="t('nacos.dashboardConfigs')" :value="optionalCount(snapshot?.configCount)" :sub="namespaceLabel" :icon="Braces" />
|
||||
<MetricCard :label="t('nacos.dashboardServices')" :value="optionalCount(snapshot?.serviceCount)" :sub="namespaceLabel" :icon="Boxes" />
|
||||
<MetricCard :label="t('nacos.dashboardInstances')" :value="optionalCount(metrics?.instanceCount)" :icon="Network" />
|
||||
<MetricCard :label="t('nacos.dashboardClients')" :value="optionalCount(metrics?.clientCount)" :sub="t('nacos.dashboardConnections', { count: optionalCount(metrics?.connectionBasedClientCount) })" :icon="Users" />
|
||||
<MetricCard :label="t('nacos.dashboardNodes')" :value="snapshot?.nodes.length ? `${healthyNodes} / ${snapshot.nodes.length}` : '—'" :icon="Server" />
|
||||
<MetricCard :label="t('nacos.dashboardCpu')" :value="formatDashboardPercent(metrics?.cpu)" :icon="Cpu" />
|
||||
<MetricCard :label="t('nacos.dashboardMemory')" :value="formatDashboardPercent(metrics?.mem)" :sub="t('nacos.dashboardLoad', { value: optionalMetric(metrics?.load) })" :icon="HardDrive" />
|
||||
</div>
|
||||
|
||||
<div v-if="prometheus" class="grid shrink-0 grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<MetricCard v-if="prometheus.resource.memoryUsedBytes !== undefined" :label="t('nacos.dashboardMemoryUsed')" :value="optionalBytes(prometheus.resource.memoryUsedBytes)" :sub="t('nacos.dashboardMemoryMax', { value: optionalBytes(prometheus.resource.memoryMaxBytes) })" :icon="HardDrive" />
|
||||
<MetricCard v-if="prometheus.resource.rssBytes !== undefined" :label="t('nacos.dashboardRss')" :value="optionalBytes(prometheus.resource.rssBytes)" :sub="t('nacos.dashboardVms', { value: optionalBytes(prometheus.resource.vmsBytes) })" :icon="HardDrive" />
|
||||
<MetricCard v-if="prometheus.resource.jvmDaemonThreads !== undefined" :label="t('nacos.dashboardJvmThreads')" :value="optionalCount(prometheus.resource.jvmDaemonThreads)" :icon="Activity" />
|
||||
<MetricCard v-if="prometheus.resource.gcPauseCount !== undefined" :label="t('nacos.dashboardGcPauses')" :value="optionalCount(prometheus.resource.gcPauseCount)" :icon="Activity" />
|
||||
</div>
|
||||
|
||||
<div class="grid shrink-0 grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<MetricLineChart :class="{ 'xl:col-span-2': !hasResourceMetrics }" :title="t('nacos.dashboardCountsChart')" :labels="chartLabels" :series="countSeries" :value-formatter="formatNumber" />
|
||||
<MetricLineChart v-if="hasResourceMetrics" :title="t('nacos.dashboardResourcesChart')" :labels="chartLabels" :series="resourceSeries" :value-formatter="(value) => `${value.toFixed(1)}%`" />
|
||||
</div>
|
||||
|
||||
<template v-if="hasTraffic || hasLatency">
|
||||
<div class="text-xs font-semibold text-foreground">{{ t("nacos.dashboardTrafficSection") }}</div>
|
||||
<div class="grid shrink-0 grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<MetricLineChart v-if="hasTraffic" :title="t('nacos.dashboardRequestRateChart')" :labels="chartLabels" :series="trafficSeries" :value-formatter="formatRate" />
|
||||
<MetricLineChart v-if="hasLatency" :class="{ 'xl:col-span-2': !hasTraffic }" :title="t('nacos.dashboardLatencyChart')" :labels="chartLabels" :series="latencySeries" :value-formatter="(value) => `${formatRate(value)} ms`" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="hasReliability">
|
||||
<div class="text-xs font-semibold text-foreground">{{ t("nacos.dashboardReliabilitySection") }}</div>
|
||||
<div class="grid shrink-0 grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<MetricLineChart v-if="hasSeries(errorRateChartSeries)" :title="t('nacos.dashboardErrorRateChart')" :labels="chartLabels" :series="errorRateChartSeries" :value-formatter="(value) => `${value.toFixed(2)}%`" />
|
||||
<MetricLineChart v-if="hasSeries(executorSeries)" :class="{ 'xl:col-span-2': !hasSeries(errorRateChartSeries) }" :title="t('nacos.dashboardExecutorChart')" :labels="chartLabels" :series="executorSeries" :value-formatter="formatNumber" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="hasConfigPrometheus">
|
||||
<div class="text-xs font-semibold text-foreground">{{ t("nacos.dashboardConfigSection") }}</div>
|
||||
<div class="grid shrink-0 grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<MetricLineChart v-if="hasSeries(configRateSeries)" :title="t('nacos.dashboardConfigRateChart')" :labels="chartLabels" :series="configRateSeries" :value-formatter="formatRate" />
|
||||
<MetricLineChart v-if="hasSeries(configStateSeries)" :class="{ 'xl:col-span-2': !hasSeries(configRateSeries) }" :title="t('nacos.dashboardConfigStateChart')" :labels="chartLabels" :series="configStateSeries" :value-formatter="formatNumber" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="hasNamingPrometheus">
|
||||
<div class="text-xs font-semibold text-foreground">{{ t("nacos.dashboardNamingSection") }}</div>
|
||||
<div class="grid shrink-0 grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<MetricLineChart v-if="hasSeries(namingStateSeries)" :title="t('nacos.dashboardNamingStateChart')" :labels="chartLabels" :series="namingStateSeries" :value-formatter="formatNumber" />
|
||||
<MetricLineChart v-if="hasSeries(pushRateSeries)" :class="{ 'xl:col-span-2': !hasSeries(namingStateSeries) }" :title="t('nacos.dashboardPushRateChart')" :labels="chartLabels" :series="pushRateSeries" :value-formatter="formatRate" />
|
||||
<MetricLineChart v-if="hasSeries(pushDetailSeries)" class="xl:col-span-2" :title="t('nacos.dashboardPushDetailChart')" :labels="chartLabels" :series="pushDetailSeries" :value-formatter="formatRate" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="shrink-0 overflow-hidden rounded-lg border bg-card">
|
||||
<div class="flex items-center gap-2 border-b px-3 py-2 text-xs font-medium">
|
||||
<Activity class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{{ t("nacos.dashboardClusterNodes") }}
|
||||
<Badge variant="secondary" class="h-4 rounded px-1 text-[10px]">{{ snapshot?.nodes.length ?? 0 }}</Badge>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-xs">
|
||||
<thead class="bg-muted/50 text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th class="px-3 py-2 font-medium">{{ t("nacos.address") }}</th>
|
||||
<th class="px-3 py-2 font-medium">{{ t("nacos.state") }}</th>
|
||||
<th class="px-3 py-2 font-medium">{{ t("nacos.dashboardSite") }}</th>
|
||||
<th class="px-3 py-2 font-medium">{{ t("nacos.weight") }}</th>
|
||||
<th class="px-3 py-2 font-medium">{{ t("nacos.dashboardLastRefresh") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="node in snapshot?.nodes ?? []" :key="node.address" class="border-t hover:bg-accent/40">
|
||||
<td class="px-3 py-2 font-mono">{{ node.address }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<Badge :variant="isHealthyNacosNode(node) ? 'secondary' : 'destructive'" class="h-5 rounded-md px-1.5 text-[10px]">{{ nodeState(node) }}</Badge>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-muted-foreground">{{ node.site || "—" }}</td>
|
||||
<td class="px-3 py-2 tabular-nums text-muted-foreground">{{ node.weight ?? "—" }}</td>
|
||||
<td class="px-3 py-2 text-muted-foreground">{{ nodeLastRefresh(node.lastRefreshTime) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!snapshot?.nodes.length">
|
||||
<td colspan="5" class="px-3 py-8 text-center text-muted-foreground">{{ t("nacos.dashboardNoNodes") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details v-if="snapshot?.warnings.length" class="shrink-0 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs">
|
||||
<summary class="cursor-pointer font-medium text-amber-700 dark:text-amber-300">{{ t("nacos.dashboardPartial", { count: snapshot.warnings.length }) }}</summary>
|
||||
<ul class="mt-2 list-disc space-y-1 pl-5 text-muted-foreground">
|
||||
<li v-for="warning in snapshot.warnings" :key="warning">{{ warning }}</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<NacosDashboardSnapshot> {
|
||||
return post("/api/nacos/dashboard", { connectionId, query });
|
||||
}
|
||||
|
||||
export async function nacosRawRequest(connectionId: string, req: NacosRawRequest): Promise<NacosRawResponse> {
|
||||
return post("/api/nacos/raw", { connectionId, req });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NacosDashboardSnapshot> {
|
||||
return invoke("nacos_get_dashboard", { connectionId, query });
|
||||
}
|
||||
|
||||
export async function nacosRawRequest(connectionId: string, req: NacosRawRequest): Promise<NacosRawResponse> {
|
||||
return invoke("nacos_raw_request", { connectionId, req });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() ?? "");
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -626,6 +626,10 @@ mod tests {
|
|||
Err("unused".to_string())
|
||||
}
|
||||
|
||||
async fn get_dashboard(&self, _: NacosDashboardQuery) -> Result<NacosDashboardSnapshot, String> {
|
||||
Err("unused".to_string())
|
||||
}
|
||||
|
||||
async fn raw_request(&self, _: NacosRawRequest) -> Result<NacosRawResponse, String> {
|
||||
Err("unused".to_string())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, String> {
|
||||
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<String, String> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<Vec<NacosClusterNode>, 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<NacosDashboardSnapshot, String> {
|
||||
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<NacosRawResponse, String> {
|
||||
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<NacosNamespaceInfo> {
|
|||
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<NacosDashboardMetrics>,
|
||||
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<f64>) -> Option<u64> {
|
||||
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<NacosClusterNode> {
|
||||
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<reqwest
|
|||
return Ok(resp);
|
||||
}
|
||||
let detail = resp.text().await.unwrap_or_default();
|
||||
let message = format!("Nacos admin {path} returned {status}: {}", detail.trim());
|
||||
let detail = compact_response_detail(&detail);
|
||||
let message = if detail.is_empty() {
|
||||
format!("Nacos admin {path} returned {status}")
|
||||
} else {
|
||||
format!("Nacos admin {path} returned {status}: {detail}")
|
||||
};
|
||||
Err(classified_error(classify_nacos_error(&message), &message))
|
||||
}
|
||||
|
||||
fn compact_response_detail(detail: &str) -> String {
|
||||
let detail = detail.trim();
|
||||
if detail.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if detail.to_ascii_lowercase().contains("<!doctype html") || detail.to_ascii_lowercase().contains("<html") {
|
||||
return "HTML error page".to_string();
|
||||
}
|
||||
|
||||
const MAX_CHARS: usize = 512;
|
||||
let mut chars = detail.chars();
|
||||
let compact: String = chars.by_ref().take(MAX_CHARS).collect();
|
||||
if chars.next().is_some() {
|
||||
format!("{compact}…")
|
||||
} else {
|
||||
compact
|
||||
}
|
||||
}
|
||||
|
||||
fn classified_error(kind: &str, message: &str) -> 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<u64> {
|
||||
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<f64> {
|
||||
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<bool> {
|
||||
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<i64> {
|
||||
|
|
@ -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("<!doctype html><html><body><h1>HTTP Status 404</h1></body></html>"),
|
||||
"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"] {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,5 +31,6 @@ pub trait NacosAdmin: Send + Sync {
|
|||
async fn list_services(&self, query: NacosServiceQuery) -> Result<NacosServiceList, String>;
|
||||
async fn list_instances(&self, query: NacosInstanceQuery) -> Result<Vec<NacosInstanceInfo>, String>;
|
||||
async fn update_instance(&self, req: NacosInstanceUpdate) -> Result<(), String>;
|
||||
async fn get_dashboard(&self, query: NacosDashboardQuery) -> Result<NacosDashboardSnapshot, String>;
|
||||
async fn raw_request(&self, req: NacosRawRequest) -> Result<NacosRawResponse, String>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Vec<String>, 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<Option<NacosPrometheusSnapshot>, String> {
|
||||
scrape_with_timeout(client, cfg, SCRAPE_TIMEOUT).await
|
||||
}
|
||||
|
||||
async fn scrape_with_timeout(
|
||||
client: &reqwest::Client,
|
||||
cfg: &NacosAdminConfig,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<NacosPrometheusSnapshot>, String> {
|
||||
let candidates = endpoint_candidates(cfg)?;
|
||||
if candidates.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let candidate_summary =
|
||||
candidates.iter().map(|candidate| redact_endpoint(candidate)).collect::<Vec<_>>().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<NacosPrometheusSnapshot, String> {
|
||||
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<NacosPrometheusSnapshot, String> {
|
||||
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<f64> {
|
||||
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<f64> {
|
||||
scalar_sum_filtered(samples, metric, |_| true)
|
||||
}
|
||||
|
||||
fn scalar_sum_any(samples: &[Sample], metrics: &[&str]) -> Option<f64> {
|
||||
metrics.iter().find_map(|metric| scalar_sum(samples, metric))
|
||||
}
|
||||
|
||||
fn scalar_sum_filtered(samples: &[Sample], metric: &str, predicate: impl Fn(&Labels) -> bool) -> Option<f64> {
|
||||
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<f64> {
|
||||
metrics.iter().find_map(|metric| scalar_sum_filtered(samples, metric, predicate))
|
||||
}
|
||||
|
||||
fn scalar_max(samples: &[Sample], metric: &str) -> Option<f64> {
|
||||
scalar_max_filtered(samples, metric, |_| true)
|
||||
}
|
||||
|
||||
fn scalar_max_any(samples: &[Sample], metrics: &[&str]) -> Option<f64> {
|
||||
metrics.iter().find_map(|metric| scalar_max(samples, metric))
|
||||
}
|
||||
|
||||
fn scalar_max_filtered(samples: &[Sample], metric: &str, predicate: impl Fn(&Labels) -> bool) -> Option<f64> {
|
||||
samples
|
||||
.iter()
|
||||
.filter(|sample| sample.metric == metric && predicate(&sample.labels))
|
||||
.filter_map(scalar_value)
|
||||
.reduce(f64::max)
|
||||
}
|
||||
|
||||
fn sum_non_empty(values: impl Iterator<Item = f64>) -> Option<f64> {
|
||||
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<f64> {
|
||||
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<f64>, denominator: Option<f64>) -> Option<f64> {
|
||||
match (numerator, denominator) {
|
||||
(Some(numerator), Some(denominator)) if denominator > 0.0 => normalize_ratio(numerator / denominator),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_ratio(value: f64) -> Option<f64> {
|
||||
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<f64> {
|
||||
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<f64> {
|
||||
let mut histogram_buckets = HashMap::<u64, (f64, f64)>::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<f64> {
|
||||
metrics.iter().find_map(|metric| quantile_ms(samples, metric, quantile, multiplier))
|
||||
}
|
||||
|
||||
fn nearest_quantile(values: impl Iterator<Item = (f64, f64)>, target: f64) -> Option<f64> {
|
||||
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<Item = (f64, f64)>, target: f64) -> Option<f64> {
|
||||
let mut buckets =
|
||||
values.filter(|(boundary, count)| !boundary.is_nan() && count.is_finite() && *count >= 0.0).collect::<Vec<_>>();
|
||||
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("<html>not metrics</html>", &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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<NacosDashboardSnapshot, String> {
|
||||
Err("unused".to_string())
|
||||
}
|
||||
async fn raw_request(&self, _: NacosRawRequest) -> Result<NacosRawResponse, String> {
|
||||
Err("unused".to_string())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NacosDashboardSnapshot, String> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -496,6 +496,175 @@ pub struct NacosInstanceUpdate {
|
|||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NacosDashboardQuery {
|
||||
#[serde(default)]
|
||||
pub namespace: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NacosDashboardMetrics {
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub service_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub instance_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub subscribe_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub raft_notify_task_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub responsible_service_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub responsible_instance_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub client_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub connection_based_client_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub ephemeral_ip_port_client_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub persistent_ip_port_client_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub responsible_client_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub cpu: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub load: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub mem: Option<f64>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NacosPrometheusResourceMetrics {
|
||||
pub cpu_ratio: Option<f64>,
|
||||
pub memory_ratio: Option<f64>,
|
||||
pub memory_used_bytes: Option<f64>,
|
||||
pub memory_max_bytes: Option<f64>,
|
||||
pub rss_bytes: Option<f64>,
|
||||
pub vms_bytes: Option<f64>,
|
||||
pub system_total_memory_bytes: Option<f64>,
|
||||
pub load_1m: Option<f64>,
|
||||
pub jvm_daemon_threads: Option<f64>,
|
||||
pub gc_pause_count: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NacosPrometheusTrafficMetrics {
|
||||
pub http_requests_total: Option<f64>,
|
||||
pub grpc_requests_total: Option<f64>,
|
||||
pub http_errors_total: Option<f64>,
|
||||
pub grpc_errors_total: Option<f64>,
|
||||
pub http_duration_seconds_total: Option<f64>,
|
||||
pub http_duration_count: Option<f64>,
|
||||
pub grpc_duration_seconds_total: Option<f64>,
|
||||
pub grpc_duration_count: Option<f64>,
|
||||
pub http_p50_ms: Option<f64>,
|
||||
pub http_p95_ms: Option<f64>,
|
||||
pub http_p99_ms: Option<f64>,
|
||||
pub grpc_p50_ms: Option<f64>,
|
||||
pub grpc_p95_ms: Option<f64>,
|
||||
pub grpc_p99_ms: Option<f64>,
|
||||
pub executor_pool_size: Option<f64>,
|
||||
pub executor_active_count: Option<f64>,
|
||||
pub executor_queued_tasks: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NacosPrometheusConfigMetrics {
|
||||
pub config_count: Option<f64>,
|
||||
pub get_config_total: Option<f64>,
|
||||
pub publish_total: Option<f64>,
|
||||
pub long_polling: Option<f64>,
|
||||
pub listener_clients: Option<f64>,
|
||||
pub listener_keys: Option<f64>,
|
||||
pub notify_tasks: Option<f64>,
|
||||
pub notify_client_tasks: Option<f64>,
|
||||
pub dump_tasks: Option<f64>,
|
||||
pub subscriber_count: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NacosPrometheusNamingMetrics {
|
||||
pub service_count: Option<f64>,
|
||||
pub instance_count: Option<f64>,
|
||||
pub subscriber_count: Option<f64>,
|
||||
pub connection_count: Option<f64>,
|
||||
pub total_push: Option<f64>,
|
||||
pub failed_push: Option<f64>,
|
||||
pub empty_push: Option<f64>,
|
||||
pub push_pending_tasks: Option<f64>,
|
||||
pub avg_push_cost_ms: Option<f64>,
|
||||
pub max_push_cost_ms: Option<f64>,
|
||||
pub leader_status: Option<f64>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
pub port: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub state: Option<String>,
|
||||
#[serde(default)]
|
||||
pub alive: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub site: Option<String>,
|
||||
#[serde(default)]
|
||||
pub weight: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub last_refresh_time: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NacosDashboardSnapshot {
|
||||
pub namespace: String,
|
||||
#[serde(default)]
|
||||
pub namespace_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub config_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub service_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub metrics: Option<NacosDashboardMetrics>,
|
||||
#[serde(default)]
|
||||
pub prometheus: Option<NacosPrometheusSnapshot>,
|
||||
#[serde(default)]
|
||||
pub nodes: Vec<NacosClusterNode>,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NacosRawRequest {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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<Arc<WebState>>,
|
||||
Json(req): Json<DashboardReq>,
|
||||
) -> Result<Json<dbx_core::nacos::NacosDashboardSnapshot>, 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<Arc<WebState>>,
|
||||
Json(req): Json<RawReq>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<AppState>>,
|
||||
connection_id: String,
|
||||
query: dbx_core::nacos::NacosDashboardQuery,
|
||||
) -> Result<dbx_core::nacos::NacosDashboardSnapshot, String> {
|
||||
dbx_core::nacos::service::nacos_get_dashboard_core(&state, &connection_id, query).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn nacos_raw_request(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue