MQTT Revamp (#2381)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
This commit is contained in:
@@ -161,6 +161,7 @@
|
||||
"body": "This request does not have a body",
|
||||
"collection": "Collection is empty",
|
||||
"collections": "Collections are empty",
|
||||
"subscription": "Subscriptions are empty",
|
||||
"documentation": "Connect to a GraphQL endpoint to view documentation",
|
||||
"endpoint": "Endpoint cannot be empty",
|
||||
"environments": "Environments are empty",
|
||||
@@ -312,6 +313,8 @@
|
||||
"import_export": "Import / Export"
|
||||
},
|
||||
"mqtt": {
|
||||
"new": "New Subscription",
|
||||
"invalid_topic": "Please provide a topic for the subscription",
|
||||
"communication": "Communication",
|
||||
"log": "Log",
|
||||
"message": "Message",
|
||||
@@ -321,7 +324,23 @@
|
||||
"topic_name": "Topic Name",
|
||||
"topic_title": "Publish / Subscribe topic",
|
||||
"unsubscribe": "Unsubscribe",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"client_id": "Client ID",
|
||||
"qos": "QoS",
|
||||
"color": "Color",
|
||||
"connection_not_authorized": "This MQTT connection does not use any authentication.",
|
||||
"not_connected": "Please start a MQTT connection first.",
|
||||
"already_subscribed": "You are already subscribed to this topic.",
|
||||
"connection_config": "Connection Config",
|
||||
"keep_alive": "Keep Alive",
|
||||
"clean_session": "Clean Session",
|
||||
"ssl": "SSL",
|
||||
"lw_qos": "Last-Will QoS",
|
||||
"lw_retain": "Last-Will Retain",
|
||||
"lw_topic": "Last-Will Topic",
|
||||
"lw_message": "Last-Will Message",
|
||||
"clear_input": "Clear input",
|
||||
"clear_input_on_send": "Clear input on send"
|
||||
},
|
||||
"navigation": {
|
||||
"doc": "Docs",
|
||||
@@ -530,6 +549,15 @@
|
||||
"title": "Theme"
|
||||
}
|
||||
},
|
||||
"shortcodes": {
|
||||
"actions": "Actions",
|
||||
"created_on": "Created on",
|
||||
"deleted": "Shortcode deleted",
|
||||
"method": "Method",
|
||||
"not_found": "Shortcode not found",
|
||||
"short_code": "Short code",
|
||||
"url": "URL"
|
||||
},
|
||||
"show": {
|
||||
"code": "Show code",
|
||||
"collection": "Expand Collection Panel",
|
||||
@@ -539,7 +567,7 @@
|
||||
"socketio": {
|
||||
"communication": "Communication",
|
||||
"connection_not_authorized": "This SocketIO connection does not use any authentication.",
|
||||
"event_name": "Event Name",
|
||||
"event_name": "Event/Topic Name",
|
||||
"events": "Events",
|
||||
"log": "Log",
|
||||
"url": "URL"
|
||||
|
||||
5
packages/hoppscotch-app/src/components.d.ts
vendored
5
packages/hoppscotch-app/src/components.d.ts
vendored
@@ -106,6 +106,7 @@ declare module '@vue/runtime-core' {
|
||||
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
|
||||
IconLucideLoader: typeof import('~icons/lucide/loader')['default']
|
||||
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
|
||||
IconLucideRss: typeof import('~icons/lucide/rss')['default']
|
||||
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
IconLucideUser: typeof import('~icons/lucide/user')['default']
|
||||
IconLucideUsers: typeof import('~icons/lucide/users')['default']
|
||||
@@ -121,8 +122,10 @@ declare module '@vue/runtime-core' {
|
||||
ProfilePicture: typeof import('./components/profile/Picture.vue')['default']
|
||||
ProfileShortcode: typeof import('./components/profile/Shortcode.vue')['default']
|
||||
RealtimeCommunication: typeof import('./components/realtime/Communication.vue')['default']
|
||||
RealtimeConnectionConfig: typeof import('./components/realtime/ConnectionConfig.vue')['default']
|
||||
RealtimeLog: typeof import('./components/realtime/Log.vue')['default']
|
||||
RealtimeLogEntry: typeof import('./components/realtime/LogEntry.vue')['default']
|
||||
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
|
||||
SmartAccentModePicker: typeof import('./components/smart/AccentModePicker.vue')['default']
|
||||
SmartAnchor: typeof import('./components/smart/Anchor.vue')['default']
|
||||
SmartAutoComplete: typeof import('./components/smart/AutoComplete.vue')['default']
|
||||
@@ -146,6 +149,8 @@ declare module '@vue/runtime-core' {
|
||||
SmartTab: typeof import('./components/smart/Tab.vue')['default']
|
||||
SmartTabs: typeof import('./components/smart/Tabs.vue')['default']
|
||||
SmartToggle: typeof import('./components/smart/Toggle.vue')['default']
|
||||
SmartWindow: typeof import('./components/smart/Window.vue')['default']
|
||||
SmartWindows: typeof import('./components/smart/Windows.vue')['default']
|
||||
TabPrimary: typeof import('./components/tab/Primary.vue')['default']
|
||||
TabSecondary: typeof import('./components/tab/Secondary.vue')['default']
|
||||
Teams: typeof import('./components/teams/index.vue')['default']
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
|
||||
:title="`${t(
|
||||
'request.run'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>G</kbd>`"
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>↩</kbd>`"
|
||||
:label="`${t('request.run')}`"
|
||||
:icon="IconPlay"
|
||||
class="rounded-none !text-accent !hover:text-accentDark"
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
>
|
||||
<div class="flex pb-4 my-4 space-x-2">
|
||||
<div class="flex flex-col items-end space-y-4 text-right">
|
||||
<span class="flex items-center flex-1">
|
||||
{{ t("shortcut.request.send_request") }}
|
||||
</span>
|
||||
<span class="flex items-center flex-1">
|
||||
{{ t("shortcut.general.show_all") }}
|
||||
</span>
|
||||
<span class="flex items-center flex-1">
|
||||
{{ t("shortcut.general.command_menu") }}
|
||||
</span>
|
||||
@@ -56,6 +62,14 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
|
||||
<kbd class="shortcut-key">↩</kbd>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
|
||||
<kbd class="shortcut-key">K</kbd>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">/</kbd>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<span class="select-wrapper">
|
||||
<input
|
||||
id="method"
|
||||
class="flex px-4 py-2 font-semibold rounded-l cursor-pointer transition text-secondaryDark w-26 bg-primaryLight"
|
||||
class="flex px-4 py-2 font-semibold transition rounded-l cursor-pointer text-secondaryDark w-26 bg-primaryLight"
|
||||
:value="newMethod"
|
||||
:readonly="!isCustomMethod"
|
||||
:placeholder="`${t('request.method')}`"
|
||||
@@ -47,7 +47,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 overflow-auto border-l rounded-r transition border-divider bg-primaryLight whitespace-nowrap"
|
||||
class="flex flex-1 overflow-auto transition border-l rounded-r border-divider bg-primaryLight whitespace-nowrap"
|
||||
>
|
||||
<SmartEnvInput
|
||||
v-model="newEndpoint"
|
||||
@@ -61,7 +61,7 @@
|
||||
<ButtonPrimary
|
||||
id="send"
|
||||
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
|
||||
:title="`${t('action.send')} <kbd>${getSpecialKey()}</kbd><kbd>G</kbd>`"
|
||||
:title="`${t('action.send')} <kbd>${getSpecialKey()}</kbd><kbd>↩</kbd>`"
|
||||
:label="`${!loading ? t('action.send') : t('action.cancel')}`"
|
||||
class="flex-1 rounded-r-none min-w-20"
|
||||
@click="!loading ? newSendRequest() : cancelRequest()"
|
||||
@@ -131,7 +131,7 @@
|
||||
</tippy>
|
||||
</span>
|
||||
<span
|
||||
class="flex ml-2 border rounded transition border-dividerLight hover:border-dividerDark"
|
||||
class="flex ml-2 transition border rounded border-dividerLight hover:border-dividerDark"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
class="flex flex-col items-center justify-center flex-1 text-secondaryLight"
|
||||
>
|
||||
<div class="flex pb-4 my-4 space-x-2">
|
||||
<div class="flex flex-col items-end text-right space-y-4">
|
||||
<div class="flex flex-col items-end space-y-4 text-right">
|
||||
<span class="flex items-center flex-1">
|
||||
{{ t("shortcut.request.send_request") }}
|
||||
</span>
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
|
||||
<kbd class="shortcut-key">G</kbd>
|
||||
<kbd class="shortcut-key">↩</kbd>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1">
|
||||
<div v-if="showEventField" class="flex items-center justify-between p-4">
|
||||
<div
|
||||
v-if="showEventField"
|
||||
class="sticky z-10 flex items-center justify-center border-b bg-primary border-dividerLight"
|
||||
:class="eventFieldStyles"
|
||||
>
|
||||
<icon-lucide-rss class="mx-4 svg-icons text-accentLight" />
|
||||
<input
|
||||
id="event_name"
|
||||
v-model="eventName"
|
||||
class="input"
|
||||
class="w-full py-2 pr-4 truncate bg-primary"
|
||||
name="event_name"
|
||||
:placeholder="`${t('socketio.event_name')}`"
|
||||
type="text"
|
||||
@@ -12,7 +17,8 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight top-upperSecondaryStickyFold"
|
||||
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight"
|
||||
:class="stickyHeaderStyles"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<label class="font-semibold text-secondaryLight">
|
||||
@@ -41,7 +47,9 @@
|
||||
v-for="(contentTypeItem, index) in validContentTypes"
|
||||
:key="`contentTypeItem-${index}`"
|
||||
:label="contentTypeItem"
|
||||
:info-icon="contentTypeItem === contentType ? IconDone : null"
|
||||
:info-icon="
|
||||
contentTypeItem === contentType ? IconDone : undefined
|
||||
"
|
||||
:active-info-icon="contentTypeItem === contentType"
|
||||
@click="
|
||||
() => {
|
||||
@@ -64,6 +72,15 @@
|
||||
class="rounded-none !text-accent !hover:text-accentDark"
|
||||
@click="sendMessage()"
|
||||
/>
|
||||
<SmartCheckbox
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:on="clearInputOnSend"
|
||||
class="px-2"
|
||||
:title="`${t('mqtt.clear_input_on_send')}`"
|
||||
@change="clearInputOnSend = !clearInputOnSend"
|
||||
>
|
||||
{{ t("mqtt.clear_input") }}
|
||||
</SmartCheckbox>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/realtime"
|
||||
@@ -132,12 +149,21 @@ import { readFileAsText } from "@functional/files"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { isJSONContentType } from "@helpers/utils/contenttypes"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
|
||||
defineProps({
|
||||
showEventField: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
eventFieldStyles: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
stickyHeaderStyles: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
isConnected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -164,6 +190,7 @@ const wsCommunicationBody = ref<HTMLElement>()
|
||||
const payload = ref<HTMLInputElement>()
|
||||
|
||||
const prettifyIcon = refAutoReset<Component>(IconWand2, 1000)
|
||||
const clearInputOnSend = ref(false)
|
||||
|
||||
const knownContentTypes = {
|
||||
JSON: "application/ld+json",
|
||||
@@ -197,7 +224,10 @@ useCodemirror(
|
||||
)
|
||||
|
||||
const clearContent = () => {
|
||||
communicationBody.value = ""
|
||||
if (clearInputOnSend.value) {
|
||||
communicationBody.value = ""
|
||||
eventName.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = () => {
|
||||
@@ -207,10 +237,10 @@ const sendMessage = () => {
|
||||
eventName: eventName.value,
|
||||
message: communicationBody.value,
|
||||
})
|
||||
communicationBody.value = ""
|
||||
clearContent()
|
||||
}
|
||||
|
||||
const uploadPayload = async (e: InputEvent) => {
|
||||
const uploadPayload = async (e: Event) => {
|
||||
const result = await pipe(
|
||||
(e.target as HTMLInputElement).files?.[0],
|
||||
TO.fromNullable,
|
||||
@@ -235,4 +265,6 @@ const prettifyRequestBody = () => {
|
||||
toast.error(`${t("error.json_prettify_invalid_body")}`)
|
||||
}
|
||||
}
|
||||
|
||||
defineActionHandler("request.send-cancel", sendMessage)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1">
|
||||
<div
|
||||
class="sticky z-10 flex items-center justify-between py-2 pl-4 pr-2 border-b bg-primary border-dividerLight top-upperPrimaryStickyFold"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<label class="font-semibold text-secondaryLight">
|
||||
{{ t("mqtt.connection_config") }}
|
||||
</label>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<SmartCheckbox
|
||||
:on="config.cleanSession"
|
||||
class="px-2"
|
||||
@change="config.cleanSession = !config.cleanSession"
|
||||
>{{ t("mqtt.clean_session") }}
|
||||
</SmartCheckbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 h-full border-dividerLight">
|
||||
<div class="w-1/3 border-r border-dividerLight">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="config.username"
|
||||
:placeholder="t('authorization.username')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="config.password"
|
||||
:placeholder="t('authorization.password')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center border-b border-dividerLight">
|
||||
<label class="ml-4 text-secondaryLight">
|
||||
{{ t("mqtt.keep_alive") }}
|
||||
</label>
|
||||
<SmartEnvInput
|
||||
v-model="config.keepAlive"
|
||||
:placeholder="t('mqtt.keep_alive')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-2/3">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="config.lwTopic"
|
||||
:placeholder="t('mqtt.lw_topic')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="config.lwMessage"
|
||||
:placeholder="t('mqtt.lw_message')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between px-4 border-b border-dividerLight"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<label class="font-semibold text-secondaryLight">
|
||||
{{ t("mqtt.lw_qos") }}
|
||||
</label>
|
||||
<tippy interactive trigger="click" theme="popover">
|
||||
<span class="select-wrapper">
|
||||
<ButtonSecondary
|
||||
class="pr-8 ml-2 rounded-none"
|
||||
:label="`${config.lwQos}`"
|
||||
/>
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div class="flex flex-col" role="menu">
|
||||
<SmartItem
|
||||
v-for="item in QOS_VALUES"
|
||||
:key="`qos-${item}`"
|
||||
:label="`${item}`"
|
||||
:icon="config.lwQos === item ? IconCheckCircle : IconCircle"
|
||||
:active="config.lwQos === item"
|
||||
@click="
|
||||
() => {
|
||||
config.lwQos = item
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</div>
|
||||
<SmartCheckbox
|
||||
:on="config.lwRetain"
|
||||
class="py-2"
|
||||
@change="config.lwRetain = !config.lwRetain"
|
||||
>{{ t("mqtt.lw_retain") }}
|
||||
</SmartCheckbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import { ref, watch } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import {
|
||||
MQTTConnectionConfig,
|
||||
QOS_VALUES,
|
||||
} from "~/helpers/realtime/MQTTConnection"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "change", body: MQTTConnectionConfig): void
|
||||
}>()
|
||||
const config = ref<MQTTConnectionConfig>({
|
||||
username: "",
|
||||
password: "",
|
||||
keepAlive: "60",
|
||||
cleanSession: true,
|
||||
lwTopic: "",
|
||||
lwMessage: "",
|
||||
lwQos: 0,
|
||||
lwRetain: false,
|
||||
})
|
||||
|
||||
watch(
|
||||
config,
|
||||
(newVal) => {
|
||||
emit("change", newVal)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -52,6 +52,51 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<div class="flex pb-4 my-4 space-x-2">
|
||||
<div class="flex flex-col items-end space-y-4 text-right">
|
||||
<span class="flex items-center flex-1">
|
||||
{{ t("shortcut.request.send_request") }}
|
||||
</span>
|
||||
<span class="flex items-center flex-1">
|
||||
{{ t("shortcut.general.show_all") }}
|
||||
</span>
|
||||
<span class="flex items-center flex-1">
|
||||
{{ t("shortcut.general.command_menu") }}
|
||||
</span>
|
||||
<span class="flex items-center flex-1">
|
||||
{{ t("shortcut.general.help_menu") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
|
||||
<kbd class="shortcut-key">↩</kbd>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
|
||||
<kbd class="shortcut-key">K</kbd>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">/</kbd>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">?</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonSecondary
|
||||
:label="`${t('app.documentation')}`"
|
||||
to="https://docs.hoppscotch.io/realtime"
|
||||
:icon="IconExternalLink"
|
||||
blank
|
||||
outline
|
||||
reverse
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -61,8 +106,10 @@ import IconTrash from "~icons/lucide/trash"
|
||||
import IconArrowUp from "~icons/lucide/arrow-up"
|
||||
import IconArrowDown from "~icons/lucide/arrow-down"
|
||||
import IconChevronsDown from "~icons/lucide/chevron-down"
|
||||
import IconExternalLink from "~icons/lucide/external-link"
|
||||
import { useThrottleFn, useScroll } from "@vueuse/core"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
|
||||
|
||||
export type LogEntryData = {
|
||||
prefix?: string
|
||||
|
||||
@@ -371,6 +371,7 @@ const icon = computed(() => markRaw(ICONS[props.entry.source].icon))
|
||||
.realtime-log {
|
||||
@apply text-secondary;
|
||||
@apply overflow-hidden;
|
||||
@apply hover:cursor-nsResize;
|
||||
|
||||
&,
|
||||
span {
|
||||
|
||||
138
packages/hoppscotch-app/src/components/realtime/Subscription.vue
Normal file
138
packages/hoppscotch-app/src/components/realtime/Subscription.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<SmartModal v-if="show" dialog :title="t('mqtt.new')" @close="hideModal">
|
||||
<template #body>
|
||||
<div class="flex justify-between mb-4">
|
||||
<div
|
||||
class="flex items-center border divide-x rounded border-divider divide-divider"
|
||||
>
|
||||
<label class="mx-4">
|
||||
{{ t("mqtt.qos") }}
|
||||
</label>
|
||||
<tippy interactive trigger="click" theme="popover">
|
||||
<span class="select-wrapper">
|
||||
<ButtonSecondary class="pr-8" :label="`${QoS}`" />
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div class="flex flex-col" role="menu">
|
||||
<SmartItem
|
||||
v-for="item in QOS_VALUES"
|
||||
:key="`qos-${item}`"
|
||||
:label="`${item}`"
|
||||
:icon="QoS === item ? IconCheckCircle : IconCircle"
|
||||
:active="QoS === item"
|
||||
@click="
|
||||
() => {
|
||||
QoS = item
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<label for="select-color">
|
||||
{{ t("mqtt.color") }}
|
||||
</label>
|
||||
<input
|
||||
id="select-color"
|
||||
v-model="color"
|
||||
type="color"
|
||||
class="w-8 h-8 p-1 ml-4 border rounded bg-primary border-divider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="selectLabelAdd"
|
||||
v-model="name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="addNewSubscription"
|
||||
/>
|
||||
<label for="selectLabelAdd">
|
||||
{{ t("action.label") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="t('mqtt.subscribe')"
|
||||
:loading="loadingState"
|
||||
outline
|
||||
@click="addNewSubscription"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import { ref, watch } from "vue"
|
||||
import { MQTTTopic, QOS_VALUES } from "~/helpers/realtime/MQTTConnection"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
|
||||
const toastr = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loadingState: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
(e: "submit", body: MQTTTopic): void
|
||||
}>()
|
||||
|
||||
const QoS = ref<typeof QOS_VALUES[number]>(2)
|
||||
const name = ref("")
|
||||
const color = ref("#f58290")
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
() => {
|
||||
name.value = ""
|
||||
QoS.value = 2
|
||||
const randomColor = Math.floor(Math.random() * 16777215).toString(16)
|
||||
color.value = `#${randomColor}`
|
||||
}
|
||||
)
|
||||
|
||||
const addNewSubscription = () => {
|
||||
if (!name.value) {
|
||||
toastr.error(t("mqtt.invalid_topic").toString())
|
||||
return
|
||||
}
|
||||
emit("submit", {
|
||||
name: name.value,
|
||||
qos: QoS.value,
|
||||
color: color.value,
|
||||
})
|
||||
}
|
||||
const hideModal = () => {
|
||||
name.value = ""
|
||||
QoS.value = 2
|
||||
emit("hide-modal")
|
||||
}
|
||||
</script>
|
||||
49
packages/hoppscotch-app/src/components/smart/Window.vue
Normal file
49
packages/hoppscotch-app/src/components/smart/Window.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div v-show="active" class="flex flex-col flex-1 overflow-y-auto">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
inject,
|
||||
computed,
|
||||
watch,
|
||||
useSlots,
|
||||
} from "vue"
|
||||
import { TabMeta, TabProvider } from "./Windows.vue"
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, default: null },
|
||||
info: { type: String, default: null },
|
||||
id: { type: String, default: null, required: true },
|
||||
isRemovable: { type: Boolean, default: true },
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
const tabMeta = computed<TabMeta>(() => ({
|
||||
info: props.info,
|
||||
label: props.label,
|
||||
isRemovable: props.isRemovable,
|
||||
icon: slots.icon,
|
||||
}))
|
||||
const { activeTabID, addTabEntry, updateTabEntry, removeTabEntry } =
|
||||
inject<TabProvider>("tabs-system")!
|
||||
const active = computed(() => activeTabID.value === props.id)
|
||||
|
||||
onMounted(() => {
|
||||
addTabEntry(props.id, tabMeta.value)
|
||||
})
|
||||
watch(tabMeta, (newMeta) => {
|
||||
updateTabEntry(props.id, newMeta)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
removeTabEntry(props.id)
|
||||
})
|
||||
</script>
|
||||
274
packages/hoppscotch-app/src/components/smart/Windows.vue
Normal file
274
packages/hoppscotch-app/src/components/smart/Windows.vue
Normal file
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 h-auto overflow-y-hidden flex-nowrap">
|
||||
<div class="relative sticky top-0 z-10 tabs bg-primaryLight">
|
||||
<div class="flex flex-1 w-0 overflow-x-auto">
|
||||
<div class="flex justify-between divide-x divide-dividerLight">
|
||||
<div class="flex">
|
||||
<draggable
|
||||
v-bind="dragOptions"
|
||||
:list="tabEntries"
|
||||
:style="tabsWidth"
|
||||
:item-key="'window-'"
|
||||
class="flex overflow-x-auto transition divide-x divide-dividerLight"
|
||||
@sort="sortTabs"
|
||||
>
|
||||
<template #item="{ element: [tabID, tabMeta] }">
|
||||
<button
|
||||
:key="`removable-tab-${tabID}`"
|
||||
class="tab"
|
||||
:class="[{ active: modelValue === tabID }]"
|
||||
:aria-label="tabMeta.label || ''"
|
||||
role="button"
|
||||
@keyup.enter="selectTab(tabID)"
|
||||
@click="selectTab(tabID)"
|
||||
>
|
||||
<div class="flex items-stretch truncate">
|
||||
<span
|
||||
v-if="tabMeta.icon"
|
||||
class="flex items-center justify-center mx-4 cursor-pointer"
|
||||
>
|
||||
<component :is="tabMeta.icon" class="w-4 h-4 svg-icons" />
|
||||
</span>
|
||||
<span class="truncate">
|
||||
{{ tabMeta.label }}
|
||||
</span>
|
||||
</div>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', delay: [500, 20] }"
|
||||
:icon="IconX"
|
||||
:style="{
|
||||
visibility: tabMeta.isRemovable ? 'visible' : 'hidden',
|
||||
}"
|
||||
:title="t('action.close')"
|
||||
:class="[{ active: modelValue === tabID }, 'close']"
|
||||
class="rounded mx-2 !py-0.5 !px-1"
|
||||
@click.stop="emit('removeTab', tabID)"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
<div class="sticky right-0 flex items-center justify-center z-8">
|
||||
<slot name="actions">
|
||||
<span
|
||||
v-if="canAddNewTab"
|
||||
class="flex items-center justify-center px-2 py-1.5 bg-primaryLight z-8"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.new')"
|
||||
:icon="IconPlus"
|
||||
class="rounded !p-1"
|
||||
filled
|
||||
@click="addTab"
|
||||
/>
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-full contents">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconX from "~icons/lucide/x"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { not } from "fp-ts/Predicate"
|
||||
import * as A from "fp-ts/Array"
|
||||
import * as O from "fp-ts/Option"
|
||||
import { ref, ComputedRef, computed, provide } from "vue"
|
||||
import type { Slot } from "vue"
|
||||
import draggable from "vuedraggable"
|
||||
import { throwError } from "~/helpers/functional/error"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
|
||||
export type TabMeta = {
|
||||
label: string | null
|
||||
icon: Slot | undefined
|
||||
info: string | null
|
||||
isRemovable: boolean
|
||||
}
|
||||
export type TabProvider = {
|
||||
activeTabID: ComputedRef<string>
|
||||
addTabEntry: (tabID: string, meta: TabMeta) => void
|
||||
updateTabEntry: (tabID: string, newMeta: TabMeta) => void
|
||||
removeTabEntry: (tabID: string) => void
|
||||
}
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
styles: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
canAddNewTab: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", newTabID: string): void
|
||||
(e: "sort", body: { oldIndex: number; newIndex: number }): void
|
||||
(e: "removeTab", tabID: string): void
|
||||
(e: "addTab"): void
|
||||
}>()
|
||||
const tabEntries = ref<Array<[string, TabMeta]>>([])
|
||||
const tabsWidth = computed(() => ({
|
||||
maxWidth: `${tabEntries.value.length * 184}px`,
|
||||
width: "100%",
|
||||
minWidth: "0px",
|
||||
transition: "max-width 0.2s",
|
||||
}))
|
||||
const dragOptions = {
|
||||
group: "tabs",
|
||||
animation: 250,
|
||||
handle: ".tab",
|
||||
draggable: ".tab",
|
||||
ghostClass: "cursor-move",
|
||||
}
|
||||
const addTabEntry = (tabID: string, meta: TabMeta) => {
|
||||
tabEntries.value = pipe(
|
||||
tabEntries.value,
|
||||
O.fromPredicate(not(A.exists(([id]) => id === tabID))),
|
||||
O.map(A.append([tabID, meta] as [string, TabMeta])),
|
||||
O.getOrElseW(() => throwError(`Tab with duplicate ID created: '${tabID}'`))
|
||||
)
|
||||
}
|
||||
const updateTabEntry = (tabID: string, newMeta: TabMeta) => {
|
||||
tabEntries.value = pipe(
|
||||
tabEntries.value,
|
||||
A.findIndex(([id]) => id === tabID),
|
||||
O.chain((index) =>
|
||||
pipe(
|
||||
tabEntries.value,
|
||||
A.updateAt(index, [tabID, newMeta] as [string, TabMeta])
|
||||
)
|
||||
),
|
||||
O.getOrElseW(() => throwError(`Failed to update tab entry: ${tabID}`))
|
||||
)
|
||||
}
|
||||
const removeTabEntry = (tabID: string) => {
|
||||
tabEntries.value = pipe(
|
||||
tabEntries.value,
|
||||
A.findIndex(([id]) => id === tabID),
|
||||
O.chain((index) => pipe(tabEntries.value, A.deleteAt(index))),
|
||||
O.getOrElseW(() => throwError(`Failed to remove tab entry: ${tabID}`))
|
||||
)
|
||||
// If we tried to remove the active tabEntries, switch to first tab entry
|
||||
if (props.modelValue === tabID)
|
||||
if (tabEntries.value.length > 0) selectTab(tabEntries.value[0][0])
|
||||
}
|
||||
const sortTabs = (e: {
|
||||
oldDraggableIndex: number
|
||||
newDraggableIndex: number
|
||||
}) => {
|
||||
emit("sort", {
|
||||
oldIndex: e.oldDraggableIndex,
|
||||
newIndex: e.newDraggableIndex,
|
||||
})
|
||||
}
|
||||
provide<TabProvider>("tabs-system", {
|
||||
activeTabID: computed(() => props.modelValue),
|
||||
addTabEntry,
|
||||
updateTabEntry,
|
||||
removeTabEntry,
|
||||
})
|
||||
const selectTab = (id: string) => {
|
||||
emit("update:modelValue", id)
|
||||
}
|
||||
const addTab = () => {
|
||||
emit("addTab")
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tabs {
|
||||
@apply flex;
|
||||
@apply whitespace-nowrap;
|
||||
@apply overflow-auto;
|
||||
@apply flex-shrink-0;
|
||||
|
||||
&::after {
|
||||
@apply absolute;
|
||||
@apply inset-x-0;
|
||||
@apply bottom-0;
|
||||
@apply bg-dividerLight;
|
||||
@apply z-10;
|
||||
@apply h-0.25;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.tab {
|
||||
@apply relative;
|
||||
@apply flex;
|
||||
@apply py-2;
|
||||
@apply font-semibold;
|
||||
@apply w-46;
|
||||
@apply transition;
|
||||
@apply flex-1;
|
||||
@apply items-center;
|
||||
@apply justify-between;
|
||||
@apply text-secondaryLight;
|
||||
@apply hover:bg-primaryDark;
|
||||
@apply hover:text-secondary;
|
||||
@apply focus-visible:text-secondaryDark;
|
||||
|
||||
&::before {
|
||||
@apply absolute;
|
||||
@apply left-0;
|
||||
@apply right-0;
|
||||
@apply top-0;
|
||||
@apply bg-transparent;
|
||||
@apply z-2;
|
||||
@apply h-0.5;
|
||||
content: "";
|
||||
}
|
||||
|
||||
// &::after {
|
||||
// @apply absolute;
|
||||
// @apply left-0;
|
||||
// @apply right-0;
|
||||
// @apply bottom-0;
|
||||
// @apply bg-divider;
|
||||
// @apply z-2;
|
||||
// @apply h-0.25;
|
||||
// content: "";
|
||||
// }
|
||||
|
||||
&:focus::before {
|
||||
@apply bg-divider;
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply text-secondaryDark;
|
||||
@apply bg-primary;
|
||||
|
||||
&::before {
|
||||
@apply bg-accent;
|
||||
}
|
||||
|
||||
// &::after {
|
||||
// @apply bg-transparent;
|
||||
// }
|
||||
}
|
||||
|
||||
.close {
|
||||
@apply opacity-50;
|
||||
|
||||
&.active {
|
||||
@apply opacity-80;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -68,7 +68,7 @@ type HoppActionWithArgs = keyof HoppActionArgs
|
||||
/**
|
||||
* HoppActions which do not require arguments for their invocation
|
||||
*/
|
||||
type HoppActionWithNoArgs = Exclude<HoppAction, HoppActionWithArgs>
|
||||
export type HoppActionWithNoArgs = Exclude<HoppAction, HoppActionWithArgs>
|
||||
|
||||
/**
|
||||
* Resolves the argument type for a given HoppAction
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { onBeforeUnmount, onMounted } from "vue"
|
||||
import { HoppAction, invokeAction } from "./actions"
|
||||
import { HoppActionWithNoArgs, invokeAction } from "./actions"
|
||||
import { isAppleDevice } from "./platformutils"
|
||||
import { isDOMElement, isTypableElement } from "./utils/dom"
|
||||
|
||||
@@ -23,7 +23,7 @@ type Key =
|
||||
| "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t"
|
||||
| "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3"
|
||||
| "4" | "5" | "6" | "7" | "8" | "9" | "up" | "down" | "left"
|
||||
| "right" | "/" | "?" | "."
|
||||
| "right" | "/" | "?" | "." | "enter"
|
||||
/* eslint-enable */
|
||||
|
||||
type ModifierBasedShortcutKey = `${ModifierKeys}-${Key}`
|
||||
@@ -34,9 +34,9 @@ type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey
|
||||
|
||||
export const bindings: {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
[_ in ShortcutKey]?: HoppAction
|
||||
[_ in ShortcutKey]?: HoppActionWithNoArgs
|
||||
} = {
|
||||
"ctrl-g": "request.send-cancel",
|
||||
"ctrl-enter": "request.send-cancel",
|
||||
"ctrl-i": "request.reset",
|
||||
"ctrl-u": "request.copy-link",
|
||||
"ctrl-s": "request.save",
|
||||
@@ -136,6 +136,8 @@ function getPressedKey(ev: KeyboardEvent): Key | null {
|
||||
// Check if period
|
||||
if (val === ".") return "."
|
||||
|
||||
if (val === "enter") return "enter"
|
||||
|
||||
// If no other cases match, this is not a valid key
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -2,6 +2,17 @@ import Paho, { ConnectionOptions } from "paho-mqtt"
|
||||
import { BehaviorSubject, Subject } from "rxjs"
|
||||
import { logHoppRequestRunToAnalytics } from "../fb/analytics"
|
||||
|
||||
export type MQTTConnectionConfig = {
|
||||
username?: string
|
||||
password?: string
|
||||
keepAlive?: string
|
||||
cleanSession?: boolean
|
||||
lwTopic?: string
|
||||
lwMessage: string
|
||||
lwQos: 2 | 1 | 0
|
||||
lwRetain: boolean
|
||||
}
|
||||
|
||||
export type MQTTMessage = { topic: string; message: string }
|
||||
export type MQTTError =
|
||||
| { type: "CONNECTION_NOT_ESTABLISHED"; value: unknown }
|
||||
@@ -21,12 +32,22 @@ export type MQTTEvent = { time: number } & (
|
||||
| { type: "ERROR"; error: MQTTError }
|
||||
)
|
||||
|
||||
export type MQTTTopic = {
|
||||
name: string
|
||||
color: string
|
||||
qos: 2 | 1 | 0
|
||||
}
|
||||
|
||||
export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED"
|
||||
|
||||
export const QOS_VALUES = [2, 1, 0] as const
|
||||
|
||||
export class MQTTConnection {
|
||||
subscribing$ = new BehaviorSubject(false)
|
||||
subscriptionState$ = new BehaviorSubject<boolean>(false)
|
||||
connectionState$ = new BehaviorSubject<ConnectionState>("DISCONNECTED")
|
||||
event$: Subject<MQTTEvent> = new Subject()
|
||||
subscribedTopics$ = new BehaviorSubject<MQTTTopic[]>([])
|
||||
|
||||
private mqttClient: Paho.Client | undefined
|
||||
private manualDisconnect = false
|
||||
@@ -35,7 +56,7 @@ export class MQTTConnection {
|
||||
this.event$.next(event)
|
||||
}
|
||||
|
||||
connect(url: string, username: string, password: string) {
|
||||
connect(url: string, clientID: string, config: MQTTConnectionConfig) {
|
||||
try {
|
||||
this.connectionState$.next("CONNECTING")
|
||||
|
||||
@@ -49,19 +70,34 @@ export class MQTTConnection {
|
||||
this.mqttClient = new Paho.Client(
|
||||
`${hostname + (pathname !== "/" ? pathname : "")}`,
|
||||
port !== "" ? Number(port) : 8081,
|
||||
"hoppscotch"
|
||||
clientID ?? "hoppscotch"
|
||||
)
|
||||
const connectOptions: ConnectionOptions = {
|
||||
onSuccess: this.onConnectionSuccess.bind(this),
|
||||
onFailure: this.onConnectionFailure.bind(this),
|
||||
timeout: 3,
|
||||
keepAliveInterval: Number(config.keepAlive) ?? 60,
|
||||
cleanSession: config.cleanSession ?? true,
|
||||
useSSL: parseUrl.protocol !== "ws:",
|
||||
}
|
||||
if (username !== "") {
|
||||
|
||||
const { username, password, lwTopic, lwMessage, lwQos, lwRetain } = config
|
||||
|
||||
if (username) {
|
||||
connectOptions.userName = username
|
||||
}
|
||||
if (password !== "") {
|
||||
if (password) {
|
||||
connectOptions.password = password
|
||||
}
|
||||
|
||||
if (lwTopic?.length) {
|
||||
const willmsg = new Paho.Message(lwMessage)
|
||||
willmsg.qos = lwQos
|
||||
willmsg.destinationName = lwTopic
|
||||
willmsg.retained = lwRetain
|
||||
connectOptions.willMessage = willmsg
|
||||
}
|
||||
|
||||
this.mqttClient.connect(connectOptions)
|
||||
this.mqttClient.onConnectionLost = this.onConnectionLost.bind(this)
|
||||
this.mqttClient.onMessageArrived = this.onMessageArrived.bind(this)
|
||||
@@ -112,6 +148,7 @@ export class MQTTConnection {
|
||||
}
|
||||
this.manualDisconnect = false
|
||||
this.subscriptionState$.next(false)
|
||||
this.subscribedTopics$.next([])
|
||||
}
|
||||
|
||||
onMessageArrived({
|
||||
@@ -170,34 +207,45 @@ export class MQTTConnection {
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(topic: string) {
|
||||
subscribe(topic: MQTTTopic) {
|
||||
this.subscribing$.next(true)
|
||||
try {
|
||||
this.mqttClient?.subscribe(topic, {
|
||||
onSuccess: this.usubSuccess.bind(this, topic),
|
||||
onFailure: this.usubFailure.bind(this, topic),
|
||||
this.mqttClient?.subscribe(topic.name, {
|
||||
onSuccess: this.subSuccess.bind(this, topic),
|
||||
onFailure: this.usubFailure.bind(this, topic.name),
|
||||
qos: topic.qos,
|
||||
})
|
||||
} catch (e) {
|
||||
this.subscribing$.next(false)
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "ERROR",
|
||||
error: {
|
||||
type: "SUBSCRIPTION_FAILED",
|
||||
topic,
|
||||
topic: topic.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
usubSuccess(topic: string) {
|
||||
subSuccess(topic: MQTTTopic) {
|
||||
this.subscribing$.next(false)
|
||||
this.subscriptionState$.next(!this.subscriptionState$.value)
|
||||
this.addSubscription(topic)
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "SUBSCRIBED",
|
||||
topic,
|
||||
topic: topic.name,
|
||||
})
|
||||
}
|
||||
|
||||
usubSuccess(topic: string) {
|
||||
this.subscribing$.next(false)
|
||||
this.removeSubscription(topic)
|
||||
}
|
||||
|
||||
usubFailure(topic: string) {
|
||||
this.subscribing$.next(false)
|
||||
this.addEvent({
|
||||
time: Date.now(),
|
||||
type: "ERROR",
|
||||
@@ -215,6 +263,21 @@ export class MQTTConnection {
|
||||
})
|
||||
}
|
||||
|
||||
addSubscription(topic: MQTTTopic) {
|
||||
const subscriptions = this.subscribedTopics$.getValue()
|
||||
subscriptions.push({
|
||||
name: topic.name,
|
||||
color: topic.color,
|
||||
qos: topic.qos,
|
||||
})
|
||||
this.subscribedTopics$.next(subscriptions)
|
||||
}
|
||||
|
||||
removeSubscription(topic: string) {
|
||||
const subscriptions = this.subscribedTopics$.getValue()
|
||||
this.subscribedTopics$.next(subscriptions.filter((t) => t.name !== topic))
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.manualDisconnect = true
|
||||
this.mqttClient?.disconnect()
|
||||
|
||||
@@ -34,7 +34,7 @@ export default [
|
||||
section: "shortcut.request.title",
|
||||
shortcuts: [
|
||||
{
|
||||
keys: [getPlatformSpecialKey(), "G"],
|
||||
keys: [getPlatformSpecialKey(), "Enter"],
|
||||
label: "shortcut.request.send_request",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,8 +6,17 @@ import {
|
||||
HoppRealtimeLogLine,
|
||||
} from "~/helpers/types/HoppRealtimeLog"
|
||||
|
||||
type MQTTTab = {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
removable: boolean
|
||||
logs: HoppRealtimeLog[]
|
||||
}
|
||||
|
||||
type HoppMQTTRequest = {
|
||||
endpoint: string
|
||||
clientID: string
|
||||
}
|
||||
|
||||
type HoppMQTTSession = {
|
||||
@@ -15,10 +24,21 @@ type HoppMQTTSession = {
|
||||
subscriptionState: boolean
|
||||
log: HoppRealtimeLog
|
||||
socket: MQTTConnection
|
||||
tabs: MQTTTab[]
|
||||
currentTabId: string
|
||||
}
|
||||
|
||||
const defaultMQTTRequest: HoppMQTTRequest = {
|
||||
endpoint: "wss://test.mosquitto.org:8081",
|
||||
clientID: "hoppscotch",
|
||||
}
|
||||
|
||||
const defaultTab: MQTTTab = {
|
||||
id: "all",
|
||||
name: "All Topics",
|
||||
color: "var(--accent-color)",
|
||||
removable: false,
|
||||
logs: [],
|
||||
}
|
||||
|
||||
const defaultMQTTSession: HoppMQTTSession = {
|
||||
@@ -26,6 +46,8 @@ const defaultMQTTSession: HoppMQTTSession = {
|
||||
subscriptionState: false,
|
||||
socket: new MQTTConnection(),
|
||||
log: [],
|
||||
tabs: [defaultTab],
|
||||
currentTabId: defaultTab.id,
|
||||
}
|
||||
|
||||
const dispatchers = defineDispatchers({
|
||||
@@ -37,13 +59,22 @@ const dispatchers = defineDispatchers({
|
||||
request: newRequest,
|
||||
}
|
||||
},
|
||||
setEndpoint(_: HoppMQTTSession, { newEndpoint }: { newEndpoint: string }) {
|
||||
setEndpoint(curr: HoppMQTTSession, { newEndpoint }: { newEndpoint: string }) {
|
||||
return {
|
||||
request: {
|
||||
clientID: curr.request.clientID,
|
||||
endpoint: newEndpoint,
|
||||
},
|
||||
}
|
||||
},
|
||||
setClientID(curr: HoppMQTTSession, { newClientID }: { newClientID: string }) {
|
||||
return {
|
||||
request: {
|
||||
endpoint: curr.request.endpoint,
|
||||
clientID: newClientID,
|
||||
},
|
||||
}
|
||||
},
|
||||
setConn(_: HoppMQTTSession, { socket }: { socket: MQTTConnection }) {
|
||||
return {
|
||||
socket,
|
||||
@@ -64,6 +95,47 @@ const dispatchers = defineDispatchers({
|
||||
log: [...curr.log, line],
|
||||
}
|
||||
},
|
||||
setTabs(_: HoppMQTTSession, { tabs }: { tabs: MQTTTab[] }) {
|
||||
return {
|
||||
tabs,
|
||||
}
|
||||
},
|
||||
addTab(curr: HoppMQTTSession, { tab }: { tab: MQTTTab }) {
|
||||
return {
|
||||
tabs: [...curr.tabs, tab],
|
||||
}
|
||||
},
|
||||
setCurrentTabId(_: HoppMQTTSession, { tabId }: { tabId: string }) {
|
||||
return {
|
||||
currentTabId: tabId,
|
||||
}
|
||||
},
|
||||
setCurrentTabLog(
|
||||
_: HoppMQTTSession,
|
||||
{ log, tabId }: { log: HoppRealtimeLog[]; tabId: string }
|
||||
) {
|
||||
const newTabs = _.tabs.map((tab) => {
|
||||
if (tab.id === tabId) tab.logs = log
|
||||
return tab
|
||||
})
|
||||
|
||||
return {
|
||||
tabs: newTabs,
|
||||
}
|
||||
},
|
||||
addCurrentTabLogLine(
|
||||
_: HoppMQTTSession,
|
||||
{ line, tabId }: { tabId: string; line: HoppRealtimeLog }
|
||||
) {
|
||||
const newTabs = _.tabs.map((tab) => {
|
||||
if (tab.id === tabId) tab.logs = [...tab.logs, line]
|
||||
return tab
|
||||
})
|
||||
|
||||
return {
|
||||
tabs: newTabs,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const MQTTSessionStore = new DispatchingStore(defaultMQTTSession, dispatchers)
|
||||
@@ -86,6 +158,15 @@ export function setMQTTEndpoint(newEndpoint: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function setMQTTClientID(newClientID: string) {
|
||||
MQTTSessionStore.dispatch({
|
||||
dispatcher: "setClientID",
|
||||
payload: {
|
||||
newClientID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setMQTTConn(socket: MQTTConnection) {
|
||||
MQTTSessionStore.dispatch({
|
||||
dispatcher: "setConn",
|
||||
@@ -122,6 +203,56 @@ export function addMQTTLogLine(line: HoppRealtimeLogLine) {
|
||||
})
|
||||
}
|
||||
|
||||
export function setMQTTTabs(tabs: MQTTTab[]) {
|
||||
MQTTSessionStore.dispatch({
|
||||
dispatcher: "setTabs",
|
||||
payload: {
|
||||
tabs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function addMQTTTab(tab: MQTTTab) {
|
||||
MQTTSessionStore.dispatch({
|
||||
dispatcher: "addTab",
|
||||
payload: {
|
||||
tab,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setCurrentTab(tabId: string) {
|
||||
MQTTSessionStore.dispatch({
|
||||
dispatcher: "setCurrentTabId",
|
||||
payload: {
|
||||
tabId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setMQTTCurrentTabLog(tabId: string, log: HoppRealtimeLog) {
|
||||
MQTTSessionStore.dispatch({
|
||||
dispatcher: "setCurrentTabLog",
|
||||
payload: {
|
||||
tabId,
|
||||
log,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function addMQTTCurrentTabLogLine(
|
||||
tabId: string,
|
||||
line: HoppRealtimeLogLine
|
||||
) {
|
||||
MQTTSessionStore.dispatch({
|
||||
dispatcher: "addCurrentTabLogLine",
|
||||
payload: {
|
||||
tabId,
|
||||
line,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const MQTTRequest$ = MQTTSessionStore.subject$.pipe(
|
||||
pluck("request"),
|
||||
distinctUntilChanged()
|
||||
@@ -132,6 +263,11 @@ export const MQTTEndpoint$ = MQTTSessionStore.subject$.pipe(
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const MQTTClientID$ = MQTTSessionStore.subject$.pipe(
|
||||
pluck("request", "clientID"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const MQTTConnectingState$ = MQTTSessionStore.subject$.pipe(
|
||||
pluck("connectingState"),
|
||||
distinctUntilChanged()
|
||||
@@ -156,3 +292,13 @@ export const MQTTLog$ = MQTTSessionStore.subject$.pipe(
|
||||
pluck("log"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const MQTTTabs$ = MQTTSessionStore.subject$.pipe(
|
||||
pluck("tabs"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const MQTTCurrentTab$ = MQTTSessionStore.subject$.pipe(
|
||||
pluck("currentTabId"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -6670,7 +6670,7 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
|
||||
/escape-string-regexp/1.0.5:
|
||||
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
|
||||
resolution: {integrity: sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=}
|
||||
engines: {node: '>=0.8.0'}
|
||||
|
||||
/escape-string-regexp/2.0.0:
|
||||
@@ -7918,7 +7918,7 @@ packages:
|
||||
wrappy: 1.0.2
|
||||
|
||||
/inherits/2.0.3:
|
||||
resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==}
|
||||
resolution: {integrity: sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=}
|
||||
dev: false
|
||||
|
||||
/inherits/2.0.4:
|
||||
@@ -9366,7 +9366,7 @@ packages:
|
||||
dev: false
|
||||
|
||||
/memory-fs/0.3.0:
|
||||
resolution: {integrity: sha512-QTNXnl79X97kZ9jJk/meJrtDuvgvRakX5LU7HZW1L7MsXHuSTwoMIzN9tOLLH3Xfsj/gbsSqX/ovnsqz246zKQ==}
|
||||
resolution: {integrity: sha1-e8xrYp46Q+hx1+Kaymrop/FcuyA=}
|
||||
dependencies:
|
||||
errno: 0.1.8
|
||||
readable-stream: 2.3.7
|
||||
@@ -9537,7 +9537,7 @@ packages:
|
||||
dev: true
|
||||
|
||||
/ms/2.0.0:
|
||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||
resolution: {integrity: sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=}
|
||||
dev: false
|
||||
|
||||
/ms/2.1.2:
|
||||
@@ -10191,7 +10191,7 @@ packages:
|
||||
dev: false
|
||||
|
||||
/prr/1.0.1:
|
||||
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
|
||||
resolution: {integrity: sha1-0/wRS6BplaRexok/SEzrHXj19HY=}
|
||||
dev: false
|
||||
|
||||
/psl/1.9.0:
|
||||
|
||||
Reference in New Issue
Block a user