feat(consul): improve deregister flow and agent write hints

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-10 12:43:46 +08:00
1 parent 0f07d337c3
commit 2cee2da21d
22 files changed
+4285 -400

No files matched your search

@@ -10,7 +10,7 @@ import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { useConsulStore } from "@/stores/consulStore";
import { nextWatchIndex } from "@/lib/consul/watchState";
import { consulAgentWriteTargetSafe } from "@/lib/consul/agentTarget";
import { consulAgentWriteBlockedReason } from "@/lib/consul/agentTarget";
import { clampConsulPage, CONSUL_LIST_PAGE_SIZE, paginateConsulItems } from "@/lib/consul/pagination";
import { useI18n } from "vue-i18n";
import type { ConsulAgentIdentity, ConsulAgentService, ConsulAgentServiceRegistration, ConsulCatalogNode, ConsulCatalogServiceNode, ConsulDomainWatchResponse, ConsulHealthCheck, ConsulNodeServices, ConsulScope } from "@/types/consul";
@@ -35,7 +35,13 @@ const agentTarget = computed(() => {
const address = String(target.address || "").trim();
return node && address ? { node, address } : null;
});
const canAgentWrite = computed(() => consulAgentWriteTargetSafe(store.getConfig(props.connectionId), identity.value?.node));
const agentWriteBlockedReason = computed(() => consulAgentWriteBlockedReason(store.getConfig(props.connectionId), identity.value?.node));
const canAgentWrite = computed(() => agentWriteBlockedReason.value === null);
const agentWriteDisabledMessage = computed(() => {
if (agentLoading.value && !identity.value) return t("consul.ui.agentTargetVerifying");
const reason = agentWriteBlockedReason.value;
return reason ? t(`consul.ui.agentWriteBlocked.${reason}`) : "";
});
const agentTargetStatus = computed<"verifying" | "writable" | "readonly">(() => {
if (agentLoading.value && !identity.value) return "verifying";
return canAgentWrite.value ? "writable" : "readonly";
@@ -59,6 +65,8 @@ const agentLoading = ref(true);
const saving = ref(false);
const editorOpen = ref(false);
const maintenancePendingId = ref("");
const deregisterPendingId = ref("");
const deregisterSuccess = ref("");
const form = ref({ id: "", name: "", tags: "", address: "", port: 0 });
let sequence = 0;
const watchEnabled = ref(false);
@@ -361,11 +369,12 @@ async function load() {
if (browseMode.value === "service") await loadInstances();
else await loadNodeServices();
}
void loadAgentData(current);
if (current === sequence) await loadAgentData(current);
}
async function loadAgentData(current = sequence) {
agentLoading.value = true;
identity.value = null;
await Promise.all([
independent(t("consul.ui.agent"), async () => {
const result = await withTimeout(api.consulAgentSelf(props.connectionId), 8_000, t("consul.ui.agentIdentityRequestTimedOut"));
@@ -498,15 +507,41 @@ async function registerService() {
}
async function deregister(id: string) {
if (!canAgentWrite.value || !window.confirm(t("consul.ui.deregisterService", { id, node: agentTarget.value?.node || t("consul.ui.targetAgent") }))) return;
await independent(t("consul.ui.writeOperation"), async () => {
await api.consulAgentDeregisterService(props.connectionId, id);
if (!canAgentWrite.value || deregisterPendingId.value || maintenancePendingId.value) return;
const node = agentTarget.value?.node;
if (!node) return;
if (!window.confirm(`${t("consul.ui.deregisterService", { id, node })}\n\n${t("consul.ui.deregisterServiceHint")}`)) return;
const connectionId = props.connectionId;
const generation = consulStore.generation;
const isCurrent = () => connectionId === props.connectionId && generation === consulStore.generation;
deregisterPendingId.value = id;
deregisterSuccess.value = "";
const writeErrorKey = t("consul.ui.writeOperation");
const remainingErrors = { ...errors.value };
delete remainingErrors[writeErrorKey];
errors.value = remainingErrors;
try {
await api.consulAgentDeregisterService(connectionId, id);
} catch (error) {
if (isCurrent()) errors.value = { ...errors.value, [writeErrorKey]: errorMessage(error) };
deregisterPendingId.value = "";
return;
}
try {
if (!isCurrent()) return;
deregisterSuccess.value = t("consul.ui.deregisterServiceSucceeded", { id, node });
if (selectedLocalService.value?.ID === id) selectedLocalService.value = null;
// The write has succeeded; subsequent read failures must not be reported as a failed deregistration.
await load();
});
} catch (error) {
if (isCurrent()) errors.value = { ...errors.value, [t("consul.ui.deregisterRefreshFailed")]: errorMessage(error) };
} finally {
deregisterPendingId.value = "";
}
}
async function maintenance(id: string, enable: boolean) {
if (!canAgentWrite.value) return;
if (!canAgentWrite.value || deregisterPendingId.value || maintenancePendingId.value) return;
maintenancePendingId.value = id;
try {
await independent(t("consul.ui.writeOperation"), async () => {
@@ -540,9 +575,16 @@ watch(nodeServiceItems, (items) => {
watch(filteredLocalServiceItems, (items) => {
localServicePage.value = clampConsulPage(localServicePage.value, items.length);
});
watch(
() => consulStore.generation,
() => {
deregisterSuccess.value = "";
},
);
watch(
() => props.connectionId,
async () => {
deregisterSuccess.value = "";
await stopWatches();
watchEnabled.value = false;
watchPaused.value = false;
@@ -873,8 +915,9 @@ defineExpose({ refresh: () => (void load(), true) });
</div>
</div>
<div v-if="!canAgentWrite && (identity || !agentLoading)" class="flex items-start gap-2 border-b border-amber-300/70 bg-amber-50/70 px-3 py-2 text-xs text-amber-800 dark:bg-amber-950/20 dark:text-amber-200">
<LockKeyhole class="mt-0.5 h-3.5 w-3.5 shrink-0" /><span>{{ t("consul.ui.agentWriteDisabledHint", { target: identity ? `${identity.node} (${identity.address})` : t("consul.ui.unknownWritesDisabled") }) }}</span>
<LockKeyhole class="mt-0.5 h-3.5 w-3.5 shrink-0" /><span>{{ agentWriteDisabledMessage }}</span>
</div>
<div v-if="deregisterSuccess" role="status" class="border-b px-3 py-2 text-xs text-emerald-700 dark:text-emerald-300">{{ deregisterSuccess }}</div>
<div v-for="service in pagedLocalServices" :key="service.ID" class="border-b last:border-0" :class="selectedLocalService?.ID === service.ID && 'bg-muted/20'">
<div class="grid gap-3 px-3 py-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<button type="button" class="flex min-w-0 items-start gap-3 text-left" :aria-expanded="selectedLocalService?.ID === service.ID" @click="openLocalService(service.ID)">
@@ -890,12 +933,21 @@ defineExpose({ refresh: () => (void load(), true) });
></span
>
</button>
<div v-if="canAgentWrite" class="flex shrink-0 items-center gap-1 lg:border-l lg:pl-3">
<Button v-if="!isServiceInMaintenance(service.ID)" size="sm" variant="ghost" class="h-7 gap-1 px-2 text-xs" :disabled="maintenancePendingId === service.ID" :title="t('consul.ui.enableMaintenance')" @click.stop="maintenance(service.ID, true)"
<div class="flex shrink-0 items-center gap-1 lg:border-l lg:pl-3" :title="agentWriteDisabledMessage || undefined">
<Button v-if="canAgentWrite && !isServiceInMaintenance(service.ID)" size="sm" variant="ghost" class="h-7 gap-1 px-2 text-xs" :disabled="!!maintenancePendingId || !!deregisterPendingId" :title="t('consul.ui.enableMaintenance')" @click.stop="maintenance(service.ID, true)"
><Loader2 v-if="maintenancePendingId === service.ID" class="h-3.5 w-3.5 animate-spin" /><Wrench v-else class="h-3.5 w-3.5" />{{ t("consul.ui.enableMaintenance") }}</Button
><Button v-else size="sm" variant="ghost" class="h-7 gap-1 px-2 text-xs text-amber-700 hover:text-amber-700 dark:text-amber-300" :disabled="maintenancePendingId === service.ID" :title="t('consul.ui.disableMaintenance')" @click.stop="maintenance(service.ID, false)"
><Button
v-else-if="canAgentWrite"
size="sm"
variant="ghost"
class="h-7 gap-1 px-2 text-xs text-amber-700 hover:text-amber-700 dark:text-amber-300"
:disabled="!!maintenancePendingId || !!deregisterPendingId"
:title="t('consul.ui.disableMaintenance')"
@click.stop="maintenance(service.ID, false)"
><Loader2 v-if="maintenancePendingId === service.ID" class="h-3.5 w-3.5 animate-spin" /><CircleCheck v-else class="h-3.5 w-3.5" />{{ t("consul.ui.disableMaintenance") }}</Button
><Button size="sm" variant="ghost" class="h-7 gap-1 px-2 text-xs text-destructive hover:text-destructive" :title="t('consul.ui.deregister')" @click.stop="deregister(service.ID)"><Trash2 class="h-3.5 w-3.5" />{{ t("consul.ui.deregister") }}</Button>
><Button size="sm" variant="ghost" class="h-7 gap-1 px-2 text-xs text-destructive hover:text-destructive" :disabled="!canAgentWrite || !!deregisterPendingId || !!maintenancePendingId" :title="agentWriteDisabledMessage || t('consul.ui.deregister')" @click.stop="deregister(service.ID)"
><Loader2 v-if="deregisterPendingId === service.ID" class="h-3.5 w-3.5 animate-spin" /><Trash2 v-else class="h-3.5 w-3.5" />{{ deregisterPendingId === service.ID ? t("consul.ui.deregistering") : t("consul.ui.deregister") }}</Button
>
</div>
</div>
<div v-if="selectedLocalService?.ID === service.ID" class="border-t bg-background px-4 py-3 text-xs">
@@ -198,7 +198,7 @@ describe("ConsulServices interactions", () => {
expect(catalogRow!.getAttribute("aria-expanded")).toBe("false");
});
it("keeps local service cards interactive while explaining read-only Agent actions", async () => {
it("keeps local service cards interactive and deregistration disabled when the Agent target is missing", async () => {
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp(ConsulServices, { connectionId: "connection-1" });
@@ -207,9 +207,14 @@ describe("ConsulServices interactions", () => {
await settle();
expect(host.textContent).toContain("consul.ui.agentReadOnly");
expect(host.textContent).toContain("consul.ui.agentWriteDisabledHint");
expect(host.textContent).toContain("consul.ui.agentWriteBlocked.targetRequired");
expect(host.textContent).not.toContain("consul.ui.enableMaintenance");
const deregisterButton = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.trim() === "consul.ui.deregister");
expect(deregisterButton).toBeTruthy();
expect(deregisterButton!.disabled).toBe(true);
expect(deregisterButton!.title).toBe("consul.ui.agentWriteBlocked.targetRequired");
const localServiceButton = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes("dbx-demo-api-1"));
expect(localServiceButton).toBeTruthy();
expect(localServiceButton!.hasAttribute("disabled")).toBe(false);
@@ -87,8 +87,10 @@ describe("Consul Catalog and Health watch views", () => {
expect(services).toContain("maintenance(service.ID, false)");
expect(services).toContain('t("consul.ui.enableMaintenance")');
expect(services).toContain('t("consul.ui.disableMaintenance")');
expect(services).toContain('v-if="canAgentWrite"');
expect(services).toContain('t("consul.ui.agentWriteDisabledHint"');
expect(services).toContain('v-if="canAgentWrite && !isServiceInMaintenance(service.ID)"');
expect(services).toContain('v-else-if="canAgentWrite"');
expect(services).toContain("t(`consul.ui.agentWriteBlocked.${reason}`)");
expect(services).toContain("{{ agentWriteDisabledMessage }}");
});
it("keeps Catalog browsing searchable and separates readable Catalog data from Agent actions", () => {
@@ -104,7 +106,11 @@ describe("Consul Catalog and Health watch views", () => {
expect(services).toContain('@click="selectService(name)"');
expect(services).toContain('@click="selectNode(name)"');
expect(services).toContain("Catalog browsing must remain responsive even when an Agent endpoint is slow or unavailable.");
expect(services).toContain("void loadAgentData(current);");
const loadSource = services.slice(services.indexOf("async function load() {"), services.indexOf("async function loadAgentData("));
const catalogReady = loadSource.indexOf("loading.value = false;");
const agentLoad = loadSource.indexOf("if (current === sequence) await loadAgentData(current);");
expect(catalogReady).toBeGreaterThanOrEqual(0);
expect(agentLoad).toBeGreaterThan(catalogReady);
expect(services).toContain("t('consul.ui.searchAllServices')");
expect(services).toContain("t('consul.ui.searchLocalServices')");
});
@@ -138,9 +144,11 @@ describe("Consul Catalog and Health watch views", () => {
it("gates Agent writes on an explicit target that matches the Agent node", () => {
for (const source of [services, health]) {
expect(source).toContain("config.agentTarget || config.agent_target");
expect(source).toContain("consulAgentWriteTargetSafe(store.getConfig(props.connectionId), identity.value?.node)");
expect(source).toContain(':disabled="!canAgentWrite"');
}
expect(services).toContain("consulAgentWriteBlockedReason(store.getConfig(props.connectionId), identity.value?.node)");
expect(services).toContain("const canAgentWrite = computed(() => agentWriteBlockedReason.value === null);");
expect(health).toContain("consulAgentWriteTargetSafe(store.getConfig(props.connectionId), identity.value?.node)");
});
it("supports exact-Key and prefix KV watches and exposes batch atomicity", () => {