mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 10:45:35 +08:00
@@ -48,7 +48,7 @@ import {
|
||||
type NacosConfigDeleteSnapshot,
|
||||
} from "@/lib/nacos/nacosAdmin";
|
||||
import { createNacosNamespaceRequestGuard, subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache";
|
||||
import { nacosInstanceMatchesPatch, nacosInstanceRefIdentity, nacosIpAddressIsValid, nacosServiceDetailMatches } from "@/lib/nacos/nacosServiceManagement";
|
||||
import { nacosInstanceMatchesPatch, nacosInstanceRefIdentity, nacosIpAddressIsValid, nacosServiceDetailMatches, nacosServiceInstanceHealthSummary } from "@/lib/nacos/nacosServiceManagement";
|
||||
import { loadReadableNacosNamespaces, nacosNamespaceIdentity } from "@/lib/nacos/nacosNamespaceVisibility";
|
||||
import { clipboardLineEndings, copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
|
||||
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
|
||||
@@ -473,6 +473,12 @@ const canRequestBatchDeleteConfigs = computed(() => !props.readOnly && !savingCo
|
||||
const pendingBatchDeleteDetails = computed(() => pendingBatchDelete.value?.keys.map((key) => `namespace=${key.namespace || "public"}\ndataId=${key.dataId}\ngroup=${key.group || "DEFAULT_GROUP"}`).join("\n\n") || "");
|
||||
const pendingHistoryRollbackDetails = computed(() => (pendingHistoryRollback.value ? buildNacosConfigHistoryRollbackConfirm(pendingHistoryRollback.value, namespace.value) : ""));
|
||||
const pendingInstanceDetails = computed(() => (pendingInstanceUpdate.value && selectedService.value ? buildNacosInstanceConfirm(selectedService.value, pendingInstanceUpdate.value.instance, pendingInstanceUpdate.value.patch, serviceGroup.value, namespace.value) : ""));
|
||||
const pendingInstanceAvailabilityAction = computed(() => {
|
||||
const enabled = pendingInstanceUpdate.value?.patch.enabled;
|
||||
return enabled == null ? "" : t(enabled ? "nacos.online" : "nacos.offline");
|
||||
});
|
||||
const pendingInstanceTitle = computed(() => (pendingInstanceAvailabilityAction.value ? `${t("nacos.confirmInstanceTitle")}: ${pendingInstanceAvailabilityAction.value}` : t("nacos.confirmInstanceTitle")));
|
||||
const pendingInstanceConfirmLabel = computed(() => pendingInstanceAvailabilityAction.value || t("dangerDialog.confirm"));
|
||||
const pendingInstanceDeregisterDetails = computed(() => {
|
||||
if (!pendingInstanceDeregister.value || !selectedService.value) return "";
|
||||
const ref = instanceRef(pendingInstanceDeregister.value);
|
||||
@@ -2111,6 +2117,35 @@ function serviceIdentity(service: NacosServiceInfo | null) {
|
||||
return service ? `${service.groupName || "DEFAULT_GROUP"}\u0000${service.serviceName}` : "";
|
||||
}
|
||||
|
||||
function serviceInstanceHealthLabel(service: NacosServiceInfo) {
|
||||
const summary = nacosServiceInstanceHealthSummary(service);
|
||||
if (!summary) return "";
|
||||
const counts = { healthy: summary.healthy, total: summary.total };
|
||||
switch (summary.status) {
|
||||
case "allHealthy":
|
||||
return t("nacos.serviceInstancesAllHealthy", counts);
|
||||
case "partiallyHealthy":
|
||||
return t("nacos.serviceInstancesPartiallyHealthy", counts);
|
||||
case "noHealthyInstances":
|
||||
return t("nacos.serviceInstancesNoHealthyInstances", counts);
|
||||
case "noInstances":
|
||||
return t("nacos.serviceInstancesNoInstances");
|
||||
}
|
||||
}
|
||||
|
||||
function serviceInstanceHealthClass(service: NacosServiceInfo) {
|
||||
switch (nacosServiceInstanceHealthSummary(service)?.status) {
|
||||
case "allHealthy":
|
||||
return "border-emerald-500/50 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
||||
case "partiallyHealthy":
|
||||
return "border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
||||
case "noHealthyInstances":
|
||||
return "border-destructive/50 bg-destructive/10 text-destructive";
|
||||
default:
|
||||
return "border-muted-foreground/40 text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServiceDetail() {
|
||||
const service = selectedService.value ? { ...selectedService.value } : null;
|
||||
if (!service || !getServiceCapability.value.supported) return;
|
||||
@@ -2367,7 +2402,9 @@ async function reconcileInstancePresence(ref: NacosInstanceRef, shouldExist: boo
|
||||
async function updateInstance(instance: NacosInstanceInfo, patch: NacosInstancePatch) {
|
||||
if (!selectedService.value || props.readOnly || !supportsInstanceUpdate.value) return;
|
||||
const ref = instanceRef(instance);
|
||||
if (!(await confirmNacosMutation(t("nacos.confirmInstanceTitle"), props.connectionId, ref.namespace || namespace.value))) return;
|
||||
const availabilityAction = patch.enabled == null ? "" : t(patch.enabled ? "nacos.online" : "nacos.offline");
|
||||
const reviewText = availabilityAction ? `${t("nacos.confirmInstanceTitle")}: ${availabilityAction}` : t("nacos.confirmInstanceTitle");
|
||||
if (!(await confirmNacosMutation(reviewText, props.connectionId, ref.namespace || namespace.value))) return;
|
||||
const key = instanceIdentity(instance);
|
||||
const updateId = ++instanceUpdateSequence;
|
||||
const operationToken = beginInstanceOperation(key);
|
||||
@@ -3193,8 +3230,11 @@ useUpdateBlocker(() =>
|
||||
<span class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Server class="h-3.5 w-3.5" />
|
||||
{{ service.groupName || serviceGroup }}
|
||||
<span v-if="service.ipCount != null">· {{ t("nacos.instanceCount", { count: service.ipCount }) }}</span>
|
||||
<span v-if="service.healthyInstanceCount != null">· {{ t("nacos.healthy") }} {{ service.healthyInstanceCount }}</span>
|
||||
<Badge v-if="serviceInstanceHealthLabel(service)" data-testid="nacos-service-instance-status" variant="outline" class="h-5" :class="serviceInstanceHealthClass(service)">{{ serviceInstanceHealthLabel(service) }}</Badge>
|
||||
<template v-else>
|
||||
<span v-if="service.ipCount != null">· {{ t("nacos.instanceCount", { count: service.ipCount }) }}</span>
|
||||
<span v-if="service.healthyInstanceCount != null">· {{ t("nacos.healthy") }} {{ service.healthyInstanceCount }}</span>
|
||||
</template>
|
||||
<span v-if="service.clusterCount != null">· {{ t("nacos.cluster") }} {{ service.clusterCount }}</span>
|
||||
<Badge v-if="service.triggerFlag === 'true'" variant="outline" class="h-5 border-amber-500 text-amber-700">{{ t("nacos.protectionTriggered") }}</Badge>
|
||||
</span>
|
||||
@@ -3337,7 +3377,9 @@ useUpdateBlocker(() =>
|
||||
<span class="nacos-instance-address max-w-full font-mono text-sm font-medium">{{ instance.ip }}:{{ instance.port }}</span>
|
||||
<Badge variant="outline">{{ instance.clusterName || "DEFAULT" }}</Badge>
|
||||
<Badge variant="outline" :class="instance.healthy === false ? 'border-destructive/50 text-destructive' : 'border-emerald-500/50 text-emerald-700 dark:text-emerald-300'">{{ instance.healthy === false ? t("nacos.unhealthy") : t("nacos.healthy") }}</Badge>
|
||||
<Badge :variant="instance.enabled === false ? 'outline' : 'secondary'" :class="instance.enabled === false ? 'border-muted-foreground/50 text-muted-foreground' : ''">{{ instance.enabled === false ? t("nacos.offline") : t("nacos.enabled") }}</Badge>
|
||||
<Badge data-testid="nacos-instance-availability-status" :variant="instance.enabled === false ? 'outline' : 'secondary'" :class="instance.enabled === false ? 'border-muted-foreground/50 text-muted-foreground' : ''">{{
|
||||
instance.enabled === false ? t("nacos.offline") : t("nacos.online")
|
||||
}}</Badge>
|
||||
<Badge v-if="instance.ephemeral != null" variant="outline">{{ instance.ephemeral ? t("nacos.ephemeral") : t("nacos.persistent") }}</Badge>
|
||||
</div>
|
||||
<div class="nacos-instance-detail-grid mt-3 grid gap-x-5 gap-y-3 text-xs">
|
||||
@@ -3380,12 +3422,13 @@ useUpdateBlocker(() =>
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 gap-1"
|
||||
data-testid="nacos-instance-availability-action"
|
||||
:class="instance.enabled === false ? 'border-emerald-500/50 text-emerald-700 hover:bg-emerald-500/10 hover:text-emerald-800 dark:text-emerald-300 dark:hover:text-emerald-200' : 'border-destructive/50 text-destructive hover:bg-destructive/10'"
|
||||
:disabled="readOnly || !supportsInstanceUpdate || isInstanceUpdating(instance)"
|
||||
@click="requestUpdateInstance(instance, { enabled: !instance.enabled })"
|
||||
>
|
||||
<Loader2 v-if="isInstanceUpdating(instance)" class="h-3 w-3 animate-spin" />
|
||||
{{ instance.enabled === false ? t("nacos.enable") : t("nacos.disable") }}
|
||||
{{ instance.enabled === false ? t("nacos.online") : t("nacos.offline") }}
|
||||
</Button>
|
||||
<Button v-if="updateInstanceHealthCapability.supported" size="sm" variant="outline" class="h-7" :disabled="readOnly || isInstanceUpdating(instance)" @click="requestUpdateInstance(instance, { healthy: !instance.healthy })">
|
||||
{{ instance.healthy === false ? t("nacos.markHealthy") : t("nacos.markUnhealthy") }}
|
||||
@@ -3711,10 +3754,10 @@ useUpdateBlocker(() =>
|
||||
|
||||
<DangerConfirmDialog
|
||||
:open="!!pendingInstanceUpdate"
|
||||
:title="t('nacos.confirmInstanceTitle')"
|
||||
:title="pendingInstanceTitle"
|
||||
:message="t('nacos.confirmInstanceMessage')"
|
||||
:details="pendingInstanceDetails"
|
||||
:confirm-label="t('dangerDialog.confirm')"
|
||||
:confirm-label="pendingInstanceConfirmLabel"
|
||||
:loading="pendingInstanceUpdate ? isInstanceUpdating(pendingInstanceUpdate.instance) : false"
|
||||
:close-on-confirm="false"
|
||||
@update:open="
|
||||
|
||||
+130
-4
@@ -2,15 +2,19 @@
|
||||
|
||||
import { createApp, defineComponent, h, KeepAlive, nextTick, ref, type App, type ComponentPublicInstance } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { NacosConfigItem, NacosConfigKey, NacosConfigList } from "@/types/nacos";
|
||||
import type { NacosConfigItem, NacosConfigKey, NacosConfigList, NacosInstanceInfo, NacosServiceInfo } from "@/types/nacos";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureConnected: vi.fn(),
|
||||
nacosDeleteConfig: vi.fn(),
|
||||
nacosGetConfig: vi.fn(),
|
||||
nacosGetService: vi.fn(),
|
||||
nacosListInstances: vi.fn(),
|
||||
nacosListConfigs: vi.fn(),
|
||||
nacosListServices: vi.fn(),
|
||||
nacosTestConnection: vi.fn(),
|
||||
nacosUpdateInstance: vi.fn(),
|
||||
confirmNacosMutation: vi.fn(),
|
||||
queryTabs: [] as Array<Record<string, unknown>>,
|
||||
updateNacosConfigEditorViewport: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
@@ -18,15 +22,32 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("vue-i18n", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("vue-i18n")>()),
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
const template =
|
||||
{
|
||||
"nacos.confirmInstanceTitle": "Update Nacos instance",
|
||||
"nacos.offline": "Offline",
|
||||
"nacos.online": "Online",
|
||||
"nacos.serviceInstancesAllHealthy": "All healthy · {healthy}/{total}",
|
||||
"nacos.serviceInstancesPartiallyHealthy": "Partially healthy · {healthy}/{total}",
|
||||
"nacos.serviceInstancesNoHealthyInstances": "No healthy instances · {healthy}/{total}",
|
||||
"nacos.serviceInstancesNoInstances": "No instances",
|
||||
}[key] ?? key;
|
||||
return Object.entries(params ?? {}).reduce((message, [name, value]) => message.replaceAll(`{${name}}`, String(value)), template);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
nacosDeleteConfig: mocks.nacosDeleteConfig,
|
||||
nacosGetConfig: mocks.nacosGetConfig,
|
||||
nacosGetService: mocks.nacosGetService,
|
||||
nacosListInstances: mocks.nacosListInstances,
|
||||
nacosListConfigs: mocks.nacosListConfigs,
|
||||
nacosListServices: mocks.nacosListServices,
|
||||
nacosTestConnection: mocks.nacosTestConnection,
|
||||
nacosUpdateInstance: mocks.nacosUpdateInstance,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
@@ -68,7 +89,7 @@ vi.mock("@/composables/useTheme", async () => {
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
|
||||
vi.mock("@/lib/database/productionExecutionGuard", () => ({
|
||||
executeWithProductionContextGuard: ({ execute }: { execute: () => Promise<unknown> }) => execute(),
|
||||
executeWithProductionContextGuard: mocks.confirmNacosMutation,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/database/productionSafety", () => ({
|
||||
@@ -101,7 +122,35 @@ vi.mock("@/components/ui/select", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@/components/common/ProductionContextBadge.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("ProductionContextBadge") }));
|
||||
vi.mock("@/components/editor/DangerConfirmDialog.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("DangerConfirmDialog") }));
|
||||
vi.mock("@/components/editor/DangerConfirmDialog.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
props: {
|
||||
open: Boolean,
|
||||
title: String,
|
||||
message: String,
|
||||
confirmLabel: String,
|
||||
},
|
||||
emits: ["confirm", "update:open"],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
props.open
|
||||
? h(
|
||||
"button",
|
||||
{
|
||||
"data-testid": "nacos-instance-update-confirm",
|
||||
"data-title": props.title,
|
||||
"data-message": props.message,
|
||||
onClick: () => emit("confirm"),
|
||||
},
|
||||
props.confirmLabel,
|
||||
)
|
||||
: null;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@/components/editor/EditorSearchPanel.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("EditorSearchPanel") }));
|
||||
vi.mock("@/components/nacos/NacosConfigDiffDialog.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("NacosConfigDiffDialog") }));
|
||||
vi.mock("@/components/nacos/NacosConfigHistoryDialog.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("NacosConfigHistoryDialog") }));
|
||||
@@ -117,11 +166,13 @@ vi.mock("splitpanes", async () => {
|
||||
import NacosAdminConsole from "@/components/nacos/NacosAdminConsole.vue";
|
||||
|
||||
type NacosAdminSetupState = {
|
||||
activeTab: "configs" | "services";
|
||||
configGroup: string;
|
||||
configPageNo: number;
|
||||
configPageSize: number;
|
||||
configEditorView: { scrollDOM: HTMLElement } | null;
|
||||
configs: NacosConfigItem[];
|
||||
instances: NacosInstanceInfo[];
|
||||
deleteConfig: () => Promise<void>;
|
||||
deleteSelectedConfigs: () => Promise<void>;
|
||||
requestBatchDeleteConfigs: () => void;
|
||||
@@ -129,6 +180,8 @@ type NacosAdminSetupState = {
|
||||
selectedConfig: NacosConfigItem | null;
|
||||
selectedConfigKeys: string[];
|
||||
selectedConfigOriginalKey: NacosConfigKey | null;
|
||||
selectedService: NacosServiceInfo | null;
|
||||
services: NacosServiceInfo[];
|
||||
selectConfig: (item: NacosConfigItem) => Promise<void>;
|
||||
toggleConfigSelection: (item: NacosConfigItem, checked: boolean) => void;
|
||||
};
|
||||
@@ -160,6 +213,8 @@ beforeEach(async () => {
|
||||
mocks.ensureConnected.mockReset().mockResolvedValue(undefined);
|
||||
mocks.nacosDeleteConfig.mockReset().mockResolvedValue(undefined);
|
||||
mocks.nacosGetConfig.mockReset().mockResolvedValue(configA);
|
||||
mocks.nacosGetService.mockReset().mockResolvedValue({ serviceName: "api", groupName: "DEFAULT_GROUP" });
|
||||
mocks.nacosListInstances.mockReset().mockResolvedValue([]);
|
||||
mocks.nacosListConfigs.mockReset().mockResolvedValue(configList(1, 20, 2, [configA, configB]));
|
||||
mocks.nacosListServices.mockReset().mockResolvedValue({ pageNo: 1, pageSize: 20, totalCount: 0, items: [] });
|
||||
mocks.nacosTestConnection.mockReset().mockResolvedValue({
|
||||
@@ -169,6 +224,8 @@ beforeEach(async () => {
|
||||
auth: "none",
|
||||
capabilities: { supportsConfigManagement: true, supportsConfigHistory: true, supportsServiceManagement: true, supportsInstanceUpdate: true, supportsRawApi: true },
|
||||
});
|
||||
mocks.nacosUpdateInstance.mockReset().mockResolvedValue(undefined);
|
||||
mocks.confirmNacosMutation.mockReset().mockImplementation(({ execute }: { execute: () => Promise<unknown> }) => execute());
|
||||
mocks.toast.mockReset();
|
||||
mocks.updateNacosConfigEditorViewport.mockReset();
|
||||
mocks.queryTabs.splice(0);
|
||||
@@ -232,6 +289,75 @@ describe("NacosAdminConsole config deletion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("NacosAdminConsole service instance status", () => {
|
||||
it("renders accessible text for every service health-count state", async () => {
|
||||
state.activeTab = "services";
|
||||
state.services = [
|
||||
{ serviceName: "all-healthy", ipCount: 3, healthyInstanceCount: 3 },
|
||||
{ serviceName: "partially-healthy", ipCount: 3, healthyInstanceCount: 2 },
|
||||
{ serviceName: "no-healthy", ipCount: 3, healthyInstanceCount: 0 },
|
||||
{ serviceName: "no-instances", ipCount: 0, healthyInstanceCount: 0 },
|
||||
];
|
||||
await flushUi();
|
||||
|
||||
const statuses = Array.from(host!.querySelectorAll<HTMLElement>("[data-testid=nacos-service-instance-status]"));
|
||||
expect(statuses.map((status) => status.textContent?.trim())).toEqual(["All healthy · 3/3", "Partially healthy · 2/3", "No healthy instances · 0/3", "No instances"]);
|
||||
expect(statuses.map((status) => status.className)).toEqual([expect.stringContaining("text-emerald-700"), expect.stringContaining("text-amber-700"), expect.stringContaining("text-destructive"), expect.stringContaining("text-muted-foreground")]);
|
||||
});
|
||||
|
||||
it("uses Online and Offline in actions and confirmations without changing the enabled patch", async () => {
|
||||
const service: NacosServiceInfo = { serviceName: "api", groupName: "DEFAULT_GROUP", ipCount: 1, healthyInstanceCount: 1 };
|
||||
let currentInstance: NacosInstanceInfo = { ip: "127.0.0.1", port: 8080, clusterName: "DEFAULT", groupName: "DEFAULT_GROUP", enabled: true, healthy: true };
|
||||
mocks.nacosUpdateInstance.mockImplementation(async (_connectionId, request) => {
|
||||
currentInstance = { ...currentInstance, ...request.patch };
|
||||
});
|
||||
mocks.nacosListInstances.mockImplementation(async () => [currentInstance]);
|
||||
state.activeTab = "services";
|
||||
state.selectedService = service;
|
||||
state.instances = [currentInstance];
|
||||
await flushUi();
|
||||
|
||||
expect(host!.querySelector("[data-testid=nacos-instance-availability-status]")?.textContent?.trim()).toBe("Online");
|
||||
let action = host!.querySelector<HTMLButtonElement>("[data-testid=nacos-instance-availability-action]");
|
||||
expect(action?.textContent?.trim()).toBe("Offline");
|
||||
action?.click();
|
||||
await flushUi();
|
||||
|
||||
let confirm = host!.querySelector<HTMLButtonElement>("[data-testid=nacos-instance-update-confirm]");
|
||||
expect(confirm?.textContent?.trim()).toBe("Offline");
|
||||
expect(confirm?.getAttribute("data-title")).toBe("Update Nacos instance: Offline");
|
||||
confirm?.click();
|
||||
await vi.waitFor(() => expect(mocks.nacosUpdateInstance).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.nacosUpdateInstance).toHaveBeenNthCalledWith(1, "connection-1", {
|
||||
target: {
|
||||
namespace: "public",
|
||||
serviceName: "api",
|
||||
groupName: "DEFAULT_GROUP",
|
||||
ip: "127.0.0.1",
|
||||
port: 8080,
|
||||
clusterName: "DEFAULT",
|
||||
ephemeral: undefined,
|
||||
},
|
||||
patch: { enabled: false },
|
||||
});
|
||||
expect(mocks.confirmNacosMutation).toHaveBeenNthCalledWith(1, expect.objectContaining({ reviewText: "Update Nacos instance: Offline" }));
|
||||
await flushUi();
|
||||
|
||||
expect(host!.querySelector("[data-testid=nacos-instance-availability-status]")?.textContent?.trim()).toBe("Offline");
|
||||
action = host!.querySelector<HTMLButtonElement>("[data-testid=nacos-instance-availability-action]");
|
||||
expect(action?.textContent?.trim()).toBe("Online");
|
||||
action?.click();
|
||||
await flushUi();
|
||||
confirm = host!.querySelector<HTMLButtonElement>("[data-testid=nacos-instance-update-confirm]");
|
||||
expect(confirm?.textContent?.trim()).toBe("Online");
|
||||
expect(confirm?.getAttribute("data-title")).toBe("Update Nacos instance: Online");
|
||||
confirm?.click();
|
||||
await vi.waitFor(() => expect(mocks.nacosUpdateInstance).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.nacosUpdateInstance.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ patch: { enabled: true } }));
|
||||
expect(mocks.confirmNacosMutation).toHaveBeenNthCalledWith(2, expect.objectContaining({ reviewText: "Update Nacos instance: Online" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("NacosAdminConsole cached tab restoration", () => {
|
||||
it("does not let a hidden editor overwrite its viewport before reactivation", async () => {
|
||||
app?.unmount();
|
||||
|
||||
@@ -58,6 +58,7 @@ const FEATURE_MESSAGES: FeatureMessages[] = [
|
||||
{ feature: "process list batch terminate", keys: under("processList", ["batchTerminate", "batchTerminateTitle", "batchTerminateConfirm", "batchTerminateRunning", "batchTerminateSummary"]) },
|
||||
{ feature: "cached result fallback", keys: ["grid.cachedResultUnavailable", "grid.reexecuteQuery"] },
|
||||
{ feature: "Nacos global replace", keys: ["nacos.contentReplace*", "nacos.replaceHistory.*"], locales: EXCEPT_AZ_TR, translated: true },
|
||||
{ feature: "Nacos instance health and availability (#10116)", keys: under("nacos", ["serviceInstancesAllHealthy", "serviceInstancesPartiallyHealthy", "serviceInstancesNoHealthyInstances", "serviceInstancesNoInstances", "online", "offline"]) },
|
||||
{ feature: "SQLite table rebuild notice", keys: ["structureEditor.sqliteRebuildNotice"] },
|
||||
{ feature: "custom types", keys: ["customType.kinds.composite", "customType.tabs.properties", "customType.members.empty", "customType.properties.empty", "customType.ddl.empty", "customType.ddl.incomplete", "contextMenu.viewDetails"], translated: true },
|
||||
{ feature: "Dameng object compilation", keys: ["contextMenu.compileObjectFailedTitle", "contextMenu.compileObjectFailedMessage"], locales: EXCEPT_AZ_TR, translated: true },
|
||||
|
||||
@@ -10125,6 +10125,10 @@ export default withEnglishFallback({
|
||||
service: "Xidmət",
|
||||
instances: "İnstansiyalar",
|
||||
instanceCount: "{count} instansiya",
|
||||
serviceInstancesAllHealthy: "Hamısı sağlamdır · {healthy}/{total}",
|
||||
serviceInstancesPartiallyHealthy: "Qismən sağlamdır · {healthy}/{total}",
|
||||
serviceInstancesNoHealthyInstances: "Sağlam instansiya yoxdur · {healthy}/{total}",
|
||||
serviceInstancesNoInstances: "İnstansiya yoxdur",
|
||||
refresh: "Yenilə",
|
||||
address: "Ünvan",
|
||||
cluster: "Klaster",
|
||||
@@ -10159,6 +10163,7 @@ export default withEnglishFallback({
|
||||
healthy: "Sağlam",
|
||||
unhealthy: "Qeyri-sağlam",
|
||||
enabled: "Aktiv",
|
||||
online: "Onlayn",
|
||||
offline: "Oflayn",
|
||||
ephemeral: "Müvəqqəti",
|
||||
persistent: "Daimi",
|
||||
|
||||
@@ -10888,6 +10888,10 @@ export default {
|
||||
service: "Service",
|
||||
instances: "Instances",
|
||||
instanceCount: "{count} instances",
|
||||
serviceInstancesAllHealthy: "All healthy · {healthy}/{total}",
|
||||
serviceInstancesPartiallyHealthy: "Partially healthy · {healthy}/{total}",
|
||||
serviceInstancesNoHealthyInstances: "No healthy instances · {healthy}/{total}",
|
||||
serviceInstancesNoInstances: "No instances",
|
||||
refresh: "Refresh",
|
||||
address: "Address",
|
||||
cluster: "Cluster",
|
||||
@@ -10922,6 +10926,7 @@ export default {
|
||||
healthy: "Healthy",
|
||||
unhealthy: "Unhealthy",
|
||||
enabled: "Enabled",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
ephemeral: "Ephemeral",
|
||||
persistent: "Persistent",
|
||||
|
||||
@@ -8925,6 +8925,10 @@ export default withEnglishFallback({
|
||||
service: "Servicio",
|
||||
instances: "Instancias",
|
||||
instanceCount: "{count} instancias",
|
||||
serviceInstancesAllHealthy: "Todas saludables · {healthy}/{total}",
|
||||
serviceInstancesPartiallyHealthy: "Parcialmente saludables · {healthy}/{total}",
|
||||
serviceInstancesNoHealthyInstances: "Sin instancias saludables · {healthy}/{total}",
|
||||
serviceInstancesNoInstances: "Sin instancias",
|
||||
refresh: "Actualizar",
|
||||
address: "Dirección",
|
||||
cluster: "Clúster",
|
||||
@@ -8959,7 +8963,8 @@ export default withEnglishFallback({
|
||||
healthy: "Saludable",
|
||||
unhealthy: "No saludable",
|
||||
enabled: "Habilitado",
|
||||
offline: "Offline",
|
||||
online: "En línea",
|
||||
offline: "Fuera de línea",
|
||||
ephemeral: "Efímera",
|
||||
persistent: "Persistente",
|
||||
enable: "Habilitar",
|
||||
|
||||
@@ -8851,6 +8851,10 @@ export default withEnglishFallback({
|
||||
service: "Servizio",
|
||||
instances: "Istanze",
|
||||
instanceCount: "{count} istanze",
|
||||
serviceInstancesAllHealthy: "Tutte sane · {healthy}/{total}",
|
||||
serviceInstancesPartiallyHealthy: "Parzialmente sane · {healthy}/{total}",
|
||||
serviceInstancesNoHealthyInstances: "Nessuna istanza sana · {healthy}/{total}",
|
||||
serviceInstancesNoInstances: "Nessuna istanza",
|
||||
refresh: "Aggiorna",
|
||||
address: "Indirizzo",
|
||||
cluster: "Cluster",
|
||||
@@ -8885,6 +8889,7 @@ export default withEnglishFallback({
|
||||
healthy: "Sana",
|
||||
unhealthy: "Non sana",
|
||||
enabled: "Abilitata",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
ephemeral: "Effimera",
|
||||
persistent: "Persistente",
|
||||
|
||||
@@ -8881,6 +8881,10 @@ export default withEnglishFallback({
|
||||
service: "サービス",
|
||||
instances: "インスタンス",
|
||||
instanceCount: "{count}件のインスタンス",
|
||||
serviceInstancesAllHealthy: "すべて正常 · {healthy}/{total}",
|
||||
serviceInstancesPartiallyHealthy: "一部正常 · {healthy}/{total}",
|
||||
serviceInstancesNoHealthyInstances: "正常なインスタンスなし · {healthy}/{total}",
|
||||
serviceInstancesNoInstances: "インスタンスなし",
|
||||
refresh: "更新",
|
||||
address: "アドレス",
|
||||
cluster: "クラスター",
|
||||
@@ -8915,6 +8919,7 @@ export default withEnglishFallback({
|
||||
healthy: "正常",
|
||||
unhealthy: "異常",
|
||||
enabled: "有効",
|
||||
online: "オンライン",
|
||||
offline: "オフライン",
|
||||
ephemeral: "一時",
|
||||
persistent: "永続",
|
||||
|
||||
@@ -9722,6 +9722,10 @@ export default withEnglishFallback({
|
||||
service: "서비스",
|
||||
instances: "인스턴스",
|
||||
instanceCount: "인스턴스 {count}개",
|
||||
serviceInstancesAllHealthy: "모두 정상 · {healthy}/{total}",
|
||||
serviceInstancesPartiallyHealthy: "일부 정상 · {healthy}/{total}",
|
||||
serviceInstancesNoHealthyInstances: "정상 인스턴스 없음 · {healthy}/{total}",
|
||||
serviceInstancesNoInstances: "인스턴스 없음",
|
||||
refresh: "새로고침",
|
||||
address: "주소",
|
||||
cluster: "클러스터",
|
||||
@@ -9756,6 +9760,7 @@ export default withEnglishFallback({
|
||||
healthy: "정상",
|
||||
unhealthy: "비정상",
|
||||
enabled: "활성화됨",
|
||||
online: "온라인",
|
||||
offline: "오프라인",
|
||||
ephemeral: "임시",
|
||||
persistent: "영구",
|
||||
|
||||
@@ -8851,6 +8851,10 @@ export default withEnglishFallback({
|
||||
service: "Serviço",
|
||||
instances: "Instâncias",
|
||||
instanceCount: "{count} instâncias",
|
||||
serviceInstancesAllHealthy: "Todas saudáveis · {healthy}/{total}",
|
||||
serviceInstancesPartiallyHealthy: "Parcialmente saudáveis · {healthy}/{total}",
|
||||
serviceInstancesNoHealthyInstances: "Nenhuma instância saudável · {healthy}/{total}",
|
||||
serviceInstancesNoInstances: "Nenhuma instância",
|
||||
refresh: "Atualizar",
|
||||
address: "Endereço",
|
||||
cluster: "Cluster",
|
||||
@@ -8885,6 +8889,7 @@ export default withEnglishFallback({
|
||||
healthy: "Saudável",
|
||||
unhealthy: "Não saudável",
|
||||
enabled: "Ativado",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
ephemeral: "Efêmera",
|
||||
persistent: "Persistente",
|
||||
|
||||
@@ -11402,6 +11402,10 @@ export default withEnglishFallback({
|
||||
service: "Сервис",
|
||||
instances: "Экземпляры",
|
||||
instanceCount: "Экземпляров: {count}",
|
||||
serviceInstancesAllHealthy: "Все здоровы · {healthy}/{total}",
|
||||
serviceInstancesPartiallyHealthy: "Часть здорова · {healthy}/{total}",
|
||||
serviceInstancesNoHealthyInstances: "Нет здоровых экземпляров · {healthy}/{total}",
|
||||
serviceInstancesNoInstances: "Нет экземпляров",
|
||||
refresh: "Обновить",
|
||||
address: "Адрес",
|
||||
cluster: "Кластер",
|
||||
@@ -11436,6 +11440,7 @@ export default withEnglishFallback({
|
||||
healthy: "Здоровый",
|
||||
unhealthy: "Нездоровый",
|
||||
enabled: "Включено",
|
||||
online: "В сети",
|
||||
offline: "Не в сети",
|
||||
ephemeral: "Временный",
|
||||
persistent: "Постоянный",
|
||||
|
||||
@@ -10006,6 +10006,10 @@ export default withEnglishFallback({
|
||||
service: "Servis",
|
||||
instances: "Örnekler",
|
||||
instanceCount: "{count} örnek",
|
||||
serviceInstancesAllHealthy: "Tümü sağlıklı · {healthy}/{total}",
|
||||
serviceInstancesPartiallyHealthy: "Kısmen sağlıklı · {healthy}/{total}",
|
||||
serviceInstancesNoHealthyInstances: "Sağlıklı örnek yok · {healthy}/{total}",
|
||||
serviceInstancesNoInstances: "Örnek yok",
|
||||
refresh: "Yenile",
|
||||
address: "Adres",
|
||||
cluster: "Küme",
|
||||
@@ -10040,6 +10044,7 @@ export default withEnglishFallback({
|
||||
healthy: "Sağlıklı",
|
||||
unhealthy: "Sağlıksız",
|
||||
enabled: "Etkin",
|
||||
online: "Çevrimiçi",
|
||||
offline: "Çevrimdışı",
|
||||
ephemeral: "Geçici",
|
||||
persistent: "Kalıcı",
|
||||
|
||||
@@ -10849,6 +10849,10 @@ export default withEnglishFallback({
|
||||
service: "服务",
|
||||
instances: "实例",
|
||||
instanceCount: "{count} 个实例",
|
||||
serviceInstancesAllHealthy: "全部健康 · {healthy}/{total}",
|
||||
serviceInstancesPartiallyHealthy: "部分健康 · {healthy}/{total}",
|
||||
serviceInstancesNoHealthyInstances: "无健康实例 · {healthy}/{total}",
|
||||
serviceInstancesNoInstances: "无实例",
|
||||
refresh: "刷新",
|
||||
address: "地址",
|
||||
cluster: "集群",
|
||||
@@ -10883,7 +10887,8 @@ export default withEnglishFallback({
|
||||
healthy: "健康",
|
||||
unhealthy: "不健康",
|
||||
enabled: "已启用",
|
||||
offline: "已下线",
|
||||
online: "上线",
|
||||
offline: "下线",
|
||||
ephemeral: "临时实例",
|
||||
persistent: "持久实例",
|
||||
enable: "启用",
|
||||
|
||||
@@ -8167,6 +8167,10 @@ export default withEnglishFallback({
|
||||
service: "服務",
|
||||
instances: "實例",
|
||||
instanceCount: "{count} 個實例",
|
||||
serviceInstancesAllHealthy: "全部健康 · {healthy}/{total}",
|
||||
serviceInstancesPartiallyHealthy: "部分健康 · {healthy}/{total}",
|
||||
serviceInstancesNoHealthyInstances: "無健康實例 · {healthy}/{total}",
|
||||
serviceInstancesNoInstances: "無實例",
|
||||
refresh: "重新整理",
|
||||
address: "位址",
|
||||
cluster: "叢集",
|
||||
@@ -8201,7 +8205,8 @@ export default withEnglishFallback({
|
||||
healthy: "健康",
|
||||
unhealthy: "不健康",
|
||||
enabled: "已啟用",
|
||||
offline: "已下線",
|
||||
online: "上線",
|
||||
offline: "下線",
|
||||
ephemeral: "臨時實例",
|
||||
persistent: "持久實例",
|
||||
enable: "啟用",
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { nacosInstanceMatchesPatch, nacosInstanceRefIdentity, nacosIpAddressIsValid, nacosJsonObjectMatches, nacosServiceDetailMatches } from "../nacosServiceManagement";
|
||||
import { nacosInstanceMatchesPatch, nacosInstanceRefIdentity, nacosIpAddressIsValid, nacosJsonObjectMatches, nacosServiceDetailMatches, nacosServiceInstanceHealthSummary } from "../nacosServiceManagement";
|
||||
|
||||
describe("Nacos service management state reconciliation", () => {
|
||||
it.each([
|
||||
[{ ipCount: 3, healthyInstanceCount: 3 }, "allHealthy"],
|
||||
[{ ipCount: 3, healthyInstanceCount: 2 }, "partiallyHealthy"],
|
||||
[{ ipCount: 3, healthyInstanceCount: 0 }, "noHealthyInstances"],
|
||||
[{ ipCount: 0, healthyInstanceCount: 0 }, "noInstances"],
|
||||
] as const)("classifies the service instance health matrix for %o", (service, status) => {
|
||||
expect(nacosServiceInstanceHealthSummary(service)).toEqual({ status, healthy: service.healthyInstanceCount, total: service.ipCount });
|
||||
});
|
||||
|
||||
it.each([{ ipCount: 2 }, { healthyInstanceCount: 1 }, { ipCount: 1, healthyInstanceCount: 2 }, { ipCount: -1, healthyInstanceCount: 0 }])("does not infer health from incomplete or inconsistent counts for %o", (service) => {
|
||||
expect(nacosServiceInstanceHealthSummary(service)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps unknown, persistent and ephemeral instances as separate identities", () => {
|
||||
const base = { serviceName: "api", ip: "127.0.0.1", port: 8080, clusterName: "blue" };
|
||||
expect(new Set([nacosInstanceRefIdentity(base), nacosInstanceRefIdentity({ ...base, ephemeral: false }), nacosInstanceRefIdentity({ ...base, ephemeral: true })]).size).toBe(3);
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
import type { NacosInstanceInfo, NacosInstancePatch, NacosInstanceRef, NacosServiceDetail, NacosServiceUpsert } from "@/types/nacos";
|
||||
import type { NacosInstanceInfo, NacosInstancePatch, NacosInstanceRef, NacosServiceDetail, NacosServiceInfo, NacosServiceUpsert } from "@/types/nacos";
|
||||
|
||||
export type NacosServiceInstanceHealth = "allHealthy" | "partiallyHealthy" | "noHealthyInstances" | "noInstances";
|
||||
|
||||
export interface NacosServiceInstanceHealthSummary {
|
||||
status: NacosServiceInstanceHealth;
|
||||
healthy: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function nacosServiceInstanceHealthSummary(service: Pick<NacosServiceInfo, "ipCount" | "healthyInstanceCount">): NacosServiceInstanceHealthSummary | null {
|
||||
const total = service.ipCount;
|
||||
const healthy = service.healthyInstanceCount;
|
||||
if (total == null || healthy == null || !Number.isInteger(total) || !Number.isInteger(healthy) || total < 0 || healthy < 0 || healthy > total) return null;
|
||||
if (total === 0) return { status: "noInstances", healthy, total };
|
||||
if (healthy === total) return { status: "allHealthy", healthy, total };
|
||||
if (healthy === 0) return { status: "noHealthyInstances", healthy, total };
|
||||
return { status: "partiallyHealthy", healthy, total };
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonicalJson);
|
||||
|
||||
Reference in New Issue
Block a user