feat(kafka): redesign message browsing
This commit is contained in:
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 }">▶</span>
|
||||
<span>{{ t("mqMessages.advancedFilter") }}</span>
|
||||
<span v-if="(partition || offset) && !advancedExpanded" class="collapse-badge">·</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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user