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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user