feat(kafka): redesign message browsing

This commit is contained in:
onenewcode authored and GitHub committed 2026-08-04 13:13:49 +08:00
1 parent 2e57ac8bc0
commit ae87353a28
32 files changed
+2776 -624

No files matched your search

@@ -0,0 +1,462 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { MqSystemKind, PeekedMessage, PeekMessagesOptions, TopicRef } from "@/types/mq";
import { mqPeekMessages } from "@/lib/backend/api";
import { formatError } from "@/lib/backend/errorUtils";
import { parseNonNegativeSafeInteger } from "@/lib/mq/mqPeekFilters";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
type MessageBrowserAppearance = "form" | "monitoring";
interface Props {
connectionId: string;
topic?: TopicRef | null;
mqSystemKind?: MqSystemKind;
/** Flatten chrome when embedded in MonitoringPanel so it is not a second nested card. */
appearance?: MessageBrowserAppearance;
}
const props = withDefaults(defineProps<Props>(), {
appearance: "form",
});
const { t } = useI18n();
const loading = ref(false);
const error = ref<string>();
const messages = ref<PeekedMessage[]>([]);
const incomplete = ref(false);
const partition = ref<string | number>("");
const offset = ref<string | number>("");
const count = ref(20);
const advancedExpanded = ref(false);
type KafkaPeekStartPosition = NonNullable<PeekMessagesOptions["startPosition"]>;
const kafkaStartPosition = ref<KafkaPeekStartPosition>("latest");
let messageRequestVersion = 0;
const isKafka = computed(() => props.mqSystemKind === "kafka");
const isKafkaOffsetMode = computed(() => kafkaStartPosition.value === "offset");
const isMonitoring = computed(() => props.appearance === "monitoring");
function peekGroupName(): string {
if (props.mqSystemKind === "rocketmq") return "__dbx_rocketmq_viewer__";
return "__dbx_kafka_viewer__";
}
async function loadMessages() {
const topic = props.topic;
if (!topic || loading.value) return;
const requestVersion = ++messageRequestVersion;
loading.value = true;
error.value = undefined;
incomplete.value = false;
try {
const resultLimit = Math.max(1, Math.min(100, Math.trunc(Number(count.value) || 20)));
count.value = resultLimit;
const options: PeekMessagesOptions = {};
const partitionText = String(partition.value).trim();
const offsetText = String(offset.value).trim();
if (isKafka.value) {
options.startPosition = kafkaStartPosition.value;
if (partitionText !== "") {
const parsedPartition = parseNonNegativeSafeInteger(partitionText);
if (parsedPartition == null) throw new Error(t("mqMessages.partitionMustBeNonNegativeInt"));
options.partition = parsedPartition;
partition.value = String(parsedPartition);
}
if (isKafkaOffsetMode.value) {
if (offsetText === "") throw new Error(t("mqMessages.offsetRequiredForOffset"));
const parsedOffset = parseNonNegativeSafeInteger(offsetText);
if (parsedOffset == null) throw new Error(t("mqMessages.offsetMustBeNonNegativeIntRequired"));
options.offset = parsedOffset;
offset.value = String(parsedOffset);
}
} else {
if (partitionText !== "") {
const parsedPartition = parseNonNegativeSafeInteger(partitionText);
if (parsedPartition == null) throw new Error(t("mqMessages.partitionMustBeNonNegativeInt"));
options.partition = parsedPartition;
partition.value = String(parsedPartition);
}
if (offsetText !== "") {
const parsedOffset = parseNonNegativeSafeInteger(offsetText);
if (parsedOffset == null) throw new Error(t("mqMessages.offsetMustBeNonNegativeInt"));
options.offset = parsedOffset;
offset.value = String(parsedOffset);
}
}
const result = await mqPeekMessages(props.connectionId, topic, peekGroupName(), resultLimit, options);
if (requestVersion === messageRequestVersion) {
const browseResult = Array.isArray(result) ? { messages: result, incomplete: false } : result;
messages.value = browseResult.messages;
incomplete.value = browseResult.incomplete;
}
} catch (cause: unknown) {
if (requestVersion === messageRequestVersion) {
error.value = formatError(cause);
}
} finally {
if (requestVersion === messageRequestVersion) {
loading.value = false;
}
}
}
function invalidateMessageRequest() {
messageRequestVersion += 1;
loading.value = false;
error.value = undefined;
messages.value = [];
incomplete.value = false;
}
function messagePayload(message: PeekedMessage): string {
return message.payloadText ?? message.payloadBase64;
}
function formatMessageTimestamp(value?: string): string {
if (!value) return "-";
const numeric = Number(value);
if (!Number.isFinite(numeric)) return value;
return new Date(numeric).toLocaleString();
}
watch([() => props.connectionId, () => props.mqSystemKind, () => JSON.stringify(props.topic ?? null)], () => {
invalidateMessageRequest();
});
watch(kafkaStartPosition, () => {
// Keep offset values for switching back, but never retain results from another start mode.
invalidateMessageRequest();
});
</script>
<template>
<section v-if="topic" class="message-browser" :class="{ 'is-monitoring': isMonitoring }" data-testid="message-browser">
<div class="message-browser-header">
<h4>{{ t("mqMessages.messageList") }}</h4>
<button type="button" class="btn-sm" :disabled="loading" @click="loadMessages">
{{ loading ? t("mqMessages.loading") : t("mqMessages.loadMessages") }}
</button>
</div>
<p v-if="isKafka" class="peek-default-hint">
<template v-if="kafkaStartPosition === 'latest'">
{{ t("mqMessages.kafkaLatestHint") }}
</template>
<template v-else-if="kafkaStartPosition === 'earliest'">
{{ t("mqMessages.kafkaEarliestHint") }}
</template>
<template v-else>
{{ t("mqMessages.kafkaOffsetHint") }}
</template>
</p>
<p v-else class="peek-default-hint">{{ t("mqMessages.peekDefaultHint") }}</p>
<p v-if="incomplete" class="peek-incomplete" role="status" data-testid="peek-incomplete">
{{ t("mqMessages.peekIncomplete") }}
</p>
<div class="peek-controls">
<label>
<span>{{ t("mqMessages.count") }}</span>
<input v-model.number="count" data-testid="peek-count" type="number" min="1" max="100" :disabled="loading" />
</label>
<label v-if="isKafka">
<span>{{ t("mqMessages.startPosition") }}</span>
<Select v-model="kafkaStartPosition" :disabled="loading">
<SelectTrigger data-testid="kafka-peek-start-position" class="message-browser-start-position">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" class="message-browser-start-position-content">
<SelectItem value="latest">{{ t("mqMessages.kafkaLatest") }}</SelectItem>
<SelectItem value="earliest">{{ t("mqMessages.kafkaEarliest") }}</SelectItem>
<SelectItem value="offset">{{ t("mqMessages.kafkaOffset") }}</SelectItem>
</SelectContent>
</Select>
</label>
<label v-if="isKafka">
<span>{{ t("mqMessages.partition") }}</span>
<input v-model="partition" data-testid="kafka-peek-partition" type="number" min="0" :placeholder="t('mqMessages.partitionPlaceholderAll')" :disabled="loading" />
</label>
<label v-if="isKafkaOffsetMode">
<span>{{ t("mqMessages.offset") }}</span>
<input v-model="offset" data-testid="kafka-peek-offset" type="number" min="0" :placeholder="t('mqMessages.offsetPlaceholderRequired')" :disabled="loading" />
</label>
</div>
<template v-if="!isKafka">
<button type="button" class="collapse-toggle peek-advanced-toggle" @click="advancedExpanded = !advancedExpanded">
<span class="collapse-arrow" :class="{ expanded: advancedExpanded }">&#9654;</span>
<span>{{ t("mqMessages.advancedFilter") }}</span>
<span v-if="(partition || offset) && !advancedExpanded" class="collapse-badge">&middot;</span>
</button>
<div v-if="advancedExpanded" class="peek-controls non-kafka-controls">
<label>
<span>{{ t("mqMessages.partition") }}</span>
<input v-model="partition" type="number" min="0" :placeholder="t('mqMessages.partitionPlaceholderAll')" :disabled="loading" />
</label>
<label>
<span>{{ t("mqMessages.offset") }}</span>
<input v-model="offset" type="number" min="0" :placeholder="t('mqMessages.offsetPlaceholderEarliest')" :disabled="loading" />
</label>
</div>
</template>
<div v-if="error" class="panel-error">{{ error }}</div>
<div v-else-if="loading" class="message-empty">{{ t("mqMessages.messagesLoading") }}</div>
<div v-else-if="!messages.length" class="message-empty">{{ t("mqMessages.noMessages") }}</div>
<div v-else class="message-list">
<article v-for="message in messages" :key="`${message.properties?.partition ?? 'p'}-${message.messageId || message.position}`" class="message-row">
<div class="message-meta">
<span>#{{ message.position }}</span>
<span v-if="message.properties?.partition != null">{{ t("mqMessages.metaPartition", { partition: message.properties.partition }) }}</span>
<span>{{ t("mqMessages.metaOffset", { offset: message.messageId || "-" }) }}</span>
<span v-if="message.key">{{ t("mqMessages.metaKey", { key: message.key }) }}</span>
<span>{{ formatMessageTimestamp(message.publishTime) }}</span>
</div>
<pre class="message-payload">{{ messagePayload(message) }}</pre>
<div v-if="Object.keys(message.headers || {}).length" class="message-headers">
<span v-for="(value, key) in message.headers" :key="key">{{ key }}: {{ value }}</span>
</div>
</article>
</div>
</section>
</template>
<style scoped>
.message-browser {
margin-top: 4px;
padding: 14px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background-secondary);
}
/* Only flatten outer chrome when embedded in MonitoringPanel — do not restyle list rows. */
.message-browser.is-monitoring {
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.message-browser-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.message-browser-header h4 {
margin: 0;
color: var(--color-text);
font-size: 14px;
font-weight: 600;
}
.btn-sm {
padding: 4px 10px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 12px;
}
.btn-sm:hover:not(:disabled) {
background: var(--color-background-secondary);
}
.btn-sm:disabled,
.peek-controls input:disabled,
.peek-controls :deep(.message-browser-start-position[data-disabled]) {
opacity: 0.5;
cursor: not-allowed;
}
.peek-default-hint {
margin: 0 0 12px;
padding: 8px 10px;
border-radius: var(--dbx-radius-fixed-6);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
color: var(--color-text-secondary);
font-size: 12px;
line-height: 1.5;
}
.peek-incomplete {
margin: 0 0 12px;
padding: 8px 10px;
border: 1px solid var(--color-warning-border, #d99a22);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-warning-background, #fff6df);
color: var(--color-warning-text, #7a4a00);
font-size: 12px;
}
.peek-controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.peek-controls label {
display: flex;
flex-direction: column;
gap: 5px;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 500;
}
.peek-controls input,
.peek-controls :deep(.message-browser-start-position) {
height: 32px;
width: 100%;
padding: 7px 10px;
box-sizing: border-box;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text);
font-size: 13px;
}
.peek-controls input:focus,
.peek-controls :deep(.message-browser-start-position:focus-visible) {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha);
}
.non-kafka-controls {
margin-top: 6px;
}
.collapse-toggle {
display: flex;
align-items: center;
gap: 6px;
padding: 0;
border: none;
background: none;
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
user-select: none;
}
.peek-advanced-toggle {
margin-bottom: 10px;
}
.collapse-arrow {
display: inline-block;
font-size: 10px;
transition: transform 0.15s;
}
.collapse-arrow.expanded {
transform: rotate(90deg);
}
.collapse-badge {
color: var(--color-primary);
font-weight: 700;
}
.panel-error {
padding: 10px 14px;
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-error-bg);
color: var(--color-error);
font-size: 13px;
}
.message-empty {
padding: 18px;
border: 1px dashed var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
color: var(--color-text-tertiary);
text-align: center;
font-size: 13px;
}
.message-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 360px;
overflow: auto;
}
/* Monitoring page already scrolls via .stats-container — avoid a second vertical bar. */
.message-browser.is-monitoring .message-list {
max-height: none;
overflow: visible;
}
.message-row {
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
}
.message-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
color: var(--color-text-tertiary);
font-size: 12px;
}
.message-meta span:first-child {
color: var(--color-primary);
font-weight: 700;
}
.message-payload {
margin: 8px 0 0;
padding: 10px;
max-height: 160px;
overflow: auto;
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background-tertiary, var(--color-background-secondary));
color: var(--color-text);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.message-headers {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.message-headers span {
padding: 2px 6px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
color: var(--color-text-secondary);
background: var(--color-background-secondary);
font-size: 12px;
}
@media (max-width: 720px) {
.peek-controls {
grid-template-columns: 1fr;
}
}
</style>
@@ -151,7 +151,8 @@ async function runQuery() {
} else {
const group = consumerGroup.value.trim() || "__dbx_rocketmq_dlq__";
const peeked = await mqPeekMessages(props.connectionId, topic, group, 64);
queryMessages.value = peeked.map((msg) => rocketMqDisplayFromPeeked(msg, topic.topic));
const messages = Array.isArray(peeked) ? peeked : peeked.messages;
queryMessages.value = messages.map((msg) => rocketMqDisplayFromPeeked(msg, topic.topic));
}
} else if (activeQueryMode.value === "msgId") {
const id = queryMsgId.value.trim();
@@ -8,8 +8,9 @@ import { LineChart } from "echarts/charts";
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
import VChart from "vue-echarts";
import { Activity, AlertTriangle, BarChart3, Boxes, CheckCircle2, Database, Download, Gauge, Hash, HardDrive, Layers3, Loader2, Package, RadioTower, RefreshCw, Send, ShieldCheck, Table2, Upload, Users } from "@lucide/vue";
import type { MqSystemKind, TopicRef, TopicInfo, TopicStats, BacklogStats, PeekedMessage } from "@/types/mq";
import { mqGetTopicStats, mqGetBacklog, mqPeekMessages } from "@/lib/backend/api";
import type { MqSystemKind, TopicRef, TopicInfo, TopicStats, BacklogStats } from "@/types/mq";
import { mqGetTopicStats, mqGetBacklog } from "@/lib/backend/api";
import MessageBrowser from "./MessageBrowser.vue";
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]);
@@ -73,10 +74,6 @@ const error = ref<string>();
const autoRefresh = ref(true);
const refreshInterval = ref(5); // seconds
const selectedPartitionName = ref<string>();
const kafkaMessageSql = ref("");
const kafkaMessageLoading = ref(false);
const kafkaMessageError = ref<string>();
const kafkaMessages = ref<PeekedMessage[]>([]);
let refreshTimer: number | undefined;
const history = ref<MetricPoint[]>([]);
@@ -221,69 +218,6 @@ function refreshNow() {
void loadStats();
}
function defaultKafkaMessageSql(): string {
const topic = props.topic?.shortName;
return topic ? `SELECT * FROM "${topic}" LIMIT 20` : "";
}
function parseKafkaMessageSql(sql: string): { topic: string; partition?: number; offset?: number; limit: number } {
const match = sql.trim().match(/^\s*select\s+\*\s+from\s+(?:"([^"]+)"|`([^`]+)`|'([^']+)'|([^\s;]+))(?:\s+partition\s+(\d+))?(?:\s+offset\s+(\d+))?(?:\s+limit\s+(\d+))?\s*;?\s*$/i);
if (!match) {
throw new Error(t("mqMonitoring.sqlSyntaxError"));
}
const topic = match[1] || match[2] || match[3] || match[4] || "";
const partition = match[5] != null ? Math.max(0, Number(match[5])) : undefined;
const offset = match[6] != null ? Math.max(0, Number(match[6])) : undefined;
const limit = Math.max(1, Math.min(100, Number(match[7] ?? 20)));
return { topic, partition, offset, limit };
}
function flatMqMonitorGroupName(): string {
if (props.mqSystemKind === "rocketmq") return "__dbx_rocketmq_monitor__";
return "__dbx_kafka_monitor__";
}
async function runKafkaMessageSql() {
if (!props.tenant || !props.namespace) return;
kafkaMessageLoading.value = true;
kafkaMessageError.value = undefined;
try {
const parsed = parseKafkaMessageSql(kafkaMessageSql.value);
const selected = props.topic && parsed.topic === props.topic.shortName ? props.topic : undefined;
const options: { partition?: number; offset?: number } = {};
if (parsed.partition != null) options.partition = parsed.partition;
if (parsed.offset != null) options.offset = parsed.offset;
kafkaMessages.value = await mqPeekMessages(
props.connectionId,
{
tenant: props.tenant,
namespace: props.namespace,
topic: parsed.topic,
persistent: selected?.persistent ?? true,
partitioned: selected?.partitioned,
},
flatMqMonitorGroupName(),
parsed.limit,
options,
);
} catch (e: unknown) {
kafkaMessageError.value = formatError(e);
} finally {
kafkaMessageLoading.value = false;
}
}
function kafkaMessagePayload(message: PeekedMessage): string {
return message.payloadText ?? message.payloadBase64;
}
function formatKafkaMessageTimestamp(value?: string): string {
if (!value) return "-";
const numeric = Number(value);
if (!Number.isFinite(numeric)) return value;
return new Date(numeric).toLocaleString();
}
function appendHistoryPoint(statsData: TopicStats, backlogData?: BacklogStats) {
const point: MetricPoint = {
time: new Date().toLocaleTimeString(),
@@ -452,9 +386,6 @@ watch(
() => {
history.value = [];
selectedPartitionName.value = undefined;
kafkaMessageSql.value = defaultKafkaMessageSql();
kafkaMessageError.value = undefined;
kafkaMessages.value = [];
void loadStats();
startAutoRefresh();
},
@@ -515,16 +446,16 @@ onUnmounted(() => {
</div>
</div>
<div v-if="error && topic" class="panel-error">
<AlertTriangle :size="18" />
<span>{{ error }}</span>
</div>
<div v-if="!topic" class="panel-placeholder">
<Table2 :size="24" />
<span>{{ t("mqMonitoring.selectTopicFirst") }}</span>
</div>
<div v-else-if="error" class="panel-error">
<AlertTriangle :size="18" />
<span>{{ error }}</span>
</div>
<div v-else-if="loading && !stats" class="panel-loading">
<Loader2 class="loading-icon spinning" :size="22" />
<span>{{ t("mqMonitoring.loadingStats") }}</span>
@@ -698,36 +629,7 @@ onUnmounted(() => {
</div>
<div class="stats-section">
<div class="section-title-row">
<h4>{{ t("mqMonitoring.kafkaMessageQuery") }}</h4>
<button type="button" class="btn-sm" :disabled="kafkaMessageLoading || !kafkaMessageSql.trim()" @click="runKafkaMessageSql">
<Loader2 v-if="kafkaMessageLoading" class="btn-icon spinning" :size="14" />
<span>{{ kafkaMessageLoading ? t("mqMonitoring.querying") : t("mqMonitoring.queryMessages") }}</span>
</button>
</div>
<textarea v-model="kafkaMessageSql" class="kafka-sql-input" rows="2" spellcheck="false" />
<div class="query-hint">{{ t("mqMonitoring.queryHint") }}</div>
<div v-if="kafkaMessageError" class="panel-error inline-error">
<AlertTriangle :size="16" />
<span>{{ kafkaMessageError }}</span>
</div>
<div v-else-if="kafkaMessageLoading" class="empty-state compact">{{ t("mqMonitoring.messagesLoading") }}</div>
<div v-else-if="!kafkaMessages.length" class="empty-state compact">{{ t("mqMonitoring.noMessages") }}</div>
<div v-else class="kafka-message-list">
<article v-for="message in kafkaMessages" :key="`${message.properties?.partition ?? 'p'}-${message.messageId || message.position}`" class="kafka-message-row">
<div class="kafka-message-meta">
<span>#{{ message.position }}</span>
<span v-if="message.properties?.partition != null">{{ t("mqMonitoring.metaPartition", { partition: message.properties.partition }) }}</span>
<span>{{ t("mqMonitoring.metaOffset", { offset: message.messageId || "-" }) }}</span>
<span v-if="message.key">{{ t("mqMonitoring.metaKey", { key: message.key }) }}</span>
<span>{{ formatKafkaMessageTimestamp(message.publishTime) }}</span>
</div>
<pre class="kafka-message-payload">{{ kafkaMessagePayload(message) }}</pre>
<div v-if="Object.keys(message.headers || {}).length" class="kafka-message-headers">
<span v-for="(value, key) in message.headers" :key="key">{{ key }}: {{ value }}</span>
</div>
</article>
</div>
<MessageBrowser :connection-id="connectionId" :topic="getTopicRef()" mq-system-kind="kafka" appearance="monitoring" />
</div>
</div>
@@ -1266,87 +1168,6 @@ onUnmounted(() => {
margin-bottom: 0;
}
.kafka-sql-input {
width: 100%;
min-height: 54px;
padding: 10px 12px;
border: 1px solid var(--monitor-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--monitor-surface);
color: var(--monitor-text);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
resize: vertical;
box-sizing: border-box;
}
.kafka-sql-input:focus {
outline: none;
border-color: var(--monitor-accent);
box-shadow: 0 0 0 3px var(--monitor-accent-soft);
}
.query-hint {
margin-top: 6px;
color: var(--monitor-faint);
font-size: 12px;
}
.inline-error {
justify-content: flex-start;
margin-top: 10px;
border: 1px solid color-mix(in srgb, var(--monitor-danger) 22%, transparent);
border-radius: var(--dbx-radius-fixed-6);
padding: 10px 12px;
}
.kafka-message-list {
display: grid;
gap: 10px;
margin-top: 12px;
}
.kafka-message-row {
border: 1px solid var(--monitor-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--monitor-surface);
overflow: hidden;
}
.kafka-message-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 8px 10px;
border-bottom: 1px solid var(--monitor-border);
color: var(--monitor-muted);
font-size: 12px;
}
.kafka-message-payload {
max-height: 220px;
margin: 0;
overflow: auto;
padding: 10px;
color: var(--monitor-text);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.kafka-message-headers {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 10px;
border-top: 1px solid var(--monitor-border);
color: var(--monitor-muted);
font-size: 11px;
}
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { MqSystemKind, PeekedMessage, TopicInfo, TopicRef, SendMessageRequest, SendMessageResponse } from "@/types/mq";
import { mqSendMessage, mqListTopics, mqPeekMessages } from "@/lib/backend/api";
import type { MqSystemKind, TopicInfo, TopicRef, SendMessageRequest, SendMessageResponse } from "@/types/mq";
import { mqSendMessage, mqListTopics } from "@/lib/backend/api";
import { formatError } from "@/lib/backend/errorUtils";
import { parseNonNegativeSafeInteger } from "@/lib/mq/mqPeekFilters";
import { resolveRabbitMqSendNamespace } from "@/lib/mq/mqConsoleDefaults";
import MessageBrowser from "./MessageBrowser.vue";
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
interface Props {
@@ -38,13 +38,6 @@ const availableTopics = ref<TopicInfo[]>([]);
const topicsLoading = ref(false);
const rocketMqTopicSelectRef = ref<InstanceType<typeof RocketMqTopicSelect>>();
const headersExpanded = ref(false);
const peekAdvancedExpanded = ref(false);
const peekLoading = ref(false);
const peekError = ref<string>();
const peekMessages = ref<PeekedMessage[]>([]);
const peekPartition = ref<string | number>("");
const peekOffset = ref<string | number>("");
const peekCount = ref(20);
let successTimer: ReturnType<typeof setTimeout> | undefined;
@@ -76,11 +69,6 @@ const selectedTopicRef = computed<TopicRef | null>(() => {
});
const canBrowseMessages = computed(() => props.isFlatMqCluster === true && props.supportsPeekMessages !== false && !isRocketMqCluster.value);
function flatMqPeekGroupName(): string {
if (props.mqSystemKind === "rocketmq") return "__dbx_rocketmq_viewer__";
return "__dbx_kafka_viewer__";
}
const topicListId = computed(() => `mq-topic-options-${props.connectionId}`);
function clearSuccessLater() {
@@ -206,55 +194,6 @@ async function sendMessage() {
}
}
async function loadMessages() {
const topic = selectedTopicRef.value;
if (!topic) {
peekError.value = t("mqMessages.selectTopicBeforeLoad");
return;
}
peekLoading.value = true;
peekError.value = undefined;
try {
const count = Math.max(1, Math.min(100, Number(peekCount.value) || 20));
peekCount.value = count;
const options: { partition?: number; offset?: number } = {};
const partitionText = String(peekPartition.value).trim();
const offsetText = String(peekOffset.value).trim();
if (partitionText !== "") {
const partition = parseNonNegativeSafeInteger(partitionText);
if (partition == null) {
throw new Error(t("mqMessages.partitionMustBeNonNegativeInt"));
}
options.partition = partition;
peekPartition.value = String(partition);
}
if (offsetText !== "") {
const offset = parseNonNegativeSafeInteger(offsetText);
if (offset == null) {
throw new Error(t("mqMessages.offsetMustBeNonNegativeInt"));
}
options.offset = offset;
peekOffset.value = String(offset);
}
peekMessages.value = await mqPeekMessages(props.connectionId, topic, flatMqPeekGroupName(), count, options);
} catch (e: unknown) {
peekError.value = formatError(e);
} finally {
peekLoading.value = false;
}
}
function messagePayload(message: PeekedMessage): string {
return message.payloadText ?? message.payloadBase64;
}
function formatMessageTimestamp(value?: string): string {
if (!value) return "-";
const numeric = Number(value);
if (!Number.isFinite(numeric)) return value;
return new Date(numeric).toLocaleString();
}
function formatJson() {
try {
const parsed = JSON.parse(messageValue.value);
@@ -283,11 +222,6 @@ watch(
{ immediate: true },
);
watch(topicName, () => {
peekError.value = undefined;
peekMessages.value = [];
});
watch(
() => props.topic,
(newTopic) => {
@@ -387,58 +321,7 @@ watch(
</button>
</div>
<section v-if="canBrowseMessages" class="message-browser">
<div class="message-browser-header">
<h4>{{ t("mqMessages.messageList") }}</h4>
<button type="button" class="btn-sm" :disabled="peekLoading || !selectedTopicRef" @click="loadMessages">
{{ peekLoading ? t("mqMessages.loading") : t("mqMessages.loadMessages") }}
</button>
</div>
<p class="peek-default-hint">{{ t("mqMessages.peekDefaultHint", { count: peekCount }) }}</p>
<div class="peek-controls">
<label>
<span>{{ t("mqMessages.count") }}</span>
<input v-model.number="peekCount" type="number" min="1" max="100" :disabled="peekLoading" />
</label>
</div>
<button type="button" class="collapse-toggle peek-advanced-toggle" @click="peekAdvancedExpanded = !peekAdvancedExpanded">
<span class="collapse-arrow" :class="{ expanded: peekAdvancedExpanded }">▶</span>
<span>{{ t("mqMessages.advancedFilter") }}</span>
<span v-if="(peekPartition || peekOffset) && !peekAdvancedExpanded" class="collapse-badge">·</span>
</button>
<div v-if="peekAdvancedExpanded" class="peek-controls collapse-body">
<label>
<span>{{ t("mqMessages.partition") }}</span>
<input v-model="peekPartition" type="number" min="0" :placeholder="t('mqMessages.partitionPlaceholderAll')" :disabled="peekLoading" />
</label>
<label>
<span>{{ t("mqMessages.offset") }}</span>
<input v-model="peekOffset" type="number" min="0" :placeholder="t('mqMessages.offsetPlaceholderEarliest')" :disabled="peekLoading" />
</label>
</div>
<div v-if="peekError" class="panel-error">{{ peekError }}</div>
<div v-else-if="peekLoading" class="message-empty">{{ t("mqMessages.messagesLoading") }}</div>
<div v-else-if="!peekMessages.length" class="message-empty">{{ t("mqMessages.noMessages") }}</div>
<div v-else class="message-list">
<article v-for="message in peekMessages" :key="`${message.properties?.partition ?? 'p'}-${message.messageId || message.position}`" class="message-row">
<div class="message-meta">
<span>#{{ message.position }}</span>
<span v-if="message.properties?.partition != null">{{ t("mqMessages.metaPartition", { partition: message.properties.partition }) }}</span>
<span>{{ t("mqMessages.metaOffset", { offset: message.messageId || "-" }) }}</span>
<span v-if="message.key">{{ t("mqMessages.metaKey", { key: message.key }) }}</span>
<span>{{ formatMessageTimestamp(message.publishTime) }}</span>
</div>
<pre class="message-payload">{{ messagePayload(message) }}</pre>
<div v-if="Object.keys(message.headers || {}).length" class="message-headers">
<span v-for="(value, key) in message.headers" :key="key">{{ key }}: {{ value }}</span>
</div>
</article>
</div>
</section>
<MessageBrowser v-if="canBrowseMessages" :connection-id="connectionId" :topic="selectedTopicRef" :mq-system-kind="mqSystemKind" />
</div>
</div>
</template>
@@ -733,151 +616,6 @@ input[type="number"]:focus {
opacity: 0.9;
}
.query-mode-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.query-mode-tabs .btn-sm.active {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--color-primary-alpha);
}
.message-browser {
margin-top: 4px;
padding: 14px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background-secondary);
}
.message-browser-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.message-browser-header h4 {
margin: 0;
color: var(--color-text);
font-size: 14px;
font-weight: 600;
}
.peek-default-hint {
margin: 0 0 12px;
padding: 8px 10px;
border-radius: var(--dbx-radius-fixed-6);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
color: var(--color-text-secondary);
font-size: 12px;
line-height: 1.5;
}
.peek-advanced-toggle {
margin-bottom: 10px;
}
.peek-controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.peek-controls label {
display: flex;
flex-direction: column;
gap: 5px;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 500;
}
.peek-controls input {
width: 100%;
}
.message-empty {
padding: 18px;
border: 1px dashed var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
color: var(--color-text-tertiary);
text-align: center;
font-size: 13px;
}
.message-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 360px;
overflow: auto;
}
.message-row {
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
}
.message-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
color: var(--color-text-tertiary);
font-size: 12px;
}
.message-meta span:first-child {
color: var(--color-primary);
font-weight: 700;
}
.message-payload {
margin: 8px 0 0;
padding: 10px;
max-height: 160px;
overflow: auto;
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background-tertiary, var(--color-background-secondary));
color: var(--color-text);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.message-headers {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 8px;
}
.message-headers span {
padding: 2px 6px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
color: var(--color-text-secondary);
background: var(--color-background-secondary);
font-size: 12px;
}
@media (max-width: 720px) {
.peek-controls {
grid-template-columns: 1fr;
}
}
button:disabled,
input:disabled,
textarea:disabled,
@@ -43,7 +43,9 @@ 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);
@@ -212,6 +214,7 @@ function openPeekDialog(sub: SubscriptionInfo) {
selectedSub.value = sub;
peekCount.value = 5;
peekedMessages.value = [];
peekIncomplete.value = false;
showPeekDialog.value = true;
void handlePeekMessages();
}
@@ -344,20 +347,39 @@ async function confirmClearBacklog() {
async function handlePeekMessages() {
const topicRef = getPulsarTopicRef();
if (!selectedSub.value || !topicRef) return;
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 {
peekedMessages.value = await mqPeekMessages(props.connectionId, topicRef, selectedSub.value.name, count);
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) {
error.value = formatError(e);
if (requestVersion === peekRequestVersion) error.value = formatError(e);
} finally {
peekLoading.value = false;
if (requestVersion === peekRequestVersion) peekLoading.value = false;
}
}
function invalidatePeekRequest() {
peekRequestVersion += 1;
peekLoading.value = false;
peekedMessages.value = [];
peekIncomplete.value = false;
}
async function handleExpireMessages() {
if (!guardWritable()) return;
const topicRef = getPulsarTopicRef();
@@ -379,6 +401,7 @@ watch(
() => [props.topic, props.tenant, props.namespace, props.mqSystemKind],
() => {
selectedSub.value = undefined;
invalidatePeekRequest();
loadSubscriptions();
},
{ immediate: true },
@@ -605,6 +628,9 @@ watch(
{{ 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>
@@ -1050,6 +1076,16 @@ button:disabled {
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;
@@ -0,0 +1,458 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, h, nextTick, ref, type App } from "vue";
import type { MqSystemKind, PeekedMessage } from "@/types/mq";
const backend = vi.hoisted(() => ({
mqPeekMessages: vi.fn(),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@/lib/backend/api", () => ({
mqPeekMessages: backend.mqPeekMessages,
}));
vi.mock("@/components/ui/select", async () => (await import("./selectStub")).createSelectStub());
import MessageBrowser from "@/components/mq/MessageBrowser.vue";
const TOPIC = {
tenant: "_kafka",
namespace: "default",
topic: "events",
persistent: true,
partitioned: false,
};
let app: App<Element> | null = null;
let root: HTMLDivElement | null = null;
async function flushUi() {
await Promise.resolve();
await Promise.resolve();
await nextTick();
}
function buttonByText(container: ParentNode, text: string): HTMLButtonElement {
const button = [...container.querySelectorAll<HTMLButtonElement>("button")].find((item) => item.textContent?.includes(text));
if (!button) throw new Error(`Button not found: ${text}`);
return button;
}
async function setInputValue(input: HTMLInputElement, value: string) {
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
}
async function setSelectValue(trigger: HTMLElement, value: string) {
trigger.click();
await flushUi();
const item = document.querySelector<HTMLElement>(`[data-slot="select-item"][data-value="${value}"]`);
if (!item) throw new Error(`Select item not found: ${value}`);
item.click();
await flushUi();
}
async function mountBrowser(mqSystemKind: "kafka" | "rabbitmq" = "kafka") {
root = document.createElement("div");
document.body.appendChild(root);
app = createApp(MessageBrowser, {
connectionId: "mq-1",
topic: { ...TOPIC, tenant: mqSystemKind === "rabbitmq" ? "_rabbitmq" : "_kafka" },
mqSystemKind,
});
app.mount(root);
await flushUi();
return root;
}
async function mountBrowserWithMutableTopic() {
const topic = ref({ ...TOPIC });
const connectionId = ref("mq-1");
const mqSystemKind = ref<MqSystemKind>("kafka");
root = document.createElement("div");
document.body.appendChild(root);
app = createApp({
setup: () => () =>
h(MessageBrowser, {
connectionId: connectionId.value,
topic: topic.value,
mqSystemKind: mqSystemKind.value,
}),
});
app.mount(root);
await flushUi();
return { browser: root, topic, connectionId, mqSystemKind };
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
async function loadMessages(container: ParentNode) {
buttonByText(container, "mqMessages.loadMessages").click();
await flushUi();
}
beforeEach(() => {
backend.mqPeekMessages.mockReset();
backend.mqPeekMessages.mockResolvedValue([
{
position: 1,
messageId: "17",
payloadBase64: "",
payloadText: "existing message",
properties: { partition: "0" },
headers: {},
},
]);
});
afterEach(() => {
app?.unmount();
app = null;
root?.remove();
root = null;
});
describe("MessageBrowser", () => {
it("loads Kafka's latest messages by default", async () => {
const browser = await mountBrowser();
await loadMessages(browser);
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expect.objectContaining({ topic: "events" }), "__dbx_kafka_viewer__", 20, { startPosition: "latest" });
});
it("normalizes a decimal count before sending the request", async () => {
const browser = await mountBrowser();
const countInput = browser.querySelector<HTMLInputElement>('[data-testid="peek-count"]');
if (!countInput) throw new Error("Peek count input not found");
await setInputValue(countInput, "2.9");
await loadMessages(browser);
expect(countInput.value).toBe("2");
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expect.objectContaining({ topic: "events" }), "__dbx_kafka_viewer__", 2, { startPosition: "latest" });
});
it("shows an explicit warning when the broker returns an incomplete snapshot", async () => {
backend.mqPeekMessages.mockResolvedValueOnce({
messages: [
{
position: 1,
messageId: "partial",
payloadBase64: "",
payloadText: "partial message",
properties: {},
headers: {},
},
],
incomplete: true,
});
const browser = await mountBrowser();
await loadMessages(browser);
expect(browser.querySelector('[data-testid="peek-incomplete"]')?.textContent).toContain("mqMessages.peekIncomplete");
expect(browser.textContent).toContain("partial message");
});
it("sends explicit earliest and offset read positions", async () => {
const browser = await mountBrowser();
const startPosition = browser.querySelector<HTMLElement>('[data-testid="kafka-peek-start-position"]');
if (!startPosition) throw new Error("Kafka start position select not found");
await setSelectValue(startPosition, "earliest");
await loadMessages(browser);
expect(backend.mqPeekMessages).toHaveBeenLastCalledWith("mq-1", expect.objectContaining({ topic: "events" }), "__dbx_kafka_viewer__", 20, { startPosition: "earliest" });
await setSelectValue(startPosition, "offset");
const partition = browser.querySelector<HTMLInputElement>('[data-testid="kafka-peek-partition"]');
const offset = browser.querySelector<HTMLInputElement>('[data-testid="kafka-peek-offset"]');
if (!partition || !offset) throw new Error("Kafka offset inputs not found");
await setInputValue(partition, "2");
await setInputValue(offset, "17");
await loadMessages(browser);
expect(backend.mqPeekMessages).toHaveBeenLastCalledWith("mq-1", expect.objectContaining({ topic: "events" }), "__dbx_kafka_viewer__", 20, { startPosition: "offset", partition: 2, offset: 17 });
});
it("allows an all-partition offset read but requires an offset", async () => {
const browser = await mountBrowser();
const startPosition = browser.querySelector<HTMLElement>('[data-testid="kafka-peek-start-position"]');
if (!startPosition) throw new Error("Kafka start position control not found");
await setSelectValue(startPosition, "offset");
await loadMessages(browser);
expect(backend.mqPeekMessages).not.toHaveBeenCalled();
expect(browser.textContent).toContain("mqMessages.offsetRequiredForOffset");
const offset = browser.querySelector<HTMLInputElement>('[data-testid="kafka-peek-offset"]');
if (!offset) throw new Error("Kafka offset input not found");
await setInputValue(offset, "-1");
await loadMessages(browser);
expect(backend.mqPeekMessages).not.toHaveBeenCalled();
expect(browser.textContent).toContain("mqMessages.offsetMustBeNonNegativeIntRequired");
await setInputValue(offset, "17");
await loadMessages(browser);
expect(backend.mqPeekMessages).toHaveBeenLastCalledWith("mq-1", expect.objectContaining({ topic: "events" }), "__dbx_kafka_viewer__", 20, { startPosition: "offset", offset: 17 });
});
it("clears results and does not leak an offset into a different read mode", async () => {
const browser = await mountBrowser();
const startPosition = browser.querySelector<HTMLElement>('[data-testid="kafka-peek-start-position"]');
const partition = browser.querySelector<HTMLInputElement>('[data-testid="kafka-peek-partition"]');
if (!startPosition || !partition) throw new Error("Kafka start position controls not found");
await loadMessages(browser);
expect(browser.textContent).toContain("existing message");
await setSelectValue(startPosition, "offset");
expect(browser.textContent).not.toContain("existing message");
await setInputValue(partition, "2");
const offset = browser.querySelector<HTMLInputElement>('[data-testid="kafka-peek-offset"]');
if (!offset) throw new Error("Kafka offset input not found");
await setInputValue(offset, "17");
await setSelectValue(startPosition, "latest");
await loadMessages(browser);
expect(backend.mqPeekMessages).toHaveBeenLastCalledWith("mq-1", expect.objectContaining({ topic: "events" }), "__dbx_kafka_viewer__", 20, { startPosition: "latest", partition: 2 });
});
it("ignores an older topic request that resolves after the current request", async () => {
const first = deferred<PeekedMessage[]>();
const second = deferred<PeekedMessage[]>();
backend.mqPeekMessages.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const { browser, topic } = await mountBrowserWithMutableTopic();
await loadMessages(browser);
topic.value = { ...TOPIC, topic: "payments" };
await flushUi();
await loadMessages(browser);
second.resolve([
{
position: 1,
messageId: "new",
payloadBase64: "",
payloadText: "new topic message",
properties: {},
headers: {},
},
]);
await flushUi();
expect(browser.textContent).toContain("new topic message");
first.resolve([
{
position: 1,
messageId: "old",
payloadBase64: "",
payloadText: "old topic message",
properties: {},
headers: {},
},
]);
await flushUi();
expect(browser.textContent).toContain("new topic message");
expect(browser.textContent).not.toContain("old topic message");
});
it("invalidates a request when the topic persistence changes", async () => {
const first = deferred<PeekedMessage[]>();
const second = deferred<PeekedMessage[]>();
backend.mqPeekMessages.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const { browser, topic } = await mountBrowserWithMutableTopic();
await loadMessages(browser);
topic.value = { ...TOPIC, persistent: false };
await flushUi();
await loadMessages(browser);
second.resolve([
{
position: 1,
messageId: "non-persistent",
payloadBase64: "",
payloadText: "new persistence message",
properties: {},
headers: {},
},
]);
await flushUi();
first.resolve([
{
position: 1,
messageId: "persistent",
payloadBase64: "",
payloadText: "stale persistence message",
properties: {},
headers: {},
},
]);
await flushUi();
expect(browser.textContent).toContain("new persistence message");
expect(browser.textContent).not.toContain("stale persistence message");
});
it("ignores an older topic request failure after the current request succeeds", async () => {
const first = deferred<PeekedMessage[]>();
const second = deferred<PeekedMessage[]>();
backend.mqPeekMessages.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const { browser, topic } = await mountBrowserWithMutableTopic();
await loadMessages(browser);
topic.value = { ...TOPIC, topic: "payments" };
await flushUi();
await loadMessages(browser);
second.resolve([
{
position: 1,
messageId: "new",
payloadBase64: "",
payloadText: "new topic message",
properties: {},
headers: {},
},
]);
await flushUi();
first.reject(new Error("old topic request failed"));
await flushUi();
expect(browser.textContent).toContain("new topic message");
expect(browser.textContent).not.toContain("old topic request failed");
});
it("ignores an older connection request after the connection changes", async () => {
const first = deferred<PeekedMessage[]>();
const second = deferred<PeekedMessage[]>();
backend.mqPeekMessages.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const { browser, connectionId } = await mountBrowserWithMutableTopic();
await loadMessages(browser);
connectionId.value = "mq-2";
await flushUi();
await loadMessages(browser);
second.resolve([
{
position: 1,
messageId: "new-connection",
payloadBase64: "",
payloadText: "new connection message",
properties: {},
headers: {},
},
]);
await flushUi();
expect(browser.textContent).toContain("new connection message");
first.resolve([
{
position: 1,
messageId: "old-connection",
payloadBase64: "",
payloadText: "old connection message",
properties: {},
headers: {},
},
]);
await flushUi();
expect(browser.textContent).toContain("new connection message");
expect(browser.textContent).not.toContain("old connection message");
});
it("ignores an older start-mode request that resolves after the mode switches", async () => {
const first = deferred<PeekedMessage[]>();
backend.mqPeekMessages.mockReturnValueOnce(first.promise);
const browser = await mountBrowser();
const startPosition = browser.querySelector<HTMLElement>('[data-testid="kafka-peek-start-position"]');
if (!startPosition) throw new Error("Kafka start position select not found");
await loadMessages(browser);
await setSelectValue(startPosition, "earliest");
expect(browser.textContent).not.toContain("latest-mode message");
first.resolve([
{
position: 1,
messageId: "stale",
payloadBase64: "",
payloadText: "latest-mode message",
properties: {},
headers: {},
},
]);
await flushUi();
expect(browser.textContent).not.toContain("latest-mode message");
expect(browser.querySelector(".panel-error")).toBeNull();
expect(browser.textContent).toContain("mqMessages.noMessages");
});
it("ignores an older start-mode failure after the mode switches and a new load succeeds", async () => {
const first = deferred<PeekedMessage[]>();
const second = deferred<PeekedMessage[]>();
backend.mqPeekMessages.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const browser = await mountBrowser();
const startPosition = browser.querySelector<HTMLElement>('[data-testid="kafka-peek-start-position"]');
if (!startPosition) throw new Error("Kafka start position select not found");
await loadMessages(browser);
await setSelectValue(startPosition, "earliest");
await loadMessages(browser);
second.resolve([
{
position: 1,
messageId: "fresh",
payloadBase64: "",
payloadText: "earliest-mode message",
properties: {},
headers: {},
},
]);
await flushUi();
expect(browser.textContent).toContain("earliest-mode message");
first.reject(new Error("latest-mode request failed"));
await flushUi();
expect(browser.textContent).toContain("earliest-mode message");
expect(browser.textContent).not.toContain("latest-mode request failed");
});
it("keeps RabbitMQ's advanced filters collapsed and sends its existing request shape", async () => {
const browser = await mountBrowser("rabbitmq");
expect(browser.querySelector('input[placeholder="mqMessages.partitionPlaceholderAll"]')).toBeNull();
buttonByText(browser, "mqMessages.advancedFilter").click();
await nextTick();
const partition = browser.querySelector<HTMLInputElement>('input[placeholder="mqMessages.partitionPlaceholderAll"]');
const offset = browser.querySelector<HTMLInputElement>('input[placeholder="mqMessages.offsetPlaceholderEarliest"]');
if (!partition || !offset) throw new Error("RabbitMQ advanced filter inputs not found");
await setInputValue(partition, "2");
await setInputValue(offset, "17");
await loadMessages(browser);
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expect.objectContaining({ tenant: "_rabbitmq", topic: "events" }), "__dbx_kafka_viewer__", 20, { partition: 2, offset: 17 });
});
});
@@ -0,0 +1,181 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, nextTick, type App } from "vue";
const backend = vi.hoisted(() => ({
mqGetTopicStats: vi.fn(),
mqGetBacklog: vi.fn(),
mqPeekMessages: vi.fn(),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@/lib/backend/api", () => backend);
vi.mock("@/components/ui/select", async () => (await import("./selectStub")).createSelectStub());
vi.mock("echarts/core", () => ({ use: vi.fn() }));
vi.mock("echarts/renderers", () => ({ CanvasRenderer: {} }));
vi.mock("echarts/charts", () => ({ LineChart: {} }));
vi.mock("echarts/components", () => ({ GridComponent: {}, LegendComponent: {}, TooltipComponent: {} }));
vi.mock("vue-echarts", async () => {
const { defineComponent, h } = await import("vue");
return { default: defineComponent({ name: "VChart", setup: () => () => h("div") }) };
});
vi.mock("@lucide/vue", async () => {
const { defineComponent, h } = await import("vue");
const Icon = defineComponent({ name: "Icon", setup: () => () => h("i") });
return {
Activity: Icon,
AlertTriangle: Icon,
BarChart3: Icon,
Boxes: Icon,
CheckCircle2: Icon,
Database: Icon,
Download: Icon,
Gauge: Icon,
Hash: Icon,
HardDrive: Icon,
Layers3: Icon,
Loader2: Icon,
Package: Icon,
RadioTower: Icon,
RefreshCw: Icon,
Send: Icon,
ShieldCheck: Icon,
Table2: Icon,
Upload: Icon,
Users: Icon,
};
});
import MonitoringPanel from "@/components/mq/MonitoringPanel.vue";
const TOPIC = {
name: "persistent://public/default/test",
shortName: "test",
persistent: true,
partitioned: false,
};
const KAFKA_STATS = {
msgRateIn: 0,
msgRateOut: 0,
msgThroughputIn: 0,
msgThroughputOut: 0,
storageSize: 0,
backlogSize: 0,
msgInCounter: 19,
msgOutCounter: 0,
subscriptionCount: 0,
producerCount: 0,
raw: {
partitions: 1,
replicationFactor: 1,
totalMessages: 19,
partitionStats: [{ partition: 0, beginOffset: 0, endOffset: 19, messageCount: 19, leader: 1, replicas: [1], isr: [1] }],
},
};
let app: App<Element> | null = null;
let root: HTMLDivElement | null = null;
async function flushUi() {
await Promise.resolve();
await Promise.resolve();
await nextTick();
}
async function mountPanel() {
root = document.createElement("div");
document.body.appendChild(root);
app = createApp(MonitoringPanel, {
connectionId: "mq-1",
tenant: "_kafka",
namespace: "default",
topic: TOPIC,
mqSystemKind: "kafka",
});
app.mount(root);
await flushUi();
return root;
}
beforeEach(() => {
backend.mqGetTopicStats.mockReset();
backend.mqGetBacklog.mockReset();
backend.mqPeekMessages.mockReset();
backend.mqGetTopicStats.mockResolvedValue(KAFKA_STATS);
backend.mqPeekMessages.mockResolvedValue([]);
});
afterEach(() => {
app?.unmount();
app = null;
root?.remove();
root = null;
});
describe("MonitoringPanel Kafka message browser", () => {
it("reuses the explicit Kafka browser instead of rendering a SQL query field", async () => {
const panel = await mountPanel();
const browser = panel.querySelector<HTMLElement>('[data-testid="message-browser"]');
const loadButton = [...panel.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("mqMessages.loadMessages"));
if (!browser || !loadButton) throw new Error("Kafka message browser not found");
expect(panel.querySelector("textarea")).toBeNull();
expect(browser.classList.contains("message-browser")).toBe(true);
expect(browser.classList.contains("is-monitoring")).toBe(true);
expect(browser.querySelector('[data-testid="kafka-peek-start-position"]')).not.toBeNull();
loadButton.click();
await flushUi();
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expect.objectContaining({ topic: "test" }), "__dbx_kafka_viewer__", 20, { startPosition: "latest" });
});
it("keeps automatic monitoring refresh separate from message browsing", async () => {
vi.useFakeTimers();
try {
const panel = await mountPanel();
const browser = panel.querySelector<HTMLElement>('[data-testid="message-browser"]');
const loadButton = [...panel.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("mqMessages.loadMessages"));
if (!browser || !loadButton) throw new Error("Kafka message browser not found");
backend.mqPeekMessages.mockResolvedValueOnce([
{
position: 1,
messageId: "18",
payloadBase64: "",
payloadText: "loaded message",
properties: { partition: "0" },
headers: {},
},
]);
loadButton.click();
await flushUi();
expect(browser.textContent).toContain("loaded message");
expect(backend.mqPeekMessages).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(5_000);
await flushUi();
expect(backend.mqGetTopicStats).toHaveBeenCalledTimes(2);
expect(backend.mqPeekMessages).toHaveBeenCalledTimes(1);
expect(browser.textContent).toContain("loaded message");
backend.mqGetTopicStats.mockRejectedValueOnce(new Error("monitoring refresh failed"));
await vi.advanceTimersByTimeAsync(5_000);
await flushUi();
expect(backend.mqGetTopicStats).toHaveBeenCalledTimes(3);
expect(backend.mqPeekMessages).toHaveBeenCalledTimes(1);
expect(browser.isConnected).toBe(true);
expect(browser.textContent).toContain("loaded message");
} finally {
vi.useRealTimers();
}
});
});
@@ -19,6 +19,8 @@ vi.mock("@/lib/backend/api", () => ({
mqSendMessage: backend.mqSendMessage,
}));
vi.mock("@/components/ui/select", async () => (await import("./selectStub")).createSelectStub());
import SendMessagePanel from "@/components/mq/SendMessagePanel.vue";
const TOPIC = {
@@ -51,6 +53,15 @@ async function setInputValue(input: HTMLInputElement | HTMLTextAreaElement, valu
await nextTick();
}
async function setSelectValue(trigger: HTMLElement, value: string) {
trigger.click();
await flushUi();
const item = document.querySelector<HTMLElement>(`[data-slot="select-item"][data-value="${value}"]`);
if (!item) throw new Error(`Select item not found: ${value}`);
item.click();
await flushUi();
}
function expectedTopic(system: BrowseableSystem) {
return {
tenant: system === "rabbitmq" ? "_rabbitmq" : "_kafka",
@@ -124,8 +135,10 @@ describe("SendMessagePanel post-send browsing", () => {
it(`keeps ${system} results unchanged after sending`, async () => {
const panel = await mountPanel(system);
expect(panel.querySelector('[data-testid="message-browser"]')?.classList.contains("message-browser")).toBe(true);
expect(panel.querySelector('[data-testid="message-browser"]')?.classList.contains("is-monitoring")).toBe(false);
await loadMessages(panel);
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expectedTopic(system), "__dbx_kafka_viewer__", 20, {});
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expectedTopic(system), "__dbx_kafka_viewer__", 20, system === "kafka" ? { startPosition: "latest" } : {});
expect(panel.textContent).toContain("existing message");
await sendMessage(panel, "new message");
@@ -135,14 +148,19 @@ describe("SendMessagePanel post-send browsing", () => {
});
}
it("preserves Kafka advanced filters until the user explicitly loads messages", async () => {
it("sends Kafka's selected start position and specific offset", async () => {
const panel = await mountPanel("kafka");
buttonByText(panel, "mqMessages.advancedFilter").click();
await flushUi();
const partitionInput = panel.querySelector<HTMLInputElement>('input[placeholder="mqMessages.partitionPlaceholderAll"]');
const offsetInput = panel.querySelector<HTMLInputElement>('input[placeholder="mqMessages.offsetPlaceholderEarliest"]');
if (!partitionInput || !offsetInput) throw new Error("Advanced filter inputs not found");
const startPosition = panel.querySelector<HTMLElement>('[data-testid="kafka-peek-start-position"]');
if (!startPosition) throw new Error("Kafka start position select not found");
await setSelectValue(startPosition, "earliest");
await loadMessages(panel);
expect(backend.mqPeekMessages).toHaveBeenLastCalledWith("mq-1", expectedTopic("kafka"), "__dbx_kafka_viewer__", 20, { startPosition: "earliest" });
await setSelectValue(startPosition, "offset");
const partitionInput = panel.querySelector<HTMLInputElement>('[data-testid="kafka-peek-partition"]');
const offsetInput = panel.querySelector<HTMLInputElement>('[data-testid="kafka-peek-offset"]');
if (!partitionInput || !offsetInput) throw new Error("Kafka offset inputs not found");
await setInputValue(partitionInput, "2");
await setInputValue(offsetInput, "17");
@@ -150,9 +168,99 @@ describe("SendMessagePanel post-send browsing", () => {
expect(partitionInput.value).toBe("2");
expect(offsetInput.value).toBe("17");
expect(backend.mqPeekMessages).not.toHaveBeenCalled();
expect(backend.mqPeekMessages).toHaveBeenCalledTimes(1);
await loadMessages(panel);
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expectedTopic("kafka"), "__dbx_kafka_viewer__", 20, { partition: 2, offset: 17 });
expect(backend.mqPeekMessages).toHaveBeenLastCalledWith("mq-1", expectedTopic("kafka"), "__dbx_kafka_viewer__", 20, { startPosition: "offset", partition: 2, offset: 17 });
});
it("keeps Kafka's result limit within the supported range", async () => {
const panel = await mountPanel("kafka");
const countInput = panel.querySelector<HTMLInputElement>('[data-testid="peek-count"]');
if (!countInput) throw new Error("Peek count input not found");
await setInputValue(countInput, "999");
await loadMessages(panel);
expect(countInput.value).toBe("100");
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expectedTopic("kafka"), "__dbx_kafka_viewer__", 100, { startPosition: "latest" });
await setInputValue(countInput, "");
await loadMessages(panel);
expect(countInput.value).toBe("20");
expect(backend.mqPeekMessages).toHaveBeenLastCalledWith("mq-1", expectedTopic("kafka"), "__dbx_kafka_viewer__", 20, { startPosition: "latest" });
});
it("does not request Kafka offset mode without an offset", async () => {
const panel = await mountPanel("kafka");
const startPosition = panel.querySelector<HTMLElement>('[data-testid="kafka-peek-start-position"]');
if (!startPosition) throw new Error("Kafka start position control not found");
await setSelectValue(startPosition, "offset");
await loadMessages(panel);
expect(backend.mqPeekMessages).not.toHaveBeenCalled();
expect(panel.textContent).toContain("mqMessages.offsetRequiredForOffset");
const offsetInput = panel.querySelector<HTMLInputElement>('[data-testid="kafka-peek-offset"]');
if (!offsetInput) throw new Error("Kafka offset input not found");
await setInputValue(offsetInput, "17");
await loadMessages(panel);
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expectedTopic("kafka"), "__dbx_kafka_viewer__", 20, { startPosition: "offset", offset: 17 });
});
it("clears Kafka results and validation errors when the start position changes", async () => {
const panel = await mountPanel("kafka");
const startPosition = panel.querySelector<HTMLElement>('[data-testid="kafka-peek-start-position"]');
if (!startPosition) throw new Error("Kafka start position select not found");
await loadMessages(panel);
expect(panel.textContent).toContain("existing message");
await setSelectValue(startPosition, "offset");
expect(panel.textContent).not.toContain("existing message");
await loadMessages(panel);
expect(panel.textContent).toContain("mqMessages.offsetRequiredForOffset");
await setSelectValue(startPosition, "earliest");
expect(panel.textContent).not.toContain("existing message");
expect(panel.textContent).not.toContain("mqMessages.offsetRequiredForOffset");
});
it("does not leak a saved Kafka offset after switching start positions", async () => {
const panel = await mountPanel("kafka");
const startPosition = panel.querySelector<HTMLElement>('[data-testid="kafka-peek-start-position"]');
const partitionInput = panel.querySelector<HTMLInputElement>('[data-testid="kafka-peek-partition"]');
if (!startPosition || !partitionInput) throw new Error("Kafka start position controls not found");
await setSelectValue(startPosition, "offset");
const offsetInput = panel.querySelector<HTMLInputElement>('[data-testid="kafka-peek-offset"]');
if (!offsetInput) throw new Error("Kafka offset input not found");
await setInputValue(partitionInput, "2");
await setInputValue(offsetInput, "17");
await setSelectValue(startPosition, "latest");
await loadMessages(panel);
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expectedTopic("kafka"), "__dbx_kafka_viewer__", 20, { startPosition: "latest", partition: 2 });
await setSelectValue(startPosition, "offset");
const restoredOffsetInput = panel.querySelector<HTMLInputElement>('[data-testid="kafka-peek-offset"]');
expect(restoredOffsetInput?.value).toBe("17");
});
it("keeps RabbitMQ advanced-filter requests unchanged", async () => {
const panel = await mountPanel("rabbitmq");
buttonByText(panel, "mqMessages.advancedFilter").click();
await flushUi();
const partitionInput = panel.querySelector<HTMLInputElement>('input[placeholder="mqMessages.partitionPlaceholderAll"]');
const offsetInput = panel.querySelector<HTMLInputElement>('input[placeholder="mqMessages.offsetPlaceholderEarliest"]');
if (!partitionInput || !offsetInput) throw new Error("RabbitMQ advanced filter inputs not found");
await setInputValue(partitionInput, "2");
await setInputValue(offsetInput, "17");
await loadMessages(panel);
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expectedTopic("rabbitmq"), "__dbx_kafka_viewer__", 20, { partition: 2, offset: 17 });
});
});
@@ -0,0 +1,178 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, nextTick, type App } from "vue";
import type { PeekedMessage } from "@/types/mq";
const backend = vi.hoisted(() => ({
mqListSubscriptions: vi.fn(),
mqCreateSubscription: vi.fn(),
mqDeleteSubscription: vi.fn(),
mqResetCursor: vi.fn(),
mqSkipMessages: vi.fn(),
mqClearBacklog: vi.fn(),
mqPeekMessages: vi.fn(),
mqExpireMessages: vi.fn(),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@/lib/backend/api", () => backend);
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({
default: { template: "<div />" },
}));
vi.mock("@/components/mq/rocketmq/RocketMqConsumerGroupDialogs.vue", () => ({
default: { template: "<div />" },
}));
import SubscriptionsPanel from "@/components/mq/SubscriptionsPanel.vue";
const TOPIC = {
name: "persistent://public/default/events",
shortName: "events",
partitioned: false,
persistent: true,
};
let app: App<Element> | null = null;
let root: HTMLDivElement | null = null;
async function flushUi() {
await Promise.resolve();
await Promise.resolve();
await nextTick();
}
function buttonByText(container: ParentNode, text: string): HTMLButtonElement {
const button = [...container.querySelectorAll<HTMLButtonElement>("button")].find((item) => item.textContent?.includes(text));
if (!button) throw new Error(`Button not found: ${text}`);
return button;
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
async function mountPanel() {
root = document.createElement("div");
document.body.appendChild(root);
app = createApp(SubscriptionsPanel, {
connectionId: "mq-1",
topic: TOPIC,
tenant: "_kafka",
namespace: "default",
mqSystemKind: "kafka",
supportsPeekMessages: true,
});
app.mount(root);
await flushUi();
return root;
}
beforeEach(() => {
Object.values(backend).forEach((mock) => mock.mockReset());
backend.mqListSubscriptions.mockResolvedValue([
{
name: "orders-consumer",
subType: "shared",
msgBacklog: 0,
msgRateOut: 0,
msgThroughputOut: 0,
consumers: [],
},
]);
});
afterEach(() => {
app?.unmount();
app = null;
root?.remove();
root = null;
});
describe("SubscriptionsPanel message peek", () => {
it("warns when a Kafka peek is incomplete and clears the warning after a complete refresh", async () => {
backend.mqPeekMessages
.mockResolvedValueOnce({
messages: [
{
position: 1,
messageId: "17",
payloadBase64: "",
payloadText: "partial message",
properties: { partition: "0" },
headers: {},
},
],
incomplete: true,
})
.mockResolvedValueOnce({ messages: [], incomplete: false });
const panel = await mountPanel();
buttonByText(panel, "mqSubscriptions.peek").click();
await flushUi();
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expect.objectContaining({ topic: "events" }), "orders-consumer", 5);
expect(panel.querySelector('[data-testid="peek-incomplete"]')?.textContent).toContain("mqMessages.peekIncomplete");
expect(panel.textContent).toContain("partial message");
buttonByText(panel, "mqSubscriptions.refresh").click();
await flushUi();
expect(panel.querySelector('[data-testid="peek-incomplete"]')).toBeNull();
});
it("ignores a stale peek when another subscription is opened", async () => {
const first = deferred<{ messages: PeekedMessage[]; incomplete: boolean }>();
const second = deferred<{ messages: PeekedMessage[]; incomplete: boolean }>();
backend.mqListSubscriptions.mockResolvedValue([
{
name: "orders-consumer",
subType: "shared",
msgBacklog: 0,
msgRateOut: 0,
msgThroughputOut: 0,
consumers: [],
},
{
name: "payments-consumer",
subType: "shared",
msgBacklog: 0,
msgRateOut: 0,
msgThroughputOut: 0,
consumers: [],
},
]);
backend.mqPeekMessages.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const panel = await mountPanel();
const peekButtons = [...panel.querySelectorAll<HTMLButtonElement>("button")].filter((button) => button.textContent?.includes("mqSubscriptions.peek"));
expect(peekButtons).toHaveLength(2);
peekButtons[0].click();
await flushUi();
peekButtons[1].click();
await flushUi();
second.resolve({
messages: [{ position: 1, messageId: "new", payloadBase64: "", payloadText: "payments message", properties: {}, headers: {} }],
incomplete: false,
});
await flushUi();
first.resolve({
messages: [{ position: 1, messageId: "old", payloadBase64: "", payloadText: "orders message", properties: {}, headers: {} }],
incomplete: false,
});
await flushUi();
expect(panel.textContent).toContain("payments message");
expect(panel.textContent).not.toContain("orders message");
});
});
+36
View File
@@ -0,0 +1,36 @@
import { defineComponent, h, inject, provide } from "vue";
export function createSelectStub() {
const updateKey = Symbol("select-update");
const Select = defineComponent({
emits: ["update:modelValue"],
setup(_, { emit, slots }) {
provide(updateKey, (value: string) => emit("update:modelValue", value));
return () => h("div", { "data-slot": "select" }, slots.default?.());
},
});
const SelectTrigger = defineComponent({
setup(_, { attrs, slots }) {
return () => h("button", { ...attrs, type: "button" }, slots.default?.());
},
});
const SelectContent = defineComponent({
setup(_, { attrs, slots }) {
return () => h("div", attrs, slots.default?.());
},
});
const SelectItem = defineComponent({
props: { value: { type: String, required: true } },
setup(props, { attrs, slots }) {
const update = inject<(value: string) => void>(updateKey);
return () => h("button", { ...attrs, "data-slot": "select-item", "data-value": props.value, type: "button", onClick: () => update?.(props.value) }, slots.default?.());
},
});
const SelectValue = defineComponent({
setup(_, { attrs, slots }) {
return () => h("span", attrs, slots.default?.());
},
});
return { Select, SelectContent, SelectItem, SelectTrigger, SelectValue };
}