feat(nacos): add Nacos dashboard metrics
This commit is contained in:
41 files changed
+3268
-36
No files matched your search
@@ -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() ?? "");
|
||||
}
|
||||
Reference in new issue
Block a user