Files
hoppscotch/packages/hoppscotch-common/src/pages/realtime/mqtt.vue
2023-12-12 14:26:13 +05:30

470 lines
14 KiB
Vue

<template>
<AppPaneLayout layout-id="mqtt">
<template #primary>
<div
class="sticky top-0 z-10 flex flex-shrink-0 space-x-2 overflow-x-auto bg-primary p-4"
>
<div class="inline-flex flex-1 space-x-2">
<div class="flex flex-1">
<input
id="mqtt-url"
v-model="url"
type="url"
autocomplete="off"
:class="{ error: !isUrlValid }"
class="flex w-full flex-1 rounded-l border border-divider bg-primaryLight px-4 py-2 text-secondaryDark"
:placeholder="`${t('mqtt.url')}`"
:disabled="
connectionState === 'CONNECTED' ||
connectionState === 'CONNECTING'
"
@keyup.enter="isUrlValid ? toggleConnection() : null"
/>
<label
for="client-id"
class="truncate border-b border-t border-divider bg-primaryLight px-4 py-2 font-semibold text-secondaryLight"
>
{{ t("mqtt.client_id") }}
</label>
<input
id="client-id"
v-model="clientID"
class="flex w-full flex-1 rounded-r border border-divider bg-primaryLight px-4 py-2 text-secondaryDark"
spellcheck="false"
:disabled="
connectionState === 'CONNECTED' ||
connectionState === 'CONNECTING'
"
@keyup.enter="isUrlValid ? toggleConnection() : null"
/>
</div>
<HoppButtonPrimary
id="connect"
:disabled="!isUrlValid"
class="w-32"
:label="
connectionState === 'CONNECTING'
? t('action.connecting')
: connectionState === 'DISCONNECTED'
? t('action.connect')
: t('action.disconnect')
"
:loading="connectionState === 'CONNECTING'"
@click="toggleConnection"
/>
</div>
</div>
<div
class="flex flex-1 flex-col"
: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 #secondary>
<HoppSmartWindows
:id="'communication_tab'"
v-model="currentTabId"
:can-add-new-tab="false"
@remove-tab="removeTab"
@sort="sortTabs"
>
<HoppSmartWindow
v-for="tab in tabs"
:id="tab.id"
:key="'removable_tab_' + tab.id"
:label="tab.name"
:is-removable="tab.removable"
>
<template #prefix>
<icon-lucide-rss
:style="{
color: tab.color,
}"
class="svg-icons h-4 w-4"
/>
</template>
<RealtimeLog
:title="t('mqtt.log')"
:log="(tab.id === 'all' ? logs : tab.logs) as LogEntryData[]"
@delete="clearLogEntries()"
/>
</HoppSmartWindow>
</HoppSmartWindows>
</template>
<template #sidebar>
<div
class="sticky z-10 flex flex-shrink-0 flex-col divide-y divide-dividerLight overflow-x-auto rounded-t border-b border-dividerLight bg-primary"
>
<div class="flex flex-1 justify-between">
<HoppButtonSecondary
:icon="IconPlus"
:label="t('mqtt.new')"
class="!rounded-none"
@click="showSubscriptionModal(true)"
/>
<span class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/getting-started/realtime/mqtt"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
</span>
</div>
</div>
<HoppSmartPlaceholder
v-if="topics.length === 0"
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.subscription')}`"
:text="`${t('empty.subscription')}`"
>
<template #body>
<HoppButtonSecondary
:label="t('mqtt.new')"
filled
outline
@click="showSubscriptionModal(true)"
/>
</template>
</HoppSmartPlaceholder>
<div v-else>
<div
v-for="(topic, index) in topics"
:key="`subscription-${index}`"
class="flex flex-col"
>
<div class="group flex items-stretch">
<span class="flex cursor-pointer items-center justify-center px-4">
<icon-lucide-rss
:style="{
color: topic.color,
}"
class="svg-icons h-4 w-4"
/>
</span>
<span
class="flex min-w-0 flex-1 cursor-pointer py-2 pr-2 transition group-hover:text-secondaryDark"
@click="openTopicAsTab(topic)"
>
<span class="truncate">
{{ topic.name }}
</span>
</span>
<HoppButtonSecondary
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/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 colorMode = useColorMode()
const { subscribeToStream } = useStreamSubscriber()
const url = useStream(MQTTEndpoint$, "", setMQTTEndpoint)
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$,
"DISCONNECTED"
)
const subscriptionState = useReadonlyStream(
socket.value.subscriptionState$,
false
)
const subscribing = useReadonlyStream(socket.value.subscribing$, false)
const isUrlValid = ref(true)
const subTopic = ref("")
let worker: Worker
const subscriptionModalShown = ref(false)
const canSubscribe = computed(() => connectionState.value === "CONNECTED")
const topics = useReadonlyStream(socket.value.subscribedTopics$, [])
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,
}: {
data: { url: string; result: boolean }
}) => {
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":
logs.value = [
{
payload: `${t("state.connecting_to", { name: url.value })}`,
source: "info",
color: "var(--accent-color)",
ts: undefined,
},
]
break
case "CONNECTED":
logs.value = [
{
payload: `${t("state.connected_to", { name: url.value })}`,
source: "info",
color: "var(--accent-color)",
ts: Date.now(),
},
]
toast.success(`${t("state.connected")}`)
break
case "MESSAGE_SENT":
addLog(
{
prefix: `${event.message.topic}`,
payload: event.message.message,
source: "client",
ts: Date.now(),
},
event.message.topic
)
break
case "MESSAGE_RECEIVED":
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: event.topic })}`
: `${t("state.unsubscribed_success", { topic: event.topic })}`,
source: "server",
ts: event.time,
})
break
case "SUBSCRIPTION_FAILED":
addMQTTLogLine({
payload: subscriptionState.value
? `${t("state.subscribed_failed", { topic: subTopic.value })}`
: `${t("state.unsubscribed_failed", { topic: subTopic.value })}`,
source: "server",
ts: event.time,
})
break
case "ERROR":
addMQTTLogLine({
payload: getI18nError(event.error),
source: "info",
color: "#ff5555",
ts: event.time,
})
break
case "DISCONNECTED":
addMQTTLogLine({
payload: t("state.disconnected_from", { name: url.value }).toString(),
source: "disconnected",
color: "#ff5555",
ts: event.time,
})
toast.error(`${t("state.disconnected")}`)
break
}
})
})
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, clientID.value, config.value)
}
// Otherwise, it's disconnecting.
socket.value.disconnect()
}
const publish = (event: { message: string; eventName: string }) => {
socket.value?.publish(event.eventName, event.message)
}
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 {
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()
case "SUBSCRIPTION_FAILED":
return t("state.mqtt_subscription_failed", {
topic: error.topic,
}).toString()
case "PUBLISH_ERROR":
return t("state.publish_error", { topic: error.topic }).toString()
case "CONNECTION_LOST":
return t("state.connection_lost").toString()
case "CONNECTION_FAILED":
return t("state.connection_failed").toString()
default:
return t("state.disconnected_from", { name: url.value }).toString()
}
}
const clearLogEntries = () => {
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>