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 };
}
+12 -6
View File
@@ -6011,18 +6011,29 @@ export default {
messageList: "Messages",
loadMessages: "Load messages",
loading: "Loading...",
peekDefaultHint: "Without a partition, reads all partitions from earliest, up to {count} messages.",
peekDefaultHint: "Without a partition, reads all partitions from earliest.",
count: "Count",
advancedFilter: "Advanced (partition / offset)",
partition: "Partition",
partitionPlaceholderAll: "Empty = all",
offset: "Offset",
offsetPlaceholderEarliest: "Empty = earliest",
offsetPlaceholderRequired: "Required",
startPosition: "Start position",
kafkaLatest: "Recent N messages (default)",
kafkaEarliest: "Earliest messages",
kafkaOffset: "From offset",
kafkaLatestHint: "Reads a balanced window near the end of each partition and merges by timestamp newest first.",
kafkaEarliestHint: "Reads history forward from each selected partition.",
kafkaOffsetHint: "Reads forward from the specified offset in the chosen partition, or every partition when left empty.",
messagesLoading: "Loading messages...",
noMessages: "No messages",
peekIncomplete: "The message results are incomplete because the broker did not finish the snapshot.",
selectTopicBeforeLoad: "Select a topic before loading messages",
partitionMustBeNonNegativeInt: "Partition must be an integer ≥ 0; leave empty for all partitions",
offsetMustBeNonNegativeInt: "Offset must be an integer ≥ 0; leave empty for earliest",
offsetMustBeNonNegativeIntRequired: "Offset must be an integer ≥ 0",
offsetRequiredForOffset: "An offset is required when reading from a specific offset",
metaPartition: "partition {partition}",
metaOffset: "offset {offset}",
metaKey: "key {key}",
@@ -6099,11 +6110,6 @@ export default {
statusNoLeader: "No leader",
statusIsrIncomplete: "ISR incomplete",
statusHealthy: "Healthy",
kafkaMessageQuery: "Kafka message query",
querying: "Querying...",
queryMessages: "Query",
queryHint: "Omit PARTITION / OFFSET to read all partitions (max 100). Add them to narrow the range.",
sqlSyntaxError: 'Only SELECT * FROM "topic" [PARTITION n] [OFFSET n] [LIMIT n] is supported',
messagesLoading: "Loading messages...",
noMessages: "No messages",
messageRate: "Message rate",
+12 -6
View File
@@ -5864,18 +5864,29 @@ export default withEnglishFallback({
messageList: "Mensajes",
loadMessages: "Cargar mensajes",
loading: "Cargando...",
peekDefaultHint: "Sin partición, lee todas las particiones desde earliest, hasta {count} mensajes.",
peekDefaultHint: "Sin partición, lee todas las particiones desde earliest.",
count: "Cantidad",
advancedFilter: "Avanzado (partición / offset)",
partition: "Partición",
partitionPlaceholderAll: "Vacío = todas",
offset: "Offset",
offsetPlaceholderEarliest: "Vacío = earliest",
offsetPlaceholderRequired: "Obligatorio",
startPosition: "Punto de inicio",
kafkaLatest: "Últimos N mensajes (predeterminado)",
kafkaEarliest: "Mensajes más antiguos",
kafkaOffset: "Desde offset",
kafkaLatestHint: "Lee una ventana equilibrada cerca del final de cada partición y combina por marca de tiempo con los más nuevos primero.",
kafkaEarliestHint: "Lee el historial hacia adelante desde cada partición seleccionada.",
kafkaOffsetHint: "Lee hacia adelante desde el offset indicado en la partición elegida, o en todas si se deja vacía.",
messagesLoading: "Cargando mensajes...",
noMessages: "No hay mensajes",
peekIncomplete: "Los resultados de mensajes están incompletos porque el broker no terminó la instantánea.",
selectTopicBeforeLoad: "Seleccione un tema antes de cargar mensajes",
partitionMustBeNonNegativeInt: "La partición debe ser un entero ≥ 0; déjelo vacío para todas las particiones",
offsetMustBeNonNegativeInt: "El offset debe ser un entero ≥ 0; déjelo vacío para earliest",
offsetMustBeNonNegativeIntRequired: "El offset debe ser un entero ≥ 0",
offsetRequiredForOffset: "Se requiere un offset para leer desde un offset específico",
metaPartition: "partición {partition}",
metaOffset: "offset {offset}",
metaKey: "clave {key}",
@@ -5957,11 +5968,6 @@ export default withEnglishFallback({
statusNoLeader: "Sin leader",
statusIsrIncomplete: "ISR incompleto",
statusHealthy: "Saludable",
kafkaMessageQuery: "Consulta de mensajes de Kafka",
querying: "Consultando...",
queryMessages: "Consultar",
queryHint: "Omita PARTITION / OFFSET para leer todas las particiones (máx. 100). Agréguelos para acotar el rango.",
sqlSyntaxError: 'Solo se admite SELECT * FROM "topic" [PARTITION n] [OFFSET n] [LIMIT n]',
messagesLoading: "Cargando mensajes...",
noMessages: "No hay mensajes",
messageRate: "Tasa de mensajes",
+12 -6
View File
@@ -5864,18 +5864,29 @@ export default withEnglishFallback({
messageList: "Messaggi",
loadMessages: "Carica messaggi",
loading: "Caricamento in corso...",
peekDefaultHint: "Senza partizione, legge tutte le partizioni dall'inizio (earliest), fino a {count} messaggi.",
peekDefaultHint: "Senza partizione, legge tutte le partizioni dall'inizio (earliest).",
count: "Quantità",
advancedFilter: "Avanzate (partizione / offset)",
partition: "Partizione",
partitionPlaceholderAll: "Vuoto = tutte",
offset: "Offset",
offsetPlaceholderEarliest: "Vuoto = inizio (earliest)",
offsetPlaceholderRequired: "Obbligatorio",
startPosition: "Posizione iniziale",
kafkaLatest: "Ultimi N messaggi (predefinito)",
kafkaEarliest: "Primi messaggi",
kafkaOffset: "Da offset",
kafkaLatestHint: "Legge una finestra bilanciata vicino alla fine di ogni partizione e unisce per timestamp con i più recenti prima.",
kafkaEarliestHint: "Legge la cronologia in avanti da ogni partizione selezionata.",
kafkaOffsetHint: "Legge in avanti dall'offset indicato nella partizione scelta, o in tutte se lasciata vuota.",
messagesLoading: "Caricamento messaggi...",
noMessages: "Nessun messaggio",
peekIncomplete: "I risultati dei messaggi sono incompleti perché il broker non ha completato lo snapshot.",
selectTopicBeforeLoad: "Seleziona un topic prima di caricare i messaggi",
partitionMustBeNonNegativeInt: "La partizione deve essere un intero ≥ 0; lascia vuoto per tutte le partizioni",
offsetMustBeNonNegativeInt: "L'offset deve essere un intero ≥ 0; lascia vuoto per l'inizio (earliest)",
offsetMustBeNonNegativeIntRequired: "L'offset deve essere un intero ≥ 0",
offsetRequiredForOffset: "Per leggere da un offset specifico è richiesto un offset",
metaPartition: "partizione {partition}",
metaOffset: "offset {offset}",
metaKey: "chiave {key}",
@@ -5957,11 +5968,6 @@ export default withEnglishFallback({
statusNoLeader: "Senza leader",
statusIsrIncomplete: "ISR incompleto",
statusHealthy: "Integro",
kafkaMessageQuery: "Interrogazione messaggi Kafka",
querying: "Interrogazione in corso...",
queryMessages: "Interroga",
queryHint: "Ometti PARTITION / OFFSET per leggere tutte le partizioni (max 100). Aggiungili per restringere l'intervallo.",
sqlSyntaxError: 'È supportato solo SELECT * FROM "topic" [PARTITION n] [OFFSET n] [LIMIT n]',
messagesLoading: "Caricamento messaggi...",
noMessages: "Nessun messaggio",
messageRate: "Velocità messaggi",
+12 -6
View File
@@ -5919,18 +5919,29 @@ export default withEnglishFallback({
messageList: "メッセージ一覧",
loadMessages: "メッセージを読み込む",
loading: "読み込み中...",
peekDefaultHint: "パーティション未指定時は、全パーティションから Earliest 位置で最大 {count} 件まで読み取ります。",
peekDefaultHint: "パーティション未指定時は、全パーティションから Earliest 位置で読み取ります。",
count: "件数",
advancedFilter: "詳細 (パーティション / offset)",
partition: "パーティション",
partitionPlaceholderAll: "空欄 = すべて",
offset: "Offset",
offsetPlaceholderEarliest: "空欄 = Earliest",
offsetPlaceholderRequired: "必須",
startPosition: "読み取り開始位置",
kafkaLatest: "最新 N 件(デフォルト)",
kafkaEarliest: "最も古いメッセージ",
kafkaOffset: "Offset から読み取る",
kafkaLatestHint: "各パーティションの末尾付近から均等に読み取り、タイムスタンプの新しい順に統合します。",
kafkaEarliestHint: "選択した各パーティションの先頭から昇順に読み取ります。",
kafkaOffsetHint: "指定 Offset から、選択したパーティション(空欄ならすべて)を昇順に読み取ります。",
messagesLoading: "メッセージを読み込み中...",
noMessages: "メッセージがありません",
peekIncomplete: "ブローカーがスナップショットの読み取りを完了できなかったため、メッセージ結果は不完全です。",
selectTopicBeforeLoad: "メッセージを読み込む前にトピックを選択してください",
partitionMustBeNonNegativeInt: "パーティションは 0 以上の整数である必要があります。空欄の場合はすべてのパーティション",
offsetMustBeNonNegativeInt: "Offset は 0 以上の整数である必要があります。空欄の場合は Earliest",
offsetMustBeNonNegativeIntRequired: "Offset は 0 以上の整数である必要があります",
offsetRequiredForOffset: "指定 Offset から読み取るには Offset が必要です",
metaPartition: "パーティション {partition}",
metaOffset: "オフセット {offset}",
metaKey: "キー {key}",
@@ -6012,11 +6023,6 @@ export default withEnglishFallback({
statusNoLeader: "Leader なし",
statusIsrIncomplete: "ISR 不完全",
statusHealthy: "正常",
kafkaMessageQuery: "Kafka メッセージクエリ",
querying: "クエリ中...",
queryMessages: "クエリ",
queryHint: "PARTITION / OFFSET を省略すると全パーティションを読み取ります (最大 100 件)。指定すると範囲を絞り込めます。",
sqlSyntaxError: 'SELECT * FROM "topic" [PARTITION n] [OFFSET n] [LIMIT n] のみサポートしています',
messagesLoading: "メッセージを読み込み中...",
noMessages: "メッセージがありません",
messageRate: "メッセージレート",
+12 -6
View File
@@ -5603,18 +5603,29 @@ export default withEnglishFallback({
messageList: "메시지",
loadMessages: "메시지 불러오기",
loading: "불러오는 중...",
peekDefaultHint: "파티션 없이 처음부터 모든 파티션을 최대 {count}개의 메시지까지 읽습니다.",
peekDefaultHint: "파티션 없이 처음부터 모든 파티션을 읽습니다.",
count: "개수",
advancedFilter: "고급 (파티션 / 오프셋)",
partition: "파티션",
partitionPlaceholderAll: "비움 = 전체",
offset: "오프셋",
offsetPlaceholderEarliest: "비움 = 처음",
offsetPlaceholderRequired: "필수",
startPosition: "읽기 시작 위치",
kafkaLatest: "최근 N개 메시지(기본값)",
kafkaEarliest: "가장 이른 메시지",
kafkaOffset: "오프셋부터 읽기",
kafkaLatestHint: "각 파티션 끝 부근에서 균등하게 읽고 타임스탬프 내림차순으로 병합합니다.",
kafkaEarliestHint: "선택한 각 파티션의 처음부터 오름차순으로 읽습니다.",
kafkaOffsetHint: "지정한 오프셋부터 선택한 파티션(비워 두면 전체)을 오름차순으로 읽습니다.",
messagesLoading: "메시지를 불러오는 중...",
noMessages: "메시지가 없습니다",
peekIncomplete: "브로커가 스냅샷 읽기를 완료하지 못해 메시지 결과가 불완전합니다.",
selectTopicBeforeLoad: "메시지를 불러오기 전에 토픽을 선택하세요",
partitionMustBeNonNegativeInt: "파티션은 0 이상의 정수여야 하며, 모든 파티션은 비워 두세요",
offsetMustBeNonNegativeInt: "오프셋은 0 이상의 정수여야 하며, 처음은 비워 두세요",
offsetMustBeNonNegativeIntRequired: "오프셋은 0 이상의 정수여야 합니다",
offsetRequiredForOffset: "특정 오프셋부터 읽으려면 오프셋이 필요합니다",
metaPartition: "파티션 {partition}",
metaOffset: "오프셋 {offset}",
metaKey: "키 {key}",
@@ -5691,11 +5702,6 @@ export default withEnglishFallback({
statusNoLeader: "리더 없음",
statusIsrIncomplete: "ISR 불완전",
statusHealthy: "정상",
kafkaMessageQuery: "Kafka 메시지 쿼리",
querying: "쿼리 중...",
queryMessages: "쿼리",
queryHint: "PARTITION / OFFSET를 생략하면 모든 파티션을 읽습니다 (최대 100). 범위를 좁히려면 추가하세요.",
sqlSyntaxError: 'SELECT * FROM "topic" [PARTITION n] [OFFSET n] [LIMIT n] 만 지원됩니다',
messagesLoading: "메시지를 불러오는 중...",
noMessages: "메시지가 없습니다",
messageRate: "메시지 비율",
+12 -6
View File
@@ -5866,18 +5866,29 @@ export default withEnglishFallback({
messageList: "Mensagens",
loadMessages: "Carregar mensagens",
loading: "Carregando...",
peekDefaultHint: "Sem partição definida, lê todas as partições desde Earliest, até {count} mensagens.",
peekDefaultHint: "Sem partição definida, lê todas as partições desde Earliest.",
count: "Quantidade",
advancedFilter: "Avançado (partição / offset)",
partition: "Partição",
partitionPlaceholderAll: "Vazio = todas",
offset: "Offset",
offsetPlaceholderEarliest: "Vazio = Earliest",
offsetPlaceholderRequired: "Obrigatório",
startPosition: "Posição inicial",
kafkaLatest: "Últimas N mensagens (padrão)",
kafkaEarliest: "Mensagens mais antigas",
kafkaOffset: "A partir do offset",
kafkaLatestHint: "Lê uma janela equilibrada perto do fim de cada partição e mescla por data e hora com as mais novas primeiro.",
kafkaEarliestHint: "Lê o histórico em ordem crescente de cada partição selecionada.",
kafkaOffsetHint: "Lê em ordem crescente a partir do offset informado na partição escolhida, ou em todas se deixada vazia.",
messagesLoading: "Carregando mensagens...",
noMessages: "Nenhuma mensagem",
peekIncomplete: "Os resultados das mensagens estão incompletos porque o broker não concluiu o snapshot.",
selectTopicBeforeLoad: "Selecione um tópico antes de carregar mensagens",
partitionMustBeNonNegativeInt: "A partição deve ser um inteiro ≥ 0; deixe vazio para todas as partições",
offsetMustBeNonNegativeInt: "O Offset deve ser um inteiro ≥ 0; deixe vazio para Earliest",
offsetMustBeNonNegativeIntRequired: "O Offset deve ser um inteiro ≥ 0",
offsetRequiredForOffset: "Um offset é obrigatório para ler de um offset específico",
metaPartition: "partição {partition}",
metaOffset: "offset {offset}",
metaKey: "chave {key}",
@@ -5959,11 +5970,6 @@ export default withEnglishFallback({
statusNoLeader: "Sem leader",
statusIsrIncomplete: "ISR incompleto",
statusHealthy: "Saudável",
kafkaMessageQuery: "Consulta de mensagens Kafka",
querying: "Consultando...",
queryMessages: "Consultar",
queryHint: "Omita PARTITION / OFFSET para ler todas as partições (máx. 100). Adicione-os para restringir o intervalo.",
sqlSyntaxError: 'Apenas SELECT * FROM "topic" [PARTITION n] [OFFSET n] [LIMIT n] é suportado',
messagesLoading: "Carregando mensagens...",
noMessages: "Nenhuma mensagem",
messageRate: "Taxa de mensagens",
+12 -6
View File
@@ -6011,18 +6011,29 @@ export default withEnglishFallback({
messageList: "消息列表",
loadMessages: "加载消息",
loading: "加载中...",
peekDefaultHint: "未指定分区时读取全部区,从各区 earliest 起,最多 {count} 条。",
peekDefaultHint: "未指定分区时读取全部区,从各区 earliest 起。",
count: "数量",
advancedFilter: "高级筛选(分区 / Offset)",
partition: "分区",
partitionPlaceholderAll: "留空=全部",
offset: "Offset",
offsetPlaceholderEarliest: "留空=最早",
offsetPlaceholderRequired: "必填",
startPosition: "读取起点",
kafkaLatest: "最近 N 条(默认)",
kafkaEarliest: "最早消息",
kafkaOffset: "从指定 Offset 读取",
kafkaLatestHint: "从各分区末尾附近均衡读取,按时间倒序合并。",
kafkaEarliestHint: "从每个选定分区的最早可读消息开始正序读取。",
kafkaOffsetHint: "从指定 Offset 开始正序读取;填写分区时仅读该分区,留空则对全部分区分别读取。",
messagesLoading: "消息加载中...",
noMessages: "暂无消息",
peekIncomplete: "消息结果不完整:Broker 未能在限制内完成快照读取。",
selectTopicBeforeLoad: "请先选择主题再加载消息",
partitionMustBeNonNegativeInt: "分区必须是大于等于 0 的整数;留空表示查询全部分区",
offsetMustBeNonNegativeInt: "Offset 必须是大于等于 0 的整数;留空表示从各分区最早可读位置开始",
offsetMustBeNonNegativeIntRequired: "Offset 必须是大于等于 0 的整数",
offsetRequiredForOffset: "按指定 Offset 读取时必须填写 Offset",
metaPartition: "分区 {partition}",
metaOffset: "偏移 {offset}",
metaKey: "键 {key}",
@@ -6098,11 +6109,6 @@ export default withEnglishFallback({
statusNoLeader: "无 leader",
statusIsrIncomplete: "ISR 不完整",
statusHealthy: "正常",
kafkaMessageQuery: "Kafka 消息查询",
querying: "查询中...",
queryMessages: "查询消息",
queryHint: "省略 PARTITION / OFFSET 时读取全部区,最多 100 条;指定分区或 offset 时仅读对应范围。",
sqlSyntaxError: '仅支持 SELECT * FROM "topic" [PARTITION n] [OFFSET n] [LIMIT n]',
messagesLoading: "消息加载中...",
noMessages: "暂无消息",
messageRate: "消息速率",
+12 -6
View File
@@ -5859,18 +5859,29 @@ export default withEnglishFallback({
messageList: "訊息清單",
loadMessages: "載入訊息",
loading: "載入中...",
peekDefaultHint: "未指定分區時讀取全部分區,從各分區 earliest 起,最多 {count} 則。",
peekDefaultHint: "未指定分區時讀取全部分區,從各分區 earliest 起。",
count: "數量",
advancedFilter: "進階篩選(分區 / Offset)",
partition: "分區",
partitionPlaceholderAll: "留空=全部",
offset: "Offset",
offsetPlaceholderEarliest: "留空=最早",
offsetPlaceholderRequired: "必填",
startPosition: "讀取起點",
kafkaLatest: "最近 N 則(預設)",
kafkaEarliest: "最早訊息",
kafkaOffset: "從指定 Offset 讀取",
kafkaLatestHint: "從各分區末尾附近平均讀取,依時間倒序合併。",
kafkaEarliestHint: "從每個選定分區最早可讀的訊息開始正序讀取。",
kafkaOffsetHint: "從指定 Offset 開始正序讀取;填寫分區時只讀該分區,留空則對全部分區分別讀取。",
messagesLoading: "訊息載入中...",
noMessages: "暫無訊息",
peekIncomplete: "訊息結果不完整:Broker 未能在限制內完成快照讀取。",
selectTopicBeforeLoad: "請先選取主題再載入訊息",
partitionMustBeNonNegativeInt: "分區必須是大於等於 0 的整數;留空表示查詢全部分區",
offsetMustBeNonNegativeInt: "Offset 必須是大於等於 0 的整數;留空表示從各分區最早可讀位置開始",
offsetMustBeNonNegativeIntRequired: "Offset 必須是大於等於 0 的整數",
offsetRequiredForOffset: "以指定 Offset 讀取時必須填寫 Offset",
metaPartition: "分區 {partition}",
metaOffset: "偏移 {offset}",
metaKey: "鍵 {key}",
@@ -5952,11 +5963,6 @@ export default withEnglishFallback({
statusNoLeader: "無 leader",
statusIsrIncomplete: "ISR 不完整",
statusHealthy: "正常",
kafkaMessageQuery: "Kafka 訊息查詢",
querying: "查詢中...",
queryMessages: "查詢訊息",
queryHint: "省略 PARTITION / OFFSET 時讀取全部分區,最多 100 則;指定分區或 offset 時僅讀取對應範圍。",
sqlSyntaxError: '僅支援 SELECT * FROM "topic" [PARTITION n] [OFFSET n] [LIMIT n]',
messagesLoading: "訊息載入中...",
noMessages: "暫無訊息",
messageRate: "訊息速率",
+2 -2
View File
@@ -30,7 +30,7 @@ import type {
MqIssuedToken,
BacklogStats,
RocketMqConsumerGroupConfig,
PeekedMessage,
PeekMessagesResult,
PeekMessagesOptions,
MqRawRequest,
MqRawResponse,
@@ -237,7 +237,7 @@ export async function mqClearBacklog(connectionId: string, topic: TopicRef, sub:
return post("/api/mq/subscriptions/clear-backlog", { connectionId, topic, sub });
}
export async function mqPeekMessages(connectionId: string, topic: TopicRef, sub: string, count: number, options?: PeekMessagesOptions): Promise<PeekedMessage[]> {
export async function mqPeekMessages(connectionId: string, topic: TopicRef, sub: string, count: number, options?: PeekMessagesOptions): Promise<PeekMessagesResult> {
return post("/api/mq/subscriptions/peek-messages", { connectionId, topic, sub, count, options });
}
+2 -2
View File
@@ -30,7 +30,7 @@ import type {
MqIssuedToken,
BacklogStats,
RocketMqConsumerGroupConfig,
PeekedMessage,
PeekMessagesResult,
PeekMessagesOptions,
MqRawRequest,
MqRawResponse,
@@ -231,7 +231,7 @@ export async function mqClearBacklog(connectionId: string, topic: TopicRef, sub:
return invoke("mq_clear_backlog", { connectionId, topic, sub });
}
export async function mqPeekMessages(connectionId: string, topic: TopicRef, sub: string, count: number, options?: PeekMessagesOptions): Promise<PeekedMessage[]> {
export async function mqPeekMessages(connectionId: string, topic: TopicRef, sub: string, count: number, options?: PeekMessagesOptions): Promise<PeekMessagesResult> {
return invoke("mq_peek_messages", { connectionId, topic, sub, count, options });
}
+8
View File
@@ -263,7 +263,15 @@ export interface PeekedMessage {
payloadText?: string;
}
export interface PeekMessagesResult {
messages: PeekedMessage[];
/** True when the broker could not finish the requested snapshot before its limit. */
incomplete: boolean;
}
export interface PeekMessagesOptions {
/** Kafka only. Omitted starts at earliest unless a legacy caller supplies offset. */
startPosition?: "latest" | "earliest" | "offset";
partition?: number;
offset?: number;
}