feat: initial MQTT v5 debugging tool

- Tauri v2 + Vue 3 + TypeScript + Element Plus
- Connection management (CRUD + enable/disable/test)
- Preset system with {{variable}} placeholders
- Dashboard with live message scrolling + topic tree
- Payload inspector (JSON/Hex/Base64/Plain)
- Protocol-level logging (file + UI dual channel)
- Global variable manager with localStorage persistence
- MQTT v5 property support (user properties, content type, etc.)
- SubAck result tracking with subscribe_tracked API
This commit is contained in:
Joe Allen
2026-08-18 14:32:57 +08:00
commit 154fb15130
63 changed files with 11626 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
import { ref, watch } from 'vue'
import type { MqttConnection } from '../types/mqtt'
import { defaultConnection } from '../types/mqtt'
const STORAGE_KEY = 'mqttx_connections'
function loadConnections(): MqttConnection[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as MqttConnection[]
// Ensure each connection has runtime status fields
return parsed.map((c) => ({
...c,
status: 'disconnected' as const,
lastError: '',
}))
} catch {
return []
}
}
function saveConnections(connections: MqttConnection[]) {
try {
// Strip runtime fields before persisting
const toSave = connections.map(({ status: _, lastError: __, ...rest }) => rest)
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave))
} catch {
// localStorage full or unavailable — silently ignore
}
}
const connections = ref<MqttConnection[]>(loadConnections())
// Auto-persist on changes
watch(
connections,
(val) => {
saveConnections(val)
},
{ deep: true },
)
export function useConnections() {
function add() {
const conn = defaultConnection()
connections.value.push(conn)
return conn
}
function update(id: string, data: Partial<MqttConnection>) {
const idx = connections.value.findIndex((c) => c.id === id)
if (idx !== -1) {
connections.value[idx] = { ...connections.value[idx], ...data }
}
}
function remove(id: string) {
const idx = connections.value.findIndex((c) => c.id === id)
if (idx !== -1) {
connections.value.splice(idx, 1)
}
}
function getById(id: string): MqttConnection | undefined {
return connections.value.find((c) => c.id === id)
}
function updateStatus(
id: string,
status: MqttConnection['status'],
lastError = '',
) {
const conn = getById(id)
if (conn) {
conn.status = status
conn.lastError = lastError
}
}
function getConnected(): MqttConnection[] {
return connections.value.filter((c) => c.status === 'connected')
}
return {
connections,
add,
update,
remove,
getById,
updateStatus,
getConnected,
}
}
+79
View File
@@ -0,0 +1,79 @@
import { ref, watch } from 'vue'
const STORAGE_KEY = 'mqttx_global_variables'
function loadVariables(): Record<string, string> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
return JSON.parse(raw) as Record<string, string>
} catch {
return {}
}
}
function saveVariables(vars: Record<string, string>) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(vars))
} catch {
// silently ignore
}
}
const variables = ref<Record<string, string>>(loadVariables())
watch(
variables,
(val) => {
saveVariables(val)
},
{ deep: true },
)
export function useGlobalVariables() {
function setVariable(name: string, value: string) {
if (value.trim() === '') {
delete variables.value[name]
} else {
variables.value = { ...variables.value, [name]: value }
}
}
function deleteVariable(name: string) {
const newVars = { ...variables.value }
delete newVars[name]
variables.value = newVars
}
function getVariable(name: string): string {
return variables.value[name] ?? ''
}
function clear() {
variables.value = {}
}
function importFromBindings(bindings: Record<string, string>) {
const newVars = { ...variables.value }
for (const [key, value] of Object.entries(bindings)) {
if (value.trim()) {
newVars[key] = value
}
}
variables.value = newVars
}
function getAllVariables(): Record<string, string> {
return { ...variables.value }
}
return {
variables,
setVariable,
deleteVariable,
getVariable,
clear,
importFromBindings,
getAllVariables,
}
}
+46
View File
@@ -0,0 +1,46 @@
import { ref } from 'vue'
export interface LogEntry {
id: string
timestamp: number
connectionId: string
level: 'info' | 'warn' | 'error'
event: string
message: string
details?: string
}
const MAX_LOGS = 1000
const logs = ref<LogEntry[]>([])
export function useLogs() {
function push(entry: LogEntry) {
logs.value.push(entry)
if (logs.value.length > MAX_LOGS) {
logs.value.splice(0, logs.value.length - MAX_LOGS)
}
}
function clear() {
logs.value = []
}
function filterByConnection(connectionId: string): LogEntry[] {
if (!connectionId) return logs.value
return logs.value.filter((l) => l.connectionId === connectionId)
}
function filterByLevel(level: string): LogEntry[] {
if (!level) return logs.value
return logs.value.filter((l) => l.level === level)
}
return {
logs,
push,
clear,
filterByConnection,
filterByLevel,
}
}
+41
View File
@@ -0,0 +1,41 @@
import { ref, computed } from 'vue'
import type { MqttMessage } from '../types/mqtt'
const MAX_MESSAGES = 500
const messages = ref<MqttMessage[]>([])
export function useMessages() {
function push(msg: MqttMessage) {
messages.value.push(msg)
if (messages.value.length > MAX_MESSAGES) {
messages.value.splice(0, messages.value.length - MAX_MESSAGES)
}
}
function clear() {
messages.value = []
}
function filterByTopic(topicFilter: string): MqttMessage[] {
if (!topicFilter.trim()) return messages.value
const filter = topicFilter.trim().toLowerCase()
return messages.value.filter((m) => m.topic.toLowerCase().includes(filter))
}
function filterByConnection(connectionId: string): MqttMessage[] {
if (!connectionId) return messages.value
return messages.value.filter((m) => m.connectionId === connectionId)
}
const messageCount = computed(() => messages.value.length)
return {
messages,
push,
clear,
filterByTopic,
filterByConnection,
messageCount,
}
}
+144
View File
@@ -0,0 +1,144 @@
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { onUnmounted } from 'vue'
import type {
MqttConnectionDto,
PublishDto,
SubscribeDto,
MqttMessageEvent,
MqttStatusEvent,
MqttLogEvent,
ConnectionStatus,
} from '../types/mqtt'
import type { MqttMessage } from '../types/mqtt'
import { useConnections } from './useConnections'
import { useMessages } from './useMessages'
import { useLogs } from './useLogs'
import type { LogEntry } from './useLogs'
/**
* Initialize MQTT event listeners.
* Call this once in App.vue setup.
*/
export function useMqttBridge() {
const { updateStatus } = useConnections()
const { push: pushMessage } = useMessages()
const { push: pushLog } = useLogs()
let unlistenMessage: (() => void) | null = null
let unlistenStatus: (() => void) | null = null
let unlistenLog: (() => void) | null = null
async function init() {
// Listen for incoming messages
unlistenMessage = await listen<MqttMessageEvent>('mqtt:message', (event) => {
const e = event.payload
const msg: MqttMessage = {
id: crypto.randomUUID(),
connectionId: e.connection_id,
topic: e.topic,
payload: e.payload,
qos: e.qos,
retain: e.retain,
timestamp: e.timestamp,
direction: 'in',
reasonCode: e.reason_code ?? undefined,
userProperties: e.user_properties.map(([k, v]) => ({ key: k, value: v })),
contentType: e.content_type ?? undefined,
contentEncoding: e.content_encoding ?? undefined,
responseTopic: e.response_topic ?? undefined,
correlationData: e.correlation_data ?? undefined,
messageExpiryInterval: e.message_expiry_interval ?? undefined,
subscriptionIdentifier: e.subscription_identifier ?? undefined,
}
pushMessage(msg)
})
// Listen for connection status changes
unlistenStatus = await listen<MqttStatusEvent>('mqtt:status', (event) => {
const e = event.payload
switch (e.status) {
case 'connecting':
updateStatus(e.connection_id, 'connecting')
break
case 'connected':
updateStatus(e.connection_id, 'connected')
break
case 'disconnected':
updateStatus(e.connection_id, 'disconnected')
break
case 'error':
updateStatus(e.connection_id, 'error', e.error ?? 'Unknown error')
break
}
})
// Listen for log events
unlistenLog = await listen<MqttLogEvent>('mqtt:log', (event) => {
const e = event.payload
const log: LogEntry = {
id: crypto.randomUUID(),
timestamp: e.timestamp,
connectionId: e.connection_id,
level: e.level as 'info' | 'warn' | 'error',
event: e.event,
message: e.message,
details: e.details ?? undefined,
}
pushLog(log)
})
}
onUnmounted(() => {
unlistenMessage?.()
unlistenStatus?.()
unlistenLog?.()
})
return { init }
}
// ─── Tauri invoke wrappers ─────────────────────────────────────────────────────
export async function mqttConnect(conn: MqttConnectionDto): Promise<void> {
await invoke('mqtt_connect', { conn })
}
export async function mqttDisconnect(connectionId: string): Promise<void> {
await invoke('mqtt_disconnect', { connectionId })
}
export async function mqttPublish(publish: PublishDto): Promise<void> {
await invoke('mqtt_publish', { publish })
}
export async function mqttSubscribe(subscribe: SubscribeDto): Promise<void> {
await invoke('mqtt_subscribe', { subscribe })
}
export async function mqttUnsubscribe(
connectionId: string,
topic: string,
): Promise<void> {
await invoke('mqtt_unsubscribe', { connectionId, topic })
}
export async function mqttGetConnections(): Promise<ConnectionStatus[]> {
return await invoke<ConnectionStatus[]>('mqtt_get_connections')
}
export async function mqttGetConnectionStatus(
connectionId: string,
): Promise<ConnectionStatus> {
return await invoke<ConnectionStatus>('mqtt_get_connection_status', {
connectionId,
})
}
export async function mqttTestConnection(conn: MqttConnectionDto): Promise<string> {
return await invoke<string>('mqtt_test_connection', { conn })
}
export async function mqttGetLogDir(): Promise<string> {
return await invoke<string>('mqtt_get_log_dir')
}
+67
View File
@@ -0,0 +1,67 @@
import { ref, watch } from 'vue'
import type { Preset } from '../types/mqtt'
import { defaultPreset } from '../types/mqtt'
const STORAGE_KEY = 'mqttx_presets'
function loadPresets(): Preset[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
return JSON.parse(raw) as Preset[]
} catch {
return []
}
}
function savePresets(presets: Preset[]) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets))
} catch {
// silently ignore
}
}
const presets = ref<Preset[]>(loadPresets())
watch(
presets,
(val) => {
savePresets(val)
},
{ deep: true },
)
export function usePresets() {
function add() {
const preset = defaultPreset()
presets.value.push(preset)
return preset
}
function update(id: string, data: Partial<Preset>) {
const idx = presets.value.findIndex((p) => p.id === id)
if (idx !== -1) {
presets.value[idx] = { ...presets.value[idx], ...data }
}
}
function remove(id: string) {
const idx = presets.value.findIndex((p) => p.id === id)
if (idx !== -1) {
presets.value.splice(idx, 1)
}
}
function getById(id: string): Preset | undefined {
return presets.value.find((p) => p.id === id)
}
return {
presets,
add,
update,
remove,
getById,
}
}
+78
View File
@@ -0,0 +1,78 @@
import { ref, computed, watch } from 'vue'
import type { Subscription } from '../types/mqtt'
const STORAGE_KEY = 'mqttx_subscriptions'
function loadSubscriptions(): Subscription[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
return JSON.parse(raw) as Subscription[]
} catch {
return []
}
}
function saveSubscriptions(subs: Subscription[]) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(subs))
} catch {
// silently ignore
}
}
const subscriptions = ref<Subscription[]>(loadSubscriptions())
watch(
subscriptions,
(val) => {
saveSubscriptions(val)
},
{ deep: true },
)
export function useSubscriptions() {
function addSubscription(connId: string, topic: string, qos: number = 0) {
const existing = subscriptions.value.find(
(s) => s.connectionId === connId && s.topic === topic,
)
if (existing) {
existing.qos = qos
return false
}
subscriptions.value.push({
id: crypto.randomUUID(),
connectionId: connId,
topic,
qos,
})
return true
}
function removeSubscription(connId: string, topic: string) {
subscriptions.value = subscriptions.value.filter(
(s) => !(s.connectionId === connId && s.topic === topic),
)
}
function removeAllByConnection(connId: string) {
subscriptions.value = subscriptions.value.filter(
(s) => s.connectionId !== connId,
)
}
function getSubscriptionsByConnection(connId: string): Subscription[] {
return subscriptions.value.filter((s) => s.connectionId === connId)
}
const allSubscriptions = computed(() => subscriptions.value)
return {
subscriptions,
addSubscription,
removeSubscription,
removeAllByConnection,
getSubscriptionsByConnection,
allSubscriptions,
}
}