feat(nacos): add fuzzy configuration group filtering

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-06 17:19:27 +08:00
1 parent 939c8d493d
commit cd6a3c8b5c
7 files changed
+313 -29

No files matched your search

@@ -178,6 +178,7 @@ const importSourceName = ref("");
const configEditorTheme = new Compartment();
const configEditorFontTheme = new Compartment();
const configEditorLanguage = new Compartment();
const configListRequestGuard = createNacosLatestRequestGuard();
const configDetailRequestGuard = createNacosLatestRequestGuard();
let configEditorGeneration = 0;
let configEditorSessionId = 0;
@@ -389,6 +390,7 @@ function buildConfigSelector(scope: NacosConfigSelectionScope): NacosConfigSelec
? {
namespace: namespace.value || undefined,
group: configGroup.value.trim() || undefined,
groupContains: true,
dataId: configDataId.value.trim() || undefined,
appName: configAppName.value.trim() || undefined,
}
@@ -656,36 +658,69 @@ async function loadInfo() {
}
}
async function loadConfigs(page = configPageNo.value) {
async function loadConfigs(page = configPageNo.value): Promise<boolean> {
const requestId = configListRequestGuard.begin();
const connectionId = props.connectionId;
const requestNamespace = namespace.value;
const requestGroup = configGroup.value.trim();
const requestDataId = configDataId.value.trim();
const requestAppName = configAppName.value.trim();
const requestPageSize = configPageSize.value;
const isCurrentRequest = () =>
configListRequestGuard.isCurrent(requestId) &&
connectionId === props.connectionId &&
requestNamespace === namespace.value &&
requestGroup === configGroup.value.trim() &&
requestDataId === configDataId.value.trim() &&
requestAppName === configAppName.value.trim() &&
requestPageSize === configPageSize.value;
configLoading.value = true;
configError.value = "";
configPageNo.value = page;
try {
const result = await api.nacosListConfigs(props.connectionId, {
namespace: namespace.value || undefined,
group: configGroup.value.trim() || undefined,
dataId: configDataId.value.trim() || undefined,
appName: configAppName.value.trim() || undefined,
pageNo: configPageNo.value,
pageSize: configPageSize.value,
const result = await api.nacosListConfigs(connectionId, {
namespace: requestNamespace || undefined,
group: requestGroup || undefined,
groupContains: true,
dataId: requestDataId || undefined,
appName: requestAppName || undefined,
pageNo: page,
pageSize: requestPageSize,
});
if (!isCurrentRequest()) return false;
configs.value = applyKnownConfigFormats(result.items.map(normalizeConfigItemFormat));
configTotal.value = result.totalCount;
return true;
} catch (error) {
await handleRNacosConsoleError(error, () => loadConfigs(page), "config");
if (!isCurrentRequest()) return false;
await handleRNacosConsoleError(
error,
async () => {
await loadConfigs(page);
},
"config",
);
return true;
} finally {
configLoading.value = false;
if (configListRequestGuard.isCurrent(requestId)) configLoading.value = false;
}
}
async function loadConfigsWithRetry(page = configPageNo.value) {
for (let attempt = 0; ; attempt += 1) {
await loadConfigs(page);
if (!isConnectionNotFoundError(configError.value) || attempt >= CONNECTION_NOT_FOUND_RETRY_DELAYS_MS.length) return;
const current = await loadConfigs(page);
if (!current || !isConnectionNotFoundError(configError.value) || attempt >= CONNECTION_NOT_FOUND_RETRY_DELAYS_MS.length) return;
await delay(CONNECTION_NOT_FOUND_RETRY_DELAYS_MS[attempt]);
}
}
function clearConfigFilter(filter: "dataId" | "group" | "appName") {
if (filter === "dataId") configDataId.value = "";
else if (filter === "group") configGroup.value = "";
else configAppName.value = "";
void loadConfigsWithRetry(1);
}
function closePendingConfigMutationConfirmations() {
pendingConfigSave.value = false;
if (!deletingConfig.value) pendingDeleteConfig.value = null;
@@ -1552,6 +1587,12 @@ async function loadServicesWithRetry(page = servicePageNo.value) {
}
}
function clearServiceFilter(filter: "name" | "group") {
if (filter === "name") serviceName.value = "";
else serviceGroup.value = "";
void loadServicesWithRetry(1);
}
async function selectService(service: NacosServiceInfo) {
serviceMutationSequence += 1;
instanceUpdateSequence += 1;
@@ -2092,6 +2133,7 @@ watch(
() => [props.connectionId, props.namespace] as const,
async () => {
closePendingConfigMutationConfirmations();
configListRequestGuard.invalidate();
configDetailRequestGuard.invalidate();
configEditorSessionId += 1;
latestConfigSaveRequestId += 1;
@@ -2141,6 +2183,7 @@ onMounted(async () => {
});
onBeforeUnmount(() => {
configListRequestGuard.invalidate();
configDetailRequestGuard.invalidate();
servicesRequestGuard.invalidate();
serviceDetailRequestGuard.invalidate();
@@ -2210,9 +2253,45 @@ onBeforeUnmount(() => {
<Pane :size="nacosSplitSize" min-size="24">
<div class="flex h-full min-h-0 flex-col">
<div class="grid shrink-0 grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto_auto] gap-2 border-b p-2">
<Input v-model="configDataId" class="h-8 min-w-0" placeholder="dataId" @keyup.enter="loadConfigsWithRetry(1)" />
<Input v-model="configGroup" class="h-8 min-w-0" :placeholder="t('nacos.allGroups')" @keyup.enter="loadConfigsWithRetry(1)" />
<Input v-model="configAppName" class="h-8 min-w-0" :placeholder="t('nacos.application')" @keyup.enter="loadConfigsWithRetry(1)" />
<div class="relative min-w-0">
<Input v-model="configDataId" class="h-8 min-w-0 pr-8" placeholder="dataId" @keyup.enter="loadConfigsWithRetry(1)" />
<button
v-if="configDataId"
type="button"
class="absolute right-2 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
:title="t('nacos.clear')"
:aria-label="t('nacos.clear')"
@click="clearConfigFilter('dataId')"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
<div class="relative min-w-0">
<Input v-model="configGroup" class="h-8 min-w-0 pr-8" :placeholder="t('nacos.allGroups')" @keyup.enter="loadConfigsWithRetry(1)" />
<button
v-if="configGroup"
type="button"
class="absolute right-2 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
:title="t('nacos.clear')"
:aria-label="t('nacos.clear')"
@click="clearConfigFilter('group')"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
<div class="relative min-w-0">
<Input v-model="configAppName" class="h-8 min-w-0 pr-8" :placeholder="t('nacos.application')" @keyup.enter="loadConfigsWithRetry(1)" />
<button
v-if="configAppName"
type="button"
class="absolute right-2 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
:title="t('nacos.clear')"
:aria-label="t('nacos.clear')"
@click="clearConfigFilter('appName')"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
<Button size="sm" variant="outline" class="h-8 w-9 px-0" :title="t('nacos.load')" :disabled="configLoading" @click="loadConfigsWithRetry(1)">
<Loader2 v-if="configLoading" class="h-3.5 w-3.5 animate-spin" />
<RefreshCw v-else class="h-3.5 w-3.5" />
@@ -2471,8 +2550,32 @@ onBeforeUnmount(() => {
<Pane :size="nacosSplitSize" min-size="24">
<div class="flex h-full min-h-0 flex-col">
<div class="grid shrink-0 grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] gap-2 border-b p-2">
<Input v-model="serviceName" class="h-8 min-w-0" :placeholder="t('nacos.service')" @keyup.enter="loadServicesWithRetry(1)" />
<Input v-model="serviceGroup" class="h-8 min-w-0" :placeholder="t('nacos.allGroups')" @keyup.enter="loadServicesWithRetry(1)" />
<div class="relative min-w-0">
<Input v-model="serviceName" class="h-8 min-w-0 pr-8" :placeholder="t('nacos.service')" @keyup.enter="loadServicesWithRetry(1)" />
<button
v-if="serviceName"
type="button"
class="absolute right-2 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
:title="t('nacos.clear')"
:aria-label="t('nacos.clear')"
@click="clearServiceFilter('name')"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
<div class="relative min-w-0">
<Input v-model="serviceGroup" class="h-8 min-w-0 pr-8" :placeholder="t('nacos.allGroups')" @keyup.enter="loadServicesWithRetry(1)" />
<button
v-if="serviceGroup"
type="button"
class="absolute right-2 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
:title="t('nacos.clear')"
:aria-label="t('nacos.clear')"
@click="clearServiceFilter('group')"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="readOnly || !createServiceCapability.supported" :title="readOnly || !createServiceCapability.supported ? capabilityReason(createServiceCapability) : undefined" @click="openCreateService">
<Plus class="h-3.5 w-3.5" />
{{ t("nacos.service") }}
@@ -2642,7 +2745,7 @@ onBeforeUnmount(() => {
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-sm font-medium">{{ instance.ip }}:{{ instance.port }}</span>
<Badge variant="outline">{{ instance.clusterName || "DEFAULT" }}</Badge>
<Badge :variant="instance.healthy === false ? 'outline' : 'secondary'" :class="instance.healthy === false ? 'border-destructive/50 text-destructive' : ''">{{ instance.healthy === false ? t("nacos.unhealthy") : t("nacos.healthy") }}</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 v-if="instance.ephemeral != null" variant="outline">{{ instance.ephemeral ? t("nacos.ephemeral") : t("nacos.persistent") }}</Badge>
</div>
@@ -46,6 +46,16 @@ describe("NacosAdminConsole config workbench layout", () => {
expect(source).toContain("await Promise.all([loadServiceDetail(), loadInstances()]);");
});
it("keeps configuration list responses scoped to the latest filters", () => {
expect(source).toContain("const configListRequestGuard = createNacosLatestRequestGuard();");
expect(source).toContain("const requestId = configListRequestGuard.begin();");
expect(source).toContain("if (!isCurrentRequest()) return false;");
expect(source).toContain("if (configListRequestGuard.isCurrent(requestId)) configLoading.value = false;");
expect(source).toContain("const current = await loadConfigs(page);");
expect(source).toContain("if (!current || !isConnectionNotFoundError(configError.value)");
expect(source.match(/configListRequestGuard\.invalidate\(\);/g)?.length).toBeGreaterThanOrEqual(2);
});
it("keeps instance weight edits as drafts until the explicit save action", () => {
expect(source).toContain("instanceWeightDrafts.value[instanceIdentity(instance)] = String(value);");
expect(source).toContain('@click="requestInstanceWeightUpdate(instance)"');
@@ -84,6 +94,27 @@ describe("NacosAdminConsole config workbench layout", () => {
expect(source).not.toContain('v-if="serviceCluster"\n size="sm"');
});
it("adds icon-only clear controls to populated configuration and service filters", () => {
for (const [filter, clear] of [
["configDataId", "clearConfigFilter('dataId')"],
["configGroup", "clearConfigFilter('group')"],
["configAppName", "clearConfigFilter('appName')"],
["serviceName", "clearServiceFilter('name')"],
["serviceGroup", "clearServiceFilter('group')"],
]) {
expect(source).toContain(`v-if="${filter}"`);
expect(source).toContain(`@click="${clear}"`);
}
expect(source).toContain("void loadConfigsWithRetry(1);");
expect(source).toContain("void loadServicesWithRetry(1);");
expect(source.match(/groupContains: true/g)?.length).toBeGreaterThanOrEqual(2);
expect(source.match(/:aria-label="t\('nacos\.clear'\)"/g)?.length).toBeGreaterThanOrEqual(5);
});
it("uses green outlined styling for healthy instances and red styling for unhealthy instances", () => {
expect(source).toContain("instance.healthy === false ? 'border-destructive/50 text-destructive' : 'border-emerald-500/50 text-emerald-700 dark:text-emerald-300'");
});
it("separates the service header, filtering controls, and management actions", () => {
expect(source).toContain('<header class="shrink-0 border-b bg-background">');
expect(source).toContain('class="flex flex-wrap items-center gap-x-4 gap-y-2 border-t bg-muted/30 px-4 py-2"');
+1
View File
@@ -95,6 +95,7 @@ export interface NacosAdminConfig {
export interface NacosConfigQuery {
namespace?: string;
group?: string;
groupContains?: boolean;
dataId?: string;
appName?: string;
search?: string;