feat(nacos): show service instance health at a glance

Closes #10116
This commit is contained in:
t8y2
2026-09-27 05:54:32 +00:00
parent 5f8bcf74a5
commit e797954334
16 changed files with 273 additions and 17 deletions
@@ -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="
@@ -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 },
+5
View File
@@ -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",
+5
View File
@@ -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",
+6 -1
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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: "永続",
+5
View File
@@ -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: "영구",
+5
View File
@@ -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",
+5
View File
@@ -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: "Постоянный",
+5
View File
@@ -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ı",
+6 -1
View File
@@ -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: "启用",
+6 -1
View File
@@ -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);