167 lines
9.6 KiB
Vue
167 lines
9.6 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from "vue";
|
|
import { Download, RefreshCcw } from "@lucide/vue";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import * as api from "@/lib/backend/api";
|
|
import type { ConsulCapabilities, ConsulOperatorDocument, ConsulOperatorReadKind } from "@/types/consul";
|
|
import { useConnectionStore } from "@/stores/connectionStore";
|
|
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
|
import { useI18n } from "vue-i18n";
|
|
|
|
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
|
|
const { t } = useI18n();
|
|
const connectionStore = useConnectionStore();
|
|
const kinds: ConsulOperatorReadKind[] = ["autopilot_configuration", "autopilot_health", "autopilot_state", "raft_configuration", "usage", "license", "audit"];
|
|
const documents = ref<Record<string, ConsulOperatorDocument>>({});
|
|
const failures = ref<Record<string, string>>({});
|
|
const loading = ref(false);
|
|
const writeError = ref("");
|
|
const snapshot = ref({ data: "", datacenter: "", confirmation: "" });
|
|
const autopilot = ref({ cleanupDeadServers: true, confirmation: "" });
|
|
const raft = ref({ serverId: "", address: "", confirmation: "" });
|
|
const keyring = ref({ operation: "install" as "install" | "use" | "remove", key: "", confirmation: "" });
|
|
const license = ref({ value: "", confirmation: "" });
|
|
const settings = computed(() => {
|
|
const external = connectionStore.getConfig(props.connectionId)?.external_config;
|
|
return external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
|
|
});
|
|
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
|
|
const autopilotWriteVisible = computed(() => settings.value.consulOperatorAutopilotWriteEnabled && props.capabilities?.operatorAutopilot === "supported");
|
|
const raftWriteVisible = computed(() => settings.value.consulOperatorRaftWriteEnabled && props.capabilities?.operatorRaft === "supported");
|
|
const keyringWriteVisible = computed(() => settings.value.consulOperatorKeyringWriteEnabled && props.capabilities?.operatorKeyring === "supported");
|
|
const licenseWriteVisible = computed(() => settings.value.consulOperatorLicenseWriteEnabled && props.capabilities?.operatorLicense === "supported");
|
|
|
|
async function refresh() {
|
|
loading.value = true;
|
|
await Promise.all(
|
|
kinds.map(async (kind) => {
|
|
try {
|
|
documents.value[kind] = await api.consulOperatorRead(props.connectionId, kind);
|
|
delete failures.value[kind];
|
|
} catch (error) {
|
|
failures.value[kind] = error instanceof Error ? error.message : String(error);
|
|
}
|
|
}),
|
|
);
|
|
loading.value = false;
|
|
}
|
|
|
|
async function downloadSnapshot() {
|
|
const snapshot = await api.consulSnapshotGenerate(props.connectionId);
|
|
const binary = atob(snapshot.dataBase64);
|
|
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
const link = document.createElement("a");
|
|
link.href = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" }));
|
|
link.download = `consul-${snapshot.datacenter || "snapshot"}.snap`;
|
|
link.click();
|
|
URL.revokeObjectURL(link.href);
|
|
}
|
|
|
|
async function selectSnapshot(event: Event) {
|
|
const file = (event.target as HTMLInputElement).files?.[0];
|
|
if (!file) return;
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
if (bytes.length < 10 || bytes[0] !== 0x1f || bytes[1] !== 0x8b || bytes[2] !== 0x08) {
|
|
snapshot.value.data = "";
|
|
writeError.value = "CONSUL_SNAPSHOT_INVALID: Snapshot must be a gzip archive generated by Consul";
|
|
return;
|
|
}
|
|
let binary = "";
|
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
snapshot.value.data = btoa(binary);
|
|
}
|
|
|
|
async function runWrite(action: () => Promise<void>) {
|
|
writeError.value = "";
|
|
try {
|
|
await action();
|
|
await refresh();
|
|
} catch (error) {
|
|
writeError.value = error instanceof Error ? error.message : String(error);
|
|
}
|
|
}
|
|
|
|
onMounted(refresh);
|
|
defineExpose({ refresh });
|
|
</script>
|
|
|
|
<template>
|
|
<div class="h-full overflow-auto">
|
|
<div class="sticky top-0 z-10 flex h-10 items-center justify-between border-b bg-background px-3">
|
|
<span class="text-sm font-medium">{{ t("consul.ui.operatorReadOnly") }}</span>
|
|
<div class="flex gap-1">
|
|
<Button variant="outline" size="sm" @click="downloadSnapshot"><Download class="mr-1 h-3.5 w-3.5" />{{ t("consul.ui.snapshot") }}</Button
|
|
><Button variant="ghost" size="icon" class="h-7 w-7" :disabled="loading" :title="t('consul.ui.refresh')" @click="refresh"><RefreshCcw class="h-3.5 w-3.5" /></Button>
|
|
</div>
|
|
</div>
|
|
<div class="grid gap-3 p-3 lg:grid-cols-2">
|
|
<section v-for="kind in kinds" :key="kind" class="border">
|
|
<h3 class="border-b px-3 py-2 text-xs font-medium uppercase">{{ kind.replaceAll("_", " ") }}</h3>
|
|
<p v-if="failures[kind]" class="px-3 py-3 text-xs text-destructive">{{ failures[kind] }}</p>
|
|
<dl v-else class="divide-y">
|
|
<div v-for="field in documents[kind]?.fields || []" :key="field.name" class="grid grid-cols-[minmax(8rem,1fr)_2fr] gap-3 px-3 py-2 text-xs">
|
|
<dt class="text-muted-foreground">{{ field.name }}</dt>
|
|
<dd class="break-all">{{ field.value }}</dd>
|
|
</div>
|
|
</dl>
|
|
</section>
|
|
</div>
|
|
<div v-if="writeError" class="mx-3 mb-3 border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">{{ writeError }}</div>
|
|
<div v-if="!readOnly" class="grid gap-3 px-3 pb-3 lg:grid-cols-2">
|
|
<section v-if="settings.consulOperatorSnapshotRestoreEnabled" class="border p-3">
|
|
<h3 class="mb-3 text-xs font-medium uppercase">{{ t("consul.ui.snapshotRestore") }}</h3>
|
|
<div class="grid gap-2">
|
|
<Input type="file" @change="selectSnapshot" /><Input v-model="snapshot.datacenter" :placeholder="t('consul.ui.targetDatacenter')" /><Input v-model="snapshot.confirmation" :placeholder="t('consul.ui.restoreSnapshotConfirmation', { datacenter: snapshot.datacenter })" /><Button
|
|
size="sm"
|
|
:disabled="!snapshot.data"
|
|
@click="runWrite(() => api.consulSnapshotRestore(props.connectionId, { snapshotBase64: snapshot.data, targetDatacenter: snapshot.datacenter, confirmation: snapshot.confirmation }))"
|
|
>{{ t("consul.ui.restoreSnapshot") }}</Button
|
|
>
|
|
</div>
|
|
</section>
|
|
<section v-if="autopilotWriteVisible" class="border p-3">
|
|
<h3 class="mb-3 text-xs font-medium uppercase">{{ t("consul.ui.autopilotUpdate") }}</h3>
|
|
<label class="mb-2 flex items-center gap-2 text-xs"><input v-model="autopilot.cleanupDeadServers" type="checkbox" />{{ t("consul.ui.cleanupDeadServers") }}</label
|
|
><Input v-model="autopilot.confirmation" :placeholder="t('consul.ui.updateAutopilotConfirmation')" /><Button class="mt-2" size="sm" @click="runWrite(() => api.consulAutopilotUpdate(props.connectionId, { CleanupDeadServers: autopilot.cleanupDeadServers }, autopilot.confirmation))">{{
|
|
t("consul.ui.updateAutopilot")
|
|
}}</Button>
|
|
</section>
|
|
<section v-if="raftWriteVisible" class="border p-3">
|
|
<h3 class="mb-3 text-xs font-medium uppercase">{{ t("consul.ui.raftOperations") }}</h3>
|
|
<div class="grid gap-2">
|
|
<Input v-model="raft.serverId" :placeholder="t('consul.ui.serverId')" /><Input v-model="raft.address" :placeholder="t('consul.ui.peerAddress')" /><Input v-model="raft.confirmation" :placeholder="t('consul.ui.reenterIdAddress')" />
|
|
<div class="flex gap-2">
|
|
<Button size="sm" @click="runWrite(() => api.consulRaftTransfer(props.connectionId, { serverId: raft.serverId || null, address: null, confirmation: raft.confirmation }))">{{ t("consul.ui.transferLeader") }}</Button
|
|
><Button size="sm" variant="destructive" @click="runWrite(() => api.consulRaftRemove(props.connectionId, { serverId: raft.serverId || null, address: raft.address || null, confirmation: raft.confirmation }))">{{ t("consul.ui.removePeer") }}</Button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<section v-if="keyringWriteVisible" class="border p-3">
|
|
<h3 class="mb-3 text-xs font-medium uppercase">{{ t("consul.ui.gossipKeyring") }}</h3>
|
|
<div class="grid gap-2">
|
|
<Label>{{ t("consul.ui.operation") }}</Label
|
|
><select v-model="keyring.operation" class="h-9 border bg-background px-2 text-sm">
|
|
<option value="install">{{ t("consul.ui.install") }}</option>
|
|
<option value="use">{{ t("consul.ui.use") }}</option>
|
|
<option value="remove">{{ t("consul.ui.remove") }}</option></select
|
|
><Input v-model="keyring.key" type="password" :placeholder="t('consul.ui.gossipKey')" /><Input v-model="keyring.confirmation" :placeholder="t('consul.ui.changeKeyringConfirmation')" /><Button size="sm" @click="runWrite(() => api.consulKeyringWrite(props.connectionId, keyring))">{{
|
|
t("consul.ui.changeKeyring")
|
|
}}</Button>
|
|
</div>
|
|
</section>
|
|
<section v-if="licenseWriteVisible" class="border p-3">
|
|
<h3 class="mb-3 text-xs font-medium uppercase">{{ t("consul.ui.licenseUpdate") }}</h3>
|
|
<div class="grid gap-2">
|
|
<Input v-model="license.value" type="password" :placeholder="t('consul.ui.license')" /><Input v-model="license.confirmation" :placeholder="t('consul.ui.updateLicenseConfirmation')" /><Button
|
|
size="sm"
|
|
@click="runWrite(() => api.consulLicenseWrite(props.connectionId, { license: license.value, confirmation: license.confirmation }))"
|
|
>{{ t("consul.ui.updateLicense") }}</Button
|
|
>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</template>
|