feat(mqtt): add MQTT broker connection support

This commit is contained in:
jinmo authored and GitHub committed 2026-08-04 19:05:05 +08:00
1 parent dff33662fc
commit 8cfe380784
125 files changed
+23056 -107

No files matched your search

@@ -17,6 +17,7 @@ import { Switch } from "@/components/ui/switch";
import type { ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType, HttpTunnelConfig, IdentifierCase, JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb";
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
import type { MqttConnectionConfig } from "@/types/mqtt";
import type { NacosAdminConfig, NacosAuthConfig, NacosImplementation, NacosMetricsMode, NacosRNacosConsoleAuth, NacosVersionMode } from "@/types/nacos";
import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
@@ -657,6 +658,23 @@ const nacosTlsSkipVerify = ref(false);
const nacosMetricsMode = ref<NacosMetricsMode>("auto");
const nacosMetricsUrl = ref("");
const nacosPageSize = ref(20);
// --- MQTT-specific form fields ---
const mqttHost = ref("127.0.0.1");
const mqttPort = ref(1883);
const mqttClientId = ref("");
const mqttProtocolVersion = ref<"v3" | "v4" | "v5">("v5");
const mqttTransportMode = ref<"tcp" | "websocket">("tcp");
const mqttWsPath = ref("/mqtt");
const mqttAuthKind = ref<"none" | "password">("none");
const mqttUsername = ref("");
const mqttPassword = ref("");
const mqttTls = ref(false);
const mqttTlsSkipVerify = ref(false);
const mqttKeepAliveSecs = ref(60);
const mqttConnectTimeoutSecs = ref(30);
const mqttMaxPacketSizeBytes = ref(16 * 1024 * 1024);
const nacosPrimaryAddressLabel = computed(() => {
if (nacosImplementation.value === "rnacos") return t("connection.nacosPrimaryAddressRNacos");
if (nacosVersionMode.value === "v2") return t("connection.nacosPrimaryAddressV2");
@@ -973,6 +991,7 @@ const driverProfiles: Record<
rocketmq: { type: "mq", port: 9876, user: "", label: "Apache RocketMQ", icon: "rocketmq", host: "127.0.0.1" },
rabbitmq: { type: "mq", port: 5672, user: "", label: "RabbitMQ", icon: "rabbitmq", host: "127.0.0.1" },
nacos: { type: "nacos", port: 8848, user: "nacos", label: "Nacos", icon: "nacos", host: "127.0.0.1" },
mqtt: { type: "mqtt", port: 1883, user: "", label: "MQTT", icon: "mqtt", host: "127.0.0.1" },
iris: { type: "iris", port: 1972, user: "_SYSTEM", label: "IRIS", icon: "iris" },
influxdb: { type: "influxdb", port: 8086, user: "", label: "InfluxDB", icon: "InfluxDB" },
custom_mysql: {
@@ -1188,6 +1207,57 @@ function hydrateNacosFields(value: unknown) {
resetNacosFields(value as Partial<NacosAdminConfig>);
}
function resetMqttFields(config?: Partial<MqttConnectionConfig>) {
mqttHost.value = config?.host?.trim() || "127.0.0.1";
mqttPort.value = config?.port || 1883;
mqttClientId.value = config?.clientId || "";
mqttProtocolVersion.value = config?.protocolVersion || "v5";
mqttTransportMode.value = config?.transport || "tcp";
mqttWsPath.value = config?.wsPath || "/mqtt";
mqttTls.value = config?.tls || false;
mqttTlsSkipVerify.value = config?.tlsSkipVerify || false;
mqttKeepAliveSecs.value = Math.max(1, config?.keepAliveSecs || 60);
mqttConnectTimeoutSecs.value = Math.max(1, config?.connectTimeoutSecs || 30);
mqttMaxPacketSizeBytes.value = Math.min(268435455, Math.max(1024, config?.maxPacketSizeBytes || 16 * 1024 * 1024));
const auth = config?.auth;
if (auth && auth.kind === "password") {
mqttAuthKind.value = "password";
mqttUsername.value = auth.username || "";
mqttPassword.value = auth.password || "";
} else {
mqttAuthKind.value = "none";
mqttUsername.value = "";
mqttPassword.value = "";
}
}
function hydrateMqttFields(value: unknown) {
if (!value || typeof value !== "object") {
resetMqttFields();
return;
}
resetMqttFields(value as Partial<MqttConnectionConfig>);
}
function buildMqttExternalConfig(): MqttConnectionConfig {
const auth: MqttConnectionConfig["auth"] = mqttAuthKind.value === "password" ? { kind: "password", username: mqttUsername.value, password: mqttPassword.value } : { kind: "none" };
return {
host: mqttHost.value.trim(),
port: mqttPort.value,
clientId: mqttClientId.value.trim() || `dbx-${Math.random().toString(36).slice(2, 10)}`,
protocolVersion: mqttProtocolVersion.value,
transport: mqttTransportMode.value,
tls: mqttTls.value,
tlsSkipVerify: mqttTlsSkipVerify.value,
auth,
keepAliveSecs: Math.max(1, mqttKeepAliveSecs.value),
connectTimeoutSecs: Math.max(1, mqttConnectTimeoutSecs.value),
maxPacketSizeBytes: Math.min(268435455, Math.max(1024, mqttMaxPacketSizeBytes.value)),
wsPath: mqttTransportMode.value === "websocket" ? mqttWsPath.value || "/mqtt" : undefined,
};
}
const influxDbVersion = ref<InfluxDbVersion>("1");
const influxDbOrg = ref("");
@@ -1957,6 +2027,12 @@ function applyProfile(val: string, preserveConnectionFields = false) {
form.value.connection_string = undefined;
form.value.url_params = "";
}
if (profile.type === "mqtt") {
resetMqttFields();
form.value.database = undefined;
form.value.connection_string = undefined;
form.value.url_params = "";
}
if (profile.type === "influxdb") {
resetInfluxDbFields();
form.value.database = undefined;
@@ -2060,6 +2136,11 @@ watch(
} else {
resetNacosFields();
}
if (config.db_type === "mqtt") {
hydrateMqttFields(config.external_config);
} else {
resetMqttFields();
}
if (config.db_type === "influxdb") {
hydrateInfluxDbFields(config.external_config);
} else {
@@ -2304,6 +2385,7 @@ const iconTypeMap: Record<string, string> = {
rocketmq: "rocketmq",
rabbitmq: "rabbitmq",
nacos: "nacos",
mqtt: "mqtt",
dm: "dm",
h2: "h2",
snowflake: "snowflake",
@@ -2403,6 +2485,7 @@ const dbOptions: DbOption[] = [
{ value: "kafka", label: "Apache Kafka" },
{ value: "rocketmq", label: "Apache RocketMQ" },
{ value: "rabbitmq", label: "RabbitMQ" },
{ value: "mqtt", label: "MQTT" },
{ value: "nacos", label: "Nacos" },
{ value: "influxdb", label: "InfluxDB" },
{ value: "iris", label: "IRIS" },
@@ -2456,7 +2539,7 @@ const dbCategoryDefinitions: Array<{
{
key: "mq",
titleKey: "connection.databaseCategoryMq",
optionValues: ["mq", "kafka", "rocketmq", "rabbitmq"],
optionValues: ["mq", "kafka", "rocketmq", "rabbitmq", "mqtt"],
},
{
key: "registry_config",
@@ -2864,6 +2947,7 @@ const hasRequiredConnectionTarget = computed(() => {
return !!mqAdminUrl.value.trim();
}
if (form.value.db_type === "zookeeper") return !!(form.value.host || form.value.connection_string || connectionUrlInput.value.trim());
if (form.value.db_type === "mqtt") return !!mqttHost.value.trim() && mqttPort.value > 0;
if (form.value.db_type === "nacos") return !!nacosServerAddr.value.trim();
if (isCloudflareD1Connection(form.value)) return hasCloudflareD1Credentials(form.value);
if (isH2FileMode.value) return !!(form.value.host.trim() || h2FilePathFromJdbcUrl(form.value.connection_string));
@@ -3264,6 +3348,19 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
config.database = nacosConfig.namespace || undefined;
config.connection_string = undefined;
config.url_params = "";
} else if (config.db_type === "mqtt") {
const mqttConfig = buildMqttExternalConfig();
config.external_config = mqttConfig;
config.driver_profile = "mqtt";
config.driver_label = "MQTT";
config.host = mqttConfig.host;
config.port = mqttConfig.port;
config.ssl = mqttConfig.tls;
config.username = "";
config.password = "";
config.database = undefined;
config.connection_string = undefined;
config.url_params = "";
} else if (config.db_type === "influxdb") {
config.external_config = buildInfluxDbExternalConfig();
config.connection_string = undefined;
@@ -5851,6 +5948,81 @@ function openExternalUrl(url: string) {
</template>
</template>
<!-- MQTT: broker address, client ID, protocol version, auth, TLS -->
<template v-else-if="form.db_type === 'mqtt'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttBrokerAddress") }}</Label>
<Input v-model="mqttHost" class="col-span-3" :placeholder="t('connection.mqttBrokerAddressPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttBrokerPort") }}</Label>
<Input v-model.number="mqttPort" type="number" class="col-span-3 w-24" min="1" max="65535" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttClientId") }}</Label>
<Input v-model="mqttClientId" class="col-span-3" :placeholder="t('connection.mqttClientIdPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttProtocolVersion") }}</Label>
<select v-model="mqttProtocolVersion" class="col-span-3 flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
<option value="v5">MQTT 5.0</option>
<option value="v4">MQTT 3.1.1</option>
<option value="v3">MQTT 3.1</option>
</select>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttTransport") }}</Label>
<div class="col-span-3 flex gap-2">
<Button size="sm" :variant="mqttTransportMode === 'tcp' ? 'default' : 'outline'" @click="mqttTransportMode = 'tcp'">{{ t("connection.mqttTransportTcp") }}</Button>
<Button size="sm" :variant="mqttTransportMode === 'websocket' ? 'default' : 'outline'" @click="mqttTransportMode = 'websocket'">{{ t("connection.mqttTransportWebSocket") }}</Button>
</div>
</div>
<div v-if="mqttTransportMode === 'websocket'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttWsPath") }}</Label>
<Input v-model="mqttWsPath" class="col-span-3" :placeholder="t('connection.mqttWsPathPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqAuth") }}</Label>
<div class="col-span-3 flex gap-2">
<Button size="sm" :variant="mqttAuthKind === 'none' ? 'default' : 'outline'" @click="mqttAuthKind = 'none'">{{ t("connection.mqAuthNone") }}</Button>
<Button size="sm" :variant="mqttAuthKind === 'password' ? 'default' : 'outline'" @click="mqttAuthKind = 'password'">{{ t("connection.mqAuthBasic") }}</Button>
</div>
</div>
<template v-if="mqttAuthKind === 'password'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttUsername") }}</Label>
<Input v-model="mqttUsername" class="col-span-3" :placeholder="t('connection.mqttUsernamePlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttPassword") }}</Label>
<Input v-model="mqttPassword" type="password" class="col-span-3" :placeholder="t('connection.mqttPasswordPlaceholder')" />
</div>
</template>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttTls") }}</Label>
<div class="col-span-3 flex items-center gap-2">
<Switch :checked="mqttTls" @update:checked="mqttTls = $event" />
<Label class="text-sm" :class="mqttTls ? '' : 'text-muted-foreground'">TLS</Label>
<template v-if="mqttTls">
<Switch :checked="mqttTlsSkipVerify" @update:checked="mqttTlsSkipVerify = $event" class="ml-4" />
<Label class="text-sm" :class="mqttTlsSkipVerify ? '' : 'text-muted-foreground'">{{ t("connection.mqttTlsSkipVerify") }}</Label>
</template>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttKeepAlive") }}</Label>
<Input v-model.number="mqttKeepAliveSecs" type="number" class="col-span-3 w-32" min="1" max="65535" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttConnectTimeout") }}</Label>
<Input v-model.number="mqttConnectTimeoutSecs" type="number" class="col-span-3 w-32" min="1" max="300" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">最大报文(字节)</Label>
<Input v-model.number="mqttMaxPacketSizeBytes" type="number" class="col-span-3 w-40" min="1024" max="268435455" />
</div>
</template>
<!-- InfluxDB: v1 username/password or v2 token/org/bucket -->
<template v-else-if="form.db_type === 'influxdb'">
<div class="grid grid-cols-4 items-center gap-4">
@@ -103,6 +103,7 @@ const assetIcons: Record<string, string> = {
zookeeper: "zookeeper",
oscar: "oscar.png",
jdbcx: "jdbcx",
mqtt: "mqtt",
};
const normalizedType = computed(() => props.dbType.toLowerCase().replace(/[\s-]+/g, "_"));
@@ -58,6 +58,7 @@ const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/Vec
const HBaseBrowser = defineAsyncComponent(() => import("@/components/hbase/HBaseBrowser.vue"));
const ElasticsearchJsonResponsePanel = defineAsyncComponent(() => import("@/components/common/ElasticsearchJsonResponsePanel.vue"));
const MqAdminConsole = defineAsyncComponent(() => import("@/components/mq/MqAdminConsole.vue"));
const MqttAdminConsole = defineAsyncComponent(() => import("@/components/mqtt/MqttAdminConsole.vue"));
const NacosAdminConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAdminConsole.vue"));
const NacosDashboard = defineAsyncComponent(() => import("@/components/nacos/NacosDashboard.vue"));
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
@@ -1878,6 +1879,12 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
</div>
</template>
<template v-else-if="activeTab.mode === 'mqtt'">
<div class="flex-1 min-h-0">
<MqttAdminConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :initial-topic="activeTab.mqttInitialTopic" />
</div>
</template>
<template v-else-if="activeTab.mode === 'nacos'">
<div class="flex-1 min-h-0">
<NacosAdminConsole
@@ -0,0 +1,246 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useI18n } from "vue-i18n";
import { mqttGetBrokerInfo, mqttListTopics, mqttGetTopicTree, mqttGetMessages, mqttSubscribe, mqttUnsubscribe, mqttClearMessages } from "@/lib/backend/api";
import type { MqttBrokerInfo, MqttTopicNode, MqttMessage } from "@/types/mqtt";
import { Button } from "@/components/ui/button";
import TopicTreeNode from "./TopicTreeNode.vue";
import MqttPublishPanel from "./MqttPublishDialog.vue";
import { decodePayload, PAYLOAD_ENCODINGS, PAYLOAD_ENCODING_LABELS, type PayloadEncoding } from "@/lib/mqtt/mqttPayloadCodec";
interface Props {
connectionId: string;
initialTopic?: string;
}
const props = defineProps<Props>();
const { t } = useI18n();
/* ========== State ========== */
const brokerInfo = ref<MqttBrokerInfo | null>(null);
const topicTree = ref<MqttTopicNode | null>(null);
const messages = ref<MqttMessage[]>([]);
const subscribedTopics = ref<[string, string][]>([]);
const selectedTopic = ref<string>(props.initialTopic ?? "");
const loading = ref(true);
const error = ref<string | null>(null);
const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null);
/* Payload 显示编码格式 */
const displayEncoding = ref<PayloadEncoding>("plaintext");
/* ========== Computed ========== */
const connected = computed(() => brokerInfo.value?.connected ?? false);
/* ========== 数据刷新 ========== */
async function refreshData() {
try {
const [info, topics, tree, msgs] = await Promise.all([
mqttGetBrokerInfo(props.connectionId) as Promise<MqttBrokerInfo>,
mqttListTopics(props.connectionId) as Promise<[string, string][]>,
mqttGetTopicTree(props.connectionId) as Promise<MqttTopicNode>,
mqttGetMessages(props.connectionId, selectedTopic.value || undefined, 50) as Promise<MqttMessage[]>,
]);
brokerInfo.value = info;
subscribedTopics.value = topics;
topicTree.value = tree;
messages.value = msgs;
error.value = null;
} catch (e) {
error.value = String(e);
} finally {
loading.value = false;
}
}
async function handleSubscribe(topic: string) {
try {
await mqttSubscribe(props.connectionId, topic, "atmostonce");
await refreshData();
} catch (e) {
error.value = String(e);
}
}
async function handleUnsubscribe(topics: string[]) {
const subscribed = new Set(subscribedTopics.value.map(([topic]) => topic));
const targets = [...new Set(topics)].filter((topic) => subscribed.has(topic));
if (targets.length === 0) {
error.value = t("connection.mqttUnsubscribeMissing");
return;
}
const prompt = targets.length === 1 ? t("connection.mqttUnsubscribeConfirm", { topic: targets[0] }) : t("connection.mqttUnsubscribeGroupConfirm", { count: targets.length });
if (!window.confirm(prompt)) return;
const failures: string[] = [];
for (const topic of targets) {
try {
await mqttUnsubscribe(props.connectionId, topic);
} catch (e) {
failures.push(`${topic}:${String(e)}`);
}
}
if (targets.includes(selectedTopic.value)) selectedTopic.value = "";
await refreshData();
if (failures.length > 0) {
error.value = t("connection.mqttUnsubscribeFailed", { count: failures.length, errors: failures.join("; ") });
}
}
function handleTopicClick(topic: string) {
selectedTopic.value = topic;
refreshData();
}
function handleMessagePublished() {
refreshData();
}
async function handleClearMessages() {
try {
await mqttClearMessages(props.connectionId);
messages.value = [];
} catch (e) {
error.value = String(e);
}
}
/* ========== 消息轮询 ========== */
function startPolling() {
stopPolling();
pollingTimer.value = setInterval(async () => {
try {
const msgs = (await mqttGetMessages(props.connectionId, selectedTopic.value || undefined, 50)) as MqttMessage[];
messages.value = msgs;
} catch {
/* 忽略轮询错误 */
}
}, 3000);
}
function stopPolling() {
if (pollingTimer.value) {
clearInterval(pollingTimer.value);
pollingTimer.value = null;
}
}
/* ========== 格式化消息 Payload ========== */
function formatMessagePayload(msg: MqttMessage): string {
if (displayEncoding.value === "plaintext" && msg.payloadText != null) {
return msg.payloadText;
}
return decodePayload(msg.payloadBase64, displayEncoding.value);
}
/* ========== 生命周期 ========== */
onMounted(async () => {
await refreshData();
startPolling();
});
onUnmounted(() => {
stopPolling();
});
</script>
<template>
<div class="flex flex-col h-full bg-card text-card-foreground">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-2 border-b shrink-0">
<div class="flex items-center gap-3">
<span class="text-lg font-semibold">{{ t("connection.mqttConsoleTitle") }}</span>
<span v-if="brokerInfo" class="text-sm text-muted-foreground">{{ brokerInfo.brokerUrl }}</span>
<span v-if="brokerInfo" class="px-1.5 py-0.5 rounded text-xs font-medium" :class="connected ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'">
{{ connected ? t("connection.mqttConnected") : t("connection.mqttDisconnected") }}
</span>
</div>
<div class="flex items-center gap-2">
<Button size="sm" variant="outline" @click="refreshData">{{ t("connection.mqttRefresh") }}</Button>
<Button size="sm" variant="outline" @click="handleClearMessages">{{ t("connection.mqttClearMessages") }}</Button>
</div>
</div>
<!-- Error banner -->
<div v-if="error" class="px-4 py-2 bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-400 text-sm border-b">
{{ error }}
<button class="ml-2 underline" @click="error = null">{{ t("common.close") }}</button>
</div>
<!-- Body: 双栏布局 -->
<div class="flex-1 flex min-h-0">
<!-- 左侧:主题树 -->
<div class="w-72 border-r flex flex-col shrink-0">
<div class="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase border-b">{{ t("connection.mqttTopics") }}</div>
<div class="flex-1 overflow-auto p-2">
<div v-if="loading" class="text-xs text-muted-foreground p-2">{{ t("common.loading") }}</div>
<div v-else-if="!topicTree?.children?.length" class="text-xs text-muted-foreground p-2">{{ t("connection.mqttNoTopicsHint") }}</div>
<TopicTreeNode v-for="child in topicTree?.children ?? []" :key="child.fullPath" :node="child" :depth="0" :selected-topic="selectedTopic" :subscribed-topics="subscribedTopics" @select="handleTopicClick" @subscribe="handleSubscribe" @unsubscribe="handleUnsubscribe" />
</div>
<!-- 快速订阅 -->
<div class="border-t p-2">
<form
class="flex gap-1"
@submit.prevent="
(e) => {
const input = (e.target as HTMLFormElement).querySelector('input');
const topic = input?.value.trim();
if (topic && input) {
handleSubscribe(topic);
input.value = '';
}
}
"
>
<input class="flex-1 h-7 px-2 text-xs rounded border bg-transparent" :placeholder="t('connection.mqttSubscribePlaceholder')" />
<Button size="sm" variant="ghost" class="h-7 text-xs" type="submit">{{ t("connection.mqttSubscribe") }}</Button>
</form>
</div>
</div>
<!-- 右侧:消息列表 + 发布面板 -->
<div class="flex-1 flex flex-col min-w-0">
<!-- 消息区域 -->
<div class="flex-1 flex flex-col min-h-0">
<div class="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase border-b flex items-center justify-between shrink-0">
<span v-if="selectedTopic">{{ t("connection.mqttMessagesForTopic", { topic: selectedTopic }) }}</span>
<span v-else>{{ t("connection.mqttAllMessages") }}</span>
<div class="flex items-center gap-2">
<span class="font-normal">{{ messages.length }}</span>
<!-- Payload 编码格式选择 -->
<select v-model="displayEncoding" class="h-6 px-1.5 text-[11px] rounded border bg-transparent text-muted-foreground cursor-pointer outline-none">
<option v-for="enc in PAYLOAD_ENCODINGS" :key="enc" :value="enc">
{{ PAYLOAD_ENCODING_LABELS[enc] }}
</option>
</select>
</div>
</div>
<div class="flex-1 overflow-auto">
<div v-if="messages.length === 0" class="text-xs text-muted-foreground p-4 text-center">{{ t("connection.mqttNoMessages") }}</div>
<div
v-for="(msg, i) in messages"
:key="i"
class="px-3 py-2 border-b last:border-b-0 cursor-pointer text-xs"
:class="msg.direction === 'sent' ? 'bg-emerald-50/60 dark:bg-emerald-950/20 hover:bg-emerald-100/60 dark:hover:bg-emerald-950/40' : 'hover:bg-muted/50'"
@click="handleTopicClick(msg.topic)"
>
<div class="flex items-center gap-2 mb-0.5">
<span v-if="msg.direction === 'sent'" class="text-emerald-600 dark:text-emerald-400 text-[10px] font-bold shrink-0">{{ t("connection.mqttSent") }}</span>
<span v-else class="text-blue-600 dark:text-blue-400 text-[10px] font-bold shrink-0">{{ t("connection.mqttReceived") }}</span>
<span class="font-mono text-blue-600 dark:text-blue-400 font-medium truncate">{{ msg.topic }}</span>
<span class="text-muted-foreground/60 shrink-0">QoS{{ msg.qos }}</span>
<span v-if="msg.retain" class="text-amber-600 dark:text-amber-400 text-[10px] font-medium shrink-0">{{ t("connection.mqttRetained") }}</span>
</div>
<div class="text-muted-foreground font-mono whitespace-pre-wrap break-all">{{ formatMessagePayload(msg) }}</div>
<div class="text-muted-foreground/50 mt-0.5 text-[10px]">{{ new Date(msg.receivedAtMs).toLocaleTimeString() }}</div>
</div>
</div>
</div>
<!-- 底部:内联发布面板 -->
<MqttPublishPanel v-if="connected" :connection-id="connectionId" :initial-topic="selectedTopic" @published="handleMessagePublished" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,352 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { mqttPublish } from "@/lib/backend/api";
import { Button } from "@/components/ui/button";
import { PAYLOAD_ENCODINGS, PAYLOAD_ENCODING_LABELS, encodePayload, type PayloadEncoding } from "@/lib/mqtt/mqttPayloadCodec";
interface Props {
connectionId: string;
initialTopic?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
published: [];
}>();
const { t } = useI18n();
const topic = ref(props.initialTopic || "");
const qos = ref<0 | 1 | 2>(0);
const retain = ref(false);
const payloadText = ref("");
const encoding = ref<PayloadEncoding>("plaintext");
const loading = ref(false);
const error = ref<string | null>(null);
const success = ref(false);
const qosLabels = ["QoS 0", "QoS 1", "QoS 2"];
const qosHints = computed(() => [t("connection.mqttQosAtMostOnce"), t("connection.mqttQosAtLeastOnce"), t("connection.mqttQosExactlyOnce")]);
/* 根据编码格式调整输入提示和占位符 */
const payloadPlaceholder = computed(() => {
switch (encoding.value) {
case "json":
return t("connection.mqttPayloadPlaceholderJson");
case "base64":
return t("connection.mqttPayloadPlaceholderBase64");
case "hex":
return t("connection.mqttPayloadPlaceholderHex");
case "cbor":
case "msgpack":
return t("connection.mqttPayloadPlaceholderStructured", { encoding: encoding.value.toUpperCase() });
default:
return t("connection.mqttPayloadPlaceholderPlaintext");
}
});
/* 监听 topic prop 变化 */
watch(
() => props.initialTopic,
(val) => {
if (val !== undefined && val !== topic.value) {
topic.value = val;
}
},
);
async function publish() {
const publishTopic = topic.value.trim();
if (!publishTopic) {
error.value = t("connection.mqttPublishTopicRequired");
return;
}
if (publishTopic.includes("#") || publishTopic.includes("+")) {
error.value = t("connection.mqttPublishTopicWildcard");
return;
}
loading.value = true;
error.value = null;
success.value = false;
try {
const payloadBase64 = encodePayload(payloadText.value, encoding.value);
await mqttPublish(props.connectionId, {
topic: publishTopic,
payloadBase64,
payloadText: encoding.value === "plaintext" ? payloadText.value : null,
qos: qos.value === 0 ? "atmostonce" : qos.value === 1 ? "atleastonce" : "exactlyonce",
retain: retain.value,
});
success.value = true;
emit("published");
/* 成功后保留表单内容便于连续测试,1.5 秒后清除成功提示 */
setTimeout(() => {
success.value = false;
}, 1500);
} catch (e) {
error.value = String(e);
} finally {
loading.value = false;
}
}
function clearForm() {
topic.value = "";
payloadText.value = "";
qos.value = 0;
retain.value = false;
encoding.value = "plaintext";
error.value = null;
success.value = false;
}
</script>
<template>
<div class="publish-panel">
<div class="panel-header">
<span class="panel-title">{{ t("connection.mqttPublish") }}</span>
<Button size="sm" variant="ghost" class="h-6 text-xs" @click="clearForm">{{ t("common.clear") }}</Button>
</div>
<!-- Topic + QoS + Retain 行 -->
<div class="form-row">
<div class="form-group flex-1">
<label class="form-label">{{ t("connection.mqttTopic") }}</label>
<input v-model="topic" class="form-input font-mono text-xs" :placeholder="t('connection.mqttTopicExample')" @keydown.enter="publish()" />
</div>
</div>
<div class="form-row form-row-wrap">
<!-- QoS -->
<div class="form-group">
<label class="form-label">QoS</label>
<div class="segmented-control">
<button v-for="(label, i) in qosLabels" :key="i" class="segment-item" :class="{ active: qos === i }" :title="qosHints[i]" @click="qos = i as 0 | 1 | 2">
{{ label }}
</button>
</div>
</div>
<!-- Retain -->
<div class="form-group form-group-check">
<label class="form-label">&nbsp;</label>
<label class="checkbox-label">
<input type="checkbox" :checked="retain" @change="retain = !retain" class="form-checkbox" />
<span class="text-xs">{{ t("connection.mqttRetainMessage") }}</span>
</label>
</div>
<!-- 编码格式 -->
<div class="form-group">
<label class="form-label">{{ t("connection.mqttPayloadEncoding") }}</label>
<select v-model="encoding" class="form-select text-xs">
<option v-for="enc in PAYLOAD_ENCODINGS" :key="enc" :value="enc">
{{ PAYLOAD_ENCODING_LABELS[enc] }}
</option>
</select>
</div>
</div>
<!-- Payload -->
<div class="form-group flex-1 flex flex-col min-h-0">
<label class="form-label">{{ t("connection.mqttPayload") }}</label>
<textarea v-model="payloadText" class="form-textarea flex-1 font-mono text-xs" :placeholder="payloadPlaceholder" rows="4" @keydown.ctrl.enter="publish()" />
<span class="form-hint">{{ t("connection.mqttPublishShortcut") }}</span>
</div>
<!-- 状态提示 -->
<div v-if="error" class="publish-error">{{ error }}</div>
<div v-if="success" class="publish-success">✓ {{ t("connection.mqttPublishSuccess") }}</div>
<!-- 操作按钮 -->
<div class="form-actions">
<Button size="sm" class="publish-btn" :disabled="loading || !topic.trim()" @click="publish">
{{ loading ? t("connection.mqttPublishing") : t("connection.mqttPublish") }}
</Button>
</div>
</div>
</template>
<style scoped>
.publish-panel {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--color-border);
background: var(--color-background);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: var(--color-text-secondary);
letter-spacing: 0.5px;
}
.form-row {
display: flex;
gap: 8px;
align-items: flex-end;
}
.form-row-wrap {
flex-wrap: wrap;
}
.form-group {
display: flex;
flex-direction: column;
gap: 3px;
}
.form-group-check {
justify-content: flex-end;
}
.form-label {
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary);
}
.form-input {
height: 28px;
padding: 0 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
outline: none;
box-sizing: border-box;
}
.form-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary-alpha);
}
.form-textarea {
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
outline: none;
resize: vertical;
min-height: 60px;
box-sizing: border-box;
line-height: 1.4;
}
.form-textarea:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary-alpha);
}
.form-select {
height: 28px;
padding: 0 6px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
font-size: 12px;
outline: none;
cursor: pointer;
}
.form-select:focus {
border-color: var(--color-primary);
}
.form-checkbox {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: var(--color-primary);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
}
.form-hint {
font-size: 10px;
color: var(--color-text-tertiary);
margin-top: 2px;
}
.segmented-control {
display: flex;
border: 1px solid var(--color-border);
border-radius: 4px;
overflow: hidden;
}
.segment-item {
padding: 2px 10px;
font-size: 11px;
font-weight: 500;
border: none;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.15s;
border-right: 1px solid var(--color-border);
white-space: nowrap;
}
.segment-item:last-child {
border-right: none;
}
.segment-item:hover {
background: var(--color-background-secondary);
}
.segment-item.active {
background: var(--color-primary);
color: white;
}
.publish-error {
font-size: 12px;
color: var(--color-error);
padding: 4px 8px;
background: var(--color-error-bg);
border-radius: 4px;
}
.publish-success {
font-size: 12px;
color: var(--color-success);
padding: 4px 8px;
background: color-mix(in srgb, var(--color-success) 12%, transparent);
border-radius: 4px;
font-weight: 500;
}
.form-actions {
display: flex;
gap: 8px;
}
.publish-btn {
flex: 1;
height: 30px;
font-size: 13px;
font-weight: 500;
}
</style>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import type { MqttTopicNode } from "@/types/mqtt";
import { ChevronRight, ChevronDown, Trash2 } from "@lucide/vue";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
interface Props {
node: MqttTopicNode;
depth: number;
selectedTopic: string;
subscribedTopics: [string, string][];
}
const props = defineProps<Props>();
const { t } = useI18n();
const emit = defineEmits<{
select: [topic: string];
subscribe: [topic: string];
unsubscribe: [topics: string[]];
}>();
const expanded = ref(props.depth < 2);
function isSubscribed(topic: string): boolean {
return props.subscribedTopics.some(([t]) => t === topic);
}
function collectSubscribedTopics(node: MqttTopicNode): string[] {
const topics = node.isLeaf && isSubscribed(node.fullPath) ? [node.fullPath] : [];
for (const child of node.children ?? []) {
topics.push(...collectSubscribedTopics(child));
}
return topics;
}
const topicsToUnsubscribe = computed(() => collectSubscribedTopics(props.node));
const unsubscribeLabel = computed(() => (topicsToUnsubscribe.value.length === 1 ? t("connection.mqttUnsubscribeTopic", { topic: topicsToUnsubscribe.value[0] }) : t("connection.mqttUnsubscribeGroup", { count: topicsToUnsubscribe.value.length })));
function toggle() {
expanded.value = !expanded.value;
}
</script>
<template>
<div>
<div
class="flex items-center gap-1 py-0.5 pl-2 pr-1 rounded cursor-pointer text-xs group border-l-2 border-transparent"
:class="selectedTopic === node.fullPath ? 'bg-primary/25 text-foreground font-semibold border-l-primary shadow-sm ring-1 ring-primary/20' : 'hover:bg-muted/70 text-muted-foreground'"
:style="{ paddingLeft: `${depth * 12 + 4}px` }"
@click="node.isLeaf ? emit('select', node.fullPath) : toggle()"
>
<!-- Expand/collapse toggle -->
<button v-if="node.children?.length" class="shrink-0 w-4 h-4 flex items-center justify-center text-muted-foreground" @click.stop="toggle">
<ChevronRight v-if="!expanded" class="h-3 w-3" />
<ChevronDown v-else class="h-3 w-3" />
</button>
<span v-else class="w-4 shrink-0" />
<!-- Topic name -->
<span class="truncate flex-1 font-mono" :class="selectedTopic === node.fullPath ? 'text-primary' : node.isLeaf ? 'font-medium text-blue-600 dark:text-blue-400' : ''">
{{ node.name }}
</span>
<!-- 分组节点可一次取消其下全部订阅 -->
<button
v-if="topicsToUnsubscribe.length"
type="button"
class="flex h-5 w-5 shrink-0 items-center justify-center rounded text-red-500 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 focus:opacity-100"
:title="unsubscribeLabel"
:aria-label="unsubscribeLabel"
@click.stop="emit('unsubscribe', topicsToUnsubscribe)"
>
<Trash2 class="h-3 w-3" />
</button>
</div>
<!-- Children -->
<TopicTreeNode
v-if="expanded"
v-for="child in node.children"
:key="child.fullPath"
:node="child"
:depth="depth + 1"
:selected-topic="selectedTopic"
:subscribed-topics="subscribedTopics"
@select="emit('select', $event)"
@subscribe="emit('subscribe', $event)"
@unsubscribe="emit('unsubscribe', $event)"
/>
</div>
</template>
<script lang="ts">
// Recursive component needs explicit name in non-setup script block
export default { name: "TopicTreeNode" };
</script>
@@ -603,6 +603,8 @@ async function toggle() {
await connectionStore.loadMqTenants(node.connectionId);
} else if (config?.db_type === "nacos") {
await connectionStore.loadNacosNamespaces(node.connectionId);
} else if (config?.db_type === "mqtt") {
await connectionStore.loadMqttTopics(node.connectionId);
} else {
await connectionStore.loadDatabases(node.connectionId);
}
@@ -615,6 +617,10 @@ async function toggle() {
} else if (node.type === "mq-tenant" && node.connectionId) {
await connectionStore.ensureConnected(node.connectionId);
queryStore.openMqAdmin(node.connectionId, { tenant: node.mqTenant || node.label, initialTab: node.mqInitialTab });
} else if (node.type === "mqtt-topic" && node.connectionId) {
await connectionStore.ensureConnected(node.connectionId);
const topicFromId = node.id.endsWith(":mqtt-topic:__console__") ? undefined : node.id.split(":mqtt-topic:")[1] || node.label;
queryStore.openMqttAdmin(node.connectionId, topicFromId ? { initialTopic: topicFromId } : undefined);
} else if (node.type === "nacos-namespace" && node.connectionId) {
await connectionStore.ensureConnected(node.connectionId);
queryStore.openNacosAdmin(node.connectionId, { namespace: node.nacosNamespace || "", namespaceName: node.nacosNamespaceName || node.label });
+65
View File
@@ -488,6 +488,71 @@ export default {
mqTokenSigningKeyPlaceholderHs256: "Broker SECRET",
mqTokenSigningKeyPlaceholderRs256: "-----BEGIN PRIVATE KEY-----",
mqTokenSigningHint: "Choose based on the broker jwt.broker.token.mode: SECRET uses HS256, PRIVATE uses RS256. The key is stored with connection secrets.",
mqttBrokerAddress: "Broker Address",
mqttBrokerAddressPlaceholder: "127.0.0.1",
mqttBrokerPort: "Port",
mqttClientId: "Client ID",
mqttClientIdPlaceholder: "Auto-generated",
mqttUsername: "Username",
mqttUsernamePlaceholder: "Optional",
mqttPassword: "Password",
mqttPasswordPlaceholder: "Optional",
mqttTls: "TLS",
mqttTlsSkipVerify: "Skip certificate verification",
mqttKeepAlive: "Keep Alive (seconds)",
mqttProtocolVersion: "Protocol Version",
mqttTransport: "Transport",
mqttTransportTcp: "TCP",
mqttTransportWebSocket: "WebSocket",
mqttWsPath: "WebSocket Path",
mqttWsPathPlaceholder: "/mqtt",
mqttConnectTimeout: "Connect Timeout (seconds)",
mqttConsoleTitle: "MQTT Console",
mqttTopics: "Topics",
mqttMessages: "Messages",
mqttPublish: "Publish Message",
mqttSubscribe: "Subscribe",
mqttUnsubscribe: "Unsubscribe",
mqttNoMessages: "No messages",
mqttNoTopics: "No subscribed topics",
mqttQuickSubscribe: "Quick Subscribe",
mqttTopicPlaceholder: "Topic name",
mqttConnected: "Connected",
mqttDisconnected: "Disconnected",
mqttRefresh: "Refresh",
mqttPublishDialogTitle: "Publish MQTT Message",
mqttClearMessages: "Clear messages",
mqttNoTopicsHint: "No subscriptions. Use the field below to subscribe to a topic.",
mqttSubscribePlaceholder: "Enter a topic filter...",
mqttMessagesForTopic: "Messages: {topic}",
mqttAllMessages: "Messages (all topics)",
mqttSent: "Sent",
mqttReceived: "Received",
mqttRetained: "Retained",
mqttUnsubscribeMissing: "No active subscriptions found. Refresh topics and try again.",
mqttUnsubscribeConfirm: 'Unsubscribe from "{topic}"?',
mqttUnsubscribeGroupConfirm: "Unsubscribe from {count} topics in this group?",
mqttUnsubscribeFailed: "Failed to unsubscribe from {count} topic(s): {errors}",
mqttUnsubscribeTopic: "Unsubscribe from {topic}",
mqttUnsubscribeGroup: "Unsubscribe from {count} topics in this group",
mqttTopic: "Topic",
mqttTopicExample: "e.g. sensors/temperature",
mqttRetainMessage: "Retain message",
mqttPayloadEncoding: "Payload encoding",
mqttPayload: "Payload",
mqttPayloadPlaceholderPlaintext: "Enter message content...",
mqttPayloadPlaceholderJson: 'Enter JSON, for example {"key": "value"}',
mqttPayloadPlaceholderBase64: "Enter Base64-encoded data",
mqttPayloadPlaceholderHex: "Enter hexadecimal data, for example 48 65 6C 6C 6F",
mqttPayloadPlaceholderStructured: "Enter a JSON value; it will be encoded as {encoding}",
mqttPublishShortcut: "Ctrl+Enter to publish",
mqttPublishTopicRequired: "Topic is required",
mqttPublishTopicWildcard: "Publish topics cannot contain # or + wildcards. Replace the subscription filter with a concrete topic.",
mqttPublishSuccess: "Message published",
mqttPublishing: "Publishing...",
mqttQosAtMostOnce: "At most once",
mqttQosAtLeastOnce: "At least once",
mqttQosExactlyOnce: "Exactly once",
searchDatabasePlaceholder: "Search database types",
searchResults: "Search results",
databaseCategories: "Categories",
+65
View File
@@ -378,6 +378,25 @@ export default withEnglishFallback({
nacosTls: "TLS",
nacosTlsSkipVerify: "Omitir verificación de certificado",
nacosPageSize: "Tamaño de página",
mqttBrokerAddress: "Broker Address",
mqttBrokerAddressPlaceholder: "127.0.0.1",
mqttBrokerPort: "Port",
mqttClientId: "Client ID",
mqttClientIdPlaceholder: "Auto-generated",
mqttUsername: "Username",
mqttUsernamePlaceholder: "Optional",
mqttPassword: "Password",
mqttPasswordPlaceholder: "Optional",
mqttTls: "TLS",
mqttTlsSkipVerify: "Skip certificate verification",
mqttKeepAlive: "Keep Alive (seconds)",
mqttProtocolVersion: "Protocol Version",
mqttTransport: "Transport",
mqttTransportTcp: "TCP",
mqttTransportWebSocket: "WebSocket",
mqttWsPath: "WebSocket Path",
mqttWsPathPlaceholder: "/mqtt",
mqttConnectTimeout: "Connect Timeout (seconds)",
nacosMetrics: "Métricas Prometheus",
nacosMetricsAuto: "Detección automática",
nacosMetricsDisabled: "Desactivado",
@@ -709,6 +728,52 @@ export default withEnglishFallback({
sshHostKeyNoticeRejected: "Ha rechazado la verificación de la clave del host {host}:{port}. La conexión se ha abortado, no se enviaron credenciales.",
sshHostKeyNoticeGeneric: "Falló la verificación de la clave del host SSH {host}:{port}.",
sshHostKeyNoticeLearnFailed: "No se pudo guardar la clave de host de {host}:{port} — este host solo es de confianza en esta sesión, la próxima conexión requerirá confirmación nuevamente.",
mqttConsoleTitle: "Consola MQTT",
mqttTopics: "Topics",
mqttMessages: "Mensajes",
mqttPublish: "Publicar mensaje",
mqttSubscribe: "Suscribir",
mqttUnsubscribe: "Cancelar suscripción",
mqttNoMessages: "No hay mensajes",
mqttNoTopics: "No hay Topics suscritos",
mqttQuickSubscribe: "Suscripción rápida",
mqttTopicPlaceholder: "Nombre del Topic",
mqttConnected: "Conectado",
mqttDisconnected: "Desconectado",
mqttRefresh: "Actualizar",
mqttPublishDialogTitle: "Publicar mensaje MQTT",
mqttClearMessages: "Borrar mensajes",
mqttNoTopicsHint: "No hay suscripciones. Usa el campo inferior para suscribirte a un Topic.",
mqttSubscribePlaceholder: "Introduce un filtro de Topic...",
mqttMessagesForTopic: "Mensajes: {topic}",
mqttAllMessages: "Mensajes (todos los Topics)",
mqttSent: "Enviado",
mqttReceived: "Recibido",
mqttRetained: "Retenido",
mqttUnsubscribeMissing: "No se encontraron suscripciones activas. Actualiza los Topics e inténtalo de nuevo.",
mqttUnsubscribeConfirm: '¿Cancelar la suscripción a "{topic}"?',
mqttUnsubscribeGroupConfirm: "¿Cancelar la suscripción a {count} Topics de este grupo?",
mqttUnsubscribeFailed: "No se pudo cancelar la suscripción a {count} Topic(s): {errors}",
mqttUnsubscribeTopic: "Cancelar la suscripción a {topic}",
mqttUnsubscribeGroup: "Cancelar la suscripción a {count} Topics de este grupo",
mqttTopic: "Topic",
mqttTopicExample: "p. ej. sensors/temperature",
mqttRetainMessage: "Retener mensaje",
mqttPayloadEncoding: "Codificación del Payload",
mqttPayload: "Payload",
mqttPayloadPlaceholderPlaintext: "Introduce el contenido del mensaje...",
mqttPayloadPlaceholderJson: 'Introduce JSON, por ejemplo {"key": "value"}',
mqttPayloadPlaceholderBase64: "Introduce datos codificados en Base64",
mqttPayloadPlaceholderHex: "Introduce datos hexadecimales, por ejemplo 48 65 6C 6C 6F",
mqttPayloadPlaceholderStructured: "Introduce un valor JSON; se codificará como {encoding}",
mqttPublishShortcut: "Ctrl+Enter para publicar",
mqttPublishTopicRequired: "El Topic es obligatorio",
mqttPublishTopicWildcard: "Los Topics de publicación no pueden contener los comodines # o +. Sustituye el filtro de suscripción por un Topic concreto.",
mqttPublishSuccess: "Mensaje publicado",
mqttPublishing: "Publicando...",
mqttQosAtMostOnce: "Como máximo una vez",
mqttQosAtLeastOnce: "Al menos una vez",
mqttQosExactlyOnce: "Exactamente una vez",
damengJvmOptions: "Propiedades del sistema JVM",
damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true",
damengJvmOptionsHint: "Una propiedad -Dkey o -Dkey=value por línea. Los valores con espacios no necesitan comillas del shell.",
+65
View File
@@ -377,6 +377,25 @@ export default withEnglishFallback({
nacosTls: "TLS",
nacosTlsSkipVerify: "Salta verifica certificato",
nacosPageSize: "Dimensione pagina",
mqttBrokerAddress: "Broker Address",
mqttBrokerAddressPlaceholder: "127.0.0.1",
mqttBrokerPort: "Port",
mqttClientId: "Client ID",
mqttClientIdPlaceholder: "Auto-generated",
mqttUsername: "Username",
mqttUsernamePlaceholder: "Optional",
mqttPassword: "Password",
mqttPasswordPlaceholder: "Optional",
mqttTls: "TLS",
mqttTlsSkipVerify: "Skip certificate verification",
mqttKeepAlive: "Keep Alive (seconds)",
mqttProtocolVersion: "Protocol Version",
mqttTransport: "Transport",
mqttTransportTcp: "TCP",
mqttTransportWebSocket: "WebSocket",
mqttWsPath: "WebSocket Path",
mqttWsPathPlaceholder: "/mqtt",
mqttConnectTimeout: "Connect Timeout (seconds)",
nacosMetrics: "Metriche Prometheus",
nacosMetricsAuto: "Rilevamento automatico",
nacosMetricsDisabled: "Disabilitato",
@@ -707,6 +726,52 @@ export default withEnglishFallback({
sshHostKeyNoticeRejected: "Hai rifiutato la verifica della chiave host di {host}:{port}, la connessione è stata interrotta e le credenziali non sono state inviate.",
sshHostKeyNoticeGeneric: "Verifica della chiave host SSH {host}:{port} fallita.",
sshHostKeyNoticeLearnFailed: "Impossibile salvare la chiave host di {host}:{port} — Questo host è considerato affidabile solo per questa sessione, alla prossima connessione sarà necessaria una nuova conferma.",
mqttConsoleTitle: "Console MQTT",
mqttTopics: "Topics",
mqttMessages: "Messaggi",
mqttPublish: "Pubblica messaggio",
mqttSubscribe: "Sottoscrivi",
mqttUnsubscribe: "Annulla sottoscrizione",
mqttNoMessages: "Nessun messaggio",
mqttNoTopics: "Nessun Topic sottoscritto",
mqttQuickSubscribe: "Sottoscrizione rapida",
mqttTopicPlaceholder: "Nome Topic",
mqttConnected: "Connesso",
mqttDisconnected: "Disconnesso",
mqttRefresh: "Aggiorna",
mqttPublishDialogTitle: "Pubblica messaggio MQTT",
mqttClearMessages: "Cancella messaggi",
mqttNoTopicsHint: "Nessuna sottoscrizione. Usa il campo sottostante per sottoscrivere un Topic.",
mqttSubscribePlaceholder: "Inserisci un filtro Topic...",
mqttMessagesForTopic: "Messaggi: {topic}",
mqttAllMessages: "Messaggi (tutti i Topic)",
mqttSent: "Inviato",
mqttReceived: "Ricevuto",
mqttRetained: "Mantenuto",
mqttUnsubscribeMissing: "Nessuna sottoscrizione attiva trovata. Aggiorna i Topic e riprova.",
mqttUnsubscribeConfirm: 'Annullare la sottoscrizione a "{topic}"?',
mqttUnsubscribeGroupConfirm: "Annullare la sottoscrizione a {count} Topic in questo gruppo?",
mqttUnsubscribeFailed: "Impossibile annullare la sottoscrizione a {count} Topic: {errors}",
mqttUnsubscribeTopic: "Annulla la sottoscrizione a {topic}",
mqttUnsubscribeGroup: "Annulla la sottoscrizione a {count} Topic in questo gruppo",
mqttTopic: "Topic",
mqttTopicExample: "ad es. sensors/temperature",
mqttRetainMessage: "Mantieni messaggio",
mqttPayloadEncoding: "Codifica del Payload",
mqttPayload: "Payload",
mqttPayloadPlaceholderPlaintext: "Inserisci il contenuto del messaggio...",
mqttPayloadPlaceholderJson: 'Inserisci JSON, ad esempio {"key": "value"}',
mqttPayloadPlaceholderBase64: "Inserisci dati codificati in Base64",
mqttPayloadPlaceholderHex: "Inserisci dati esadecimali, ad esempio 48 65 6C 6C 6F",
mqttPayloadPlaceholderStructured: "Inserisci un valore JSON; verrà codificato come {encoding}",
mqttPublishShortcut: "Ctrl+Invio per pubblicare",
mqttPublishTopicRequired: "Il Topic è obbligatorio",
mqttPublishTopicWildcard: "I Topic di pubblicazione non possono contenere i caratteri jolly # o +. Sostituisci il filtro di sottoscrizione con un Topic specifico.",
mqttPublishSuccess: "Messaggio pubblicato",
mqttPublishing: "Pubblicazione...",
mqttQosAtMostOnce: "Al massimo una volta",
mqttQosAtLeastOnce: "Almeno una volta",
mqttQosExactlyOnce: "Esattamente una volta",
damengJvmOptions: "Proprietà di sistema JVM",
damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true",
damengJvmOptionsHint: "Una proprietà -Dkey o -Dkey=value per riga. I valori con spazi non richiedono virgolette della shell.",
+65
View File
@@ -371,6 +371,25 @@ export default withEnglishFallback({
nacosTls: "TLS",
nacosTlsSkipVerify: "証明書検証をスキップ",
nacosPageSize: "ページサイズ",
mqttBrokerAddress: "Broker Address",
mqttBrokerAddressPlaceholder: "127.0.0.1",
mqttBrokerPort: "Port",
mqttClientId: "Client ID",
mqttClientIdPlaceholder: "Auto-generated",
mqttUsername: "Username",
mqttUsernamePlaceholder: "Optional",
mqttPassword: "Password",
mqttPasswordPlaceholder: "Optional",
mqttTls: "TLS",
mqttTlsSkipVerify: "Skip certificate verification",
mqttKeepAlive: "Keep Alive (seconds)",
mqttProtocolVersion: "Protocol Version",
mqttTransport: "Transport",
mqttTransportTcp: "TCP",
mqttTransportWebSocket: "WebSocket",
mqttWsPath: "WebSocket Path",
mqttWsPathPlaceholder: "/mqtt",
mqttConnectTimeout: "Connect Timeout (seconds)",
nacosMetrics: "Prometheus メトリクス",
nacosMetricsAuto: "自動検出",
nacosMetricsDisabled: "無効",
@@ -707,6 +726,52 @@ export default withEnglishFallback({
sshHostKeyNoticeRejected: "ホスト {host}:{port} のホストキー検証を拒否しました。接続は中止され、資格情報は送信されませんでした。",
sshHostKeyNoticeGeneric: "SSHホスト {host}:{port} のキー検証に失敗しました。",
sshHostKeyNoticeLearnFailed: "{host}:{port} のホストキーを保存できません — このホストは現在のセッションでのみ信頼され、次回の接続時に再確認が必要です。",
mqttConsoleTitle: "MQTT コンソール",
mqttTopics: "トピック",
mqttMessages: "メッセージ",
mqttPublish: "メッセージを公開",
mqttSubscribe: "購読",
mqttUnsubscribe: "購読解除",
mqttNoMessages: "メッセージはありません",
mqttNoTopics: "購読中のトピックはありません",
mqttQuickSubscribe: "クイック購読",
mqttTopicPlaceholder: "トピック名",
mqttConnected: "接続済み",
mqttDisconnected: "切断済み",
mqttRefresh: "更新",
mqttPublishDialogTitle: "MQTT メッセージを公開",
mqttClearMessages: "メッセージを消去",
mqttNoTopicsHint: "購読はありません。下の入力欄からトピックを購読してください。",
mqttSubscribePlaceholder: "トピックフィルターを入力...",
mqttMessagesForTopic: "メッセージ: {topic}",
mqttAllMessages: "メッセージ(すべてのトピック)",
mqttSent: "送信",
mqttReceived: "受信",
mqttRetained: "保持",
mqttUnsubscribeMissing: "解除できる購読が見つかりません。トピックを更新して、もう一度お試しください。",
mqttUnsubscribeConfirm: "「{topic}」の購読を解除しますか?",
mqttUnsubscribeGroupConfirm: "このグループの {count} 件のトピック購読を解除しますか?",
mqttUnsubscribeFailed: "{count} 件のトピック購読を解除できませんでした: {errors}",
mqttUnsubscribeTopic: "{topic} の購読を解除",
mqttUnsubscribeGroup: "このグループの {count} 件のトピック購読を解除",
mqttTopic: "トピック",
mqttTopicExample: "例: sensors/temperature",
mqttRetainMessage: "メッセージを保持",
mqttPayloadEncoding: "ペイロードのエンコード",
mqttPayload: "ペイロード",
mqttPayloadPlaceholderPlaintext: "メッセージ内容を入力...",
mqttPayloadPlaceholderJson: 'JSON を入力(例: {"key": "value"})',
mqttPayloadPlaceholderBase64: "Base64 エンコード済みデータを入力",
mqttPayloadPlaceholderHex: "16 進数データを入力(例: 48 65 6C 6C 6F)",
mqttPayloadPlaceholderStructured: "JSON 値を入力してください。{encoding} としてエンコードされます",
mqttPublishShortcut: "Ctrl+Enter で公開",
mqttPublishTopicRequired: "トピックは必須です",
mqttPublishTopicWildcard: "公開トピックには # または + のワイルドカードを使用できません。購読フィルターを具体的なトピックに置き換えてください。",
mqttPublishSuccess: "メッセージを公開しました",
mqttPublishing: "公開中...",
mqttQosAtMostOnce: "最大 1 回",
mqttQosAtLeastOnce: "最低 1 回",
mqttQosExactlyOnce: "正確に 1 回",
damengJvmOptions: "JVM システムプロパティ",
damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true",
damengJvmOptionsHint: "1 行に 1 つの -Dkey または -Dkey=value を入力します。空白を含む値にもシェル引用符は不要です。",
+65
View File
@@ -378,6 +378,25 @@ export default withEnglishFallback({
nacosTls: "TLS",
nacosTlsSkipVerify: "Ignorar verificação do certificado",
nacosPageSize: "Tamanho da página",
mqttBrokerAddress: "Broker Address",
mqttBrokerAddressPlaceholder: "127.0.0.1",
mqttBrokerPort: "Port",
mqttClientId: "Client ID",
mqttClientIdPlaceholder: "Auto-generated",
mqttUsername: "Username",
mqttUsernamePlaceholder: "Optional",
mqttPassword: "Password",
mqttPasswordPlaceholder: "Optional",
mqttTls: "TLS",
mqttTlsSkipVerify: "Skip certificate verification",
mqttKeepAlive: "Keep Alive (seconds)",
mqttProtocolVersion: "Protocol Version",
mqttTransport: "Transport",
mqttTransportTcp: "TCP",
mqttTransportWebSocket: "WebSocket",
mqttWsPath: "WebSocket Path",
mqttWsPathPlaceholder: "/mqtt",
mqttConnectTimeout: "Connect Timeout (seconds)",
nacosMetrics: "Métricas Prometheus",
nacosMetricsAuto: "Detecção automática",
nacosMetricsDisabled: "Desativado",
@@ -708,6 +727,52 @@ export default withEnglishFallback({
sshHostKeyNoticeRejected: "Você rejeitou a verificação da chave do host {host}:{port}. A conexão foi abortada e as credenciais não foram enviadas.",
sshHostKeyNoticeGeneric: "Falha na verificação da chave do host SSH {host}:{port}.",
sshHostKeyNoticeLearnFailed: "Não foi possível salvar a chave do host {host}:{port} — o host é confiável apenas nesta sessão, necessitando reconfirmação na próxima conexão.",
mqttConsoleTitle: "MQTT Console",
mqttTopics: "Tópicos",
mqttMessages: "Mensagens",
mqttPublish: "Publicar mensagem",
mqttSubscribe: "Inscrever",
mqttUnsubscribe: "Cancelar inscrição",
mqttNoMessages: "Nenhuma mensagem",
mqttNoTopics: "Nenhum Topic inscrito",
mqttQuickSubscribe: "Inscrição rápida",
mqttTopicPlaceholder: "Nome do Topic",
mqttConnected: "Conectado",
mqttDisconnected: "Desconectado",
mqttRefresh: "Atualizar",
mqttPublishDialogTitle: "Publicar mensagem MQTT",
mqttClearMessages: "Limpar mensagens",
mqttNoTopicsHint: "Nenhuma inscrição. Use o campo abaixo para assinar um Topic.",
mqttSubscribePlaceholder: "Digite um filtro de Topic...",
mqttMessagesForTopic: "Mensagens: {topic}",
mqttAllMessages: "Mensagens (todos os Topics)",
mqttSent: "Enviado",
mqttReceived: "Recebido",
mqttRetained: "Retido",
mqttUnsubscribeMissing: "Nenhuma inscrição ativa encontrada. Atualize os Topics e tente novamente.",
mqttUnsubscribeConfirm: 'Cancelar a inscrição em "{topic}"?',
mqttUnsubscribeGroupConfirm: "Cancelar a inscrição em {count} Topics deste grupo?",
mqttUnsubscribeFailed: "Falha ao cancelar a inscrição em {count} Topic(s): {errors}",
mqttUnsubscribeTopic: "Cancelar a inscrição em {topic}",
mqttUnsubscribeGroup: "Cancelar a inscrição em {count} Topics deste grupo",
mqttTopic: "Topic",
mqttTopicExample: "ex.: sensors/temperature",
mqttRetainMessage: "Reter mensagem",
mqttPayloadEncoding: "Codificação do Payload",
mqttPayload: "Payload",
mqttPayloadPlaceholderPlaintext: "Digite o conteúdo da mensagem...",
mqttPayloadPlaceholderJson: 'Digite JSON, por exemplo {"key": "value"}',
mqttPayloadPlaceholderBase64: "Digite dados codificados em Base64",
mqttPayloadPlaceholderHex: "Digite dados hexadecimais, por exemplo 48 65 6C 6C 6F",
mqttPayloadPlaceholderStructured: "Digite um valor JSON; ele será codificado como {encoding}",
mqttPublishShortcut: "Ctrl+Enter para publicar",
mqttPublishTopicRequired: "O Topic é obrigatório",
mqttPublishTopicWildcard: "Topics de publicação não podem conter os curingas # ou +. Substitua o filtro de inscrição por um Topic específico.",
mqttPublishSuccess: "Mensagem publicada",
mqttPublishing: "Publicando...",
mqttQosAtMostOnce: "No máximo uma vez",
mqttQosAtLeastOnce: "Pelo menos uma vez",
mqttQosExactlyOnce: "Exatamente uma vez",
damengJvmOptions: "Propriedades do sistema da JVM",
damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true",
damengJvmOptionsHint: "Uma propriedade -Dkey ou -Dkey=value por linha. Valores com espaços não precisam de aspas do shell.",
+65
View File
@@ -489,6 +489,71 @@ export default withEnglishFallback({
mqTokenSigningKeyPlaceholderHs256: "Broker SECRET",
mqTokenSigningKeyPlaceholderRs256: "-----BEGIN PRIVATE KEY-----",
mqTokenSigningHint: "按 Broker 的 jwt.broker.token.mode 选择:SECRET 使用 HS256,PRIVATE 使用 RS256。密钥会走连接 secret 存储。",
mqttBrokerAddress: "Broker 地址",
mqttBrokerAddressPlaceholder: "127.0.0.1",
mqttBrokerPort: "端口",
mqttClientId: "Client ID",
mqttClientIdPlaceholder: "自动生成",
mqttUsername: "用户名",
mqttUsernamePlaceholder: "选填",
mqttPassword: "密码",
mqttPasswordPlaceholder: "选填",
mqttTls: "TLS",
mqttTlsSkipVerify: "跳过证书校验",
mqttKeepAlive: "Keep Alive(秒)",
mqttProtocolVersion: "协议版本",
mqttTransport: "传输层",
mqttTransportTcp: "TCP",
mqttTransportWebSocket: "WebSocket",
mqttWsPath: "WebSocket 路径",
mqttWsPathPlaceholder: "/mqtt",
mqttConnectTimeout: "连接超时(秒)",
mqttConsoleTitle: "MQTT 控制台",
mqttTopics: "Topics",
mqttMessages: "消息",
mqttPublish: "发布消息",
mqttSubscribe: "订阅",
mqttUnsubscribe: "取消订阅",
mqttNoMessages: "暂无消息",
mqttNoTopics: "暂无已订阅的 Topic",
mqttQuickSubscribe: "快速订阅",
mqttTopicPlaceholder: "Topic 名称",
mqttConnected: "已连接",
mqttDisconnected: "已断开",
mqttRefresh: "刷新",
mqttPublishDialogTitle: "发布 MQTT 消息",
mqttClearMessages: "清空消息",
mqttNoTopicsHint: "暂无订阅。使用下方输入框订阅 Topic。",
mqttSubscribePlaceholder: "输入要订阅的 Topic...",
mqttMessagesForTopic: "消息:{topic}",
mqttAllMessages: "消息(所有 Topic)",
mqttSent: "发送",
mqttReceived: "接收",
mqttRetained: "保留",
mqttUnsubscribeMissing: "未找到可取消的订阅,Topic 列表可能已经刷新,请重试。",
mqttUnsubscribeConfirm: "确定要取消订阅“{topic}”吗?",
mqttUnsubscribeGroupConfirm: "确定要取消此分组下的 {count} 个订阅吗?",
mqttUnsubscribeFailed: "有 {count} 个订阅未能取消:{errors}",
mqttUnsubscribeTopic: "取消订阅 {topic}",
mqttUnsubscribeGroup: "取消此分组下的 {count} 个订阅",
mqttTopic: "Topic",
mqttTopicExample: "例如 sensors/temperature",
mqttRetainMessage: "保留消息",
mqttPayloadEncoding: "Payload 编码",
mqttPayload: "消息内容 (Payload)",
mqttPayloadPlaceholderPlaintext: "输入消息内容...",
mqttPayloadPlaceholderJson: '输入 JSON,例如 {"key": "value"}',
mqttPayloadPlaceholderBase64: "输入 Base64 编码的数据",
mqttPayloadPlaceholderHex: "输入十六进制数据,例如 48 65 6C 6C 6F",
mqttPayloadPlaceholderStructured: "输入 JSON 值,发送时将自动编码为 {encoding}",
mqttPublishShortcut: "Ctrl+Enter 发送",
mqttPublishTopicRequired: "Topic 不能为空",
mqttPublishTopicWildcard: "发布 Topic 不能包含 # 或 + 通配符,请将订阅过滤器改为具体 Topic 后再发送。",
mqttPublishSuccess: "消息已发送",
mqttPublishing: "发送中...",
mqttQosAtMostOnce: "最多一次",
mqttQosAtLeastOnce: "至少一次",
mqttQosExactlyOnce: "精确一次",
searchDatabasePlaceholder: "搜索数据库类型",
searchResults: "搜索结果",
databaseCategories: "分类",
+65
View File
@@ -377,6 +377,25 @@ export default withEnglishFallback({
nacosTls: "TLS",
nacosTlsSkipVerify: "跳過憑證驗證",
nacosPageSize: "分頁大小",
mqttBrokerAddress: "Broker Address",
mqttBrokerAddressPlaceholder: "127.0.0.1",
mqttBrokerPort: "Port",
mqttClientId: "Client ID",
mqttClientIdPlaceholder: "Auto-generated",
mqttUsername: "Username",
mqttUsernamePlaceholder: "Optional",
mqttPassword: "Password",
mqttPasswordPlaceholder: "Optional",
mqttTls: "TLS",
mqttTlsSkipVerify: "Skip certificate verification",
mqttKeepAlive: "Keep Alive (seconds)",
mqttProtocolVersion: "Protocol Version",
mqttTransport: "Transport",
mqttTransportTcp: "TCP",
mqttTransportWebSocket: "WebSocket",
mqttWsPath: "WebSocket Path",
mqttWsPathPlaceholder: "/mqtt",
mqttConnectTimeout: "Connect Timeout (seconds)",
nacosMetrics: "Prometheus 指標",
nacosMetricsAuto: "自動偵測",
nacosMetricsDisabled: "關閉",
@@ -707,6 +726,52 @@ export default withEnglishFallback({
sshHostKeyNoticeRejected: "您已拒絕 {host}:{port} 的主機金鑰驗證,連線已中止,憑證未發送。",
sshHostKeyNoticeGeneric: "SSH 主機 {host}:{port} 的金鑰驗證失敗。",
sshHostKeyNoticeLearnFailed: "無法儲存 {host}:{port} 的主機金鑰 — 該主機僅在本次會話內受信任,下次連線仍需重新確認。",
mqttConsoleTitle: "MQTT 控制台",
mqttTopics: "Topics",
mqttMessages: "訊息",
mqttPublish: "發布訊息",
mqttSubscribe: "訂閱",
mqttUnsubscribe: "取消訂閱",
mqttNoMessages: "暫無訊息",
mqttNoTopics: "暫無已訂閱的 Topic",
mqttQuickSubscribe: "快速訂閱",
mqttTopicPlaceholder: "Topic 名稱",
mqttConnected: "已連線",
mqttDisconnected: "已斷線",
mqttRefresh: "重新整理",
mqttPublishDialogTitle: "發布 MQTT 訊息",
mqttClearMessages: "清除訊息",
mqttNoTopicsHint: "目前沒有訂閱。請使用下方輸入框訂閱 Topic。",
mqttSubscribePlaceholder: "輸入要訂閱的 Topic 篩選器...",
mqttMessagesForTopic: "訊息:{topic}",
mqttAllMessages: "訊息(所有 Topic)",
mqttSent: "傳送",
mqttReceived: "接收",
mqttRetained: "保留",
mqttUnsubscribeMissing: "找不到可取消的訂閱。Topic 清單可能已重新整理,請再試一次。",
mqttUnsubscribeConfirm: "確定要取消訂閱「{topic}」嗎?",
mqttUnsubscribeGroupConfirm: "確定要取消此群組下的 {count} 個訂閱嗎?",
mqttUnsubscribeFailed: "有 {count} 個訂閱無法取消:{errors}",
mqttUnsubscribeTopic: "取消訂閱 {topic}",
mqttUnsubscribeGroup: "取消此群組下的 {count} 個訂閱",
mqttTopic: "Topic",
mqttTopicExample: "例如 sensors/temperature",
mqttRetainMessage: "保留訊息",
mqttPayloadEncoding: "Payload 編碼",
mqttPayload: "訊息內容 (Payload)",
mqttPayloadPlaceholderPlaintext: "輸入訊息內容...",
mqttPayloadPlaceholderJson: '輸入 JSON,例如 {"key": "value"}',
mqttPayloadPlaceholderBase64: "輸入 Base64 編碼的資料",
mqttPayloadPlaceholderHex: "輸入十六進位資料,例如 48 65 6C 6C 6F",
mqttPayloadPlaceholderStructured: "輸入 JSON 值,傳送時將自動編碼為 {encoding}",
mqttPublishShortcut: "Ctrl+Enter 傳送",
mqttPublishTopicRequired: "Topic 不可為空",
mqttPublishTopicWildcard: "發布 Topic 不可包含 # 或 + 萬用字元,請將訂閱篩選器改為具體 Topic 後再傳送。",
mqttPublishSuccess: "訊息已傳送",
mqttPublishing: "傳送中...",
mqttQosAtMostOnce: "最多一次",
mqttQosAtLeastOnce: "至少一次",
mqttQosExactlyOnce: "恰好一次",
damengJvmOptions: "JVM 系統屬性",
damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true",
damengJvmOptionsHint: "每行一個 -Dkey 或 -Dkey=value;值包含空格時也不要使用 shell 引號。",
+10
View File
@@ -748,3 +748,13 @@ export type {
AgentEvent,
SqlFileEntry,
} from "@/lib/backend/tauri";
// MQTT
export const mqttGetBrokerInfo = forward("mqttGetBrokerInfo");
export const mqttSubscribe = forward("mqttSubscribe");
export const mqttUnsubscribe = forward("mqttUnsubscribe");
export const mqttPublish = forward("mqttPublish");
export const mqttListTopics = forward("mqttListTopics");
export const mqttGetTopicTree = forward("mqttGetTopicTree");
export const mqttGetMessages = forward("mqttGetMessages");
export const mqttClearMessages = forward("mqttClearMessages");
+1
View File
@@ -3358,3 +3358,4 @@ export async function refreshConnections(): Promise<void> {
}
export * from "@/lib/backend/mq-http";
export * from "@/lib/backend/mqtt-http";
+38
View File
@@ -0,0 +1,38 @@
// MQTT HTTP backend stubs (MQTT is not yet supported in web/Docker mode).
// Throw descriptive errors so the user knows this isn't available.
function mqttWebNotAvailable(name: string): never {
throw new Error(`MQTT ${name}: MQTT 功能暂不支持 Web/Docker 模式。请使用桌面版。`);
}
export async function mqttGetBrokerInfo(_connectionId: string) {
mqttWebNotAvailable("mqttGetBrokerInfo");
}
export async function mqttSubscribe(_connectionId: string, _topic: string, _qos?: string | null) {
mqttWebNotAvailable("mqttSubscribe");
}
export async function mqttUnsubscribe(_connectionId: string, _topic: string) {
mqttWebNotAvailable("mqttUnsubscribe");
}
export async function mqttPublish(_connectionId: string, _request: unknown) {
mqttWebNotAvailable("mqttPublish");
}
export async function mqttListTopics(_connectionId: string) {
mqttWebNotAvailable("mqttListTopics");
}
export async function mqttGetTopicTree(_connectionId: string) {
mqttWebNotAvailable("mqttGetTopicTree");
}
export async function mqttGetMessages(_connectionId: string, _topicFilter?: string | null, _limit?: number | null) {
mqttWebNotAvailable("mqttGetMessages");
}
export async function mqttClearMessages(_connectionId: string) {
mqttWebNotAvailable("mqttClearMessages");
}
@@ -0,0 +1,33 @@
import { invoke } from "@tauri-apps/api/core";
export async function mqttGetBrokerInfo(connectionId: string) {
return invoke("mqtt_get_broker_info", { connectionId });
}
export async function mqttSubscribe(connectionId: string, topic: string, qos?: string | null) {
return invoke("mqtt_subscribe", { connectionId, topic, qos: qos ?? null });
}
export async function mqttUnsubscribe(connectionId: string, topic: string) {
return invoke("mqtt_unsubscribe", { connectionId, topic });
}
export async function mqttPublish(connectionId: string, request: { topic: string; payloadBase64: string; payloadText?: string | null; qos: string; retain: boolean }) {
return invoke("mqtt_publish", { connectionId, request });
}
export async function mqttListTopics(connectionId: string) {
return invoke("mqtt_list_topics", { connectionId });
}
export async function mqttGetTopicTree(connectionId: string) {
return invoke("mqtt_get_topic_tree", { connectionId });
}
export async function mqttGetMessages(connectionId: string, topicFilter?: string | null, limit?: number | null) {
return invoke("mqtt_get_messages", { connectionId, topicFilter: topicFilter ?? null, limit: limit ?? null });
}
export async function mqttClearMessages(connectionId: string) {
return invoke("mqtt_clear_messages", { connectionId });
}
+1
View File
@@ -3864,4 +3864,5 @@ export async function exportQueryResultMarkdown(filePath: string, columns: strin
}
export * from "@/lib/backend/mq-tauri";
export * from "@/lib/backend/mqtt-tauri";
export * from "@/lib/backend/nacos-tauri";
@@ -84,6 +84,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
jdbc: { deferred: "generic JDBC does not expose a reliable dialect-specific create target" },
mq: { deferred: "message queue namespaces are handled by MQ admin panels" },
nacos: { deferred: "Nacos namespace creation already uses the Nacos admin flow" },
mqtt: { deferred: "MQTT topics are managed via the MQTT console" },
} satisfies Record<DatabaseType, DatabaseNamespaceCreationMatrixEntry>;
export function connectionNamespaceCreationTarget(connection: CreationConnection): ConnectionCreationTarget | null {
@@ -84,6 +84,7 @@ export const DATABASE_PROPERTY_EDITING_MATRIX = {
jdbc: { deferred: "generic JDBC does not expose reliable dialect-specific properties" },
mq: { deferred: "message queue namespaces are handled by MQ admin panels" },
nacos: { deferred: "Nacos namespace editing already uses the Nacos admin flow" },
mqtt: { deferred: "MQTT topics are managed via the MQTT console" },
} satisfies Record<DatabaseType, DatabasePropertyEditingEntry>;
function entryFor(connection: PropertyEditConnection): DatabasePropertyEditingEntry | null {
@@ -0,0 +1,180 @@
/**
* MQTT Payload 编解码工具
* 支持 Plaintext / JSON / Base64 / Hex / CBOR / MsgPack 六种格式
*/
import { decode as cborDecode, encode as cborEncode } from "cbor-x";
import { decode as msgpackDecode, encode as msgpackEncode } from "@msgpack/msgpack";
/** 支持的编码格式 */
export type PayloadEncoding = "plaintext" | "json" | "base64" | "hex" | "cbor" | "msgpack";
export const PAYLOAD_ENCODING_LABELS: Record<PayloadEncoding, string> = {
plaintext: "Plaintext",
json: "JSON",
base64: "Base64",
hex: "Hex",
cbor: "CBOR",
msgpack: "MsgPack",
};
export const PAYLOAD_ENCODINGS: PayloadEncoding[] = ["plaintext", "json", "base64", "hex", "cbor", "msgpack"];
/**
* 将 base64 编码的 payload 按指定格式解码为可展示的字符串
*/
export function decodePayload(base64: string, encoding: PayloadEncoding): string {
if (!base64) return "";
try {
const bytes = base64ToBytes(base64);
switch (encoding) {
case "plaintext":
return bytesToUtf8(bytes);
case "json": {
const text = bytesToUtf8(bytes);
const parsed = JSON.parse(text);
return JSON.stringify(parsed, null, 2);
}
case "base64":
return base64;
case "hex":
return bytesToHex(bytes);
case "cbor": {
const decoded = cborDecode(bytes);
return formatDecodedValue(decoded);
}
case "msgpack": {
const decoded = msgpackDecode(bytes);
return formatDecodedValue(decoded);
}
default:
return bytesToUtf8(bytes);
}
} catch {
/* 解码失败时回退为纯文本显示 */
try {
return bytesToUtf8(base64ToBytes(base64));
} catch {
return `[解码失败] ${base64}`;
}
}
}
/**
* 将用户输入的文本按指定格式编码为 base64(用于发布消息)
*/
export function encodePayload(input: string, encoding: PayloadEncoding): string {
if (!input) return "";
try {
switch (encoding) {
case "plaintext":
return bytesToBase64(utf8ToBytes(input));
case "json": {
/* 确保输入是合法 JSON 后再编码 */
const parsed = JSON.parse(input);
return bytesToBase64(utf8ToBytes(JSON.stringify(parsed)));
}
case "base64":
/* 浏览器 atob 接受缺失 padding 和空白;规范化后再交给 Rust 严格解码 */
return bytesToBase64(base64ToBytes(input));
case "hex": {
const cleaned = input.replace(/\s/g, "");
if (!/^([0-9A-Fa-f]{2})*$/.test(cleaned)) {
throw new Error("无效的十六进制字符串");
}
const bytes = hexToBytes(cleaned);
return bytesToBase64(bytes);
}
case "cbor": {
/* 将 JSON 文本编码为 CBOR */
const parsed = JSON.parse(input);
const cborBytes = cborEncode(parsed);
return bytesToBase64(cborBytes);
}
case "msgpack": {
/* 将 JSON 文本编码为 MsgPack */
const parsed = JSON.parse(input);
const msgpackBytes = msgpackEncode(parsed);
return bytesToBase64(msgpackBytes);
}
default:
return bytesToBase64(utf8ToBytes(input));
}
} catch (e) {
throw new Error(`编码失败 (${PAYLOAD_ENCODING_LABELS[encoding]}): ${String(e)}`);
}
}
/* ========== 基础转换工具 ========== */
function base64ToBytes(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function utf8ToBytes(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
function bytesToUtf8(bytes: Uint8Array): string {
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
}
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(" ");
}
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
function formatDecodedValue(value: unknown): string {
if (value === null || value === undefined) return String(value);
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
/* Buffer / Uint8Array 等二进制类型 */
if (value instanceof Uint8Array || (ArrayBuffer.isView(value) && (value as ArrayBufferView).byteLength !== undefined)) {
return `[二进制数据, ${(value as Uint8Array).byteLength ?? (value as Uint8Array).length} bytes]`;
}
if (value instanceof ArrayBuffer) {
return `[二进制数据, ${value.byteLength} bytes]`;
}
/* 对象/数组 → JSON 格式化 */
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
@@ -29,6 +29,7 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null {
return { icon: FolderOpen, colorClass: "text-emerald-500" };
case "nacos-namespace":
case "etcd-root":
case "mqtt-topic":
return { icon: FolderOpen, colorClass: "text-sky-500" };
case "etcd-dashboard":
return { icon: Gauge, colorClass: "text-sky-500" };
@@ -2658,6 +2658,8 @@ export const useConnectionStore = defineStore("connection", () => {
await loadVectorCollections(connectionId);
} else if (config.db_type === "mq") {
await loadMqTenants(connectionId, { force: true });
} else if (config.db_type === "mqtt") {
await loadMqttTopics(connectionId);
} else if (config.db_type === "nacos") {
await loadNacosNamespaces(connectionId, { force: true });
} else {
@@ -3324,6 +3326,48 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function loadMqttTopics(connectionId: string) {
const node = findConnectionNode(connectionId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
const { mqttGetTopicTree } = await import("@/lib/backend/api");
const topicTree = (await mqttGetTopicTree(connectionId)) as { name: string; fullPath: string; children?: unknown[]; isLeaf: boolean };
const topicNodes = mqttTopicTreeToSidebarNodes(connectionId, topicTree);
// Always prepend a synthetic console entry so users can open the MQTT admin
// even when no topics are subscribed yet.
const consoleNode: TreeNode = {
id: `${connectionId}:mqtt-topic:__console__`,
label: "MQTT 控制台",
type: "mqtt-topic" as const,
connectionId,
children: topicNodes.length > 0 ? topicNodes : [],
isExpanded: topicNodes.length > 0,
};
setChildren(node, [consoleNode]);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
function mqttTopicTreeToSidebarNodes(connectionId: string, tree: { name: string; fullPath: string; children?: unknown[]; isLeaf: boolean }): TreeNode[] {
const children = tree.children ?? [];
return children.map((child: any) => ({
id: `${connectionId}:mqtt-topic:${child.fullPath}`,
label: child.isLeaf ? child.name : `${child.name}/`,
type: "mqtt-topic",
connectionId,
children: child.children?.length > 0 ? mqttTopicTreeToSidebarNodes(connectionId, child) : [],
isExpanded: false,
})) as TreeNode[];
}
async function loadMqTenants(connectionId: string, options?: LoadTreeOptions) {
const node = findConnectionNode(connectionId);
if (!node) return;
@@ -5089,6 +5133,8 @@ export const useConnectionStore = defineStore("connection", () => {
await loadVectorCollections(node.connectionId);
} else if (config?.db_type === "mq") {
await loadMqTenants(node.connectionId, options);
} else if (config?.db_type === "mqtt") {
await loadMqttTopics(node.connectionId);
} else if (config?.db_type === "nacos") {
await loadNacosNamespaces(node.connectionId, options);
} else {
@@ -6863,6 +6909,7 @@ export const useConnectionStore = defineStore("connection", () => {
loadEtcdRoot,
loadZooKeeperRoot,
loadMqTenants,
loadMqttTopics,
loadNacosNamespaces,
updateRedisDbKeyStats,
loadMongoDatabases,
+28
View File
@@ -1735,6 +1735,33 @@ export const useQueryStore = defineStore("query", () => {
return id;
}
function openMqttAdmin(connectionId: string, target?: { initialTopic?: string }) {
const existing = tabs.value.find((tab) => tab.mode === "mqtt" && tab.connectionId === connectionId);
if (existing) {
if (target?.initialTopic) existing.mqttInitialTopic = target.initialTopic;
switchTab(existing.id);
return existing.id;
}
const conn = useConnectionStore().getConfig(connectionId);
const id = uuid();
const tab: QueryTab = {
id,
title: `${conn?.name || "MQTT"} Console`,
connectionId,
database: conn?.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "mqtt",
mqttInitialTopic: target?.initialTopic,
};
tabs.value.push(tab);
activeTabId.value = id;
return id;
}
function clearNacosNavigationTarget(connectionId: string, namespace: string, requestId?: number) {
const tab = tabs.value.find((candidate) => candidate.mode === "nacos" && candidate.connectionId === connectionId && (candidate.nacosNamespace || "") === namespace);
if (!tab || (requestId !== undefined && tab.nacosTargetRequestId !== requestId)) return;
@@ -5312,6 +5339,7 @@ export const useQueryStore = defineStore("query", () => {
openNacosDashboard,
openDamengJobAdmin,
openMqAdmin,
openMqttAdmin,
openNacosAdmin,
clearNacosNavigationTarget,
openTableStructure,
+5 -1
View File
@@ -68,6 +68,7 @@ export type DatabaseType =
| "influxdb"
| "jdbc"
| "mq"
| "mqtt"
| "nacos";
export function isElasticsearchCompatibleDatabaseType(dbType?: DatabaseType): boolean {
@@ -821,7 +822,8 @@ export type TreeNodeType =
| "mongo-collection"
| "vector-database"
| "vector-collection"
| "elasticsearch-index";
| "elasticsearch-index"
| "mqtt-topic";
export interface ConnectionGroup {
id: string;
@@ -1008,6 +1010,7 @@ export interface QueryTab {
| "etcd-access-control"
| "zookeeper"
| "mq"
| "mqtt"
| "nacos"
| "nacos-dashboard"
| "objects"
@@ -1021,6 +1024,7 @@ export interface QueryTab {
hbaseCreateTableOnOpen?: boolean;
mqTenant?: string;
mqInitialTab?: "topics";
mqttInitialTopic?: string;
nacosNamespace?: string;
nacosNamespaceName?: string;
nacosTargetDataId?: string;
+75
View File
@@ -0,0 +1,75 @@
// MQTT broker connection types, matching dbx-core/src/mqtt/types.rs
export type MqttProtocolVersion = "v3" | "v4" | "v5";
export type MqttTransport = "tcp" | "websocket";
export type MqttQoS = "atmostonce" | "atleastonce" | "exactlyonce";
export interface MqttAuth {
kind: "none" | "password" | "certificate";
username?: string;
password?: string;
caCertPath?: string;
clientCertPath?: string;
clientKeyPath?: string;
}
export interface MqttConnectionConfig {
host: string;
port: number;
clientId: string;
protocolVersion: MqttProtocolVersion;
transport: MqttTransport;
tls: boolean;
tlsSkipVerify: boolean;
auth: MqttAuth;
keepAliveSecs: number;
connectTimeoutSecs: number;
maxPacketSizeBytes: number;
wsPath?: string;
}
export interface MqttBrokerInfo {
brokerUrl: string;
clientId: string;
connected: boolean;
protocolVersion: string;
subscriptionCount: number;
}
export type MqttMessageDirection = "sent" | "received";
export interface MqttMessage {
topic: string;
payloadBase64: string;
payloadText?: string | null;
qos: number;
retain: boolean;
receivedAtMs: number;
/** 消息方向:sent(发出的)或 received(接收的) */
direction?: MqttMessageDirection;
}
export interface MqttTopicNode {
name: string;
fullPath: string;
children: MqttTopicNode[];
messageCount?: number | null;
isLeaf: boolean;
}
export interface MqttPublishRequest {
topic: string;
payloadBase64: string;
payloadText?: string | null;
qos: MqttQoS;
retain: boolean;
}
export interface MqttSubscribeRequest {
topic: string;
qos: MqttQoS;
}
export interface MqttUnsubscribeRequest {
topic: string;
}