feat(rabbitmq): add message queue management support
This commit is contained in:
1 parent
503d6748d4
commit
39e554369f
76 files changed
+14915
-707
No files matched your search
@@ -0,0 +1,852 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { MqBindingInfo, MqExchangeInfo, MqExchangeType, NamespaceRef, TopicInfo } from "@/types/mq";
|
||||
import { mqBind, mqCreateExchange, mqDeleteExchange, mqListBindings, mqListExchanges, mqListTopics, mqUnbind } from "@/lib/backend/api";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { isBuiltinRabbitMqExchange, rabbitMqExchangeDisplayName, RABBITMQ_EXCHANGE_TYPES } from "@/lib/mq/rabbitmqExchanges";
|
||||
import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
tenant?: string;
|
||||
namespace?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const exchanges = ref<MqExchangeInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const exchangeSearch = ref("");
|
||||
const selectedExchange = ref<MqExchangeInfo>();
|
||||
|
||||
const bindings = ref<MqBindingInfo[]>([]);
|
||||
const bindingsLoading = ref(false);
|
||||
const bindingsError = ref<string>();
|
||||
|
||||
const dialogError = ref<string>();
|
||||
const showCreateDialog = ref(false);
|
||||
const createForm = ref({
|
||||
name: "",
|
||||
type: "direct" as MqExchangeType | string,
|
||||
durable: true,
|
||||
autoDelete: false,
|
||||
});
|
||||
const exchangeTypeOptions = RABBITMQ_EXCHANGE_TYPES;
|
||||
|
||||
const showDeleteDialog = ref(false);
|
||||
const deleteTarget = ref<MqExchangeInfo>();
|
||||
const deleting = ref(false);
|
||||
|
||||
const showBindDialog = ref(false);
|
||||
const bindForm = ref({
|
||||
destinationType: "queue" as "queue" | "exchange",
|
||||
destination: "",
|
||||
routingKey: "",
|
||||
argumentsText: "",
|
||||
});
|
||||
const binding = ref(false);
|
||||
|
||||
const showUnbindDialog = ref(false);
|
||||
const unbindTarget = ref<MqBindingInfo>();
|
||||
const unbinding = ref(false);
|
||||
|
||||
const availableQueues = ref<TopicInfo[]>([]);
|
||||
|
||||
const filteredExchanges = computed(() => {
|
||||
const query = exchangeSearch.value.trim().toLowerCase();
|
||||
if (!query) return exchanges.value;
|
||||
return exchanges.value.filter((exchange) => rabbitMqExchangeDisplayName(exchange).toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
function nsRef(): NamespaceRef | null {
|
||||
if (!props.tenant || !props.namespace) return null;
|
||||
return { tenant: props.tenant, namespace: props.namespace };
|
||||
}
|
||||
|
||||
// RabbitMQ "all vhosts" mode: rows carry their own vhost in `namespace`, and
|
||||
// row-level operations must target that vhost rather than the "*" selection.
|
||||
const showNamespaceColumn = computed(() => isAllVhostsNamespace(props.namespace));
|
||||
|
||||
function nsRefFor(row: { namespace?: string } | undefined): NamespaceRef | null {
|
||||
if (!props.tenant || !props.namespace) return null;
|
||||
const namespace = resolveMqRowNamespace(row, props.namespace);
|
||||
if (!namespace) return null;
|
||||
return { tenant: props.tenant, namespace };
|
||||
}
|
||||
|
||||
function guardWritable() {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqExchanges.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadExchanges() {
|
||||
const ns = nsRef();
|
||||
if (!ns) {
|
||||
exchanges.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
exchanges.value = await mqListExchanges(props.connectionId, ns);
|
||||
if (selectedExchange.value && !exchanges.value.some((exchange) => exchange.name === selectedExchange.value?.name && exchange.namespace === selectedExchange.value?.namespace)) {
|
||||
selectedExchange.value = undefined;
|
||||
bindings.value = [];
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBindings(exchange: MqExchangeInfo) {
|
||||
const ns = nsRefFor(exchange);
|
||||
if (!ns) return;
|
||||
bindingsLoading.value = true;
|
||||
bindingsError.value = undefined;
|
||||
try {
|
||||
bindings.value = await mqListBindings(props.connectionId, ns, { exchange: exchange.name });
|
||||
} catch (e: unknown) {
|
||||
bindingsError.value = formatError(e);
|
||||
} finally {
|
||||
bindingsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQueues() {
|
||||
const ns = nsRefFor(selectedExchange.value);
|
||||
if (!ns) return;
|
||||
try {
|
||||
availableQueues.value = await mqListTopics(props.connectionId, ns, { includeNonPersistent: false });
|
||||
} catch (e: unknown) {
|
||||
console.warn("[DBX] Failed to load queues for binding dialog:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function selectExchange(exchange: MqExchangeInfo) {
|
||||
if (selectedExchange.value?.name === exchange.name && selectedExchange.value?.namespace === exchange.namespace) {
|
||||
selectedExchange.value = undefined;
|
||||
bindings.value = [];
|
||||
return;
|
||||
}
|
||||
selectedExchange.value = exchange;
|
||||
void loadBindings(exchange);
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
dialogError.value = undefined;
|
||||
createForm.value = { name: "", type: "direct", durable: true, autoDelete: false };
|
||||
showCreateDialog.value = true;
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
const ns = nsRef();
|
||||
if (!createForm.value.name.trim() || !ns) {
|
||||
dialogError.value = t("mqExchanges.nameRequired");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
await mqCreateExchange(props.connectionId, ns, {
|
||||
name: createForm.value.name.trim(),
|
||||
type: createForm.value.type,
|
||||
durable: createForm.value.durable,
|
||||
autoDelete: createForm.value.autoDelete,
|
||||
});
|
||||
showCreateDialog.value = false;
|
||||
dialogError.value = undefined;
|
||||
await loadExchanges();
|
||||
} catch (e: unknown) {
|
||||
dialogError.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteDialog(exchange: MqExchangeInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (isBuiltinRabbitMqExchange(exchange)) return;
|
||||
deleteTarget.value = exchange;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const target = deleteTarget.value;
|
||||
if (!target) return;
|
||||
const ns = nsRefFor(target);
|
||||
if (!ns) {
|
||||
error.value = t("mqAdmin.selectNamespaceToWrite");
|
||||
return;
|
||||
}
|
||||
deleting.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
await mqDeleteExchange(props.connectionId, ns, target.name);
|
||||
showDeleteDialog.value = false;
|
||||
if (selectedExchange.value?.name === target.name && selectedExchange.value?.namespace === target.namespace) {
|
||||
selectedExchange.value = undefined;
|
||||
bindings.value = [];
|
||||
}
|
||||
await loadExchanges();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openBindDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (!selectedExchange.value) return;
|
||||
dialogError.value = undefined;
|
||||
bindForm.value = { destinationType: "queue", destination: "", routingKey: "", argumentsText: "" };
|
||||
void loadQueues();
|
||||
showBindDialog.value = true;
|
||||
}
|
||||
|
||||
function parseBindingArguments(): Record<string, unknown> | undefined {
|
||||
const text = bindForm.value.argumentsText.trim();
|
||||
if (!text) return undefined;
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(t("mqExchanges.argumentsMustBeObject"));
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function handleBind() {
|
||||
if (!guardWritable()) return;
|
||||
const exchange = selectedExchange.value;
|
||||
if (!exchange) return;
|
||||
const ns = nsRefFor(exchange);
|
||||
if (!ns) {
|
||||
dialogError.value = t("mqAdmin.selectNamespaceToWrite");
|
||||
return;
|
||||
}
|
||||
if (!bindForm.value.destination.trim()) {
|
||||
dialogError.value = t("mqExchanges.destinationRequired");
|
||||
return;
|
||||
}
|
||||
binding.value = true;
|
||||
dialogError.value = undefined;
|
||||
try {
|
||||
const args = parseBindingArguments();
|
||||
await mqBind(props.connectionId, ns, {
|
||||
source: exchange.name,
|
||||
destination: bindForm.value.destination.trim(),
|
||||
destinationType: bindForm.value.destinationType,
|
||||
routingKey: bindForm.value.routingKey.trim() || undefined,
|
||||
arguments: args,
|
||||
});
|
||||
showBindDialog.value = false;
|
||||
await loadBindings(exchange);
|
||||
} catch (e: unknown) {
|
||||
dialogError.value = formatError(e);
|
||||
} finally {
|
||||
binding.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openUnbindDialog(bindingRow: MqBindingInfo) {
|
||||
if (!guardWritable()) return;
|
||||
unbindTarget.value = bindingRow;
|
||||
showUnbindDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmUnbind() {
|
||||
const target = unbindTarget.value;
|
||||
if (!target) return;
|
||||
const ns = nsRefFor(target ?? selectedExchange.value);
|
||||
if (!ns) {
|
||||
bindingsError.value = t("mqAdmin.selectNamespaceToWrite");
|
||||
return;
|
||||
}
|
||||
unbinding.value = true;
|
||||
bindingsError.value = undefined;
|
||||
try {
|
||||
await mqUnbind(props.connectionId, ns, target);
|
||||
showUnbindDialog.value = false;
|
||||
if (selectedExchange.value) {
|
||||
await loadBindings(selectedExchange.value);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
bindingsError.value = formatError(e);
|
||||
} finally {
|
||||
unbinding.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBindingArguments(bindingRow: MqBindingInfo): string {
|
||||
if (!bindingRow.arguments || !Object.keys(bindingRow.arguments).length) return "-";
|
||||
return JSON.stringify(bindingRow.arguments);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.tenant, props.namespace],
|
||||
() => {
|
||||
selectedExchange.value = undefined;
|
||||
bindings.value = [];
|
||||
loadExchanges();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="exchanges-panel">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<input v-model="exchangeSearch" type="search" class="exchange-search" :placeholder="t('mqExchanges.searchPlaceholder')" :disabled="loading && !exchanges.length" />
|
||||
<span v-if="exchanges.length" class="exchange-count">{{ filteredExchanges.length }} / {{ exchanges.length }}</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button @click="loadExchanges" :disabled="loading || !tenant || !namespace" class="btn-secondary">
|
||||
{{ loading ? t("mqExchanges.refreshing") : t("mqExchanges.refresh") }}
|
||||
</button>
|
||||
<button @click="openCreateDialog" :disabled="loading || readOnly || !tenant || !namespace || showNamespaceColumn" :title="showNamespaceColumn ? t('mqAdmin.selectNamespaceToCreate') : undefined" class="btn-primary">+ {{ t("mqExchanges.createExchange") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!tenant || !namespace" class="panel-placeholder">{{ t("mqExchanges.selectNamespace") }}</div>
|
||||
|
||||
<div v-else-if="error" class="panel-error">{{ error }}</div>
|
||||
|
||||
<div v-else-if="loading && !exchanges.length" class="panel-loading">{{ t("mqExchanges.loading") }}</div>
|
||||
|
||||
<div v-else-if="!exchanges.length" class="panel-placeholder">{{ t("mqExchanges.noExchanges") }}</div>
|
||||
|
||||
<div v-else-if="!filteredExchanges.length" class="panel-placeholder">{{ t("mqExchanges.noMatches") }}</div>
|
||||
|
||||
<div v-else class="exchanges-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("mqExchanges.name") }}</th>
|
||||
<th v-if="showNamespaceColumn">{{ t("mqAdmin.namespace") }}</th>
|
||||
<th>{{ t("mqExchanges.type") }}</th>
|
||||
<th>{{ t("mqExchanges.durable") }}</th>
|
||||
<th>{{ t("mqExchanges.autoDelete") }}</th>
|
||||
<th>{{ t("mqExchanges.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="exchange in filteredExchanges" :key="`${exchange.namespace ?? ''}:${exchange.name || '(default)'}`" :class="{ selected: selectedExchange?.name === exchange.name && selectedExchange?.namespace === exchange.namespace }" @click="selectExchange(exchange)">
|
||||
<td class="exchange-name">
|
||||
<div class="exchange-name-cell">
|
||||
<span>{{ rabbitMqExchangeDisplayName(exchange) }}</span>
|
||||
<span v-if="isBuiltinRabbitMqExchange(exchange)" class="badge badge-warning">{{ t("mqExchanges.builtin") }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="showNamespaceColumn">{{ exchange.namespace || "-" }}</td>
|
||||
<td>
|
||||
<span class="badge badge-info">{{ exchange.type }}</span>
|
||||
</td>
|
||||
<td>{{ exchange.durable ? t("mqExchanges.yes") : t("mqExchanges.no") }}</td>
|
||||
<td>{{ exchange.autoDelete ? t("mqExchanges.yes") : t("mqExchanges.no") }}</td>
|
||||
<td class="actions" @click.stop>
|
||||
<button class="btn-sm" @click="selectExchange(exchange)">{{ t("mqExchanges.viewBindings") }}</button>
|
||||
<button class="btn-sm btn-danger" :disabled="readOnly || isBuiltinRabbitMqExchange(exchange) || (showNamespaceColumn && !exchange.namespace)" :title="showNamespaceColumn && !exchange.namespace ? t('mqAdmin.selectNamespaceToWrite') : undefined" @click="openDeleteDialog(exchange)">
|
||||
{{ t("mqExchanges.delete") }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Bindings of the selected exchange -->
|
||||
<div v-if="selectedExchange" class="bindings-section">
|
||||
<div class="bindings-header">
|
||||
<h4>{{ t("mqExchanges.bindingsTitle", { name: rabbitMqExchangeDisplayName(selectedExchange) }) }}</h4>
|
||||
<div class="toolbar-actions">
|
||||
<button class="btn-sm" :disabled="bindingsLoading" @click="loadBindings(selectedExchange)">
|
||||
{{ bindingsLoading ? t("mqExchanges.refreshing") : t("mqExchanges.refresh") }}
|
||||
</button>
|
||||
<button class="btn-sm" :disabled="readOnly" @click="openBindDialog">+ {{ t("mqExchanges.bindDestination") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="bindingsError" class="panel-error">{{ bindingsError }}</div>
|
||||
<div v-else-if="bindingsLoading && !bindings.length" class="panel-loading">{{ t("mqExchanges.loading") }}</div>
|
||||
<div v-else-if="!bindings.length" class="panel-placeholder">{{ t("mqExchanges.noBindings") }}</div>
|
||||
<div v-else class="bindings-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("mqExchanges.bindingSource") }}</th>
|
||||
<th>{{ t("mqExchanges.bindingDestination") }}</th>
|
||||
<th v-if="showNamespaceColumn">{{ t("mqAdmin.namespace") }}</th>
|
||||
<th>{{ t("mqExchanges.bindingType") }}</th>
|
||||
<th>{{ t("mqExchanges.routingKey") }}</th>
|
||||
<th>{{ t("mqExchanges.arguments") }}</th>
|
||||
<th>{{ t("mqExchanges.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(bindingRow, index) in bindings" :key="`${bindingRow.namespace ?? ''}:${bindingRow.source}->${bindingRow.destination}:${bindingRow.routingKey ?? ''}:${index}`">
|
||||
<td>{{ rabbitMqExchangeDisplayName({ name: bindingRow.source }) }}</td>
|
||||
<td class="exchange-name">{{ bindingRow.destination }}</td>
|
||||
<td v-if="showNamespaceColumn">{{ bindingRow.namespace || "-" }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="bindingRow.destinationType === 'exchange' ? 'badge-info' : 'badge-default'">
|
||||
{{ bindingRow.destinationType === "exchange" ? t("mqExchanges.destinationTypeExchange") : t("mqExchanges.destinationTypeQueue") }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ bindingRow.routingKey || "-" }}</td>
|
||||
<td class="binding-arguments">{{ formatBindingArguments(bindingRow) }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-sm btn-danger" :disabled="readOnly" @click="openUnbindDialog(bindingRow)">{{ t("mqExchanges.unbind") }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Exchange Dialog -->
|
||||
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>{{ t("mqExchanges.createExchange") }}</h3>
|
||||
<button @click="showCreateDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqExchanges.virtualHost") }}</label>
|
||||
<input type="text" :value="namespace" disabled />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqExchanges.name") }}*</label>
|
||||
<input v-model="createForm.name" type="text" :placeholder="t('mqExchanges.namePlaceholder')" :disabled="readOnly" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqExchanges.type") }}*</label>
|
||||
<select v-model="createForm.type" :disabled="readOnly">
|
||||
<option v-for="type in exchangeTypeOptions" :key="type" :value="type">{{ type }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="createForm.durable" :disabled="readOnly" />
|
||||
{{ t("mqExchanges.durable") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="createForm.autoDelete" :disabled="readOnly" />
|
||||
{{ t("mqExchanges.autoDelete") }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showCreateDialog = false" class="btn-secondary">{{ t("mqExchanges.cancel") }}</button>
|
||||
<button @click="handleCreate" :disabled="loading || readOnly" class="btn-primary">{{ t("mqExchanges.create") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bind Dialog -->
|
||||
<div v-if="showBindDialog" class="dialog-overlay" @click="showBindDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>{{ t("mqExchanges.bindDialogTitle", { name: selectedExchange ? rabbitMqExchangeDisplayName(selectedExchange) : "" }) }}</h3>
|
||||
<button @click="showBindDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqExchanges.bindingType") }}*</label>
|
||||
<select v-model="bindForm.destinationType" :disabled="readOnly">
|
||||
<option value="queue">{{ t("mqExchanges.destinationTypeQueue") }}</option>
|
||||
<option value="exchange">{{ t("mqExchanges.destinationTypeExchange") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqExchanges.bindingDestination") }}*</label>
|
||||
<input v-model="bindForm.destination" type="text" list="mq-exchange-bind-queues" :placeholder="bindForm.destinationType === 'queue' ? t('mqExchanges.queueNamePlaceholder') : t('mqExchanges.exchangeNamePlaceholder')" :disabled="readOnly" />
|
||||
<datalist id="mq-exchange-bind-queues">
|
||||
<option v-for="queue in availableQueues" :key="queue.name" :value="queue.shortName" />
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqExchanges.routingKey") }}</label>
|
||||
<input v-model="bindForm.routingKey" type="text" :placeholder="t('mqExchanges.routingKeyPlaceholder')" :disabled="readOnly" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqExchanges.arguments") }}</label>
|
||||
<textarea v-model="bindForm.argumentsText" rows="3" class="arguments-textarea" :placeholder="t('mqExchanges.argumentsPlaceholder')" :disabled="readOnly" />
|
||||
</div>
|
||||
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showBindDialog = false" class="btn-secondary">{{ t("mqExchanges.cancel") }}</button>
|
||||
<button @click="handleBind" :disabled="binding || readOnly" class="btn-primary">{{ t("mqExchanges.bind") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Exchange Confirm -->
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDeleteDialog"
|
||||
:title="t('mqExchanges.delete')"
|
||||
:message="t('mqExchanges.confirmDelete', { name: deleteTarget ? rabbitMqExchangeDisplayName(deleteTarget) : '' })"
|
||||
:confirm-label="t('mqExchanges.delete')"
|
||||
:loading="deleting"
|
||||
:close-on-confirm="false"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
|
||||
<!-- Unbind Confirm -->
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showUnbindDialog"
|
||||
:title="t('mqExchanges.unbind')"
|
||||
:message="t('mqExchanges.confirmUnbind', { destination: unbindTarget?.destination ?? '', source: unbindTarget?.source ?? '' })"
|
||||
:confirm-label="t('mqExchanges.unbind')"
|
||||
:loading="unbinding"
|
||||
:close-on-confirm="false"
|
||||
@confirm="confirmUnbind"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.exchanges-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.exchange-search {
|
||||
width: min(320px, 32vw);
|
||||
min-width: 180px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.exchange-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
.exchange-count {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.panel-placeholder,
|
||||
.panel-error,
|
||||
.panel-loading {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.panel-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.exchanges-table,
|
||||
.bindings-table {
|
||||
overflow: auto;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-background-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.exchanges-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.exchanges-table tbody tr:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.exchanges-table tbody tr.selected {
|
||||
background: var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
.exchange-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.exchange-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-default {
|
||||
background: var(--color-background-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: var(--color-info-alpha);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--color-warning-alpha);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.binding-arguments {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bindings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
background: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.bindings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bindings-header h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm,
|
||||
.btn-danger {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group select,
|
||||
.arguments-textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.arguments-textarea {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background: var(--color-background-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--color-error);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -174,7 +174,7 @@ function getTopicRef(): TopicRef | null {
|
||||
if (!props.topic || !props.tenant || !props.namespace) return null;
|
||||
return {
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
namespace: props.topic.namespace || props.namespace,
|
||||
topic: props.topic.shortName,
|
||||
persistent: props.topic.persistent,
|
||||
partitioned: props.topic.partitioned,
|
||||
|
||||
@@ -2,11 +2,25 @@
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { MqAdminConfig, MqClusterInfo, MqSystemKind, TopicInfo } from "@/types/mq";
|
||||
import { mqTestConnection } from "@/lib/backend/api";
|
||||
import type { MqAdminConfig, MqClusterInfo, MqSystemKind, NamespaceRef, TopicInfo } from "@/types/mq";
|
||||
import { mqCreateNamespace, mqListNamespaces, mqTestConnection } from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { mqClusterOptionsFromExtra } from "@/lib/mq/mqTenantForm";
|
||||
import { defaultMqCapabilitiesForSystemKind, isFlatMqSystemKind, normalizeMqTabForSystemKind, resolveAvailableMqTabs, resolveInitialMqTab, resolveMqSystemKindFromConnection, type MqTab } from "@/lib/mq/mqConsoleDefaults";
|
||||
import {
|
||||
defaultMqCapabilitiesForSystemKind,
|
||||
isAllVhostsNamespace,
|
||||
isFlatMqSystemKind,
|
||||
normalizeMqTabForSystemKind,
|
||||
RABBITMQ_ALL_VHOSTS,
|
||||
RABBITMQ_MQ_TENANT,
|
||||
resolveAvailableMqTabs,
|
||||
resolveInitialMqTab,
|
||||
resolveMqSystemKindFromConnection,
|
||||
resolveRabbitMqDefaultVhost,
|
||||
type MqTab,
|
||||
} from "@/lib/mq/mqConsoleDefaults";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import type { AcceptableValue } from "reka-ui";
|
||||
import TenantsPanel from "./TenantsPanel.vue";
|
||||
import NamespacesPanel from "./NamespacesPanel.vue";
|
||||
import TopicsPanel from "./TopicsPanel.vue";
|
||||
@@ -21,6 +35,10 @@ import RocketMqMessagesPanel from "./RocketMqMessagesPanel.vue";
|
||||
import SendMessagePanel from "./SendMessagePanel.vue";
|
||||
import MessageQueryPanel from "./MessageQueryPanel.vue";
|
||||
import BrokerPanel from "./BrokerPanel.vue";
|
||||
import RabbitMqClientsPanel from "./rabbitmq/RabbitMqClientsPanel.vue";
|
||||
import RabbitMqPermissionsPanel from "./rabbitmq/RabbitMqPermissionsPanel.vue";
|
||||
import RabbitMqPoliciesPanel from "./rabbitmq/RabbitMqPoliciesPanel.vue";
|
||||
import RabbitMqMonitoringPanel from "./rabbitmq/RabbitMqMonitoringPanel.vue";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
@@ -35,11 +53,22 @@ const connectionStore = useConnectionStore();
|
||||
const configuredSystemKind = computed(() => resolveMqSystemKindFromConnection(connectionStore.getConfig(props.connectionId)));
|
||||
const FLAT_MQ_CONTEXT = "_flat_mq";
|
||||
|
||||
function normalizeFlatMqTenant(tenant?: string): string | undefined {
|
||||
function normalizeFlatMqTenant(tenant: string | undefined, systemKind: MqSystemKind | undefined): string | undefined {
|
||||
// RabbitMQ has no tenant concept: the console pins a synthetic tenant and
|
||||
// exposes virtual hosts as namespaces instead.
|
||||
if (systemKind === "rabbitmq") return tenant ? RABBITMQ_MQ_TENANT : undefined;
|
||||
if (tenant === "_kafka" || tenant === FLAT_MQ_CONTEXT) return FLAT_MQ_CONTEXT;
|
||||
return tenant;
|
||||
}
|
||||
|
||||
// The connection's default virtual host acts as the initial RabbitMQ namespace.
|
||||
const rabbitMqDefaultVhost = computed(() => resolveRabbitMqDefaultVhost(connectionStore.getConfig(props.connectionId)));
|
||||
|
||||
function initialMqNamespace(systemKind: MqSystemKind | undefined): string | undefined {
|
||||
if (systemKind === "rabbitmq") return rabbitMqDefaultVhost.value;
|
||||
return isFlatMqSystemKind(systemKind) ? FLAT_MQ_CONTEXT : undefined;
|
||||
}
|
||||
|
||||
// State
|
||||
const activeTab = ref<MqTab>(
|
||||
resolveInitialMqTab({
|
||||
@@ -48,8 +77,8 @@ const activeTab = ref<MqTab>(
|
||||
systemKind: configuredSystemKind.value,
|
||||
}),
|
||||
);
|
||||
const selectedTenant = ref<string | undefined>(normalizeFlatMqTenant(props.initialTenant));
|
||||
const selectedNamespace = ref<string | undefined>(isFlatMqSystemKind(configuredSystemKind.value) ? FLAT_MQ_CONTEXT : undefined);
|
||||
const selectedTenant = ref<string | undefined>(normalizeFlatMqTenant(props.initialTenant, configuredSystemKind.value));
|
||||
const selectedNamespace = ref<string | undefined>(initialMqNamespace(configuredSystemKind.value));
|
||||
const selectedTopic = ref<TopicInfo>();
|
||||
const selectedSubscriptionName = ref<string>();
|
||||
const capabilities = ref<MqClusterInfo["capabilities"]>();
|
||||
@@ -58,9 +87,18 @@ const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const preferDlqTopic = ref(props.initialTab === "dlq");
|
||||
|
||||
// RabbitMQ vhost switcher (tab-bar namespace dropdown).
|
||||
const CREATE_NAMESPACE_VALUE = "__create_namespace__";
|
||||
const rabbitMqVhosts = ref<string[]>([]);
|
||||
const showCreateNamespaceDialog = ref(false);
|
||||
const createNamespaceName = ref("");
|
||||
const createNamespaceError = ref<string>();
|
||||
const creatingNamespace = ref(false);
|
||||
|
||||
// Computed
|
||||
const mqSystemKind = computed<MqSystemKind | undefined>(() => clusterInfo.value?.systemKind ?? configuredSystemKind.value);
|
||||
const isFlatMqCluster = computed(() => isFlatMqSystemKind(mqSystemKind.value));
|
||||
const isRabbitMqCluster = computed(() => mqSystemKind.value === "rabbitmq");
|
||||
const isRocketMqCluster = computed(() => mqSystemKind.value === "rocketmq");
|
||||
const rocketmqClusterLabel = computed(() => {
|
||||
if (!isRocketMqCluster.value) return undefined;
|
||||
@@ -72,10 +110,17 @@ const rocketmqClusterLabel = computed(() => {
|
||||
const clusterName = extra?.clusterName ?? extra?.cluster_name;
|
||||
return typeof clusterName === "string" && clusterName.trim() ? clusterName.trim() : undefined;
|
||||
});
|
||||
const effectiveTenant = computed(() => (isFlatMqCluster.value ? normalizeFlatMqTenant(selectedTenant.value) || FLAT_MQ_CONTEXT : selectedTenant.value));
|
||||
const effectiveNamespace = computed(() => (isFlatMqCluster.value ? selectedNamespace.value || FLAT_MQ_CONTEXT : selectedNamespace.value));
|
||||
const effectiveTenant = computed(() => {
|
||||
if (isRabbitMqCluster.value) return RABBITMQ_MQ_TENANT;
|
||||
return isFlatMqCluster.value ? normalizeFlatMqTenant(selectedTenant.value, mqSystemKind.value) || FLAT_MQ_CONTEXT : selectedTenant.value;
|
||||
});
|
||||
const effectiveNamespace = computed(() => {
|
||||
if (isRabbitMqCluster.value) return selectedNamespace.value || rabbitMqDefaultVhost.value;
|
||||
return isFlatMqCluster.value ? selectedNamespace.value || FLAT_MQ_CONTEXT : selectedNamespace.value;
|
||||
});
|
||||
const breadcrumbTenant = computed(() => (isFlatMqCluster.value ? undefined : selectedTenant.value));
|
||||
const breadcrumbNamespace = computed(() => (isFlatMqCluster.value ? undefined : selectedNamespace.value));
|
||||
const breadcrumbNamespace = computed(() => (isFlatMqCluster.value && !isRabbitMqCluster.value ? undefined : selectedNamespace.value));
|
||||
const breadcrumbNamespaceLabel = computed(() => (isAllVhostsNamespace(breadcrumbNamespace.value) ? t("mqAdmin.allNamespaces") : breadcrumbNamespace.value));
|
||||
const effectiveCapabilities = computed(() => capabilities.value ?? defaultMqCapabilitiesForSystemKind(configuredSystemKind.value));
|
||||
const canManageTenants = computed(() => effectiveCapabilities.value.supportsTenants);
|
||||
const canManageNamespaces = computed(() => effectiveCapabilities.value.supportsNamespaces);
|
||||
@@ -94,7 +139,12 @@ const canManagePolicies = computed(() => {
|
||||
return canManageRateLimits.value || canManageBacklogQuota.value || canManageRetention.value;
|
||||
});
|
||||
const canManagePermissions = computed(() => effectiveCapabilities.value.supportsPermissions);
|
||||
const canManageUserPermissions = computed(() => effectiveCapabilities.value.supportsUserPermissions ?? false);
|
||||
const canManageRabbitMqPolicies = computed(() => effectiveCapabilities.value.supportsPolicies ?? false);
|
||||
const canClusterMonitor = computed(() => effectiveCapabilities.value.supportsClusterMonitoring ?? false);
|
||||
const canSendMessage = computed(() => effectiveCapabilities.value.supportsSendMessage ?? false);
|
||||
const canManageExchanges = computed(() => effectiveCapabilities.value.supportsExchanges ?? false);
|
||||
const canManageClientConnections = computed(() => effectiveCapabilities.value.supportsClientConnections ?? false);
|
||||
const canMessageQuery = computed(() => effectiveCapabilities.value.supportsMessageQuery ?? false);
|
||||
const canMessageTrace = computed(() => effectiveCapabilities.value.supportsMessageTrace ?? false);
|
||||
const canUseRawApi = computed(() => effectiveCapabilities.value.supportsRawAdminApi);
|
||||
@@ -146,9 +196,58 @@ async function loadClusterInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRabbitMqVhosts() {
|
||||
try {
|
||||
const namespaces = await mqListNamespaces(props.connectionId, RABBITMQ_MQ_TENANT);
|
||||
rabbitMqVhosts.value = namespaces.map((ns) => ns.namespace);
|
||||
} catch (e: unknown) {
|
||||
console.warn("[DBX] Failed to load RabbitMQ vhosts:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function switchNamespace(namespace: string) {
|
||||
selectedNamespace.value = namespace;
|
||||
selectedTopic.value = undefined;
|
||||
selectedSubscriptionName.value = undefined;
|
||||
}
|
||||
|
||||
function handleNamespaceSelect(value: AcceptableValue) {
|
||||
if (typeof value !== "string") return;
|
||||
if (value === CREATE_NAMESPACE_VALUE) {
|
||||
if (props.readOnly) return;
|
||||
createNamespaceName.value = "";
|
||||
createNamespaceError.value = undefined;
|
||||
showCreateNamespaceDialog.value = true;
|
||||
return;
|
||||
}
|
||||
switchNamespace(value);
|
||||
}
|
||||
|
||||
async function handleCreateNamespace() {
|
||||
if (props.readOnly) return;
|
||||
const name = createNamespaceName.value.trim();
|
||||
if (!name) {
|
||||
createNamespaceError.value = t("mqNamespaces.namespaceNameRequired");
|
||||
return;
|
||||
}
|
||||
creatingNamespace.value = true;
|
||||
createNamespaceError.value = undefined;
|
||||
try {
|
||||
const ns: NamespaceRef = { tenant: RABBITMQ_MQ_TENANT, namespace: name };
|
||||
await mqCreateNamespace(props.connectionId, ns, {});
|
||||
showCreateNamespaceDialog.value = false;
|
||||
await loadRabbitMqVhosts();
|
||||
switchNamespace(name);
|
||||
} catch (e: unknown) {
|
||||
createNamespaceError.value = formatError(e);
|
||||
} finally {
|
||||
creatingNamespace.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectTenant(tenant: string) {
|
||||
selectedTenant.value = tenant;
|
||||
selectedNamespace.value = isFlatMqCluster.value ? FLAT_MQ_CONTEXT : undefined;
|
||||
selectedNamespace.value = initialMqNamespace(mqSystemKind.value);
|
||||
selectedTopic.value = undefined;
|
||||
selectedSubscriptionName.value = undefined;
|
||||
if (canManageNamespaces.value) {
|
||||
@@ -245,10 +344,17 @@ function reconcileActiveTab() {
|
||||
}
|
||||
|
||||
watch(availableTabs, reconcileActiveTab);
|
||||
watch(
|
||||
isRabbitMqCluster,
|
||||
(isRabbitMq) => {
|
||||
if (isRabbitMq) void loadRabbitMqVhosts();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(
|
||||
() => props.initialTenant,
|
||||
(tenant) => {
|
||||
const normalized = normalizeFlatMqTenant(tenant);
|
||||
const normalized = normalizeFlatMqTenant(tenant, mqSystemKind.value);
|
||||
if (normalized && normalized !== selectedTenant.value) {
|
||||
selectTenant(normalized);
|
||||
}
|
||||
@@ -287,7 +393,7 @@ onMounted(async () => {
|
||||
<span v-if="breadcrumbTenant" class="breadcrumb-separator">›</span>
|
||||
<button v-if="breadcrumbTenant" class="breadcrumb-button" @click="goToTenantLevel" :title="t('mqAdmin.viewTenant')">{{ breadcrumbTenant }}</button>
|
||||
<span v-if="breadcrumbNamespace" class="breadcrumb-separator">›</span>
|
||||
<button v-if="breadcrumbNamespace" class="breadcrumb-button" @click="goToNamespaceLevel" :title="t('mqAdmin.viewNamespace')">{{ breadcrumbNamespace }}</button>
|
||||
<button v-if="breadcrumbNamespace" class="breadcrumb-button" @click="goToNamespaceLevel" :title="t('mqAdmin.viewNamespace')">{{ breadcrumbNamespaceLabel }}</button>
|
||||
<span v-if="selectedTopic" class="breadcrumb-separator">›</span>
|
||||
<button v-if="selectedTopic" class="breadcrumb-button" @click="goToTopicLevel" :title="t('mqAdmin.viewTopic')">{{ selectedTopic.shortName }}</button>
|
||||
</div>
|
||||
@@ -299,15 +405,38 @@ onMounted(async () => {
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<div class="mq-tabs">
|
||||
<button v-for="tab in availableTabs" :key="tab" :class="{ active: activeTab === tab }" @click="setActiveTab(tab)">
|
||||
{{ t(tabLabelKey(tab)) }}
|
||||
</button>
|
||||
<div class="mq-tabs-list">
|
||||
<button v-for="tab in availableTabs" :key="tab" :class="{ active: activeTab === tab }" @click="setActiveTab(tab)">
|
||||
{{ t(tabLabelKey(tab)) }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isRabbitMqCluster" class="mq-namespace-switcher">
|
||||
<Select :model-value="selectedNamespace" @update:model-value="handleNamespaceSelect">
|
||||
<SelectTrigger class="h-7 w-[180px] rounded-[6px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="RABBITMQ_ALL_VHOSTS">{{ t("mqAdmin.allNamespaces") }}</SelectItem>
|
||||
<SelectItem v-for="vhost in rabbitMqVhosts" :key="vhost" :value="vhost">{{ vhost }}</SelectItem>
|
||||
<SelectItem :value="CREATE_NAMESPACE_VALUE" :disabled="readOnly">+ {{ t("mqAdmin.newNamespace") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="mq-content">
|
||||
<TenantsPanel v-if="activeTab === 'tenants'" :connection-id="connectionId" :supports-tenants="canManageTenants" :read-only="readOnly" :cluster-options="clusterOptions" @tenant-selected="handleTenantSelected" />
|
||||
<NamespacesPanel v-else-if="activeTab === 'namespaces'" :connection-id="connectionId" :tenant="selectedTenant" :supports-namespaces="canManageNamespaces" :read-only="readOnly" @namespace-selected="handleNamespaceSelected" @namespace-roles-selected="handleNamespaceRolesSelected" />
|
||||
<NamespacesPanel
|
||||
v-else-if="activeTab === 'namespaces'"
|
||||
:connection-id="connectionId"
|
||||
:tenant="effectiveTenant"
|
||||
:supports-namespaces="canManageNamespaces"
|
||||
:supports-permissions="canManagePermissions"
|
||||
:read-only="readOnly"
|
||||
@namespace-selected="handleNamespaceSelected"
|
||||
@namespace-roles-selected="handleNamespaceRolesSelected"
|
||||
/>
|
||||
<TopicsPanel
|
||||
v-else-if="activeTab === 'topics'"
|
||||
:connection-id="connectionId"
|
||||
@@ -317,6 +446,7 @@ onMounted(async () => {
|
||||
:supports-partitioned-topics="canManagePartitionedTopics"
|
||||
:is-flat-mq-cluster="isFlatMqCluster"
|
||||
:mq-system-kind="mqSystemKind"
|
||||
:supports-exchanges="canManageExchanges"
|
||||
@topic-selected="handleTopicSelected"
|
||||
@navigate-tab="handleNavigateTab"
|
||||
/>
|
||||
@@ -337,7 +467,9 @@ onMounted(async () => {
|
||||
:supports-expire-messages="canExpireMessages"
|
||||
@subscription-selected="handleSubscriptionSelected"
|
||||
/>
|
||||
<RabbitMqMonitoringPanel v-else-if="activeTab === 'monitoring' && isRabbitMqCluster && canClusterMonitor" :connection-id="connectionId" />
|
||||
<MonitoringPanel v-else-if="activeTab === 'monitoring'" :connection-id="connectionId" :topic="selectedTopic" :tenant="effectiveTenant" :namespace="effectiveNamespace" :mq-system-kind="mqSystemKind" />
|
||||
<RabbitMqClientsPanel v-else-if="activeTab === 'clients' && isRabbitMqCluster && canManageClientConnections" :connection-id="connectionId" :namespace="effectiveNamespace" :read-only="readOnly" />
|
||||
<ProducerConsumerPanel
|
||||
v-else-if="activeTab === 'clients'"
|
||||
:connection-id="connectionId"
|
||||
@@ -386,6 +518,7 @@ onMounted(async () => {
|
||||
:supports-peek-messages="canPeekMessages"
|
||||
/>
|
||||
<BrokerPanel v-else-if="activeTab === 'broker'" :connection-id="connectionId" :read-only="readOnly" :mq-system-kind="mqSystemKind" />
|
||||
<RabbitMqPoliciesPanel v-else-if="activeTab === 'policies' && isRabbitMqCluster && canManageRabbitMqPolicies" :connection-id="connectionId" :namespace="effectiveNamespace" :read-only="readOnly" />
|
||||
<PoliciesPanel
|
||||
v-else-if="activeTab === 'policies' && canManagePolicies"
|
||||
:connection-id="connectionId"
|
||||
@@ -398,9 +531,31 @@ onMounted(async () => {
|
||||
:supports-backlog-quota="canManageBacklogQuota"
|
||||
:supports-retention="canManageRetention"
|
||||
/>
|
||||
<RabbitMqPermissionsPanel v-else-if="activeTab === 'permissions' && isRabbitMqCluster && canManageUserPermissions" :connection-id="connectionId" :namespace="effectiveNamespace" :read-only="readOnly" />
|
||||
<PermissionsPanel v-else-if="activeTab === 'permissions' && canManagePermissions" :connection-id="connectionId" :topic="selectedTopic" :tenant="effectiveTenant" :namespace="effectiveNamespace" :read-only="readOnly" :mq-system-kind="mqSystemKind" />
|
||||
<RawApiPanel v-else-if="activeTab === 'raw' && canUseRawApi" :connection-id="connectionId" :tenant="selectedTenant" :namespace="selectedNamespace" :topic="selectedTopic" :read-only="readOnly" />
|
||||
</div>
|
||||
|
||||
<!-- Create Namespace (vhost) Dialog -->
|
||||
<div v-if="showCreateNamespaceDialog" class="dialog-overlay" @click="showCreateNamespaceDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>{{ t("mqAdmin.newNamespace") }}</h3>
|
||||
<button @click="showCreateNamespaceDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqNamespaces.namespaceName") }}</label>
|
||||
<input v-model="createNamespaceName" type="text" :placeholder="t('mqNamespaces.namespaceNamePlaceholder')" :disabled="readOnly" />
|
||||
</div>
|
||||
<div v-if="createNamespaceError" class="form-error">{{ createNamespaceError }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showCreateNamespaceDialog = false" class="btn-secondary">{{ t("mqNamespaces.cancel") }}</button>
|
||||
<button @click="handleCreateNamespace" :disabled="creatingNamespace || readOnly" class="btn-primary">{{ t("mqNamespaces.create") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -482,12 +637,19 @@ onMounted(async () => {
|
||||
|
||||
.mq-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.mq-tabs-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.mq-tabs button {
|
||||
.mq-tabs-list button {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -499,17 +661,24 @@ onMounted(async () => {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mq-tabs button:hover {
|
||||
.mq-tabs-list button:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.mq-tabs button.active {
|
||||
.mq-tabs-list button.active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.mq-namespace-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mq-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
@@ -530,4 +699,115 @@ onMounted(async () => {
|
||||
.mq-content :deep(tbody tr:last-child td) {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Create namespace dialog */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--color-error);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -4,15 +4,19 @@ import { ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { NamespaceRef, NamespaceInfo, NamespaceConfig } from "@/types/mq";
|
||||
import { mqListNamespaces, mqCreateNamespace, mqDeleteNamespace } from "@/lib/backend/api";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
tenant?: string;
|
||||
supportsNamespaces: boolean;
|
||||
supportsPermissions?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
supportsPermissions: true,
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
namespaceSelected: [namespace: string];
|
||||
namespaceRolesSelected: [namespace: string];
|
||||
@@ -25,6 +29,9 @@ const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const showCreateDialog = ref(false);
|
||||
const selectedNamespace = ref<string>();
|
||||
const deleteTarget = ref<NamespaceInfo>();
|
||||
const showDeleteDialog = ref(false);
|
||||
const deleting = ref(false);
|
||||
|
||||
const formData = ref({
|
||||
namespace: "",
|
||||
@@ -86,11 +93,16 @@ async function handleCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(ns: NamespaceInfo) {
|
||||
function handleDelete(ns: NamespaceInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (!confirm(t("mqNamespaces.confirmDelete", { name: ns.namespace }))) return;
|
||||
if (!props.tenant) return;
|
||||
loading.value = true;
|
||||
deleteTarget.value = ns;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const ns = deleteTarget.value;
|
||||
if (!ns || !props.tenant) return;
|
||||
deleting.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
const nsRef: NamespaceRef = {
|
||||
@@ -101,11 +113,12 @@ async function handleDelete(ns: NamespaceInfo) {
|
||||
if (selectedNamespace.value === ns.namespace) {
|
||||
selectedNamespace.value = undefined;
|
||||
}
|
||||
showDeleteDialog.value = false;
|
||||
await loadNamespaces();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +163,7 @@ watch(
|
||||
<tr>
|
||||
<th>{{ t("mqNamespaces.name") }}</th>
|
||||
<th>{{ t("mqNamespaces.tenant") }}</th>
|
||||
<th>{{ t("mqNamespaces.adminRoles") }}</th>
|
||||
<th v-if="supportsPermissions">{{ t("mqNamespaces.adminRoles") }}</th>
|
||||
<th>{{ t("mqNamespaces.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -158,14 +171,14 @@ watch(
|
||||
<tr v-for="ns in namespaces" :key="ns.namespace" :class="{ selected: selectedNamespace === ns.namespace }" @click="selectNamespace(ns)">
|
||||
<td class="namespace-name">{{ ns.namespace }}</td>
|
||||
<td>{{ ns.tenant }}</td>
|
||||
<td>
|
||||
<td v-if="supportsPermissions">
|
||||
<span v-if="ns.adminRoles.length" class="tag-list">
|
||||
<span v-for="role in ns.adminRoles" :key="role" class="tag">{{ role }}</span>
|
||||
</span>
|
||||
<span v-else class="text-muted">{{ t("mqNamespaces.none") }}</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button @click.stop="editNamespaceRoles(ns)" class="btn-sm">{{ t("mqNamespaces.editRoles") }}</button>
|
||||
<button v-if="supportsPermissions" @click.stop="editNamespaceRoles(ns)" class="btn-sm">{{ t("mqNamespaces.editRoles") }}</button>
|
||||
<button @click.stop="handleDelete(ns)" :disabled="readOnly" class="btn-sm btn-danger">{{ t("mqNamespaces.delete") }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -197,6 +210,9 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirm Dialog -->
|
||||
<DangerConfirmDialog v-model:open="showDeleteDialog" :title="t('mqNamespaces.delete')" :message="t('mqNamespaces.confirmDelete', { name: deleteTarget?.namespace ?? '' })" :confirm-label="t('mqNamespaces.delete')" :loading="deleting" :close-on-confirm="false" @confirm="confirmDelete" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ const topicRef = computed<TopicRef | null>(() => {
|
||||
if (!topic) return null;
|
||||
return {
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
namespace: topic.namespace || props.namespace,
|
||||
topic: topic.shortName,
|
||||
persistent: topic.persistent,
|
||||
partitioned: topic.partitioned,
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MqAdminConsole from '@/components/mq/MqAdminConsole.vue'
|
||||
import MqAdminConsole from "@/components/mq/MqAdminConsole.vue";
|
||||
|
||||
const connectionId = 'your-mq-connection-id'
|
||||
const connectionId = "your-mq-connection-id";
|
||||
</script>
|
||||
```
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MqSystemKind, PeekedMessage, TopicInfo, TopicRef, SendMessageReque
|
||||
import { mqSendMessage, mqListTopics, mqPeekMessages } from "@/lib/backend/api";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { parseNonNegativeSafeInteger } from "@/lib/mq/mqPeekFilters";
|
||||
import { resolveRabbitMqSendNamespace } from "@/lib/mq/mqConsoleDefaults";
|
||||
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
|
||||
|
||||
interface Props {
|
||||
@@ -26,6 +27,8 @@ const { t } = useI18n();
|
||||
const topicName = ref("");
|
||||
const messageKey = ref("");
|
||||
const messageTag = ref("");
|
||||
const exchangeName = ref("");
|
||||
const routingKey = ref("");
|
||||
const messageValue = ref("");
|
||||
const headersText = ref("");
|
||||
const loading = ref(false);
|
||||
@@ -47,6 +50,7 @@ let successTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const readOnlyMessage = computed(() => t("mqMessages.readOnlyCannotSend"));
|
||||
const isRocketMqCluster = computed(() => props.mqSystemKind === "rocketmq");
|
||||
const isRabbitMqCluster = computed(() => props.mqSystemKind === "rabbitmq");
|
||||
|
||||
const topicOptions = computed(() => {
|
||||
return availableTopics.value.map((t) => ({
|
||||
@@ -62,7 +66,9 @@ const selectedTopicRef = computed<TopicRef | null>(() => {
|
||||
const selected = availableTopics.value.find((item) => item.shortName === topic);
|
||||
return {
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
// Cross-vhost listings tag each row with its own vhost; row-level
|
||||
// operations (e.g. peek) must target that vhost, not the "*" selection.
|
||||
namespace: selected?.namespace || props.topic?.namespace || props.namespace,
|
||||
topic,
|
||||
persistent: selected?.persistent ?? true,
|
||||
partitioned: selected?.partitioned,
|
||||
@@ -172,6 +178,23 @@ async function sendMessage() {
|
||||
payloadText: messageValue.value,
|
||||
headers,
|
||||
};
|
||||
// RabbitMQ: publish through a specific exchange only when one is given;
|
||||
// an empty exchange keeps the default-exchange behavior. The vhost is
|
||||
// resolved for every publish: a topic picked from the datalist keeps the
|
||||
// vhost of that row (cross-vhost listings), then the selected topic prop,
|
||||
// then the current selection; in all-vhosts mode without a row topic the
|
||||
// publish falls back to the connection default vhost (no explicit
|
||||
// namespace).
|
||||
const exchange = exchangeName.value.trim();
|
||||
if (isRabbitMqCluster.value) {
|
||||
if (exchange) {
|
||||
req.exchange = exchange;
|
||||
req.routingKey = routingKey.value.trim() || undefined;
|
||||
}
|
||||
const datalistTopic = availableTopics.value.find((item) => item.shortName === topic);
|
||||
const sendNamespace = resolveRabbitMqSendNamespace(datalistTopic, props.namespace, props.topic);
|
||||
if (sendNamespace) req.namespace = sendNamespace;
|
||||
}
|
||||
success.value = await mqSendMessage(props.connectionId, req);
|
||||
if (canBrowseMessages.value) {
|
||||
peekPartition.value = String(success.value.partition);
|
||||
@@ -249,6 +272,8 @@ function formatJson() {
|
||||
function clearForm() {
|
||||
topicName.value = "";
|
||||
messageKey.value = "";
|
||||
exchangeName.value = "";
|
||||
routingKey.value = "";
|
||||
messageValue.value = "";
|
||||
headersText.value = "";
|
||||
error.value = undefined;
|
||||
@@ -327,6 +352,18 @@ watch(
|
||||
<input v-model="messageTag" type="text" :placeholder="t('mqMessages.optional')" :disabled="readOnly" />
|
||||
</div>
|
||||
|
||||
<template v-if="isRabbitMqCluster">
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqMessages.exchange") }}</label>
|
||||
<input v-model="exchangeName" type="text" :placeholder="t('mqMessages.exchangePlaceholder')" :disabled="readOnly" />
|
||||
<div class="form-hint">{{ t("mqMessages.exchangeHint") }}</div>
|
||||
</div>
|
||||
<div v-if="exchangeName.trim()" class="form-group">
|
||||
<label>{{ t("mqMessages.routingKey") }}</label>
|
||||
<input v-model="routingKey" type="text" :placeholder="t('mqMessages.optional')" :disabled="readOnly" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { TopicRef, TopicInfo, SubscriptionInfo, ResetPosition, SkipCount, P
|
||||
import { mqListSubscriptions, mqCreateSubscription, mqDeleteSubscription, mqResetCursor, mqSkipMessages, mqClearBacklog, mqPeekMessages, mqExpireMessages } from "@/lib/backend/api";
|
||||
import RocketMqConsumerGroupDialogs, { type RocketMqConsumerGroupDialogKind } from "./rocketmq/RocketMqConsumerGroupDialogs.vue";
|
||||
import MqTypeFilterBar from "./shared/MqTypeFilterBar.vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import { DEFAULT_ROCKETMQ_CONSUMER_GROUP_TYPE_FILTERS, matchesRocketMqConsumerGroupTypeFilters, resolveRocketMqConsumerGroupMessageModel, resolveRocketMqConsumerGroupType, ROCKETMQ_CONSUMER_GROUP_TYPES, type RocketMqConsumerGroupType } from "@/lib/mq/rocketmqConsumerGroupTypes";
|
||||
|
||||
interface Props {
|
||||
@@ -43,6 +44,12 @@ const selectedSub = ref<SubscriptionInfo>();
|
||||
const peekedMessages = ref<PeekedMessage[]>([]);
|
||||
const peekLoading = ref(false);
|
||||
const peekCount = ref(5);
|
||||
const deleteTarget = ref<SubscriptionInfo>();
|
||||
const showDeleteDialog = ref(false);
|
||||
const deleting = ref(false);
|
||||
const clearBacklogTarget = ref<SubscriptionInfo>();
|
||||
const showClearBacklogDialog = ref(false);
|
||||
const clearingBacklog = ref(false);
|
||||
|
||||
const formData = ref({
|
||||
subName: "",
|
||||
@@ -122,7 +129,7 @@ function getListTopicRef(): TopicRef | null {
|
||||
if (!props.topic) return null;
|
||||
return {
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
namespace: props.topic.namespace || props.namespace,
|
||||
topic: props.topic.shortName,
|
||||
persistent: props.topic.persistent,
|
||||
partitioned: props.topic.partitioned,
|
||||
@@ -133,7 +140,7 @@ function getPulsarTopicRef(): TopicRef | null {
|
||||
if (!props.tenant || !props.namespace || !props.topic) return null;
|
||||
return {
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
namespace: props.topic.namespace || props.namespace,
|
||||
topic: props.topic.shortName,
|
||||
persistent: props.topic.persistent,
|
||||
partitioned: props.topic.partitioned,
|
||||
@@ -246,20 +253,27 @@ async function handleCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(sub: SubscriptionInfo) {
|
||||
function handleDelete(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (!confirm(t("mqSubscriptions.confirmDelete", { name: sub.name }))) return;
|
||||
deleteTarget.value = sub;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const sub = deleteTarget.value;
|
||||
if (!sub) return;
|
||||
const topicRef = getListTopicRef();
|
||||
if (!topicRef) return;
|
||||
loading.value = true;
|
||||
deleting.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
await mqDeleteSubscription(props.connectionId, topicRef, sub.name, false);
|
||||
showDeleteDialog.value = false;
|
||||
await loadSubscriptions();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,20 +318,27 @@ async function handleSkipMessages() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearBacklog(sub: SubscriptionInfo) {
|
||||
function handleClearBacklog(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (!confirm(t("mqSubscriptions.confirmClearBacklog", { name: sub.name }))) return;
|
||||
clearBacklogTarget.value = sub;
|
||||
showClearBacklogDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmClearBacklog() {
|
||||
const sub = clearBacklogTarget.value;
|
||||
if (!sub) return;
|
||||
const topicRef = getPulsarTopicRef();
|
||||
if (!topicRef) return;
|
||||
loading.value = true;
|
||||
clearingBacklog.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
await mqClearBacklog(props.connectionId, topicRef, sub.name);
|
||||
showClearBacklogDialog.value = false;
|
||||
await loadSubscriptions();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
clearingBacklog.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,6 +649,19 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete Confirm Dialog -->
|
||||
<DangerConfirmDialog v-model:open="showDeleteDialog" :title="t('mqSubscriptions.delete')" :message="t('mqSubscriptions.confirmDelete', { name: deleteTarget?.name ?? '' })" :confirm-label="t('mqSubscriptions.delete')" :loading="deleting" :close-on-confirm="false" @confirm="confirmDelete" />
|
||||
|
||||
<!-- Clear Backlog Confirm Dialog -->
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showClearBacklogDialog"
|
||||
:title="t('mqSubscriptions.clearBacklog')"
|
||||
:message="t('mqSubscriptions.confirmClearBacklog', { name: clearBacklogTarget?.name ?? '' })"
|
||||
:confirm-label="t('mqSubscriptions.clearBacklog')"
|
||||
:loading="clearingBacklog"
|
||||
:close-on-confirm="false"
|
||||
@confirm="confirmClearBacklog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,10 +6,13 @@ import { mqListTopics, mqCreateTopic, mqDeleteTopic, mqUpdatePartitions, mqGetCl
|
||||
import type { ClusterInfo } from "@/types/mq";
|
||||
import RocketMqTopicDialogs, { type RocketMqTopicDialogKind } from "./rocketmq/RocketMqTopicDialogs.vue";
|
||||
import SendMessagePanel from "./SendMessagePanel.vue";
|
||||
import ExchangesPanel from "./ExchangesPanel.vue";
|
||||
import MqTypeFilterBar from "./shared/MqTypeFilterBar.vue";
|
||||
import type { MqTab } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { DEFAULT_ROCKETMQ_TOPIC_TYPE_FILTERS, isProtectedRocketMqTopic, isRocketMqBusinessMessageType, matchesRocketMqTypeFilters, resolveRocketMqMessageType, ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES, ROCKETMQ_TOPIC_MESSAGE_TYPES } from "@/lib/mq/rocketmqTopicTypes";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
@@ -19,6 +22,7 @@ interface Props {
|
||||
supportsPartitionedTopics?: boolean;
|
||||
isFlatMqCluster?: boolean;
|
||||
mqSystemKind?: MqSystemKind;
|
||||
supportsExchanges?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -38,6 +42,9 @@ const showPartitionsDialog = ref(false);
|
||||
const selectedTopic = ref<TopicInfo>();
|
||||
const editingTopic = ref<TopicInfo>();
|
||||
const topicSearch = ref("");
|
||||
const deleteTarget = ref<TopicInfo>();
|
||||
const showDeleteDialog = ref(false);
|
||||
const deleting = ref(false);
|
||||
|
||||
const clusterInfo = ref<ClusterInfo>();
|
||||
const activeRocketMqDialog = ref<RocketMqTopicDialogKind | null>(null);
|
||||
@@ -73,6 +80,12 @@ const messageTypeFilters = ref<Record<RocketMqTopicMessageType, boolean>>({
|
||||
|
||||
const isRocketMqCluster = computed(() => props.mqSystemKind === "rocketmq");
|
||||
const isKafkaCluster = computed(() => props.mqSystemKind === "kafka");
|
||||
const isRabbitMqCluster = computed(() => props.mqSystemKind === "rabbitmq");
|
||||
// RabbitMQ "all vhosts" mode: rows carry their own vhost in `namespace`.
|
||||
const showNamespaceColumn = computed(() => isRabbitMqCluster.value && isAllVhostsNamespace(props.namespace));
|
||||
// RabbitMQ splits the topics tab into Queues (topics table) and Exchanges views.
|
||||
const showRabbitMqSubTabs = computed(() => isRabbitMqCluster.value && props.supportsExchanges === true);
|
||||
const rabbitMqSubTab = ref<"queues" | "exchanges">("queues");
|
||||
const rocketMqTopicTypeOptions = ROCKETMQ_TOPIC_MESSAGE_TYPES;
|
||||
const rocketMqCreatableTopicTypes = ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES;
|
||||
const rocketMqClusterName = computed(() => clusterInfo.value?.clusterId ?? "-");
|
||||
@@ -294,28 +307,40 @@ async function handleCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(topic: TopicInfo) {
|
||||
function handleDelete(topic: TopicInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (!confirm(t("mqTopics.confirmDelete", { name: topic.shortName }))) return;
|
||||
if (!props.tenant || !props.namespace) return;
|
||||
loading.value = true;
|
||||
deleteTarget.value = topic;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const topic = deleteTarget.value;
|
||||
if (!topic || !props.tenant || !props.namespace) return;
|
||||
const namespace = resolveMqRowNamespace(topic, props.namespace);
|
||||
if (!namespace) {
|
||||
error.value = t("mqAdmin.selectNamespaceToWrite");
|
||||
showDeleteDialog.value = false;
|
||||
return;
|
||||
}
|
||||
deleting.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
const topicRef: TopicRef = {
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
namespace,
|
||||
topic: topic.shortName,
|
||||
persistent: topic.persistent,
|
||||
};
|
||||
await mqDeleteTopic(props.connectionId, topicRef, false);
|
||||
if (selectedTopic.value?.name === topic.name) {
|
||||
if (selectedTopic.value?.name === topic.name && selectedTopic.value?.namespace === topic.namespace) {
|
||||
selectedTopic.value = undefined;
|
||||
}
|
||||
showDeleteDialog.value = false;
|
||||
await loadTopics();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,233 +420,249 @@ watch(newPartitions, () => {
|
||||
|
||||
<template>
|
||||
<div class="topics-panel">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<h3>{{ t("mqTopics.title") }}</h3>
|
||||
<input v-model="topicSearch" type="search" class="topic-search" :placeholder="t('mqTopics.searchPlaceholder')" :disabled="loading && !topics.length" />
|
||||
<span v-if="topics.length" class="topic-count"> {{ filteredTopics.length }} / {{ typeFilteredTopics.length }} </span>
|
||||
<label v-if="isKafkaCluster" class="checkbox-label">
|
||||
<input v-model="includeSystemTopics" type="checkbox" />
|
||||
{{ t("mqTopics.includeSystemTopics") }}
|
||||
</label>
|
||||
<label v-else-if="!isRocketMqCluster" class="checkbox-label">
|
||||
<input v-model="includeNonPersistent" type="checkbox" />
|
||||
{{ t("mqTopics.includeNonPersistent") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button @click="loadTopics" :disabled="loading || !tenant || !namespace" class="btn-secondary">
|
||||
{{ loading ? t("mqTopics.refreshing") : t("mqTopics.refresh") }}
|
||||
</button>
|
||||
<button @click="openCreateDialog" :disabled="loading || readOnly || !tenant || !namespace" class="btn-primary">+ {{ t("mqTopics.createTopic") }}</button>
|
||||
</div>
|
||||
<div v-if="showRabbitMqSubTabs" class="rabbitmq-subtabs">
|
||||
<button :class="{ active: rabbitMqSubTab === 'queues' }" @click="rabbitMqSubTab = 'queues'">{{ t("mqTopics.tabQueues") }}</button>
|
||||
<button :class="{ active: rabbitMqSubTab === 'exchanges' }" @click="rabbitMqSubTab = 'exchanges'">{{ t("mqTopics.tabExchanges") }}</button>
|
||||
</div>
|
||||
|
||||
<MqTypeFilterBar v-if="isRocketMqCluster && tenant && namespace" :label="t('mqTopics.typeFilter')">
|
||||
<label v-for="type in rocketMqTopicTypeOptions" :key="type" class="checkbox-label compact">
|
||||
<input v-model="messageTypeFilters[type]" type="checkbox" />
|
||||
{{ t(`mqTopics.rocketmqType.${type.toLowerCase()}`) }}
|
||||
</label>
|
||||
</MqTypeFilterBar>
|
||||
<ExchangesPanel v-if="showRabbitMqSubTabs && rabbitMqSubTab === 'exchanges'" :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :read-only="readOnly" />
|
||||
|
||||
<div v-if="!tenant || !namespace" class="panel-placeholder">{{ t("mqTopics.selectTenantNamespace") }}</div>
|
||||
|
||||
<div v-else-if="error" class="panel-error">{{ error }}</div>
|
||||
|
||||
<div v-else-if="loading && !topics.length" class="panel-loading">{{ t("mqTopics.loading") }}</div>
|
||||
|
||||
<div v-else-if="!topics.length" class="panel-placeholder">{{ t("mqTopics.noTopics") }}</div>
|
||||
|
||||
<div v-else-if="!filteredTopics.length" class="panel-placeholder">
|
||||
{{ isRocketMqCluster && userTopicCount === 0 ? t("mqTopics.noUserTopics") : isKafkaCluster && !includeSystemTopics && userTopicCount === 0 ? t("mqTopics.noUserTopics") : t("mqTopics.noMatches") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="topics-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("mqTopics.name") }}</th>
|
||||
<th>{{ t("mqTopics.type") }}</th>
|
||||
<th v-if="!isRocketMqCluster">{{ t("mqTopics.partitions") }}</th>
|
||||
<th>{{ t("mqTopics.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="topic in filteredTopics" :key="topic.name" :class="{ selected: selectedTopic?.name === topic.name }" @click="selectTopic(topic)">
|
||||
<td class="topic-name">
|
||||
<div class="topic-name-cell">
|
||||
<span>{{ topic.shortName }}</span>
|
||||
<span v-if="!topic.persistent" class="badge badge-warning">{{ t("mqTopics.nonPersistent") }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="topicTypeBadgeClass(topic)">
|
||||
{{ topicTypeLabel(topic) }}
|
||||
</span>
|
||||
</td>
|
||||
<td v-if="!isRocketMqCluster">
|
||||
<span v-if="topic.partitioned">{{ topic.partitions ? t("mqTopics.partitionCount", { count: topic.partitions }) : t("mqTopics.partitionsUnknown") }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td class="actions" @click.stop>
|
||||
<template v-if="isRocketMqCluster">
|
||||
<button class="btn-sm" @click="openRocketMqDialog('status', topic)">{{ t("mqTopics.actionStatus") }}</button>
|
||||
<button class="btn-sm" @click="openRocketMqDialog('route', topic)">{{ t("mqTopics.actionRoute") }}</button>
|
||||
<button class="btn-sm" @click="openRocketMqDialog('consumers', topic)">{{ t("mqTopics.actionConsumers") }}</button>
|
||||
<button v-if="isDlqTopic(topic)" class="btn-sm" @click="navigateToMessageQuery(topic, true)">{{ t("mqRocketmq.viewDlqMessages") }}</button>
|
||||
<template v-else>
|
||||
<button class="btn-sm" @click="navigateToMessageQuery(topic)">{{ t("mqRocketmq.actionMessageQuery") }}</button>
|
||||
<button class="btn-sm" :disabled="readOnly" @click="navigateToMessages(topic)">{{ t("mqRocketmq.actionSendMessage") }}</button>
|
||||
</template>
|
||||
<button class="btn-sm" @click="openRocketMqDialog('config', topic)">{{ t("mqTopics.actionConfig") }}</button>
|
||||
<button class="btn-sm" :disabled="readOnly || isTopicProtected(topic)" @click="openRocketMqDialog('reset', topic)">{{ t("mqTopics.actionReset") }}</button>
|
||||
<button class="btn-sm" :disabled="readOnly || isTopicProtected(topic)" @click="openRocketMqDialog('skip', topic)">{{ t("mqTopics.actionSkip") }}</button>
|
||||
<button class="btn-sm btn-danger" :disabled="readOnly || isTopicProtected(topic)" @click="handleDelete(topic)">{{ t("mqTopics.delete") }}</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button v-if="topic.partitioned && supportsPartitionedTopics !== false && !isTopicProtected(topic)" @click="openPartitionsDialog(topic)" :disabled="readOnly || !topic.partitions" class="btn-sm">
|
||||
{{ t("mqTopics.adjustPartitions") }}
|
||||
</button>
|
||||
<button @click="handleDelete(topic)" :disabled="readOnly || isTopicProtected(topic)" class="btn-sm btn-danger">{{ t("mqTopics.delete") }}</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Create Dialog -->
|
||||
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>{{ t("mqTopics.createTopic") }}</h3>
|
||||
<button @click="showCreateDialog = false" class="btn-close">×</button>
|
||||
<template v-else>
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<h3>{{ t("mqTopics.title") }}</h3>
|
||||
<input v-model="topicSearch" type="search" class="topic-search" :placeholder="t('mqTopics.searchPlaceholder')" :disabled="loading && !topics.length" />
|
||||
<span v-if="topics.length" class="topic-count"> {{ filteredTopics.length }} / {{ typeFilteredTopics.length }} </span>
|
||||
<label v-if="isKafkaCluster" class="checkbox-label">
|
||||
<input v-model="includeSystemTopics" type="checkbox" />
|
||||
{{ t("mqTopics.includeSystemTopics") }}
|
||||
</label>
|
||||
<label v-else-if="!isRocketMqCluster" class="checkbox-label">
|
||||
<input v-model="includeNonPersistent" type="checkbox" />
|
||||
{{ t("mqTopics.includeNonPersistent") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div v-if="!isRocketMqCluster && !isFlatMqCluster" class="form-group">
|
||||
<label>{{ t("mqTopics.tenantNamespace") }}</label>
|
||||
<input type="text" :value="`${tenant} / ${namespace}`" disabled />
|
||||
<div class="toolbar-actions">
|
||||
<button @click="loadTopics" :disabled="loading || !tenant || !namespace" class="btn-secondary">
|
||||
{{ loading ? t("mqTopics.refreshing") : t("mqTopics.refresh") }}
|
||||
</button>
|
||||
<button @click="openCreateDialog" :disabled="loading || readOnly || !tenant || !namespace || showNamespaceColumn" :title="showNamespaceColumn ? t('mqAdmin.selectNamespaceToCreate') : undefined" class="btn-primary">+ {{ t("mqTopics.createTopic") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MqTypeFilterBar v-if="isRocketMqCluster && tenant && namespace" :label="t('mqTopics.typeFilter')">
|
||||
<label v-for="type in rocketMqTopicTypeOptions" :key="type" class="checkbox-label compact">
|
||||
<input v-model="messageTypeFilters[type]" type="checkbox" />
|
||||
{{ t(`mqTopics.rocketmqType.${type.toLowerCase()}`) }}
|
||||
</label>
|
||||
</MqTypeFilterBar>
|
||||
|
||||
<div v-if="!tenant || !namespace" class="panel-placeholder">{{ t("mqTopics.selectTenantNamespace") }}</div>
|
||||
|
||||
<div v-else-if="error" class="panel-error">{{ error }}</div>
|
||||
|
||||
<div v-else-if="loading && !topics.length" class="panel-loading">{{ t("mqTopics.loading") }}</div>
|
||||
|
||||
<div v-else-if="!topics.length" class="panel-placeholder">{{ t("mqTopics.noTopics") }}</div>
|
||||
|
||||
<div v-else-if="!filteredTopics.length" class="panel-placeholder">
|
||||
{{ isRocketMqCluster && userTopicCount === 0 ? t("mqTopics.noUserTopics") : isKafkaCluster && !includeSystemTopics && userTopicCount === 0 ? t("mqTopics.noUserTopics") : t("mqTopics.noMatches") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="topics-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("mqTopics.name") }}</th>
|
||||
<th v-if="showNamespaceColumn">{{ t("mqAdmin.namespace") }}</th>
|
||||
<th>{{ t("mqTopics.type") }}</th>
|
||||
<th v-if="!isRocketMqCluster">{{ t("mqTopics.partitions") }}</th>
|
||||
<th>{{ t("mqTopics.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="topic in filteredTopics" :key="showNamespaceColumn ? `${topic.namespace ?? ''}:${topic.name}` : topic.name" :class="{ selected: selectedTopic?.name === topic.name && selectedTopic?.namespace === topic.namespace }" @click="selectTopic(topic)">
|
||||
<td class="topic-name">
|
||||
<div class="topic-name-cell">
|
||||
<span>{{ topic.shortName }}</span>
|
||||
<span v-if="!topic.persistent" class="badge badge-warning">{{ t("mqTopics.nonPersistent") }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="showNamespaceColumn">{{ topic.namespace || "-" }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="topicTypeBadgeClass(topic)">
|
||||
{{ topicTypeLabel(topic) }}
|
||||
</span>
|
||||
</td>
|
||||
<td v-if="!isRocketMqCluster">
|
||||
<span v-if="topic.partitioned">{{ topic.partitions ? t("mqTopics.partitionCount", { count: topic.partitions }) : t("mqTopics.partitionsUnknown") }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td class="actions" @click.stop>
|
||||
<template v-if="isRocketMqCluster">
|
||||
<button class="btn-sm" @click="openRocketMqDialog('status', topic)">{{ t("mqTopics.actionStatus") }}</button>
|
||||
<button class="btn-sm" @click="openRocketMqDialog('route', topic)">{{ t("mqTopics.actionRoute") }}</button>
|
||||
<button class="btn-sm" @click="openRocketMqDialog('consumers', topic)">{{ t("mqTopics.actionConsumers") }}</button>
|
||||
<button v-if="isDlqTopic(topic)" class="btn-sm" @click="navigateToMessageQuery(topic, true)">{{ t("mqRocketmq.viewDlqMessages") }}</button>
|
||||
<template v-else>
|
||||
<button class="btn-sm" @click="navigateToMessageQuery(topic)">{{ t("mqRocketmq.actionMessageQuery") }}</button>
|
||||
<button class="btn-sm" :disabled="readOnly" @click="navigateToMessages(topic)">{{ t("mqRocketmq.actionSendMessage") }}</button>
|
||||
</template>
|
||||
<button class="btn-sm" @click="openRocketMqDialog('config', topic)">{{ t("mqTopics.actionConfig") }}</button>
|
||||
<button class="btn-sm" :disabled="readOnly || isTopicProtected(topic)" @click="openRocketMqDialog('reset', topic)">{{ t("mqTopics.actionReset") }}</button>
|
||||
<button class="btn-sm" :disabled="readOnly || isTopicProtected(topic)" @click="openRocketMqDialog('skip', topic)">{{ t("mqTopics.actionSkip") }}</button>
|
||||
<button class="btn-sm btn-danger" :disabled="readOnly || isTopicProtected(topic)" @click="handleDelete(topic)">{{ t("mqTopics.delete") }}</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button v-if="topic.partitioned && supportsPartitionedTopics !== false && !isTopicProtected(topic)" @click="openPartitionsDialog(topic)" :disabled="readOnly || !topic.partitions" class="btn-sm">
|
||||
{{ t("mqTopics.adjustPartitions") }}
|
||||
</button>
|
||||
<button @click="handleDelete(topic)" :disabled="readOnly || isTopicProtected(topic) || (showNamespaceColumn && !topic.namespace)" :title="showNamespaceColumn && !topic.namespace ? t('mqAdmin.selectNamespaceToWrite') : undefined" class="btn-sm btn-danger">
|
||||
{{ t("mqTopics.delete") }}
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Create Dialog -->
|
||||
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>{{ t("mqTopics.createTopic") }}</h3>
|
||||
<button @click="showCreateDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div v-if="isRocketMqCluster" class="form-group">
|
||||
<label>{{ t("mqTopics.clusterName") }}</label>
|
||||
<input type="text" :value="rocketMqClusterName" disabled />
|
||||
</div>
|
||||
<div v-if="isRocketMqCluster" class="form-group">
|
||||
<label>{{ t("mqTopics.brokerName") }}*</label>
|
||||
<select v-model="formData.brokerName" :disabled="readOnly">
|
||||
<option value="">{{ t("mqTopics.allBrokers") }}</option>
|
||||
<option v-for="broker in rocketMqMasterBrokers.length ? rocketMqMasterBrokers : rocketMqBrokerOptions" :key="broker.brokerName || broker.id" :value="broker.brokerName || ''">
|
||||
{{ broker.brokerName || `${broker.host}:${broker.port}` }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqTopics.topicName") }}*</label>
|
||||
<input v-model="formData.topicName" type="text" :placeholder="t('mqTopics.topicNamePlaceholder')" :disabled="readOnly" />
|
||||
</div>
|
||||
<div v-if="isRocketMqCluster" class="form-group">
|
||||
<label>{{ t("mqTopics.messageType") }}*</label>
|
||||
<select v-model="formData.messageType" :disabled="readOnly">
|
||||
<option v-for="type in rocketMqCreatableTopicTypes" :key="type" :value="type">
|
||||
{{ t(`mqTopics.rocketmqType.${type.toLowerCase()}`) }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="form-hint">{{ t("mqTopics.messageTypeHint") }}</div>
|
||||
</div>
|
||||
<div v-if="isRocketMqCluster" class="form-row-inline">
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqTopics.readQueues") }}*</label>
|
||||
<input v-model.number="formData.readQueueNums" type="number" min="1" max="256" :disabled="readOnly" />
|
||||
<div class="dialog-body">
|
||||
<div v-if="!isRocketMqCluster && !isFlatMqCluster" class="form-group">
|
||||
<label>{{ t("mqTopics.tenantNamespace") }}</label>
|
||||
<input type="text" :value="`${tenant} / ${namespace}`" disabled />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqTopics.writeQueues") }}*</label>
|
||||
<input v-model.number="formData.writeQueueNums" type="number" min="1" max="256" :disabled="readOnly" />
|
||||
<div v-if="isRocketMqCluster" class="form-group">
|
||||
<label>{{ t("mqTopics.clusterName") }}</label>
|
||||
<input type="text" :value="rocketMqClusterName" disabled />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqTopics.perm") }}*</label>
|
||||
<select v-model.number="formData.perm" :disabled="readOnly">
|
||||
<option v-for="opt in ROCKETMQ_PERM_OPTIONS" :key="opt.value" :value="opt.value">{{ t(opt.labelKey) }}</option>
|
||||
<div v-if="isRocketMqCluster" class="form-group">
|
||||
<label>{{ t("mqTopics.brokerName") }}*</label>
|
||||
<select v-model="formData.brokerName" :disabled="readOnly">
|
||||
<option value="">{{ t("mqTopics.allBrokers") }}</option>
|
||||
<option v-for="broker in rocketMqMasterBrokers.length ? rocketMqMasterBrokers : rocketMqBrokerOptions" :key="broker.brokerName || broker.id" :value="broker.brokerName || ''">
|
||||
{{ broker.brokerName || `${broker.host}:${broker.port}` }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isRocketMqCluster" class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="formData.persistent" :disabled="readOnly" />
|
||||
{{ t("mqTopics.persistentRecommended") }}
|
||||
</label>
|
||||
<div class="form-hint">{{ t("mqTopics.persistentHint") }}</div>
|
||||
</div>
|
||||
<div v-if="!isRocketMqCluster && supportsPartitionedTopics !== false" class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="formData.partitioned" :disabled="readOnly" />
|
||||
{{ t("mqTopics.enablePartitions") }}
|
||||
</label>
|
||||
<div v-if="formData.partitioned" class="form-subgroup">
|
||||
<label>{{ t("mqTopics.partitionQuantity") }}*</label>
|
||||
<input v-model.number="formData.partitions" type="number" min="1" max="256" :disabled="readOnly" />
|
||||
<div class="form-hint">{{ t("mqTopics.partitionHint") }}</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqTopics.topicName") }}*</label>
|
||||
<input v-model="formData.topicName" type="text" :placeholder="t('mqTopics.topicNamePlaceholder')" :disabled="readOnly" />
|
||||
</div>
|
||||
<div v-if="isRocketMqCluster" class="form-group">
|
||||
<label>{{ t("mqTopics.messageType") }}*</label>
|
||||
<select v-model="formData.messageType" :disabled="readOnly">
|
||||
<option v-for="type in rocketMqCreatableTopicTypes" :key="type" :value="type">
|
||||
{{ t(`mqTopics.rocketmqType.${type.toLowerCase()}`) }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="form-hint">{{ t("mqTopics.messageTypeHint") }}</div>
|
||||
</div>
|
||||
<div v-if="isRocketMqCluster" class="form-row-inline">
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqTopics.readQueues") }}*</label>
|
||||
<input v-model.number="formData.readQueueNums" type="number" min="1" max="256" :disabled="readOnly" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqTopics.writeQueues") }}*</label>
|
||||
<input v-model.number="formData.writeQueueNums" type="number" min="1" max="256" :disabled="readOnly" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqTopics.perm") }}*</label>
|
||||
<select v-model.number="formData.perm" :disabled="readOnly">
|
||||
<option v-for="opt in ROCKETMQ_PERM_OPTIONS" :key="opt.value" :value="opt.value">{{ t(opt.labelKey) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isRocketMqCluster" class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="formData.persistent" :disabled="readOnly" />
|
||||
{{ t("mqTopics.persistentRecommended") }}
|
||||
</label>
|
||||
<div class="form-hint">{{ t("mqTopics.persistentHint") }}</div>
|
||||
</div>
|
||||
<div v-if="!isRocketMqCluster && supportsPartitionedTopics !== false" class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="formData.partitioned" :disabled="readOnly" />
|
||||
{{ t("mqTopics.enablePartitions") }}
|
||||
</label>
|
||||
<div v-if="formData.partitioned" class="form-subgroup">
|
||||
<label>{{ t("mqTopics.partitionQuantity") }}*</label>
|
||||
<input v-model.number="formData.partitions" type="number" min="1" max="256" :disabled="readOnly" />
|
||||
<div class="form-hint">{{ t("mqTopics.partitionHint") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showCreateDialog = false" class="btn-secondary">{{ t("mqTopics.cancel") }}</button>
|
||||
<button @click="handleCreate" :disabled="loading || readOnly" class="btn-primary">{{ t("mqTopics.create") }}</button>
|
||||
</div>
|
||||
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showCreateDialog = false" class="btn-secondary">{{ t("mqTopics.cancel") }}</button>
|
||||
<button @click="handleCreate" :disabled="loading || readOnly" class="btn-primary">{{ t("mqTopics.create") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update Partitions Dialog -->
|
||||
<div v-if="showPartitionsDialog" class="dialog-overlay" @click="showPartitionsDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>{{ t("mqTopics.updatePartitionsTitle", { name: editingTopic?.shortName }) }}</h3>
|
||||
<button @click="showPartitionsDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqTopics.currentPartitions") }}</label>
|
||||
<input type="number" :value="editingTopic?.partitions" disabled />
|
||||
<!-- Update Partitions Dialog -->
|
||||
<div v-if="showPartitionsDialog" class="dialog-overlay" @click="showPartitionsDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>{{ t("mqTopics.updatePartitionsTitle", { name: editingTopic?.shortName }) }}</h3>
|
||||
<button @click="showPartitionsDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqTopics.newPartitions") }}*</label>
|
||||
<input v-model.number="newPartitions" type="number" :min="editingCurrentPartitions + 1" max="256" :disabled="readOnly" @change="normalizePartitionInput" @blur="normalizePartitionInput" />
|
||||
<div class="form-hint">{{ t("mqTopics.partitionMinHint", { min: editingCurrentPartitions + 1 }) }}</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqTopics.currentPartitions") }}</label>
|
||||
<input type="number" :value="editingTopic?.partitions" disabled />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqTopics.newPartitions") }}*</label>
|
||||
<input v-model.number="newPartitions" type="number" :min="editingCurrentPartitions + 1" max="256" :disabled="readOnly" @change="normalizePartitionInput" @blur="normalizePartitionInput" />
|
||||
<div class="form-hint">{{ t("mqTopics.partitionMinHint", { min: editingCurrentPartitions + 1 }) }}</div>
|
||||
</div>
|
||||
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showPartitionsDialog = false" class="btn-secondary">{{ t("mqTopics.cancel") }}</button>
|
||||
<button @click="handleUpdatePartitions" :disabled="loading || !canSubmitPartitionUpdate" class="btn-primary">{{ t("mqTopics.update") }}</button>
|
||||
</div>
|
||||
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showPartitionsDialog = false" class="btn-secondary">{{ t("mqTopics.cancel") }}</button>
|
||||
<button @click="handleUpdatePartitions" :disabled="loading || !canSubmitPartitionUpdate" class="btn-primary">{{ t("mqTopics.update") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RocketMqTopicDialogs
|
||||
v-if="isRocketMqCluster"
|
||||
:connection-id="connectionId"
|
||||
:tenant="tenant"
|
||||
:namespace="namespace"
|
||||
:topic="rocketMqDialogTopic"
|
||||
:dialog="activeRocketMqDialog"
|
||||
:read-only="readOnly"
|
||||
:broker-options="rocketMqBrokerOptions"
|
||||
@close="closeRocketMqDialog"
|
||||
@navigate="handleRocketMqNavigate"
|
||||
@refreshed="loadTopics"
|
||||
/>
|
||||
<!-- Delete Confirm Dialog -->
|
||||
<DangerConfirmDialog v-model:open="showDeleteDialog" :title="t('mqTopics.delete')" :message="t('mqTopics.confirmDelete', { name: deleteTarget?.shortName ?? '' })" :confirm-label="t('mqTopics.delete')" :loading="deleting" :close-on-confirm="false" @confirm="confirmDelete" />
|
||||
|
||||
<div v-if="showSendDialog && sendDialogTopic" class="dialog-overlay" @click="closeSendDialog">
|
||||
<div class="dialog send-dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>{{ t("mqMessages.title") }}</h3>
|
||||
<button type="button" class="btn-close" @click="closeSendDialog">×</button>
|
||||
</div>
|
||||
<div class="dialog-body send-dialog-body">
|
||||
<SendMessagePanel embedded :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :topic="sendDialogTopic" :read-only="readOnly" mq-system-kind="rocketmq" :is-flat-mq-cluster="true" :supports-peek-messages="false" />
|
||||
<RocketMqTopicDialogs
|
||||
v-if="isRocketMqCluster"
|
||||
:connection-id="connectionId"
|
||||
:tenant="tenant"
|
||||
:namespace="namespace"
|
||||
:topic="rocketMqDialogTopic"
|
||||
:dialog="activeRocketMqDialog"
|
||||
:read-only="readOnly"
|
||||
:broker-options="rocketMqBrokerOptions"
|
||||
@close="closeRocketMqDialog"
|
||||
@navigate="handleRocketMqNavigate"
|
||||
@refreshed="loadTopics"
|
||||
/>
|
||||
|
||||
<div v-if="showSendDialog && sendDialogTopic" class="dialog-overlay" @click="closeSendDialog">
|
||||
<div class="dialog send-dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>{{ t("mqMessages.title") }}</h3>
|
||||
<button type="button" class="btn-close" @click="closeSendDialog">×</button>
|
||||
</div>
|
||||
<div class="dialog-body send-dialog-body">
|
||||
<SendMessagePanel embedded :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :topic="sendDialogTopic" :read-only="readOnly" mq-system-kind="rocketmq" :is-flat-mq-cluster="true" :supports-peek-messages="false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -636,6 +677,37 @@ watch(newPartitions, () => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rabbitmq-subtabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 16px 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.rabbitmq-subtabs button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.rabbitmq-subtabs button:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.rabbitmq-subtabs button.active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { MqChannelInfo, MqClientConnectionInfo, NamespaceRef } from "@/types/mq";
|
||||
import { mqCloseClientConnection, mqListClientChannels, mqListClientConnections } from "@/lib/backend/api";
|
||||
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
/** RabbitMQ virtual host; undefined lists connections across all vhosts. */
|
||||
namespace?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const connections = ref<MqClientConnectionInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const connectionSearch = ref("");
|
||||
|
||||
const expandedNames = ref<Set<string>>(new Set());
|
||||
const channelsByConnection = ref<Record<string, MqChannelInfo[]>>({});
|
||||
const channelsLoading = ref<Record<string, boolean>>({});
|
||||
const channelsError = ref<Record<string, string | undefined>>({});
|
||||
|
||||
const showCloseDialog = ref(false);
|
||||
const closeTarget = ref<MqClientConnectionInfo>();
|
||||
const closing = ref(false);
|
||||
|
||||
const filteredConnections = computed(() => {
|
||||
const query = connectionSearch.value.trim().toLowerCase();
|
||||
if (!query) return connections.value;
|
||||
return connections.value.filter((connection) => {
|
||||
return connection.name.toLowerCase().includes(query) || connection.user.toLowerCase().includes(query) || connection.peerHost.toLowerCase().includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
// RabbitMQ "all vhosts" mode: rows carry their own vhost in `namespace`, and
|
||||
// row-level operations must target that vhost rather than the "*" selection.
|
||||
const showNamespaceColumn = computed(() => isAllVhostsNamespace(props.namespace));
|
||||
|
||||
// RabbitMQ namespace is the vhost; the tenant is always the synthetic one.
|
||||
function nsRef(namespace?: string): NamespaceRef {
|
||||
return { tenant: RABBITMQ_MQ_TENANT, namespace: namespace ?? "" };
|
||||
}
|
||||
|
||||
function guardWritable() {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqClientConnections.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadConnections() {
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
connections.value = await mqListClientConnections(props.connectionId, nsRef(props.namespace));
|
||||
// Drop cached channels for connections that went away.
|
||||
const liveNames = new Set(connections.value.map((connection) => connection.name));
|
||||
expandedNames.value = new Set([...expandedNames.value].filter((name) => liveNames.has(name)));
|
||||
for (const name of Object.keys(channelsByConnection.value)) {
|
||||
if (!liveNames.has(name)) {
|
||||
delete channelsByConnection.value[name];
|
||||
delete channelsLoading.value[name];
|
||||
delete channelsError.value[name];
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChannels(connection: MqClientConnectionInfo) {
|
||||
const connectionName = connection.name;
|
||||
const namespace = resolveMqRowNamespace(connection, props.namespace);
|
||||
if (!namespace) {
|
||||
channelsError.value = { ...channelsError.value, [connectionName]: t("mqAdmin.selectNamespaceToWrite") };
|
||||
return;
|
||||
}
|
||||
channelsLoading.value = { ...channelsLoading.value, [connectionName]: true };
|
||||
channelsError.value = { ...channelsError.value, [connectionName]: undefined };
|
||||
try {
|
||||
const channels = await mqListClientChannels(props.connectionId, nsRef(namespace), connectionName);
|
||||
channelsByConnection.value = { ...channelsByConnection.value, [connectionName]: channels };
|
||||
} catch (e: unknown) {
|
||||
channelsError.value = { ...channelsError.value, [connectionName]: formatError(e) };
|
||||
} finally {
|
||||
channelsLoading.value = { ...channelsLoading.value, [connectionName]: false };
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpanded(connection: MqClientConnectionInfo) {
|
||||
const next = new Set(expandedNames.value);
|
||||
if (next.has(connection.name)) {
|
||||
next.delete(connection.name);
|
||||
expandedNames.value = next;
|
||||
return;
|
||||
}
|
||||
next.add(connection.name);
|
||||
expandedNames.value = next;
|
||||
if (!channelsByConnection.value[connection.name]) {
|
||||
void loadChannels(connection);
|
||||
}
|
||||
}
|
||||
|
||||
function openCloseDialog(connection: MqClientConnectionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
closeTarget.value = connection;
|
||||
showCloseDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmClose() {
|
||||
const target = closeTarget.value;
|
||||
if (!target) return;
|
||||
const namespace = resolveMqRowNamespace(target, props.namespace);
|
||||
if (!namespace) {
|
||||
error.value = t("mqAdmin.selectNamespaceToWrite");
|
||||
return;
|
||||
}
|
||||
closing.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
await mqCloseClientConnection(props.connectionId, nsRef(namespace), target.name);
|
||||
showCloseDialog.value = false;
|
||||
await loadConnections();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
closing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRate(value: number | undefined): string {
|
||||
if (value === undefined) return "-";
|
||||
if (!value) return "0 B/s";
|
||||
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
|
||||
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||
return `${(value / 1024 ** index).toFixed(2)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function formatConnectedAt(value: number | undefined): string {
|
||||
if (value === undefined) return "-";
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatOptionalNumber(value: number | undefined): string {
|
||||
return value === undefined ? "-" : String(value);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.namespace,
|
||||
() => {
|
||||
expandedNames.value = new Set();
|
||||
channelsByConnection.value = {};
|
||||
channelsLoading.value = {};
|
||||
channelsError.value = {};
|
||||
loadConnections();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="clients-panel">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<input v-model="connectionSearch" type="search" class="connection-search" :placeholder="t('mqClientConnections.searchPlaceholder')" :disabled="loading && !connections.length" />
|
||||
<span v-if="connections.length" class="connection-count">{{ filteredConnections.length }} / {{ connections.length }}</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button @click="loadConnections" :disabled="loading" class="btn-secondary">
|
||||
{{ loading ? t("mqClientConnections.refreshing") : t("mqClientConnections.refresh") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="panel-error">{{ error }}</div>
|
||||
|
||||
<div v-else-if="loading && !connections.length" class="panel-loading">{{ t("mqClientConnections.loading") }}</div>
|
||||
|
||||
<div v-else-if="!connections.length" class="panel-placeholder">{{ t("mqClientConnections.noConnections") }}</div>
|
||||
|
||||
<div v-else-if="!filteredConnections.length" class="panel-placeholder">{{ t("mqClientConnections.noMatches") }}</div>
|
||||
|
||||
<div v-else class="connections-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="expand-col"></th>
|
||||
<th>{{ t("mqClientConnections.name") }}</th>
|
||||
<th v-if="showNamespaceColumn">{{ t("mqAdmin.namespace") }}</th>
|
||||
<th>{{ t("mqClientConnections.user") }}</th>
|
||||
<th>{{ t("mqClientConnections.peerAddress") }}</th>
|
||||
<th>{{ t("mqClientConnections.state") }}</th>
|
||||
<th>{{ t("mqClientConnections.channels") }}</th>
|
||||
<th>{{ t("mqClientConnections.recvRate") }}</th>
|
||||
<th>{{ t("mqClientConnections.sendRate") }}</th>
|
||||
<th>{{ t("mqClientConnections.connectedAt") }}</th>
|
||||
<th>{{ t("mqClientConnections.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-for="connection in filteredConnections" :key="connection.name">
|
||||
<tr :class="{ expanded: expandedNames.has(connection.name) }" @click="toggleExpanded(connection)">
|
||||
<td class="expand-col">
|
||||
<span class="expand-icon">{{ expandedNames.has(connection.name) ? "▾" : "▸" }}</span>
|
||||
</td>
|
||||
<td class="connection-name" :title="connection.name">{{ connection.name }}</td>
|
||||
<td v-if="showNamespaceColumn">{{ connection.namespace || "-" }}</td>
|
||||
<td>{{ connection.user || "-" }}</td>
|
||||
<td>{{ connection.peerHost ? `${connection.peerHost}:${connection.peerPort}` : "-" }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="connection.state === 'running' ? 'badge-info' : 'badge-warning'">{{ connection.state || "-" }}</span>
|
||||
</td>
|
||||
<td>{{ connection.channels }}</td>
|
||||
<td>{{ formatRate(connection.recvRate) }}</td>
|
||||
<td>{{ formatRate(connection.sendRate) }}</td>
|
||||
<td>{{ formatConnectedAt(connection.connectedAt) }}</td>
|
||||
<td class="actions" @click.stop>
|
||||
<button class="btn-sm btn-danger" :disabled="readOnly || (showNamespaceColumn && !connection.namespace)" :title="showNamespaceColumn && !connection.namespace ? t('mqAdmin.selectNamespaceToWrite') : undefined" @click="openCloseDialog(connection)">
|
||||
{{ t("mqClientConnections.closeConnection") }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedNames.has(connection.name)" class="channels-row">
|
||||
<td :colspan="showNamespaceColumn ? 11 : 10">
|
||||
<div v-if="channelsError[connection.name]" class="panel-error">{{ channelsError[connection.name] }}</div>
|
||||
<div v-else-if="channelsLoading[connection.name] && !channelsByConnection[connection.name]?.length" class="panel-loading">{{ t("mqClientConnections.loading") }}</div>
|
||||
<div v-else-if="!channelsByConnection[connection.name]?.length" class="panel-placeholder">{{ t("mqClientConnections.noChannels") }}</div>
|
||||
<table v-else class="channels-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("mqClientConnections.channelName") }}</th>
|
||||
<th>{{ t("mqClientConnections.state") }}</th>
|
||||
<th>{{ t("mqClientConnections.prefetch") }}</th>
|
||||
<th>{{ t("mqClientConnections.unacked") }}</th>
|
||||
<th>{{ t("mqClientConnections.consumers") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="channel in channelsByConnection[connection.name]" :key="channel.name">
|
||||
<td class="connection-name" :title="channel.name">{{ channel.name }}</td>
|
||||
<td>{{ channel.state || "-" }}</td>
|
||||
<td>{{ formatOptionalNumber(channel.prefetch) }}</td>
|
||||
<td>{{ formatOptionalNumber(channel.messagesUnacked) }}</td>
|
||||
<td>{{ formatOptionalNumber(channel.consumerCount) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Close Connection Confirm -->
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showCloseDialog"
|
||||
:title="t('mqClientConnections.closeConnection')"
|
||||
:message="t('mqClientConnections.confirmClose', { name: closeTarget?.name ?? '' })"
|
||||
:confirm-label="t('mqClientConnections.closeConnection')"
|
||||
:loading="closing"
|
||||
:close-on-confirm="false"
|
||||
@confirm="confirmClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.clients-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.connection-search {
|
||||
width: min(320px, 32vw);
|
||||
min-width: 180px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.connection-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
.connection-count {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.panel-placeholder,
|
||||
.panel-error,
|
||||
.panel-loading {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.panel-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.connections-table {
|
||||
overflow: auto;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-background-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.connections-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.connections-table tbody tr:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.connections-table tbody tr.expanded {
|
||||
background: var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
.expand-col {
|
||||
width: 24px;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.connection-name {
|
||||
font-weight: 500;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channels-row {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.channels-row:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.channels-row td {
|
||||
background: var(--color-background-secondary);
|
||||
padding: 8px 12px 12px 36px;
|
||||
}
|
||||
|
||||
.channels-table {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: var(--color-info-alpha);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--color-warning-alpha);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,437 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { MqNodeInfo, MqOverviewInfo } from "@/types/mq";
|
||||
import { mqGetOverview, mqListNodes } from "@/lib/backend/api";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const overview = ref<MqOverviewInfo>();
|
||||
const nodes = ref<MqNodeInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const autoRefresh = ref(true);
|
||||
const refreshInterval = ref(5); // seconds
|
||||
|
||||
let refreshTimer: number | undefined;
|
||||
|
||||
function isDocumentHidden(): boolean {
|
||||
return typeof document !== "undefined" && document.hidden;
|
||||
}
|
||||
|
||||
async function loadStats(options: { skipWhenHidden?: boolean } = {}) {
|
||||
if (options.skipWhenHidden && isDocumentHidden()) return;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
const [overviewData, nodeData] = await Promise.all([mqGetOverview(props.connectionId), mqListNodes(props.connectionId)]);
|
||||
overview.value = overviewData;
|
||||
nodes.value = nodeData;
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshNow() {
|
||||
void loadStats();
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh();
|
||||
if (autoRefresh.value && !isDocumentHidden()) {
|
||||
refreshTimer = window.setInterval(() => {
|
||||
void loadStats({ skipWhenHidden: true });
|
||||
}, refreshInterval.value * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshTimer !== undefined) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (isDocumentHidden()) {
|
||||
stopAutoRefresh();
|
||||
return;
|
||||
}
|
||||
startAutoRefresh();
|
||||
void loadStats();
|
||||
}
|
||||
|
||||
function formatNumber(value: number | undefined): string {
|
||||
return value === undefined ? "-" : value.toLocaleString();
|
||||
}
|
||||
|
||||
function formatRate(value: number | undefined): string {
|
||||
return value === undefined ? "-" : `${value.toFixed(2)} msg/s`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | undefined): string {
|
||||
if (bytes === undefined) return "-";
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
||||
}
|
||||
|
||||
function formatUsage(used: number | undefined, total: number | undefined): string {
|
||||
if (used === undefined) return "-";
|
||||
return total === undefined ? formatNumber(used) : `${formatNumber(used)} / ${formatNumber(total)}`;
|
||||
}
|
||||
|
||||
function formatMemory(node: MqNodeInfo): string {
|
||||
if (node.memUsed === undefined) return "-";
|
||||
if (node.memLimit === undefined || node.memLimit <= 0) return formatBytes(node.memUsed);
|
||||
const percent = Math.round((node.memUsed / node.memLimit) * 100);
|
||||
return `${formatBytes(node.memUsed)} / ${formatBytes(node.memLimit)} (${percent}%)`;
|
||||
}
|
||||
|
||||
function formatUptime(uptimeMs: number | undefined): string {
|
||||
if (uptimeMs === undefined) return "-";
|
||||
const totalSeconds = Math.floor(uptimeMs / 1000);
|
||||
const days = Math.floor(totalSeconds / 86400);
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
watch(autoRefresh, () => {
|
||||
startAutoRefresh();
|
||||
});
|
||||
|
||||
watch(refreshInterval, () => {
|
||||
if (autoRefresh.value) {
|
||||
startAutoRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
() => {
|
||||
overview.value = undefined;
|
||||
nodes.value = [];
|
||||
void loadStats();
|
||||
startAutoRefresh();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
startAutoRefresh();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
stopAutoRefresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rabbitmq-monitoring-panel">
|
||||
<div class="panel-toolbar">
|
||||
<h3 class="section-title">{{ t("mqRabbitMqMonitoring.title") }}</h3>
|
||||
<div class="toolbar-actions">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="autoRefresh" />
|
||||
<span>{{ t("mqMonitoring.autoRefresh") }}</span>
|
||||
</label>
|
||||
<select v-model.number="refreshInterval" :disabled="!autoRefresh" class="refresh-interval">
|
||||
<option :value="5">{{ t("mqMonitoring.refreshInterval5s") }}</option>
|
||||
<option :value="10">{{ t("mqMonitoring.refreshInterval10s") }}</option>
|
||||
<option :value="30">{{ t("mqMonitoring.refreshInterval30s") }}</option>
|
||||
<option :value="60">{{ t("mqMonitoring.refreshInterval60s") }}</option>
|
||||
</select>
|
||||
<button @click="refreshNow" :disabled="loading" class="btn-secondary">
|
||||
{{ loading ? t("mqMonitoring.refreshing") : t("mqMonitoring.refreshNow") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="panel-error">{{ error }}</div>
|
||||
<div v-else-if="loading && !overview" class="panel-loading">{{ t("mqRabbitMqMonitoring.loading") }}</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Overview cards -->
|
||||
<div class="panel-section">
|
||||
<h4 class="section-subtitle">{{ t("mqRabbitMqMonitoring.overviewTitle") }}</h4>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ t("mqRabbitMqMonitoring.messagesReady") }}</div>
|
||||
<div class="stat-value">{{ formatNumber(overview?.messagesReady) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ t("mqRabbitMqMonitoring.messagesUnacked") }}</div>
|
||||
<div class="stat-value">{{ formatNumber(overview?.messagesUnacked) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ t("mqRabbitMqMonitoring.publishRate") }}</div>
|
||||
<div class="stat-value">{{ formatRate(overview?.publishRate) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ t("mqRabbitMqMonitoring.deliverRate") }}</div>
|
||||
<div class="stat-value">{{ formatRate(overview?.deliverRate) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ t("mqRabbitMqMonitoring.ackRate") }}</div>
|
||||
<div class="stat-value">{{ formatRate(overview?.ackRate) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ t("mqRabbitMqMonitoring.totalQueues") }}</div>
|
||||
<div class="stat-value">{{ formatNumber(overview?.totalQueues) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ t("mqRabbitMqMonitoring.totalExchanges") }}</div>
|
||||
<div class="stat-value">{{ formatNumber(overview?.totalExchanges) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ t("mqRabbitMqMonitoring.totalConnections") }}</div>
|
||||
<div class="stat-value">{{ formatNumber(overview?.totalConnections) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ t("mqRabbitMqMonitoring.totalChannels") }}</div>
|
||||
<div class="stat-value">{{ formatNumber(overview?.totalChannels) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ t("mqRabbitMqMonitoring.totalConsumers") }}</div>
|
||||
<div class="stat-value">{{ formatNumber(overview?.totalConsumers) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node table -->
|
||||
<div class="panel-section">
|
||||
<h4 class="section-subtitle">{{ t("mqRabbitMqMonitoring.nodesTitle") }}</h4>
|
||||
<div v-if="!nodes.length" class="panel-placeholder">{{ t("mqRabbitMqMonitoring.noNodes") }}</div>
|
||||
<div v-else class="data-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("mqRabbitMqMonitoring.nodeName") }}</th>
|
||||
<th>{{ t("mqRabbitMqMonitoring.status") }}</th>
|
||||
<th>{{ t("mqRabbitMqMonitoring.memory") }}</th>
|
||||
<th>{{ t("mqRabbitMqMonitoring.diskFree") }}</th>
|
||||
<th>{{ t("mqRabbitMqMonitoring.fileDescriptors") }}</th>
|
||||
<th>{{ t("mqRabbitMqMonitoring.sockets") }}</th>
|
||||
<th>{{ t("mqRabbitMqMonitoring.uptime") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="node in nodes" :key="node.name">
|
||||
<td class="node-name" :title="node.name">{{ node.name }}</td>
|
||||
<td>
|
||||
<span :class="['status-badge', node.running ? 'running' : 'stopped']">
|
||||
{{ node.running ? t("mqRabbitMqMonitoring.running") : t("mqRabbitMqMonitoring.stopped") }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatMemory(node) }}</td>
|
||||
<td>{{ formatBytes(node.diskFree) }}</td>
|
||||
<td>{{ formatUsage(node.fdUsed, node.fdTotal) }}</td>
|
||||
<td>{{ formatUsage(node.socketsUsed, node.socketsTotal) }}</td>
|
||||
<td>{{ formatUptime(node.uptimeMs) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rabbitmq-monitoring-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.refresh-interval {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel-placeholder,
|
||||
.panel-error,
|
||||
.panel-loading {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.panel-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 12px 14px;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
overflow: auto;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-background-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.node-name {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.running {
|
||||
background: var(--color-success-alpha);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status-badge.stopped {
|
||||
background: var(--color-error-alpha);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,780 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { MqUserInfo, MqVhostPermission } from "@/types/mq";
|
||||
import { mqCreateUser, mqDeleteUser, mqGrantUserPermission, mqListNamespaces, mqListUserPermissions, mqListUsers, mqRevokeUserPermission } from "@/lib/backend/api";
|
||||
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
/** Selected RabbitMQ virtual host; "*" lists permissions across all vhosts. */
|
||||
namespace?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const users = ref<MqUserInfo[]>([]);
|
||||
const usersLoading = ref(false);
|
||||
const error = ref<string>();
|
||||
const userSearch = ref("");
|
||||
/** Clicking a user row filters the permission matrix to that user. */
|
||||
const selectedUser = ref<string>();
|
||||
|
||||
const permissions = ref<MqVhostPermission[]>([]);
|
||||
const permissionsLoading = ref(false);
|
||||
|
||||
/** Virtual hosts for the grant dialog dropdown. */
|
||||
const vhosts = ref<string[]>([]);
|
||||
|
||||
const dialogError = ref<string>();
|
||||
const showCreateDialog = ref(false);
|
||||
const createForm = ref({ name: "", password: "", tagsText: "" });
|
||||
const creating = ref(false);
|
||||
|
||||
const showDeleteDialog = ref(false);
|
||||
const deleteTarget = ref<MqUserInfo>();
|
||||
const deleting = ref(false);
|
||||
|
||||
const showGrantDialog = ref(false);
|
||||
const grantForm = ref({ user: "", virtualHost: "", configure: ".*", write: ".*", read: ".*" });
|
||||
const granting = ref(false);
|
||||
|
||||
const showRevokeDialog = ref(false);
|
||||
const revokeTarget = ref<MqVhostPermission>();
|
||||
const revoking = ref(false);
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
const query = userSearch.value.trim().toLowerCase();
|
||||
if (!query) return users.value;
|
||||
return users.value.filter((user) => user.name.toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
const filteredPermissions = computed(() => {
|
||||
if (!selectedUser.value) return permissions.value;
|
||||
return permissions.value.filter((permission) => permission.user === selectedUser.value);
|
||||
});
|
||||
|
||||
function guardWritable(): boolean {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqUserPermissions.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
usersLoading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
users.value = await mqListUsers(props.connectionId);
|
||||
if (selectedUser.value && !users.value.some((user) => user.name === selectedUser.value)) {
|
||||
selectedUser.value = undefined;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
usersLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPermissions() {
|
||||
permissionsLoading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
// "*" is a listing sentinel: translate it into the all-vhosts listing.
|
||||
if (isAllVhostsNamespace(props.namespace)) {
|
||||
permissions.value = await mqListUserPermissions(props.connectionId, { allVhosts: true });
|
||||
} else if (props.namespace) {
|
||||
permissions.value = await mqListUserPermissions(props.connectionId, { virtualHost: props.namespace });
|
||||
} else {
|
||||
permissions.value = await mqListUserPermissions(props.connectionId);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
permissionsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVhosts() {
|
||||
try {
|
||||
const namespaces = await mqListNamespaces(props.connectionId, RABBITMQ_MQ_TENANT);
|
||||
vhosts.value = namespaces.map((ns) => ns.namespace);
|
||||
} catch (e: unknown) {
|
||||
console.warn("[DBX] Failed to load RabbitMQ vhosts:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleUserFilter(user: MqUserInfo) {
|
||||
selectedUser.value = selectedUser.value === user.name ? undefined : user.name;
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
createForm.value = { name: "", password: "", tagsText: "" };
|
||||
dialogError.value = undefined;
|
||||
showCreateDialog.value = true;
|
||||
}
|
||||
|
||||
function parseTags(text: string): string[] | undefined {
|
||||
const tags = text
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0);
|
||||
return tags.length ? tags : undefined;
|
||||
}
|
||||
|
||||
async function handleCreateUser() {
|
||||
const name = createForm.value.name.trim();
|
||||
const password = createForm.value.password;
|
||||
if (!name) {
|
||||
dialogError.value = t("mqUserPermissions.nameRequired");
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
dialogError.value = t("mqUserPermissions.passwordRequired");
|
||||
return;
|
||||
}
|
||||
creating.value = true;
|
||||
dialogError.value = undefined;
|
||||
try {
|
||||
await mqCreateUser(props.connectionId, name, password, parseTags(createForm.value.tagsText));
|
||||
showCreateDialog.value = false;
|
||||
await loadUsers();
|
||||
} catch (e: unknown) {
|
||||
dialogError.value = formatError(e);
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteDialog(user: MqUserInfo) {
|
||||
if (!guardWritable()) return;
|
||||
deleteTarget.value = user;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const target = deleteTarget.value;
|
||||
if (!target) return;
|
||||
deleting.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
await mqDeleteUser(props.connectionId, target.name);
|
||||
showDeleteDialog.value = false;
|
||||
await Promise.all([loadUsers(), loadPermissions()]);
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openGrantDialog() {
|
||||
if (!guardWritable()) return;
|
||||
const currentVhost = props.namespace && !isAllVhostsNamespace(props.namespace) ? props.namespace : "";
|
||||
grantForm.value = {
|
||||
user: selectedUser.value ?? users.value[0]?.name ?? "",
|
||||
virtualHost: currentVhost || vhosts.value[0] || "",
|
||||
configure: ".*",
|
||||
write: ".*",
|
||||
read: ".*",
|
||||
};
|
||||
dialogError.value = undefined;
|
||||
showGrantDialog.value = true;
|
||||
}
|
||||
|
||||
function patternOrDefault(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
async function handleGrant() {
|
||||
const user = grantForm.value.user.trim();
|
||||
const virtualHost = grantForm.value.virtualHost.trim();
|
||||
if (!user) {
|
||||
dialogError.value = t("mqUserPermissions.userRequired");
|
||||
return;
|
||||
}
|
||||
if (!virtualHost || isAllVhostsNamespace(virtualHost)) {
|
||||
dialogError.value = t("mqUserPermissions.vhostRequired");
|
||||
return;
|
||||
}
|
||||
granting.value = true;
|
||||
dialogError.value = undefined;
|
||||
try {
|
||||
await mqGrantUserPermission(props.connectionId, user, virtualHost, {
|
||||
configure: patternOrDefault(grantForm.value.configure),
|
||||
write: patternOrDefault(grantForm.value.write),
|
||||
read: patternOrDefault(grantForm.value.read),
|
||||
});
|
||||
showGrantDialog.value = false;
|
||||
await loadPermissions();
|
||||
} catch (e: unknown) {
|
||||
dialogError.value = formatError(e);
|
||||
} finally {
|
||||
granting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openRevokeDialog(permission: MqVhostPermission) {
|
||||
if (!guardWritable()) return;
|
||||
revokeTarget.value = permission;
|
||||
showRevokeDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmRevoke() {
|
||||
const target = revokeTarget.value;
|
||||
if (!target) return;
|
||||
revoking.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
await mqRevokeUserPermission(props.connectionId, target.user, target.vhost);
|
||||
showRevokeDialog.value = false;
|
||||
await loadPermissions();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
revoking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.namespace,
|
||||
() => {
|
||||
loadPermissions();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
() => {
|
||||
selectedUser.value = undefined;
|
||||
loadUsers();
|
||||
loadPermissions();
|
||||
loadVhosts();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-permissions-panel">
|
||||
<!-- Users -->
|
||||
<div class="panel-section">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<h3 class="section-title">{{ t("mqUserPermissions.usersTitle") }}</h3>
|
||||
<input v-model="userSearch" type="search" class="user-search" :placeholder="t('mqUserPermissions.searchUsers')" :disabled="usersLoading && !users.length" />
|
||||
<span v-if="users.length" class="row-count">{{ filteredUsers.length }} / {{ users.length }}</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button @click="openCreateDialog" :disabled="readOnly" class="btn-secondary">{{ t("mqUserPermissions.createUser") }}</button>
|
||||
<button @click="loadUsers" :disabled="usersLoading" class="btn-secondary">
|
||||
{{ usersLoading ? t("mqUserPermissions.refreshing") : t("mqUserPermissions.refresh") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="usersLoading && !users.length" class="panel-loading">{{ t("mqUserPermissions.loading") }}</div>
|
||||
<div v-else-if="!users.length" class="panel-placeholder">{{ t("mqUserPermissions.noUsers") }}</div>
|
||||
<div v-else-if="!filteredUsers.length" class="panel-placeholder">{{ t("mqUserPermissions.noUserMatches") }}</div>
|
||||
<div v-else class="data-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("mqUserPermissions.name") }}</th>
|
||||
<th>{{ t("mqUserPermissions.tags") }}</th>
|
||||
<th>{{ t("mqUserPermissions.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in filteredUsers" :key="user.name" :class="{ selected: selectedUser === user.name }" @click="toggleUserFilter(user)">
|
||||
<td class="user-name" :title="user.name">{{ user.name }}</td>
|
||||
<td>
|
||||
<span v-if="user.tags.length" class="tag-list">
|
||||
<span v-for="tag in user.tags" :key="tag" class="badge badge-info">{{ tag }}</span>
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="actions" @click.stop>
|
||||
<button class="btn-sm btn-danger" :disabled="readOnly" @click="openDeleteDialog(user)">{{ t("mqUserPermissions.delete") }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Permission matrix -->
|
||||
<div class="panel-section">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<h3 class="section-title">{{ t("mqUserPermissions.permissionsTitle") }}</h3>
|
||||
<span v-if="selectedUser" class="filter-chip">
|
||||
{{ selectedUser }}
|
||||
<button class="chip-clear" @click="selectedUser = undefined">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button @click="openGrantDialog" :disabled="readOnly" class="btn-secondary">{{ t("mqUserPermissions.grantPermission") }}</button>
|
||||
<button @click="loadPermissions" :disabled="permissionsLoading" class="btn-secondary">
|
||||
{{ permissionsLoading ? t("mqUserPermissions.refreshing") : t("mqUserPermissions.refresh") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="panel-error">{{ error }}</div>
|
||||
<div v-else-if="permissionsLoading && !permissions.length" class="panel-loading">{{ t("mqUserPermissions.loading") }}</div>
|
||||
<div v-else-if="!filteredPermissions.length" class="panel-placeholder">{{ t("mqUserPermissions.noPermissions") }}</div>
|
||||
<div v-else class="data-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("mqUserPermissions.user") }}</th>
|
||||
<th>{{ t("mqUserPermissions.vhost") }}</th>
|
||||
<th>{{ t("mqUserPermissions.configure") }}</th>
|
||||
<th>{{ t("mqUserPermissions.write") }}</th>
|
||||
<th>{{ t("mqUserPermissions.read") }}</th>
|
||||
<th>{{ t("mqUserPermissions.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="permission in filteredPermissions" :key="`${permission.user}@${permission.vhost}`">
|
||||
<td class="user-name" :title="permission.user">{{ permission.user }}</td>
|
||||
<td>{{ permission.vhost }}</td>
|
||||
<td class="pattern-cell" :title="permission.configure">{{ permission.configure }}</td>
|
||||
<td class="pattern-cell" :title="permission.write">{{ permission.write }}</td>
|
||||
<td class="pattern-cell" :title="permission.read">{{ permission.read }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-sm btn-danger" :disabled="readOnly" @click="openRevokeDialog(permission)">{{ t("mqUserPermissions.revoke") }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create User Dialog -->
|
||||
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>{{ t("mqUserPermissions.createUser") }}</h3>
|
||||
<button @click="showCreateDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqUserPermissions.name") }}</label>
|
||||
<input v-model="createForm.name" type="text" :disabled="readOnly" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqUserPermissions.password") }}</label>
|
||||
<input v-model="createForm.password" type="password" :disabled="readOnly" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqUserPermissions.tags") }}</label>
|
||||
<input v-model="createForm.tagsText" type="text" :placeholder="t('mqUserPermissions.tagsPlaceholder')" :disabled="readOnly" />
|
||||
</div>
|
||||
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showCreateDialog = false" class="btn-secondary">{{ t("mqUserPermissions.cancel") }}</button>
|
||||
<button @click="handleCreateUser" :disabled="creating || readOnly" class="btn-primary">{{ t("mqUserPermissions.create") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grant Permission Dialog -->
|
||||
<div v-if="showGrantDialog" class="dialog-overlay" @click="showGrantDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>{{ t("mqUserPermissions.grantPermission") }}</h3>
|
||||
<button @click="showGrantDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqUserPermissions.user") }}</label>
|
||||
<select v-model="grantForm.user" :disabled="readOnly">
|
||||
<option value="" disabled>{{ t("mqUserPermissions.selectUser") }}</option>
|
||||
<option v-for="user in users" :key="user.name" :value="user.name">{{ user.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqUserPermissions.vhost") }}</label>
|
||||
<select v-model="grantForm.virtualHost" :disabled="readOnly">
|
||||
<option value="" disabled>{{ t("mqUserPermissions.selectVhost") }}</option>
|
||||
<option v-for="vhost in vhosts" :key="vhost" :value="vhost">{{ vhost }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqUserPermissions.configure") }}</label>
|
||||
<input v-model="grantForm.configure" type="text" placeholder=".*" :disabled="readOnly" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqUserPermissions.write") }}</label>
|
||||
<input v-model="grantForm.write" type="text" placeholder=".*" :disabled="readOnly" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqUserPermissions.read") }}</label>
|
||||
<input v-model="grantForm.read" type="text" placeholder=".*" :disabled="readOnly" />
|
||||
</div>
|
||||
<div class="form-hint">{{ t("mqUserPermissions.patternHint") }}</div>
|
||||
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showGrantDialog = false" class="btn-secondary">{{ t("mqUserPermissions.cancel") }}</button>
|
||||
<button @click="handleGrant" :disabled="granting || readOnly" class="btn-primary">{{ t("mqUserPermissions.grant") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete User Confirm -->
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDeleteDialog"
|
||||
:title="t('mqUserPermissions.delete')"
|
||||
:message="t('mqUserPermissions.confirmDeleteUser', { name: deleteTarget?.name ?? '' })"
|
||||
:confirm-label="t('mqUserPermissions.delete')"
|
||||
:loading="deleting"
|
||||
:close-on-confirm="false"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
|
||||
<!-- Revoke Permission Confirm -->
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showRevokeDialog"
|
||||
:title="t('mqUserPermissions.revoke')"
|
||||
:message="t('mqUserPermissions.confirmRevoke', { user: revokeTarget?.user ?? '', vhost: revokeTarget?.vhost ?? '' })"
|
||||
:confirm-label="t('mqUserPermissions.revoke')"
|
||||
:loading="revoking"
|
||||
:close-on-confirm="false"
|
||||
@confirm="confirmRevoke"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-permissions-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-search {
|
||||
width: min(280px, 28vw);
|
||||
min-width: 160px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
.row-count {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-primary-alpha);
|
||||
color: var(--color-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chip-clear {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.panel-placeholder,
|
||||
.panel-error,
|
||||
.panel-loading {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.panel-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
overflow: auto;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-background-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.data-table tbody tr.selected {
|
||||
background: var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pattern-cell {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: var(--color-info-alpha);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Dialogs */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="password"],
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 12px;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--color-error);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,626 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { MqPolicyInfo, MqPolicyUpsertRequest } from "@/types/mq";
|
||||
import { mqDeletePolicy, mqListNamespaces, mqListPolicies, mqSetPolicy } from "@/lib/backend/api";
|
||||
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
/** Selected RabbitMQ virtual host; "*" lists policies across all vhosts. */
|
||||
namespace?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
interface DefinitionEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
|
||||
/** Well-known policy definition keys offered in the editor dropdown. */
|
||||
const COMMON_DEFINITION_KEYS = ["message-ttl", "expires", "max-length", "max-length-bytes", "dead-letter-exchange", "dead-letter-routing-key", "max-age"];
|
||||
/** Definition keys whose values are numeric. */
|
||||
const NUMERIC_DEFINITION_KEYS = new Set(["message-ttl", "expires", "max-length", "max-length-bytes"]);
|
||||
const APPLY_TO_OPTIONS = ["queues", "exchanges", "all"];
|
||||
|
||||
const policies = ref<MqPolicyInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
|
||||
/** Virtual hosts for the create/edit dialog dropdown. */
|
||||
const vhosts = ref<string[]>([]);
|
||||
|
||||
const dialogError = ref<string>();
|
||||
const showEditDialog = ref(false);
|
||||
/** When set the dialog edits this policy; name and vhost stay fixed. */
|
||||
const editingPolicy = ref<MqPolicyInfo>();
|
||||
const editForm = ref({ name: "", virtualHost: "", pattern: "", applyTo: "queues", priority: 0 });
|
||||
const definitionEntries = ref<DefinitionEntry[]>([]);
|
||||
const saving = ref(false);
|
||||
|
||||
const showDeleteDialog = ref(false);
|
||||
const deleteTarget = ref<MqPolicyInfo>();
|
||||
const deleting = ref(false);
|
||||
|
||||
function guardWritable(): boolean {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqRabbitMqPolicies.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadPolicies() {
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
// "*" is a listing sentinel: translate it into the all-vhosts listing.
|
||||
if (isAllVhostsNamespace(props.namespace)) {
|
||||
policies.value = await mqListPolicies(props.connectionId, { allVhosts: true });
|
||||
} else if (props.namespace) {
|
||||
policies.value = await mqListPolicies(props.connectionId, { virtualHost: props.namespace });
|
||||
} else {
|
||||
policies.value = await mqListPolicies(props.connectionId);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVhosts() {
|
||||
try {
|
||||
const namespaces = await mqListNamespaces(props.connectionId, RABBITMQ_MQ_TENANT);
|
||||
vhosts.value = namespaces.map((ns) => ns.namespace);
|
||||
} catch (e: unknown) {
|
||||
console.warn("[DBX] Failed to load RabbitMQ vhosts:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function definitionEntriesFrom(policy: MqPolicyInfo): DefinitionEntry[] {
|
||||
return Object.entries(policy.definition).map(([key, value]) => ({ key, value: String(value) }));
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
const currentVhost = props.namespace && !isAllVhostsNamespace(props.namespace) ? props.namespace : "";
|
||||
editingPolicy.value = undefined;
|
||||
editForm.value = { name: "", virtualHost: currentVhost || vhosts.value[0] || "", pattern: "", applyTo: "queues", priority: 0 };
|
||||
definitionEntries.value = [];
|
||||
dialogError.value = undefined;
|
||||
showEditDialog.value = true;
|
||||
}
|
||||
|
||||
function openEditDialog(policy: MqPolicyInfo) {
|
||||
if (!guardWritable()) return;
|
||||
editingPolicy.value = policy;
|
||||
editForm.value = { name: policy.name, virtualHost: policy.vhost, pattern: policy.pattern, applyTo: policy.applyTo || "queues", priority: policy.priority };
|
||||
definitionEntries.value = definitionEntriesFrom(policy);
|
||||
dialogError.value = undefined;
|
||||
showEditDialog.value = true;
|
||||
}
|
||||
|
||||
function addDefinitionEntry() {
|
||||
definitionEntries.value = [...definitionEntries.value, { key: "", value: "" }];
|
||||
}
|
||||
|
||||
function removeDefinitionEntry(index: number) {
|
||||
definitionEntries.value = definitionEntries.value.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function buildDefinition(): Record<string, unknown> {
|
||||
const definition: Record<string, unknown> = {};
|
||||
for (const entry of definitionEntries.value) {
|
||||
const key = entry.key.trim();
|
||||
if (!key) continue;
|
||||
const raw = entry.value.trim();
|
||||
if (NUMERIC_DEFINITION_KEYS.has(key) && raw !== "" && Number.isFinite(Number(raw))) {
|
||||
definition[key] = Number(raw);
|
||||
} else {
|
||||
definition[key] = raw;
|
||||
}
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const name = editForm.value.name.trim();
|
||||
const virtualHost = editForm.value.virtualHost.trim();
|
||||
const pattern = editForm.value.pattern.trim();
|
||||
if (!name) {
|
||||
dialogError.value = t("mqRabbitMqPolicies.nameRequired");
|
||||
return;
|
||||
}
|
||||
// "*" is a listing sentinel and must never reach a write operation.
|
||||
if (!virtualHost || isAllVhostsNamespace(virtualHost)) {
|
||||
dialogError.value = t("mqRabbitMqPolicies.vhostRequired");
|
||||
return;
|
||||
}
|
||||
if (!pattern) {
|
||||
dialogError.value = t("mqRabbitMqPolicies.patternRequired");
|
||||
return;
|
||||
}
|
||||
const request: MqPolicyUpsertRequest = {
|
||||
name,
|
||||
pattern,
|
||||
applyTo: editForm.value.applyTo || undefined,
|
||||
priority: Number.isFinite(editForm.value.priority) ? editForm.value.priority : undefined,
|
||||
definition: buildDefinition(),
|
||||
};
|
||||
saving.value = true;
|
||||
dialogError.value = undefined;
|
||||
try {
|
||||
await mqSetPolicy(props.connectionId, virtualHost, request);
|
||||
showEditDialog.value = false;
|
||||
await loadPolicies();
|
||||
} catch (e: unknown) {
|
||||
dialogError.value = formatError(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteDialog(policy: MqPolicyInfo) {
|
||||
if (!guardWritable()) return;
|
||||
deleteTarget.value = policy;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const target = deleteTarget.value;
|
||||
if (!target) return;
|
||||
deleting.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
await mqDeletePolicy(props.connectionId, target.vhost, target.name);
|
||||
showDeleteDialog.value = false;
|
||||
await loadPolicies();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDefinitionValue(value: unknown): string {
|
||||
return typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.namespace,
|
||||
() => {
|
||||
loadPolicies();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
() => {
|
||||
loadPolicies();
|
||||
loadVhosts();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="policies-panel">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<h3 class="section-title">{{ t("mqRabbitMqPolicies.title") }}</h3>
|
||||
<span v-if="policies.length" class="row-count">{{ policies.length }}</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button @click="openCreateDialog" :disabled="readOnly" class="btn-secondary">{{ t("mqRabbitMqPolicies.createPolicy") }}</button>
|
||||
<button @click="loadPolicies" :disabled="loading" class="btn-secondary">
|
||||
{{ loading ? t("mqRabbitMqPolicies.refreshing") : t("mqRabbitMqPolicies.refresh") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="panel-error">{{ error }}</div>
|
||||
<div v-else-if="loading && !policies.length" class="panel-loading">{{ t("mqRabbitMqPolicies.loading") }}</div>
|
||||
<div v-else-if="!policies.length" class="panel-placeholder">{{ t("mqRabbitMqPolicies.noPolicies") }}</div>
|
||||
<div v-else class="data-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("mqRabbitMqPolicies.name") }}</th>
|
||||
<th>{{ t("mqRabbitMqPolicies.vhost") }}</th>
|
||||
<th>{{ t("mqRabbitMqPolicies.pattern") }}</th>
|
||||
<th>{{ t("mqRabbitMqPolicies.applyTo") }}</th>
|
||||
<th>{{ t("mqRabbitMqPolicies.priority") }}</th>
|
||||
<th>{{ t("mqRabbitMqPolicies.definition") }}</th>
|
||||
<th>{{ t("mqRabbitMqPolicies.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="policy in policies" :key="`${policy.vhost}/${policy.name}`">
|
||||
<td class="policy-name" :title="policy.name">{{ policy.name }}</td>
|
||||
<td>{{ policy.vhost }}</td>
|
||||
<td class="pattern-cell" :title="policy.pattern">{{ policy.pattern }}</td>
|
||||
<td>{{ policy.applyTo }}</td>
|
||||
<td>{{ policy.priority }}</td>
|
||||
<td class="definition-cell">
|
||||
<div v-for="(value, key) in policy.definition" :key="key" class="definition-item">
|
||||
<span class="definition-key">{{ key }}</span>
|
||||
<span class="definition-value">{{ formatDefinitionValue(value) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn-sm" :disabled="readOnly" @click="openEditDialog(policy)">{{ t("mqRabbitMqPolicies.edit") }}</button>
|
||||
<button class="btn-sm btn-danger" :disabled="readOnly" @click="openDeleteDialog(policy)">{{ t("mqRabbitMqPolicies.delete") }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Create / Edit Policy Dialog -->
|
||||
<div v-if="showEditDialog" class="dialog-overlay" @click="showEditDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>{{ editingPolicy ? t("mqRabbitMqPolicies.editPolicy") : t("mqRabbitMqPolicies.createPolicy") }}</h3>
|
||||
<button @click="showEditDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqRabbitMqPolicies.name") }}</label>
|
||||
<input v-model="editForm.name" type="text" :disabled="readOnly || !!editingPolicy" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqRabbitMqPolicies.vhost") }}</label>
|
||||
<select v-model="editForm.virtualHost" :disabled="readOnly || !!editingPolicy">
|
||||
<option value="" disabled>{{ t("mqRabbitMqPolicies.selectVhost") }}</option>
|
||||
<option v-for="vhost in vhosts" :key="vhost" :value="vhost">{{ vhost }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqRabbitMqPolicies.pattern") }}</label>
|
||||
<input v-model="editForm.pattern" type="text" placeholder="^dbx-" :disabled="readOnly" />
|
||||
</div>
|
||||
<div class="form-hint">{{ t("mqRabbitMqPolicies.patternHint") }}</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqRabbitMqPolicies.applyTo") }}</label>
|
||||
<select v-model="editForm.applyTo" :disabled="readOnly">
|
||||
<option v-for="option in APPLY_TO_OPTIONS" :key="option" :value="option">{{ option }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqRabbitMqPolicies.priority") }}</label>
|
||||
<input v-model.number="editForm.priority" type="number" :disabled="readOnly" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqRabbitMqPolicies.definition") }}</label>
|
||||
<div v-for="(entry, index) in definitionEntries" :key="index" class="definition-row">
|
||||
<input v-model="entry.key" type="text" list="rabbitmq-policy-definition-keys" :placeholder="t('mqRabbitMqPolicies.definitionKey')" :disabled="readOnly" />
|
||||
<input v-model="entry.value" type="text" :placeholder="t('mqRabbitMqPolicies.definitionValue')" :disabled="readOnly" />
|
||||
<button class="btn-sm btn-danger" :disabled="readOnly" @click="removeDefinitionEntry(index)">×</button>
|
||||
</div>
|
||||
<datalist id="rabbitmq-policy-definition-keys">
|
||||
<option v-for="key in COMMON_DEFINITION_KEYS" :key="key" :value="key" />
|
||||
</datalist>
|
||||
<button class="btn-sm" :disabled="readOnly" @click="addDefinitionEntry">{{ t("mqRabbitMqPolicies.addDefinition") }}</button>
|
||||
</div>
|
||||
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showEditDialog = false" class="btn-secondary">{{ t("mqRabbitMqPolicies.cancel") }}</button>
|
||||
<button @click="handleSave" :disabled="saving || readOnly" class="btn-primary">{{ t("mqRabbitMqPolicies.save") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Policy Confirm -->
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDeleteDialog"
|
||||
:title="t('mqRabbitMqPolicies.delete')"
|
||||
:message="t('mqRabbitMqPolicies.confirmDelete', { name: deleteTarget?.name ?? '', vhost: deleteTarget?.vhost ?? '' })"
|
||||
:confirm-label="t('mqRabbitMqPolicies.delete')"
|
||||
:loading="deleting"
|
||||
:close-on-confirm="false"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.policies-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-count {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.panel-placeholder,
|
||||
.panel-error,
|
||||
.panel-loading {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.panel-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
overflow: auto;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-background-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.policy-name {
|
||||
font-weight: 500;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pattern-cell {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.definition-cell {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.definition-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.definition-key {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.definition-value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Dialog */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 560px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"],
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.definition-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.definition-row input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.definition-row .btn-sm {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 12px;
|
||||
margin-top: -8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--color-error);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user