1259 lines
42 KiB
Vue
1259 lines
42 KiB
Vue
<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>
|