MQTT Revamp (#2381)

Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
This commit is contained in:
Anwarul Islam
2022-11-27 03:13:24 +06:00
committed by GitHub
parent 75c0350584
commit 2ed709796a
23 changed files with 1280 additions and 209 deletions

View File

@@ -2,23 +2,42 @@
<AppPaneLayout layout-id="mqtt">
<template #primary>
<div
class="sticky top-0 z-10 flex flex-shrink-0 p-4 overflow-x-auto space-x-2 bg-primary"
class="sticky top-0 z-10 flex flex-shrink-0 p-4 space-x-2 overflow-x-auto bg-primary"
>
<div class="inline-flex flex-1 space-x-2">
<input
id="mqtt-url"
v-model="url"
type="url"
autocomplete="off"
spellcheck="false"
class="w-full px-4 py-2 border rounded bg-primaryLight border-divider text-secondaryDark"
:placeholder="t('mqtt.url')"
:disabled="
connectionState === 'CONNECTED' ||
connectionState === 'CONNECTING'
"
@keyup.enter="isUrlValid ? toggleConnection() : null"
/>
<div class="flex flex-1">
<input
id="mqtt-url"
v-model="url"
type="url"
autocomplete="off"
:class="{ error: !isUrlValid }"
class="flex flex-1 w-full px-4 py-2 border rounded-l bg-primaryLight border-divider text-secondaryDark"
:placeholder="`${t('mqtt.url')}`"
:disabled="
connectionState === 'CONNECTED' ||
connectionState === 'CONNECTING'
"
@keyup.enter="isUrlValid ? toggleConnection() : null"
/>
<label
for="client-id"
class="px-4 py-2 font-semibold truncate border-t border-b bg-primaryLight border-divider text-secondaryLight"
>
{{ t("mqtt.client_id") }}
</label>
<input
id="client-id"
v-model="clientID"
class="flex flex-1 w-full px-4 py-2 border rounded-r bg-primaryLight border-divider text-secondaryDark"
spellcheck="false"
:disabled="
connectionState === 'CONNECTED' ||
connectionState === 'CONNECTING'
"
@keyup.enter="isUrlValid ? toggleConnection() : null"
/>
</div>
<ButtonPrimary
id="connect"
:disabled="!isUrlValid"
@@ -34,133 +53,215 @@
@click="toggleConnection"
/>
</div>
<div class="flex space-x-2">
<input
id="mqtt-username"
v-model="username"
type="text"
spellcheck="false"
class="input"
:placeholder="t('authorization.username')"
/>
<input
id="mqtt-password"
v-model="password"
type="password"
spellcheck="false"
class="input"
:placeholder="t('authorization.password')"
/>
</div>
</div>
</template>
<template #secondary>
<RealtimeLog
:title="t('mqtt.log')"
:log="log"
@delete="clearLogEntries()"
<div
class="flex flex-col flex-1"
:class="{ '!hidden': connectionState === 'CONNECTED' }"
>
<RealtimeConnectionConfig @change="onChangeConfig" />
</div>
<RealtimeCommunication
v-if="connectionState === 'CONNECTED'"
:show-event-field="currentTabId === 'all'"
:is-connected="connectionState === 'CONNECTED'"
event-field-styles="top-upperPrimaryStickyFold"
:sticky-header-styles="
currentTabId === 'all'
? 'top-upperSecondaryStickyFold'
: 'top-upperPrimaryStickyFold'
"
@send-message="
publish(
currentTabId === 'all'
? $event
: {
message: $event.message,
eventName: currentTabId,
}
)
"
/>
</template>
<template #sidebar>
<div class="flex items-center justify-between p-4">
<label for="pubTopic" class="font-semibold text-secondaryLight">
{{ t("mqtt.topic") }}
</label>
</div>
<div class="flex px-4">
<input
id="pubTopic"
v-model="pubTopic"
class="input"
:placeholder="t('mqtt.topic_name')"
type="text"
autocomplete="off"
spellcheck="false"
/>
</div>
<div class="flex items-center justify-between p-4">
<label for="mqtt-message" class="font-semibold text-secondaryLight">
{{ t("mqtt.communication") }}
</label>
</div>
<div class="flex px-4 space-x-2">
<input
id="mqtt-message"
v-model="message"
class="input"
type="text"
autocomplete="off"
:placeholder="t('mqtt.message')"
spellcheck="false"
/>
<ButtonPrimary
id="publish"
name="get"
:disabled="!canPublish"
:label="t('mqtt.publish')"
@click="publish"
/>
</div>
<div
class="flex items-center justify-between p-4 mt-4 border-t border-dividerLight"
<template #secondary>
<SmartWindows
:id="'communication_tab'"
v-model="currentTabId"
:can-add-new-tab="false"
@remove-tab="removeTab"
@sort="sortTabs"
>
<label for="subTopic" class="font-semibold text-secondaryLight">
{{ t("mqtt.topic") }}
</label>
<SmartWindow
v-for="tab in tabs"
:id="tab.id"
:key="'removable_tab_' + tab.id"
:label="tab.name"
:is-removable="tab.removable"
>
<template #icon>
<icon-lucide-rss
:style="{
color: tab.color,
}"
class="w-4 h-4 svg-icons"
/>
</template>
<RealtimeLog
:title="t('mqtt.log')"
:log="((tab.id === 'all' ? logs : tab.logs) as LogEntryData[])"
@delete="clearLogEntries()"
/>
</SmartWindow>
</SmartWindows>
</template>
<template #sidebar>
<div
class="sticky z-10 flex flex-col border-b divide-y rounded-t divide-dividerLight bg-primary border-dividerLight"
>
<div class="flex justify-between flex-1">
<ButtonSecondary
:icon="IconPlus"
:label="t('mqtt.new')"
class="!rounded-none"
@click="showSubscriptionModal(true)"
/>
<span class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/features/mqtt"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
</span>
</div>
</div>
<div class="flex px-4 space-x-2">
<input
id="subTopic"
v-model="subTopic"
type="text"
autocomplete="off"
:placeholder="t('mqtt.topic_name')"
spellcheck="false"
class="input"
<div
v-if="topics.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.subscription')}`"
/>
<ButtonPrimary
id="subscribe"
name="get"
:disabled="!canSubscribe"
:label="
subscriptionState ? t('mqtt.unsubscribe') : t('mqtt.subscribe')
"
reverse
@click="toggleSubscription"
<span class="pb-4 text-center">
{{ t("empty.subscription") }}
</span>
<ButtonSecondary
:label="t('mqtt.new')"
filled
outline
@click="showSubscriptionModal(true)"
/>
</div>
<div v-else>
<div
v-for="(topic, index) in topics"
:key="`subscription-${index}`"
class="flex flex-col"
>
<div class="flex items-stretch group">
<span class="flex items-center justify-center px-4 cursor-pointer">
<icon-lucide-rss
:style="{
color: topic.color,
}"
class="w-4 h-4 svg-icons"
/>
</span>
<span
class="flex flex-1 min-w-0 py-2 pr-2 transition cursor-pointer group-hover:text-secondaryDark"
@click="openTopicAsTab(topic)"
>
<span class="truncate">
{{ topic.name }}
</span>
</span>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconTrash"
color="red"
:title="t('mqtt.unsubscribe')"
class="hidden group-hover:inline-flex"
data-testid="unsubscribe_mqtt_subscription"
@click="unsubscribeFromTopic(topic.name)"
/>
</div>
</div>
</div>
<RealtimeSubscription
:show="subscriptionModalShown"
:loading-state="subscribing"
@submit="subscribeToTopic"
@hide-modal="showSubscriptionModal(false)"
/>
</template>
</AppPaneLayout>
</template>
<script setup lang="ts">
import IconPlus from "~icons/lucide/plus"
import IconTrash from "~icons/lucide/trash"
import IconHelpCircle from "~icons/lucide/help-circle"
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
import { debounce } from "lodash-es"
import { MQTTConnection, MQTTError } from "~/helpers/realtime/MQTTConnection"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import debounce from "lodash-es/debounce"
import {
MQTTConnection,
MQTTConnectionConfig,
MQTTError,
MQTTTopic,
} from "~/helpers/realtime/MQTTConnection"
import { HoppRealtimeLogLine } from "~/helpers/types/HoppRealtimeLog"
import { useColorMode } from "@composables/theming"
import {
useReadonlyStream,
useStream,
useStreamSubscriber,
} from "@composables/stream"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import {
addMQTTLogLine,
MQTTConn$,
MQTTEndpoint$,
MQTTClientID$,
MQTTLog$,
setMQTTConn,
setMQTTEndpoint,
setMQTTClientID,
setMQTTLog,
MQTTTabs$,
setMQTTTabs,
MQTTCurrentTab$,
setCurrentTab,
addMQTTCurrentTabLogLine,
} from "~/newstore/MQTTSession"
import RegexWorker from "@workers/regex?worker"
import { LogEntryData } from "~/components/realtime/Log.vue"
const t = useI18n()
const toast = useToast()
const { subscribeToStream } = useStreamSubscriber()
const colorMode = useColorMode()
const { subscribeToStream } = useStreamSubscriber()
const url = useStream(MQTTEndpoint$, "", setMQTTEndpoint)
const log = useStream(MQTTLog$, [], setMQTTLog)
const clientID = useStream(MQTTClientID$, "", setMQTTClientID)
const config = ref<MQTTConnectionConfig>({
username: "",
password: "",
keepAlive: "60",
cleanSession: true,
lwTopic: "",
lwMessage: "",
lwQos: 0,
lwRetain: false,
})
const logs = useStream(MQTTLog$, [], setMQTTLog)
const socket = useStream(MQTTConn$, new MQTTConnection(), setMQTTConn)
const connectionState = useReadonlyStream(
socket.value.connectionState$,
@@ -170,26 +271,24 @@ const subscriptionState = useReadonlyStream(
socket.value.subscriptionState$,
false
)
const subscribing = useReadonlyStream(socket.value.subscribing$, false)
const isUrlValid = ref(true)
const pubTopic = ref("")
const subTopic = ref("")
const message = ref("")
const username = ref("")
const password = ref("")
let worker: Worker
const subscriptionModalShown = ref(false)
const canSubscribe = computed(() => connectionState.value === "CONNECTED")
const topics = useReadonlyStream(socket.value.subscribedTopics$, [])
const canPublish = computed(
() =>
pubTopic.value !== "" &&
message.value !== "" &&
connectionState.value === "CONNECTED"
)
const canSubscribe = computed(
() => subTopic.value !== "" && connectionState.value === "CONNECTED"
)
const currentTabId = useStream(MQTTCurrentTab$, "", setCurrentTab)
const tabs = useStream(MQTTTabs$, [], setMQTTTabs)
const onChangeConfig = (e: MQTTConnectionConfig) => {
config.value = e
}
const showSubscriptionModal = (show: boolean) => {
subscriptionModalShown.value = show
}
const workerResponseHandler = ({
data,
}: {
@@ -197,15 +296,13 @@ const workerResponseHandler = ({
}) => {
if (data.url === url.value) isUrlValid.value = data.result
}
onMounted(() => {
worker = new RegexWorker()
worker.addEventListener("message", workerResponseHandler)
subscribeToStream(socket.value.event$, (event) => {
switch (event?.type) {
case "CONNECTING":
log.value = [
logs.value = [
{
payload: `${t("state.connecting_to", { name: url.value })}`,
source: "info",
@@ -214,9 +311,8 @@ onMounted(() => {
},
]
break
case "CONNECTED":
log.value = [
logs.value = [
{
payload: `${t("state.connected_to", { name: url.value })}`,
source: "info",
@@ -226,35 +322,38 @@ onMounted(() => {
]
toast.success(`${t("state.connected")}`)
break
case "MESSAGE_SENT":
addMQTTLogLine({
prefix: `${event.message.topic}`,
payload: event.message.message,
source: "client",
ts: Date.now(),
})
addLog(
{
prefix: `${event.message.topic}`,
payload: event.message.message,
source: "client",
ts: Date.now(),
},
event.message.topic
)
break
case "MESSAGE_RECEIVED":
addMQTTLogLine({
prefix: `${event.message.topic}`,
payload: event.message.message,
source: "server",
ts: event.time,
})
addLog(
{
prefix: `${event.message.topic}`,
payload: event.message.message,
source: "server",
ts: event.time,
},
event.message.topic
)
break
case "SUBSCRIBED":
showSubscriptionModal(false)
addMQTTLogLine({
payload: subscriptionState.value
? `${t("state.subscribed_success", { topic: subTopic.value })}`
: `${t("state.unsubscribed_success", { topic: subTopic.value })}`,
? `${t("state.subscribed_success", { topic: event.topic })}`
: `${t("state.unsubscribed_success", { topic: event.topic })}`,
source: "server",
ts: event.time,
})
break
case "SUBSCRIPTION_FAILED":
addMQTTLogLine({
payload: subscriptionState.value
@@ -264,7 +363,6 @@ onMounted(() => {
ts: event.time,
})
break
case "ERROR":
addMQTTLogLine({
payload: getI18nError(event.error),
@@ -273,11 +371,10 @@ onMounted(() => {
ts: event.time,
})
break
case "DISCONNECTED":
addMQTTLogLine({
payload: t("state.disconnected_from", { name: url.value }).toString(),
source: "info",
source: "disconnected",
color: "#ff5555",
ts: event.time,
})
@@ -286,42 +383,48 @@ onMounted(() => {
}
})
})
const addLog = (line: HoppRealtimeLogLine, topic: string | undefined) => {
if (topic) addMQTTCurrentTabLogLine(topic, line)
addMQTTLogLine(line)
}
const debouncer = debounce(function () {
worker.postMessage({ type: "ws", url: url.value })
}, 1000)
watch(url, (newUrl) => {
if (newUrl) debouncer()
})
onUnmounted(() => {
worker.terminate()
})
// METHODS
const toggleConnection = () => {
// If it is connecting:
if (connectionState.value === "DISCONNECTED") {
return socket.value.connect(url.value, username.value, password.value)
return socket.value.connect(url.value, clientID.value, config.value)
}
// Otherwise, it's disconnecting.
socket.value.disconnect()
}
const publish = () => {
socket.value?.publish(pubTopic.value, message.value)
const publish = (event: { message: string; eventName: string }) => {
socket.value?.publish(event.eventName, event.message)
}
const toggleSubscription = () => {
if (subscriptionState.value) {
socket.value.unsubscribe(subTopic.value)
const subscribeToTopic = (topic: MQTTTopic) => {
if (canSubscribe.value) {
if (topics.value.some((t) => t.name === topic.name)) {
return toast.error(t("mqtt.already_subscribed").toString())
}
socket.value.subscribe(topic)
} else {
socket.value.subscribe(subTopic.value)
subscriptionModalShown.value = false
toast.error(t("mqtt.not_connected").toString())
}
}
const unsubscribeFromTopic = (topic: string) => {
socket.value.unsubscribe(topic)
removeTab(topic)
}
const getI18nError = (error: MQTTError): string => {
if (typeof error === "string") return error
switch (error.type) {
case "CONNECTION_NOT_ESTABLISHED":
return t("state.connection_lost").toString()
@@ -340,6 +443,34 @@ const getI18nError = (error: MQTTError): string => {
}
}
const clearLogEntries = () => {
log.value = []
logs.value = []
}
const openTopicAsTab = (topic: MQTTTopic) => {
const { name, color } = topic
if (tabs.value.some((tab) => tab.id === topic.name)) {
return (currentTabId.value = topic.name)
}
tabs.value = [
...tabs.value,
{
id: name,
name,
color,
removable: true,
logs: [],
},
]
currentTabId.value = name
}
const sortTabs = (e: { oldIndex: number; newIndex: number }) => {
const newTabs = [...tabs.value]
newTabs.splice(e.newIndex, 0, newTabs.splice(e.oldIndex, 1)[0])
tabs.value = newTabs
}
const removeTab = (tabID: string) => {
tabs.value = tabs.value.filter((tab) => tab.id !== tabID)
}
</script>

View File

@@ -40,7 +40,7 @@
:label="`Client ${version}`"
@click="
() => {
onSelectVersion(version)
onSelectVersion(version as SIOClientVersion)
hide()
}
"
@@ -107,6 +107,8 @@
<RealtimeCommunication
:show-event-field="true"
:is-connected="connectionState === 'CONNECTED'"
event-field-styles="top-upperSecondaryStickyFold"
sticky-header-styles="top-upperTertiaryStickyFold"
@send-message="sendMessage($event)"
/>
</SmartTab>
@@ -242,7 +244,7 @@
<template #secondary>
<RealtimeLog
:title="t('socketio.log')"
:log="log"
:log="(log as LogEntryData[])"
@delete="clearLogEntries()"
/>
</template>
@@ -286,6 +288,7 @@ import {
} from "~/newstore/SocketIOSession"
import { useColorMode } from "@composables/theming"
import RegexWorker from "@workers/regex?worker"
import { LogEntryData } from "~/components/realtime/Log.vue"
const t = useI18n()
const colorMode = useColorMode()
@@ -397,7 +400,7 @@ onMounted(() => {
case "DISCONNECTED":
addSIOLogLine({
payload: t("state.disconnected_from", { name: url.value }).toString(),
source: "info",
source: "disconnected",
color: "#ff5555",
ts: event.time,
})

View File

@@ -57,7 +57,7 @@
<template #secondary>
<RealtimeLog
:title="t('sse.log')"
:log="log"
:log="(log as LogEntryData[])"
@delete="clearLogEntries()"
/>
</template>
@@ -88,6 +88,7 @@ import {
} from "@composables/stream"
import { SSEConnection } from "@helpers/realtime/SSEConnection"
import RegexWorker from "@workers/regex?worker"
import { LogEntryData } from "~/components/realtime/Log.vue"
const t = useI18n()
const toast = useToast()
@@ -170,7 +171,7 @@ onMounted(() => {
payload: t("state.disconnected_from", {
name: server.value,
}).toString(),
source: "info",
source: "disconnected",
color: "#ff5555",
ts: event.time,
})

View File

@@ -48,6 +48,7 @@
>
<RealtimeCommunication
:is-connected="connectionState === 'CONNECTED'"
sticky-header-styles="top-upperSecondaryStickyFold"
@send-message="sendMessage($event)"
/>
</SmartTab>
@@ -178,7 +179,7 @@
<template #secondary>
<RealtimeLog
:title="t('websocket.log')"
:log="log"
:log="(log as LogEntryData[])"
@delete="clearLogEntries()"
/>
</template>
@@ -220,6 +221,7 @@ import { useToast } from "@composables/toast"
import { useColorMode } from "@composables/theming"
import { WSConnection, WSErrorMessage } from "@helpers/realtime/WSConnection"
import RegexWorker from "@workers/regex?worker"
import { LogEntryData } from "~/components/realtime/Log.vue"
const t = useI18n()
const toast = useToast()
@@ -349,7 +351,7 @@ onMounted(() => {
case "DISCONNECTED":
addWSLogLine({
payload: t("state.disconnected_from", { name: url.value }).toString(),
source: "info",
source: "disconnected",
color: "#ff5555",
ts: event.time,
})