feat(mqtt): add MQTT broker connection support
This commit is contained in:
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"> </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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 を入力します。空白を含む値にもシェル引用符は不要です。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: "分类",
|
||||
|
||||
@@ -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 引號。",
|
||||
|
||||
@@ -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");
|
||||
@@ -3358,3 +3358,4 @@ export async function refreshConnections(): Promise<void> {
|
||||
}
|
||||
|
||||
export * from "@/lib/backend/mq-http";
|
||||
export * from "@/lib/backend/mqtt-http";
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user