Files
dbx/apps/desktop/src/components/mq/SubscriptionsPanel.vue
T

1259 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { formatError } from "@/lib/backend/errorUtils";
import { ref, watch, computed, onBeforeUnmount } from "vue";
import { useI18n } from "vue-i18n";
import type { TopicRef, TopicInfo, SubscriptionInfo, ResetPosition, SkipCount, PeekedMessage, MqSystemKind } from "@/types/mq";
import { mqListSubscriptions, mqEnrichSubscriptions, 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";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
interface Props {
connectionId: string;
topic?: TopicInfo;
tenant?: string;
namespace?: string;
readOnly?: boolean;
supportsCreateSubscription?: boolean;
supportsResetCursor?: boolean;
supportsSkipMessages?: boolean;
supportsClearBacklog?: boolean;
supportsPeekMessages?: boolean;
supportsExpireMessages?: boolean;
mqSystemKind?: MqSystemKind;
isFlatMqCluster?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
subscriptionSelected: [subscription: string];
}>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const subscriptions = ref<SubscriptionInfo[]>([]);
const loading = ref(false);
const enriching = ref(false);
const enrichFailedHint = ref<string>();
const error = ref<string>();
let loadSeq = 0;
const showCreateDialog = ref(false);
const showResetDialog = ref(false);
const resetError = ref<string>();
const resetting = ref(false);
let resetRequestVersion = 0;
let subscriptionContextVersion = 0;
const showSkipDialog = ref(false);
const showPeekDialog = ref(false);
const showExpireDialog = ref(false);
const selectedSub = ref<SubscriptionInfo>();
const peekedMessages = ref<PeekedMessage[]>([]);
const peekLoading = ref(false);
const peekIncomplete = ref(false);
const peekCount = ref(5);
let peekRequestVersion = 0;
const deleteTarget = ref<SubscriptionInfo>();
const showDeleteDialog = ref(false);
const deleting = ref(false);
const clearBacklogTarget = ref<SubscriptionInfo>();
const showClearBacklogDialog = ref(false);
const clearBacklogError = ref<string>();
const clearingBacklog = ref(false);
const formData = ref({
subName: "",
startFrom: "latest" as "earliest" | "latest",
});
const resetFormData = ref({
position: "latest" as "earliest" | "latest" | "timestamp" | "partitionOffset",
timestampMs: Date.now(),
partition: 0,
offset: 0,
});
const skipFormData = ref({
mode: "count" as "count" | "all",
count: 100,
});
const expireSeconds = ref(3600);
const searchKeyword = ref("");
const activeRocketMqDialog = ref<RocketMqConsumerGroupDialogKind | null>(null);
const consumerGroupTypeFilters = ref<Record<RocketMqConsumerGroupType, boolean>>({
...DEFAULT_ROCKETMQ_CONSUMER_GROUP_TYPE_FILTERS,
});
const isRocketMqCluster = computed(() => props.mqSystemKind === "rocketmq");
// RocketMQ consumer tab always lists cluster-wide groups (Dashboard groupList.query), regardless of breadcrumb topic.
const isClusterWideMode = computed(() => isRocketMqCluster.value && !!props.tenant && !!props.namespace);
const canShowPanel = computed(() => isClusterWideMode.value || !!props.topic);
const rocketMqConsumerGroupTypeOptions = ROCKETMQ_CONSUMER_GROUP_TYPES;
const panelTitle = computed(() => (isRocketMqCluster.value ? t("mqRocketmq.consumerGroupTitle") : t("mqSubscriptions.title")));
const searchPlaceholder = computed(() => (isRocketMqCluster.value ? t("mqRocketmq.searchConsumerGroup") : t("mqSubscriptions.searchPlaceholder")));
// Match TopicsPanel: denominator tracks type filters; numerator also applies search.
const typeFilteredSubscriptions = computed(() => {
let rows = subscriptions.value;
if (isRocketMqCluster.value) {
const filters = consumerGroupTypeFilters.value;
// Read each flag so checkbox toggles always invalidate this computed.
for (const type of ROCKETMQ_CONSUMER_GROUP_TYPES) {
void filters[type];
}
rows = rows.filter((sub) => matchesRocketMqConsumerGroupTypeFilters(sub, filters));
}
return rows;
});
const filteredSubscriptions = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase();
if (!keyword) return typeFilteredSubscriptions.value;
return typeFilteredSubscriptions.value.filter((sub) => sub.name.toLowerCase().includes(keyword));
});
function consumerGroupTypeLabel(sub: SubscriptionInfo): string {
const type = resolveRocketMqConsumerGroupType(sub);
return t(`mqSubscriptions.rocketmqGroupType.${type.toLowerCase()}`);
}
function consumerGroupTypeBadgeClass(sub: SubscriptionInfo): string {
const type = resolveRocketMqConsumerGroupType(sub);
if (type === "FIFO") return "badge badge-info";
if (type === "SYSTEM" || type === "UNKNOWN") return "badge badge-muted";
return "badge";
}
function consumerGroupModeLabel(sub: SubscriptionInfo): string {
const mode = resolveRocketMqConsumerGroupMessageModel(sub);
return t(`mqSubscriptions.rocketmqGroupMode.${mode.toLowerCase()}`);
}
async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return false;
}
return confirmMqWrite(operation);
}
function getListTopicRef(): TopicRef | null {
if (!props.tenant || !props.namespace) return null;
if (isClusterWideMode.value) {
return {
tenant: props.tenant,
namespace: props.namespace,
topic: "",
persistent: true,
partitioned: false,
};
}
if (!props.topic) return null;
return {
tenant: props.tenant,
namespace: props.topic.namespace || props.namespace,
topic: props.topic.shortName,
persistent: props.topic.persistent,
partitioned: props.topic.partitioned,
};
}
function getPulsarTopicRef(): TopicRef | null {
if (!props.tenant || !props.namespace || !props.topic) return null;
return {
tenant: props.tenant,
namespace: props.topic.namespace || props.namespace,
topic: props.topic.shortName,
persistent: props.topic.persistent,
partitioned: props.topic.partitioned,
};
}
async function loadSubscriptions() {
const topicRef = getListTopicRef();
if (!topicRef) {
subscriptions.value = [];
enrichFailedHint.value = undefined;
return;
}
const seq = ++loadSeq;
loading.value = true;
enriching.value = false;
error.value = undefined;
enrichFailedHint.value = undefined;
try {
// Fast list first (no enrich / online members) so large clusters paint quickly.
const page = await mqListSubscriptions(props.connectionId, topicRef);
if (seq !== loadSeq) return;
subscriptions.value = page;
syncSelectedSubscription(page);
if (isClusterWideMode.value) {
enriching.value = true;
try {
const enriched = await mqEnrichSubscriptions(props.connectionId, topicRef);
if (seq !== loadSeq) return;
// Keep the complete fast list even if an older/slow agent returns only a partial enrichment page.
const merged = mergeSubscriptionEnrichment(page, enriched);
subscriptions.value = merged;
// Detail dialog holds a snapshot; refresh so topics/members arrive after enrich.
syncSelectedSubscription(merged);
} catch (e: unknown) {
// Keep the fast list if enrichment times out; surface why online columns stayed empty.
if (seq === loadSeq) {
enrichFailedHint.value = t("mqSubscriptions.enrichFailedHint", { error: formatError(e) });
}
} finally {
if (seq === loadSeq) enriching.value = false;
}
}
} catch (e: unknown) {
if (seq === loadSeq) error.value = formatError(e);
} finally {
if (seq === loadSeq) loading.value = false;
}
}
function mergeSubscriptionEnrichment(fastList: SubscriptionInfo[], enrichedList: SubscriptionInfo[]): SubscriptionInfo[] {
const enrichedByName = new Map(enrichedList.map((subscription) => [subscription.name, subscription]));
return fastList.map((subscription) => {
const enriched = enrichedByName.get(subscription.name);
return enriched ? { ...subscription, ...enriched } : subscription;
});
}
function openCreateDialog() {
if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
formData.value = {
subName: "",
startFrom: "latest",
};
showCreateDialog.value = true;
}
/** Keep open dialog/selection pointing at the latest list row for the same group name. */
function syncSelectedSubscription(rows: SubscriptionInfo[]) {
const current = selectedSub.value;
if (!current) return;
const updated = rows.find((row) => row.name === current.name);
if (updated) selectedSub.value = updated;
}
function openRocketMqDetail(sub: SubscriptionInfo) {
selectedSub.value = sub;
activeRocketMqDialog.value = "detail";
}
function openRocketMqConfig(sub: SubscriptionInfo) {
if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
selectedSub.value = sub;
activeRocketMqDialog.value = "config";
}
function closeRocketMqDialog() {
activeRocketMqDialog.value = null;
}
function openResetDialog(sub: SubscriptionInfo) {
if (props.readOnly) return;
closeResetDialog();
selectedSub.value = sub;
resetFormData.value = {
position: "latest",
timestampMs: Date.now(),
partition: 0,
offset: 0,
};
showResetDialog.value = true;
}
function closeResetDialog() {
// A closed dialog must not receive results from an earlier reset request.
resetRequestVersion += 1;
showResetDialog.value = false;
resetError.value = undefined;
resetting.value = false;
}
function openSkipDialog(sub: SubscriptionInfo) {
if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
selectedSub.value = sub;
skipFormData.value = {
mode: "count",
count: 100,
};
showSkipDialog.value = true;
}
function openPeekDialog(sub: SubscriptionInfo) {
selectedSub.value = sub;
peekCount.value = 5;
peekedMessages.value = [];
peekIncomplete.value = false;
showPeekDialog.value = true;
void handlePeekMessages();
}
function openExpireDialog(sub: SubscriptionInfo) {
if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
selectedSub.value = sub;
expireSeconds.value = 3600;
showExpireDialog.value = true;
}
function selectSubscription(sub: SubscriptionInfo) {
if (isRocketMqCluster.value) {
openRocketMqDetail(sub);
return;
}
selectedSub.value = sub;
emit("subscriptionSelected", sub.name);
}
async function handleCreate() {
if (!(await guardWritable(t("mqSubscriptions.create")))) return;
const topicRef = getPulsarTopicRef();
if (!formData.value.subName.trim() || !topicRef) {
error.value = t("mqSubscriptions.subscriptionNameRequired");
return;
}
loading.value = true;
error.value = undefined;
try {
const pos: ResetPosition = { kind: formData.value.startFrom };
await mqCreateSubscription(props.connectionId, topicRef, formData.value.subName, pos);
showCreateDialog.value = false;
await loadSubscriptions();
} catch (e: unknown) {
error.value = formatError(e);
} finally {
loading.value = false;
}
}
function handleDelete(sub: SubscriptionInfo) {
if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
deleteTarget.value = sub;
showDeleteDialog.value = true;
}
async function confirmDelete() {
if (!(await guardWritable(t("mqSubscriptions.delete")))) return;
const sub = deleteTarget.value;
if (!sub) return;
const topicRef = getListTopicRef();
if (!topicRef) return;
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 {
deleting.value = false;
}
}
async function handleResetCursor() {
if (resetting.value || !showResetDialog.value) return;
resetError.value = undefined;
if (props.readOnly) {
resetError.value = t("mqSubscriptions.readOnly");
return;
}
const topicRef = getPulsarTopicRef();
const sub = selectedSub.value;
if (!sub || !topicRef) return;
const connectionId = props.connectionId;
const form = { ...resetFormData.value };
const requestVersion = ++resetRequestVersion;
const contextVersion = subscriptionContextVersion;
resetting.value = true;
try {
let pos: ResetPosition;
if (form.position === "timestamp") {
pos = { kind: "timestamp", timestampMs: form.timestampMs };
} else if (form.position === "partitionOffset") {
const { partition, offset } = form;
if (!Number.isSafeInteger(partition) || partition < 0 || !Number.isSafeInteger(offset) || offset < 0) {
resetError.value = t("mqSubscriptions.nonNegativeIntegerRequired");
return;
}
pos = { kind: "partitionOffset", partition, offset };
} else {
pos = { kind: form.position };
}
if (!(await confirmMqWrite(t("mqSubscriptions.reset"))) || requestVersion !== resetRequestVersion) return;
if (props.readOnly) {
resetError.value = t("mqSubscriptions.readOnly");
return;
}
await mqResetCursor(connectionId, topicRef, sub.name, pos);
if (contextVersion !== subscriptionContextVersion) return;
// Closing the dialog does not cancel a reset already sent to the backend.
if (requestVersion === resetRequestVersion) closeResetDialog();
await loadSubscriptions();
} catch (e: unknown) {
if (requestVersion === resetRequestVersion) resetError.value = formatError(e);
} finally {
if (requestVersion === resetRequestVersion) resetting.value = false;
}
}
async function handleSkipMessages() {
if (!(await guardWritable(t("mqSubscriptions.skip")))) return;
const topicRef = getPulsarTopicRef();
if (!selectedSub.value || !topicRef) return;
loading.value = true;
error.value = undefined;
try {
const count: SkipCount = skipFormData.value.mode === "all" ? { kind: "all" } : { kind: "count", count: skipFormData.value.count };
await mqSkipMessages(props.connectionId, topicRef, selectedSub.value.name, count);
showSkipDialog.value = false;
await loadSubscriptions();
} catch (e: unknown) {
error.value = formatError(e);
} finally {
loading.value = false;
}
}
function handleClearBacklog(sub: SubscriptionInfo) {
if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
clearBacklogTarget.value = sub;
clearBacklogError.value = undefined;
showClearBacklogDialog.value = true;
}
async function confirmClearBacklog() {
if (!(await guardWritable(t("mqSubscriptions.clearBacklog")))) return;
const sub = clearBacklogTarget.value;
if (!sub) return;
const topicRef = getPulsarTopicRef();
if (!topicRef) return;
clearingBacklog.value = true;
clearBacklogError.value = undefined;
try {
await mqClearBacklog(props.connectionId, topicRef, sub.name);
showClearBacklogDialog.value = false;
await loadSubscriptions();
} catch (e: unknown) {
clearBacklogError.value = formatError(e);
} finally {
clearingBacklog.value = false;
}
}
async function handlePeekMessages() {
const topicRef = getPulsarTopicRef();
const sub = selectedSub.value;
if (!sub || !topicRef) return;
const requestVersion = ++peekRequestVersion;
const count = Math.max(1, Math.min(100, Number(peekCount.value) || 1));
peekCount.value = count;
peekLoading.value = true;
peekIncomplete.value = false;
error.value = undefined;
try {
const result = await mqPeekMessages(props.connectionId, topicRef, sub.name, count);
if (requestVersion === peekRequestVersion) {
if (Array.isArray(result)) {
peekedMessages.value = result;
peekIncomplete.value = false;
} else {
peekedMessages.value = result.messages;
peekIncomplete.value = result.incomplete;
}
}
} catch (e: unknown) {
if (requestVersion === peekRequestVersion) error.value = formatError(e);
} finally {
if (requestVersion === peekRequestVersion) peekLoading.value = false;
}
}
function invalidatePeekRequest() {
peekRequestVersion += 1;
peekLoading.value = false;
peekedMessages.value = [];
peekIncomplete.value = false;
}
async function handleExpireMessages() {
if (!(await guardWritable(t("mqSubscriptions.expire")))) return;
const topicRef = getPulsarTopicRef();
if (!selectedSub.value || !topicRef) return;
loading.value = true;
error.value = undefined;
try {
await mqExpireMessages(props.connectionId, topicRef, selectedSub.value.name, expireSeconds.value);
showExpireDialog.value = false;
await loadSubscriptions();
} catch (e: unknown) {
error.value = formatError(e);
} finally {
loading.value = false;
}
}
watch(
() => [props.connectionId, props.topic, props.tenant, props.namespace, props.mqSystemKind],
() => {
subscriptionContextVersion += 1;
closeResetDialog();
selectedSub.value = undefined;
invalidatePeekRequest();
loadSubscriptions();
},
{ immediate: true },
);
onBeforeUnmount(() => {
subscriptionContextVersion += 1;
closeResetDialog();
});
</script>
<template>
<div class="subscriptions-panel">
<div class="panel-toolbar">
<div class="toolbar-left">
<h3>{{ panelTitle }}</h3>
<input v-if="isClusterWideMode" v-model="searchKeyword" type="search" class="topic-search" :placeholder="searchPlaceholder" />
<span v-if="isClusterWideMode && subscriptions.length" class="topic-count" data-testid="subscription-count"> {{ filteredSubscriptions.length }} / {{ typeFilteredSubscriptions.length }} </span>
<span v-if="enriching" class="topic-count">{{ t("mqSubscriptions.enriching") }}</span>
</div>
<div class="toolbar-actions">
<button v-if="isClusterWideMode" class="btn-secondary" :disabled="loading || enriching" @click="loadSubscriptions">
{{ loading ? t("mqSubscriptions.refreshing") : t("mqSubscriptions.refresh") }}
</button>
<button v-if="supportsCreateSubscription !== false && !isRocketMqCluster" @click="openCreateDialog" :disabled="loading || readOnly || !topic" class="btn-primary">+ {{ t("mqSubscriptions.createSubscription") }}</button>
</div>
</div>
<MqTypeFilterBar v-if="isRocketMqCluster && tenant && namespace" :label="t('mqSubscriptions.typeFilter')">
<label v-for="type in rocketMqConsumerGroupTypeOptions" :key="type" class="checkbox-label compact">
<input v-model="consumerGroupTypeFilters[type]" type="checkbox" />
{{ t(`mqSubscriptions.rocketmqGroupType.${type.toLowerCase()}`) }}
</label>
</MqTypeFilterBar>
<div v-if="!canShowPanel" class="panel-placeholder">{{ t("mqSubscriptions.selectTopicFirst") }}</div>
<template v-else>
<div v-if="error" class="panel-error">{{ error }}</div>
<div v-if="enrichFailedHint && !error" class="panel-hint">{{ enrichFailedHint }}</div>
<div v-if="!error && loading && !subscriptions.length" class="panel-loading">{{ t("mqSubscriptions.loading") }}</div>
<div v-else-if="!error && !filteredSubscriptions.length" class="panel-placeholder">
{{ isClusterWideMode ? (subscriptions.length ? t("mqSubscriptions.noMatches") : t("mqSubscriptions.noConsumerGroups")) : t("mqSubscriptions.noSubscriptions") }}
</div>
<div v-else-if="!error && filteredSubscriptions.length" class="subscriptions-table">
<table>
<thead>
<tr>
<th>{{ t("mqSubscriptions.subscriptionName") }}</th>
<th>{{ t("mqSubscriptions.type") }}</th>
<th v-if="isRocketMqCluster">{{ t("mqSubscriptions.mode") }}</th>
<th v-if="isClusterWideMode">{{ t("mqSubscriptions.subscribedTopics") }}</th>
<th v-if="!isClusterWideMode">{{ t("mqSubscriptions.backlog") }}</th>
<th v-if="!isClusterWideMode">{{ t("mqSubscriptions.consumeRate") }}</th>
<th>{{ t("mqSubscriptions.consumers") }}</th>
<th>{{ t("mqSubscriptions.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="sub in filteredSubscriptions" :key="sub.name" :class="{ selected: selectedSub?.name === sub.name }" @click="selectSubscription(sub)">
<td class="sub-name">{{ sub.name }}</td>
<td>
<span v-if="isRocketMqCluster" :class="consumerGroupTypeBadgeClass(sub)">{{ consumerGroupTypeLabel(sub) }}</span>
<span v-else class="badge">{{ sub.subType }}</span>
</td>
<td v-if="isRocketMqCluster">
<span class="badge badge-outline">{{ consumerGroupModeLabel(sub) }}</span>
</td>
<td v-if="isClusterWideMode" class="topics-cell">
<span v-if="sub.topics?.length">{{ sub.topics.join(", ") }}</span>
<span v-else class="text-muted">-</span>
</td>
<td v-if="!isClusterWideMode">
<span v-if="sub.backlogUnavailable" class="text-muted">-</span>
<span v-else :class="{ 'text-warning': sub.msgBacklog > 1000 }">
{{ sub.msgBacklog.toLocaleString() }}
</span>
</td>
<td v-if="!isClusterWideMode">{{ t("mqSubscriptions.msgRate", { rate: sub.msgRateOut.toFixed(2) }) }}</td>
<td data-testid="online-members">
<template v-if="isClusterWideMode">
<span v-if="sub.onlineMembers == null" class="text-muted">-</span>
<span v-else>{{ sub.onlineMembers }}</span>
</template>
<template v-else>{{ t("mqSubscriptions.consumerCount", { count: sub.consumers.length }) }}</template>
</td>
<td class="actions">
<template v-if="isRocketMqCluster">
<button @click.stop="openRocketMqDetail(sub)" class="btn-sm">{{ t("mqSubscriptions.viewDetail") }}</button>
<button @click.stop="openRocketMqConfig(sub)" :disabled="readOnly" class="btn-sm">{{ t("mqSubscriptions.editConfig") }}</button>
<button @click.stop="handleDelete(sub)" :disabled="readOnly" class="btn-sm btn-danger">{{ t("mqSubscriptions.delete") }}</button>
</template>
<template v-else>
<button v-if="supportsResetCursor !== false" @click.stop="openResetDialog(sub)" :disabled="readOnly || !topic" class="btn-sm">{{ t("mqSubscriptions.resetCursor") }}</button>
<button v-if="supportsSkipMessages !== false" @click.stop="openSkipDialog(sub)" :disabled="readOnly" class="btn-sm">{{ t("mqSubscriptions.skipMessages") }}</button>
<button v-if="supportsClearBacklog !== false" @click.stop="handleClearBacklog(sub)" :disabled="readOnly || !topic" class="btn-sm">{{ t("mqSubscriptions.clearBacklog") }}</button>
<button v-if="supportsPeekMessages" @click.stop="openPeekDialog(sub)" :disabled="!topic" class="btn-sm">{{ t("mqSubscriptions.peek") }}</button>
<button v-if="supportsExpireMessages !== false" @click.stop="openExpireDialog(sub)" :disabled="readOnly" class="btn-sm">{{ t("mqSubscriptions.expireMessages") }}</button>
<button @click.stop="handleDelete(sub)" :disabled="readOnly" class="btn-sm btn-danger">{{ t("mqSubscriptions.delete") }}</button>
</template>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<RocketMqConsumerGroupDialogs v-if="isRocketMqCluster" :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :group="selectedSub" :dialog="activeRocketMqDialog" :read-only="readOnly" @close="closeRocketMqDialog" @refreshed="loadSubscriptions" />
<!-- Create Dialog -->
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
<div class="dialog" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqSubscriptions.createDialogTitle") }}</h3>
<button @click="showCreateDialog = false" class="btn-close">×</button>
</div>
<div class="dialog-body">
<div class="form-group">
<label>{{ t("mqSubscriptions.topic") }}</label>
<input type="text" :value="topic?.shortName" disabled />
</div>
<div class="form-group">
<label>{{ t("mqSubscriptions.subscriptionNameLabel") }}</label>
<input v-model="formData.subName" type="text" :placeholder="t('mqSubscriptions.subscriptionNamePlaceholder')" :disabled="readOnly" />
</div>
<div class="form-group">
<label>{{ t("mqSubscriptions.startPosition") }}</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" v-model="formData.startFrom" value="earliest" :disabled="readOnly" />
{{ t("mqSubscriptions.startFromEarliest") }}
</label>
<label class="radio-label">
<input type="radio" v-model="formData.startFrom" value="latest" :disabled="readOnly" />
{{ t("mqSubscriptions.startFromLatest") }}
</label>
</div>
</div>
<div v-if="error" class="form-error">{{ error }}</div>
</div>
<div class="dialog-footer">
<button @click="showCreateDialog = false" class="btn-secondary">{{ t("mqSubscriptions.cancel") }}</button>
<button @click="handleCreate" :disabled="loading || readOnly" class="btn-primary">{{ t("mqSubscriptions.create") }}</button>
</div>
</div>
</div>
<!-- Reset Cursor Dialog -->
<div v-if="!isRocketMqCluster && showResetDialog" class="dialog-overlay" @click="closeResetDialog">
<div class="dialog" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqSubscriptions.resetDialogTitle", { name: selectedSub?.name }) }}</h3>
<button @click="closeResetDialog" class="btn-close">×</button>
</div>
<div class="dialog-body">
<div class="form-group">
<label>{{ t("mqSubscriptions.resetTo") }}</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" v-model="resetFormData.position" value="earliest" :disabled="resetting || readOnly" />
{{ t("mqSubscriptions.earliest") }}
</label>
<label class="radio-label">
<input type="radio" v-model="resetFormData.position" value="latest" :disabled="resetting || readOnly" />
{{ t("mqSubscriptions.latest") }}
</label>
<label class="radio-label">
<input type="radio" v-model="resetFormData.position" value="timestamp" :disabled="resetting || readOnly" />
{{ t("mqSubscriptions.timestamp") }}
</label>
<label v-if="mqSystemKind === 'kafka'" class="radio-label">
<input type="radio" v-model="resetFormData.position" value="partitionOffset" :disabled="resetting || readOnly" />
{{ t("mqSubscriptions.partitionOffset") }}
</label>
</div>
</div>
<div v-if="resetFormData.position === 'timestamp'" class="form-group">
<label>{{ t("mqSubscriptions.timestampMs") }}</label>
<input v-model.number="resetFormData.timestampMs" type="number" :disabled="resetting || readOnly" />
<div class="form-hint">{{ t("mqSubscriptions.currentTime", { time: new Date(resetFormData.timestampMs).toLocaleString() }) }}</div>
</div>
<div v-if="mqSystemKind === 'kafka' && resetFormData.position === 'partitionOffset'" class="form-group">
<label>{{ t("mqSubscriptions.partition") }}</label>
<input v-model.number="resetFormData.partition" data-testid="reset-partition" type="number" min="0" step="1" :disabled="resetting || readOnly" />
<label>{{ t("mqSubscriptions.offset") }}</label>
<input v-model.number="resetFormData.offset" data-testid="reset-offset" type="number" min="0" step="1" :disabled="resetting || readOnly" />
</div>
<div v-if="resetError" class="form-error">{{ resetError }}</div>
</div>
<div class="dialog-footer">
<button @click="closeResetDialog" class="btn-secondary">{{ t("mqSubscriptions.cancel") }}</button>
<button @click="handleResetCursor" :disabled="resetting || readOnly" class="btn-primary">{{ t("mqSubscriptions.reset") }}</button>
</div>
</div>
</div>
<!-- Skip Messages Dialog -->
<div v-if="!isRocketMqCluster && showSkipDialog" class="dialog-overlay" @click="showSkipDialog = false">
<div class="dialog" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqSubscriptions.skipDialogTitle", { name: selectedSub?.name }) }}</h3>
<button @click="showSkipDialog = false" class="btn-close">×</button>
</div>
<div class="dialog-body">
<div class="form-group">
<label>{{ t("mqSubscriptions.skipMode") }}</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" v-model="skipFormData.mode" value="count" :disabled="readOnly" />
{{ t("mqSubscriptions.skipCount") }}
</label>
<label class="radio-label">
<input type="radio" v-model="skipFormData.mode" value="all" :disabled="readOnly" />
{{ t("mqSubscriptions.skipAll") }}
</label>
</div>
</div>
<div v-if="skipFormData.mode === 'count'" class="form-group">
<label>{{ t("mqSubscriptions.skipCountLabel") }}</label>
<input v-model.number="skipFormData.count" type="number" min="1" :disabled="readOnly" />
</div>
<div v-if="error" class="form-error">{{ error }}</div>
</div>
<div class="dialog-footer">
<button @click="showSkipDialog = false" class="btn-secondary">{{ t("mqSubscriptions.cancel") }}</button>
<button @click="handleSkipMessages" :disabled="loading || readOnly" class="btn-primary">{{ t("mqSubscriptions.skip") }}</button>
</div>
</div>
</div>
<!-- Peek Messages Dialog -->
<div v-if="!isRocketMqCluster && showPeekDialog" class="dialog-overlay" @click="showPeekDialog = false">
<div class="dialog dialog-wide" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqSubscriptions.peekDialogTitle", { name: selectedSub?.name }) }}</h3>
<button @click="showPeekDialog = false" class="btn-close">×</button>
</div>
<div class="dialog-body">
<div class="peek-toolbar">
<label>
{{ t("mqSubscriptions.count") }}
<input v-model.number="peekCount" type="number" min="1" max="100" />
</label>
<button @click="handlePeekMessages" :disabled="peekLoading" class="btn-sm">
{{ peekLoading ? t("mqSubscriptions.loading") : t("mqSubscriptions.refresh") }}
</button>
</div>
<div v-if="peekIncomplete" class="form-warning" role="status" data-testid="peek-incomplete">
{{ t("mqMessages.peekIncomplete") }}
</div>
<div v-if="error" class="form-error">{{ error }}</div>
<div v-else-if="peekLoading && !peekedMessages.length" class="panel-loading">{{ t("mqSubscriptions.loading") }}</div>
<div v-else-if="!peekedMessages.length" class="panel-placeholder">{{ t("mqSubscriptions.noPeekMessages") }}</div>
<div v-else class="peek-results">
<div v-for="message in peekedMessages" :key="message.position" class="peek-message">
<div class="peek-message-header">
<span>#{{ message.position }}</span>
<span v-if="message.messageId">{{ message.messageId }}</span>
<span v-if="message.key">{{ t("mqSubscriptions.peekMessageKey", { key: message.key }) }}</span>
</div>
<div v-if="Object.keys(message.properties).length" class="peek-properties">
<span v-for="(value, key) in message.properties" :key="key">{{ key }}={{ value }}</span>
</div>
<pre class="peek-payload">{{ message.payloadText ?? message.payloadBase64 }}</pre>
</div>
</div>
</div>
<div class="dialog-footer">
<button @click="showPeekDialog = false" class="btn-secondary">{{ t("mqSubscriptions.close") }}</button>
</div>
</div>
</div>
<!-- Expire Messages Dialog -->
<div v-if="!isRocketMqCluster && showExpireDialog" class="dialog-overlay" @click="showExpireDialog = false">
<div class="dialog" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqSubscriptions.expireDialogTitle", { name: selectedSub?.name }) }}</h3>
<button @click="showExpireDialog = false" class="btn-close">×</button>
</div>
<div class="dialog-body">
<div class="form-group">
<label>{{ t("mqSubscriptions.expireSeconds") }}</label>
<input v-model.number="expireSeconds" type="number" min="1" :disabled="readOnly" />
<div class="form-hint">{{ t("mqSubscriptions.expireHint", { seconds: expireSeconds }) }}</div>
</div>
<div v-if="error" class="form-error">{{ error }}</div>
</div>
<div class="dialog-footer">
<button @click="showExpireDialog = false" class="btn-secondary">{{ t("mqSubscriptions.cancel") }}</button>
<button @click="handleExpireMessages" :disabled="loading || readOnly" class="btn-primary">{{ t("mqSubscriptions.expire") }}</button>
</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"
>
<template #options>
<div v-if="clearBacklogError" class="form-error" role="alert">{{ clearBacklogError }}</div>
</template>
</DangerConfirmDialog>
</div>
</template>
<style scoped>
@import "./shared/mqPanel.css";
.subscriptions-panel {
height: 100%;
display: flex;
flex-direction: column;
}
.panel-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.toolbar-left h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 0 0 auto;
}
.topic-search {
width: min(320px, 32vw);
min-width: 180px;
padding: 6px 10px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text);
font-size: 13px;
}
.topic-search:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha);
}
.topic-count {
flex: 0 0 auto;
color: var(--color-text-tertiary);
font-size: 12px;
}
.subscription-type-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
padding: 10px 16px;
border-bottom: 1px solid var(--color-border-light, var(--color-border));
background: var(--color-background-secondary);
}
.subscription-type-filters-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
margin-right: 4px;
}
.checkbox-label.compact {
font-size: 12px;
gap: 6px;
}
.badge-outline {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
}
.badge-info {
background: var(--color-info-alpha);
color: var(--color-info);
}
.badge-muted {
background: color-mix(in srgb, var(--color-text-secondary) 12%, transparent);
color: var(--color-text-secondary);
}
.topics-cell {
max-width: 280px;
word-break: break-all;
}
.panel-toolbar h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.search-input {
min-width: 220px;
padding: 6px 10px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
font-size: 13px;
background: var(--color-background);
}
.topics-cell {
max-width: 280px;
word-break: break-word;
}
.panel-placeholder,
.panel-error,
.panel-hint,
.panel-loading {
padding: 24px;
text-align: center;
color: var(--color-text-secondary);
}
.panel-error {
color: var(--color-error);
}
.panel-hint {
padding: 8px 16px;
text-align: left;
font-size: 12px;
}
.subscriptions-table {
flex: 1;
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
position: sticky;
top: 0;
background: var(--color-background-secondary);
z-index: 1;
}
th {
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border);
}
tbody tr {
cursor: pointer;
transition: background 0.2s;
}
tbody tr:hover {
background: var(--color-hover);
}
tbody tr.selected {
background: var(--color-primary-alpha);
}
td {
padding: 10px 12px;
border-bottom: 1px solid var(--color-border-light);
}
.sub-name {
font-weight: 500;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--dbx-radius-fixed-4);
font-size: 11px;
font-weight: 500;
background: var(--color-background-secondary);
color: var(--color-text-secondary);
}
.text-warning {
color: var(--color-warning);
font-weight: 500;
}
.text-muted {
color: var(--color-text-secondary);
}
.actions {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Dialog styles */
.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: var(--dbx-radius-fixed-6);
width: 90%;
max-width: 500px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.dialog-wide {
max-width: 760px;
}
.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;
}
.dialog-body {
padding: 20px;
max-height: 60vh;
overflow-y: auto;
}
.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.topic-select {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
font-size: 14px;
box-sizing: border-box;
background: var(--color-background);
color: var(--color-text);
}
.form-group input:disabled {
background: var(--color-background-secondary);
color: var(--color-text-secondary);
}
.radio-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.radio-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 8px;
border-radius: var(--dbx-radius-fixed-4);
transition: background 0.2s;
}
.radio-label:hover {
background: var(--color-hover);
}
.radio-label input[type="radio"] {
cursor: pointer;
}
.form-hint {
margin-top: 4px;
font-size: 12px;
color: var(--color-text-tertiary);
}
.form-error {
margin-top: 12px;
padding: 8px 12px;
background: var(--color-error-bg);
color: var(--color-error);
border-radius: var(--dbx-radius-fixed-4);
font-size: 13px;
}
.form-warning {
margin-top: 12px;
padding: 8px 12px;
border: 1px solid var(--color-warning-border, #d99a22);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-warning-background, #fff6df);
color: var(--color-warning-text, #7a4a00);
font-size: 13px;
}
.peek-toolbar {
display: flex;
align-items: end;
gap: 12px;
margin-bottom: 12px;
}
.peek-toolbar label {
display: grid;
gap: 6px;
font-size: 13px;
}
.peek-toolbar input {
width: 96px;
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
}
.peek-results {
display: grid;
gap: 12px;
}
.peek-message {
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background-secondary);
overflow: hidden;
}
.peek-message-header,
.peek-properties {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 10px;
font-size: 12px;
color: var(--color-text-secondary);
}
.peek-message-header {
border-bottom: 1px solid var(--color-border-light);
font-weight: 600;
}
.peek-properties {
border-bottom: 1px solid var(--color-border-light);
}
.peek-payload {
margin: 0;
max-height: 220px;
overflow: auto;
padding: 10px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 12px;
line-height: 1.5;
color: var(--color-text);
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--color-border);
}
</style>