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

@@ -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"

View File

@@ -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']

View File

@@ -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"

View File

@@ -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>

View File

@@ -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 }"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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 {

View 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>

View 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>

View 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>

View File

@@ -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

View File

@@ -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
}

View File

@@ -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()

View File

@@ -34,7 +34,7 @@ export default [
section: "shortcut.request.title",
shortcuts: [
{
keys: [getPlatformSpecialKey(), "G"],
keys: [getPlatformSpecialKey(), "Enter"],
label: "shortcut.request.send_request",
},
{

View File

@@ -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()
)

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,
})

10
pnpm-lock.yaml generated
View File

@@ -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: