chore: split app to commons and web (squash commit)
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1">
|
||||
<div
|
||||
v-if="showEventField"
|
||||
class="sticky z-10 flex items-center justify-center flex-shrink-0 overflow-x-auto 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="w-full py-2 pr-4 truncate bg-primary"
|
||||
name="event_name"
|
||||
:placeholder="`${t('socketio.event_name')}`"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="sticky z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight"
|
||||
:class="stickyHeaderStyles"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("websocket.message") }}
|
||||
</label>
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<span class="select-wrapper">
|
||||
<ButtonSecondary
|
||||
:label="contentType || t('state.none').toLowerCase()"
|
||||
class="pr-8 ml-2 rounded-none"
|
||||
/>
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
v-for="(contentTypeItem, index) in validContentTypes"
|
||||
:key="`contentTypeItem-${index}`"
|
||||
:label="contentTypeItem"
|
||||
:info-icon="
|
||||
contentTypeItem === contentType ? IconDone : undefined
|
||||
"
|
||||
:active-info-icon="contentTypeItem === contentType"
|
||||
@click="
|
||||
() => {
|
||||
contentType = contentTypeItem
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
|
||||
:title="`${t(
|
||||
'request.run'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>↩</kbd>`"
|
||||
:label="`${t('action.send')}`"
|
||||
:disabled="!communicationBody || !isConnected"
|
||||
:icon="IconSend"
|
||||
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"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="contentType && contentType == 'JSON'"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.prettify')"
|
||||
:icon="prettifyIcon"
|
||||
@click="prettifyRequestBody"
|
||||
/>
|
||||
<label for="payload">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('import.title')"
|
||||
:icon="IconFilePlus"
|
||||
@click="payload!.click()"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
ref="payload"
|
||||
class="input"
|
||||
name="payload"
|
||||
type="file"
|
||||
@change="uploadPayload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="wsCommunicationBody" class="flex flex-col flex-1"></div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Component, computed, reactive, ref } from "vue"
|
||||
import IconSend from "~icons/lucide/send"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconWand2 from "~icons/lucide/wand-2"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconInfo from "~icons/lucide/info"
|
||||
import IconDone from "~icons/lucide/check"
|
||||
import IconFilePlus from "~icons/lucide/file-plus"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as TO from "fp-ts/TaskOption"
|
||||
import * as O from "fp-ts/Option"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import jsonLinter from "@helpers/editor/linting/json"
|
||||
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"
|
||||
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
|
||||
|
||||
defineProps({
|
||||
showEventField: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
eventFieldStyles: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
stickyHeaderStyles: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
isConnected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: "send-message",
|
||||
body: {
|
||||
eventName: string
|
||||
message: string
|
||||
}
|
||||
): void
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const linewrapEnabled = ref(true)
|
||||
const wsCommunicationBody = ref<HTMLElement>()
|
||||
const payload = ref<HTMLInputElement>()
|
||||
|
||||
const prettifyIcon = refAutoReset<Component>(IconWand2, 1000)
|
||||
const clearInputOnSend = ref(false)
|
||||
|
||||
const knownContentTypes = {
|
||||
JSON: "application/ld+json",
|
||||
Raw: "text/plain",
|
||||
} as const
|
||||
|
||||
const validContentTypes = Object.keys(knownContentTypes) as ["JSON", "Raw"]
|
||||
|
||||
const contentType = ref<keyof typeof knownContentTypes>("JSON")
|
||||
const eventName = ref("")
|
||||
const communicationBody = ref("")
|
||||
|
||||
const rawInputEditorLang = computed(() => knownContentTypes[contentType.value])
|
||||
const langLinter = computed(() =>
|
||||
isJSONContentType(contentType.value) ? jsonLinter : null
|
||||
)
|
||||
|
||||
useCodemirror(
|
||||
wsCommunicationBody,
|
||||
communicationBody,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
lineWrapping: linewrapEnabled,
|
||||
mode: rawInputEditorLang,
|
||||
placeholder: t("websocket.message").toString(),
|
||||
},
|
||||
linter: langLinter,
|
||||
completer: null,
|
||||
environmentHighlights: true,
|
||||
})
|
||||
)
|
||||
|
||||
const clearContent = () => {
|
||||
if (clearInputOnSend.value) {
|
||||
communicationBody.value = ""
|
||||
eventName.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = () => {
|
||||
if (!communicationBody.value) return
|
||||
|
||||
emit("send-message", {
|
||||
eventName: eventName.value,
|
||||
message: communicationBody.value,
|
||||
})
|
||||
clearContent()
|
||||
}
|
||||
|
||||
const uploadPayload = async (e: Event) => {
|
||||
const result = await pipe(
|
||||
(e.target as HTMLInputElement).files?.[0],
|
||||
TO.fromNullable,
|
||||
TO.chain(readFileAsText)
|
||||
)()
|
||||
|
||||
if (O.isSome(result)) {
|
||||
communicationBody.value = result.value
|
||||
toast.success(`${t("state.file_imported")}`)
|
||||
} else {
|
||||
toast.error(`${t("action.choose_file")}`)
|
||||
}
|
||||
}
|
||||
const prettifyRequestBody = () => {
|
||||
try {
|
||||
const jsonObj = JSON.parse(communicationBody.value)
|
||||
communicationBody.value = JSON.stringify(jsonObj, null, 2)
|
||||
prettifyIcon.value = IconCheck
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
prettifyIcon.value = IconInfo
|
||||
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 flex-shrink-0 py-2 pl-4 pr-2 overflow-x-auto border-b bg-primary border-dividerLight top-upperPrimaryStickyFold"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<label class="font-semibold truncate 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 truncate 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>
|
||||
134
packages/hoppscotch-common/src/components/realtime/Log.vue
Normal file
134
packages/hoppscotch-common/src/components/realtime/Log.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-auto whitespace-nowrap">
|
||||
<div
|
||||
v-if="log.length !== 0"
|
||||
class="sticky top-0 z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight"
|
||||
>
|
||||
<label for="log" class="font-semibold truncate text-secondaryLight">
|
||||
{{ title }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.delete')"
|
||||
:icon="IconTrash"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
id="bottompage"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.scroll_to_top')"
|
||||
:icon="IconArrowUp"
|
||||
@click="scrollTo('top')"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
id="bottompage"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.scroll_to_bottom')"
|
||||
:icon="IconArrowDown"
|
||||
@click="scrollTo('bottom')"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
id="bottompage"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="`${t('action.autoscroll')}: ${
|
||||
autoScrollEnabled ? t('action.turn_off') : t('action.turn_on')
|
||||
}`"
|
||||
:icon="IconChevronsDown"
|
||||
:class="toggleAutoscrollColor"
|
||||
@click="toggleAutoscroll()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="log.length !== 0"
|
||||
ref="logs"
|
||||
class="flex flex-col flex-1 overflow-y-auto"
|
||||
>
|
||||
<div class="border-b border-dividerLight">
|
||||
<div class="flex flex-col divide-y divide-dividerLight">
|
||||
<RealtimeLogEntry
|
||||
v-for="(entry, index) in log"
|
||||
:key="`entry-${index}`"
|
||||
:entry="entry"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AppShortcutsPrompt v-else class="p-4" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, PropType, computed, watch } from "vue"
|
||||
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 { useThrottleFn, useScroll } from "@vueuse/core"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
export type LogEntryData = {
|
||||
prefix?: string
|
||||
ts: number | undefined
|
||||
source: "info" | "client" | "server" | "disconnected"
|
||||
payload: string
|
||||
event: "connecting" | "connected" | "disconnected" | "error"
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
log: { type: Array as PropType<LogEntryData[]>, default: () => [] },
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete"): void
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const logs = ref<HTMLElement>()
|
||||
|
||||
const autoScrollEnabled = ref(true)
|
||||
|
||||
const logListScroll = useScroll(logs)
|
||||
|
||||
// Disable autoscroll when scrolling to top
|
||||
watch(logListScroll.isScrolling, (isScrolling) => {
|
||||
if (isScrolling && logListScroll.directions.top)
|
||||
autoScrollEnabled.value = false
|
||||
})
|
||||
|
||||
const scrollTo = (position: "top" | "bottom") => {
|
||||
if (position === "top") {
|
||||
logs.value?.scroll({
|
||||
behavior: "smooth",
|
||||
top: 0,
|
||||
})
|
||||
} else if (position === "bottom") {
|
||||
logs.value?.scroll({
|
||||
behavior: "smooth",
|
||||
top: logs.value?.scrollHeight,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.log,
|
||||
useThrottleFn(() => {
|
||||
if (autoScrollEnabled.value) scrollTo("bottom")
|
||||
}, 200),
|
||||
{ flush: "post" }
|
||||
)
|
||||
|
||||
const toggleAutoscroll = () => {
|
||||
autoScrollEnabled.value = !autoScrollEnabled.value
|
||||
}
|
||||
|
||||
const toggleAutoscrollColor = computed(() =>
|
||||
autoScrollEnabled.value ? "text-green-500" : "text-red-500"
|
||||
)
|
||||
</script>
|
||||
402
packages/hoppscotch-common/src/components/realtime/LogEntry.vue
Normal file
402
packages/hoppscotch-common/src/components/realtime/LogEntry.vue
Normal file
@@ -0,0 +1,402 @@
|
||||
<template>
|
||||
<div v-if="entry" class="divide-y divide-dividerLight">
|
||||
<div :style="{ color: entryColor }" class="realtime-log">
|
||||
<div class="flex group">
|
||||
<div class="flex flex-1 divide-x divide-dividerLight">
|
||||
<div class="inline-flex items-center p-2">
|
||||
<component
|
||||
:is="icon"
|
||||
:style="{ color: iconColor }"
|
||||
@click="copyQuery(entry.payload)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="entry.ts !== undefined"
|
||||
class="items-center hidden px-1 w-34 sm:inline-flex"
|
||||
>
|
||||
<span
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="relativeTime"
|
||||
class="mx-auto truncate ts-font text-secondaryLight hover:text-secondary hover:text-center"
|
||||
>
|
||||
{{ shortDateTime(entry.ts) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="inline-grid items-center flex-1 min-w-0 p-2"
|
||||
@click="toggleExpandPayload()"
|
||||
>
|
||||
<div class="truncate">
|
||||
<span v-if="entry.prefix !== undefined" class="!inline">{{
|
||||
entry.prefix
|
||||
}}</span>
|
||||
{{ entry.payload }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copyQueryIcon"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="copyQuery(entry.payload)"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:icon="IconChevronDown"
|
||||
class="transform"
|
||||
:class="{ 'rotate-180': !minimized }"
|
||||
@click="toggleExpandPayload()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!minimized" class="overflow-hidden bg-primaryContrast">
|
||||
<SmartTabs
|
||||
v-model="selectedTab"
|
||||
styles="bg-primaryLight"
|
||||
render-inactive-tabs
|
||||
>
|
||||
<SmartTab v-if="isJSON(entry.payload)" id="json" label="JSON" />
|
||||
<SmartTab id="raw" label="Raw" />
|
||||
</SmartTabs>
|
||||
<div
|
||||
class="z-10 flex items-center justify-between pl-4 border-b border-dividerLight top-lowerSecondaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("response.body") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.download_file')"
|
||||
:icon="downloadIcon"
|
||||
@click="downloadResponse"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copyIcon"
|
||||
@click="copyResponse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="editor"></div>
|
||||
<div
|
||||
v-if="outlinePath && selectedTab === 'json'"
|
||||
class="sticky bottom-0 z-10 flex flex-shrink-0 px-2 overflow-auto overflow-x-auto border-t bg-primaryLight border-dividerLight flex-nowrap"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in outlinePath"
|
||||
:key="`item-${index}`"
|
||||
class="flex items-center"
|
||||
>
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<div v-if="item.kind === 'RootObject'" class="outline-item">{}</div>
|
||||
<div v-if="item.kind === 'RootArray'" class="outline-item">[]</div>
|
||||
<div v-if="item.kind === 'ArrayMember'" class="outline-item">
|
||||
{{ item.index }}
|
||||
</div>
|
||||
<div v-if="item.kind === 'ObjectMember'" class="outline-item">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
v-if="
|
||||
item.kind === 'ArrayMember' || item.kind === 'ObjectMember'
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="item.kind === 'ArrayMember'"
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
v-for="(arrayMember, astIndex) in item.astParent.values"
|
||||
:key="`ast-${astIndex}`"
|
||||
:label="`${astIndex}`"
|
||||
@click="
|
||||
() => {
|
||||
jumpCursor(arrayMember)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.kind === 'ObjectMember'"
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
v-for="(objectMember, astIndex) in item.astParent.members"
|
||||
:key="`ast-${astIndex}`"
|
||||
:label="objectMember.key.value"
|
||||
@click="
|
||||
() => {
|
||||
jumpCursor(objectMember)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.kind === 'RootObject'"
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
>
|
||||
<SmartItem
|
||||
label="{}"
|
||||
@click="
|
||||
() => {
|
||||
jumpCursor(item.astValue)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.kind === 'RootArray'"
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
>
|
||||
<SmartItem
|
||||
label="[]"
|
||||
@click="
|
||||
() => {
|
||||
jumpCursor(item.astValue)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
<icon-lucide-chevron-right
|
||||
v-if="index + 1 !== outlinePath.length"
|
||||
class="opacity-50 text-secondaryLight svg-icons"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>{{ t("response.waiting_for_connection") }}</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconInfo from "~icons/lucide/info"
|
||||
import IconUpRight from "~icons/lucide/arrow-up-right"
|
||||
import IconDownLeft from "~icons/lucide/arrow-down-left"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconChevronDown from "~icons/lucide/chevron-down"
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import * as LJSON from "lossless-json"
|
||||
import * as O from "fp-ts/Option"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { ref, computed, reactive, watch, markRaw } from "vue"
|
||||
import { refAutoReset, useTimeAgo } from "@vueuse/core"
|
||||
import { LogEntryData } from "./Log.vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||
import { isJSON } from "~/helpers/functional/json"
|
||||
import { useCopyResponse, useDownloadResponse } from "@composables/lens-actions"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import jsonParse, { JSONObjectMember, JSONValue } from "~/helpers/jsonParse"
|
||||
import { getJSONOutlineAtPos } from "~/helpers/newOutline"
|
||||
import {
|
||||
convertIndexToLineCh,
|
||||
convertLineChToIndex,
|
||||
} from "~/helpers/editor/utils"
|
||||
import { shortDateTime } from "~/helpers/utils/date"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{ entry: LogEntryData }>()
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const editor = ref<any | null>(null)
|
||||
const linewrapEnabled = ref(true)
|
||||
|
||||
const logPayload = computed(() => props.entry.payload)
|
||||
|
||||
const selectedTab = ref<"json" | "raw">(
|
||||
isJSON(props.entry.payload) ? "json" : "raw"
|
||||
)
|
||||
|
||||
// CodeMirror Implementation
|
||||
const jsonBodyText = computed(() =>
|
||||
pipe(
|
||||
logPayload.value,
|
||||
O.tryCatchK(LJSON.parse),
|
||||
O.map((val) => LJSON.stringify(val, undefined, 2)),
|
||||
O.getOrElse(() => logPayload.value)
|
||||
)
|
||||
)
|
||||
|
||||
const ast = computed(() =>
|
||||
pipe(
|
||||
jsonBodyText.value,
|
||||
O.tryCatchK(jsonParse),
|
||||
O.getOrElseW(() => null)
|
||||
)
|
||||
)
|
||||
|
||||
const editorText = computed(() => {
|
||||
if (selectedTab.value === "json") return jsonBodyText.value
|
||||
else return logPayload.value
|
||||
})
|
||||
|
||||
const editorMode = computed(() => {
|
||||
if (selectedTab.value === "json") return "application/ld+json"
|
||||
else return "text/plain"
|
||||
})
|
||||
|
||||
const { cursor } = useCodemirror(
|
||||
editor,
|
||||
editorText,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: editorMode,
|
||||
readOnly: true,
|
||||
lineWrapping: linewrapEnabled,
|
||||
},
|
||||
linter: null,
|
||||
completer: null,
|
||||
environmentHighlights: false,
|
||||
})
|
||||
)
|
||||
|
||||
const jumpCursor = (ast: JSONValue | JSONObjectMember) => {
|
||||
const pos = convertIndexToLineCh(jsonBodyText.value, ast.start)
|
||||
pos.line--
|
||||
cursor.value = pos
|
||||
}
|
||||
|
||||
const outlinePath = computed(() =>
|
||||
pipe(
|
||||
ast.value,
|
||||
O.fromNullable,
|
||||
O.map((ast) =>
|
||||
getJSONOutlineAtPos(
|
||||
ast,
|
||||
convertLineChToIndex(jsonBodyText.value, cursor.value)
|
||||
)
|
||||
),
|
||||
O.getOrElseW(() => null)
|
||||
)
|
||||
)
|
||||
|
||||
// Code for UI Changes
|
||||
const minimized = ref(true)
|
||||
watch(minimized, () => {
|
||||
selectedTab.value = isJSON(props.entry.payload) ? "json" : "raw"
|
||||
})
|
||||
|
||||
const toggleExpandPayload = () => {
|
||||
minimized.value = !minimized.value
|
||||
}
|
||||
|
||||
const { copyIcon, copyResponse } = useCopyResponse(logPayload)
|
||||
|
||||
const { downloadIcon, downloadResponse } = useDownloadResponse(
|
||||
"application/json",
|
||||
logPayload
|
||||
)
|
||||
|
||||
const copyQueryIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
IconCopy,
|
||||
1000
|
||||
)
|
||||
|
||||
const copyQuery = (entry: string) => {
|
||||
copyToClipboard(entry)
|
||||
copyQueryIcon.value = IconCheck
|
||||
}
|
||||
|
||||
// Relative Time
|
||||
// TS could be undefined here. We're just assigning a default value to 0 because we're not showing it in the UI
|
||||
const relativeTime = useTimeAgo(computed(() => props.entry.ts ?? 0))
|
||||
|
||||
const ENTRY_COLORS = {
|
||||
connected: "#10b981",
|
||||
connecting: "#10b981",
|
||||
error: "#ff5555",
|
||||
disconnected: "#ff5555",
|
||||
} as const
|
||||
|
||||
// Assigns color based on entry event
|
||||
const entryColor = computed(() => ENTRY_COLORS[props.entry.event])
|
||||
|
||||
const ICONS = {
|
||||
info: {
|
||||
icon: IconInfo,
|
||||
iconColor: "#10b981",
|
||||
},
|
||||
client: {
|
||||
icon: IconUpRight,
|
||||
iconColor: "#eaaa45",
|
||||
},
|
||||
server: {
|
||||
icon: IconDownLeft,
|
||||
iconColor: "#38d4ea",
|
||||
},
|
||||
disconnected: {
|
||||
icon: IconInfo,
|
||||
iconColor: "#ff5555",
|
||||
},
|
||||
} as const
|
||||
|
||||
const iconColor = computed(() => ICONS[props.entry.source].iconColor)
|
||||
const icon = computed(() => markRaw(ICONS[props.entry.source].icon))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.realtime-log {
|
||||
@apply text-secondary;
|
||||
@apply overflow-hidden;
|
||||
@apply hover:cursor-nsResize;
|
||||
|
||||
&,
|
||||
span {
|
||||
@apply select-text;
|
||||
}
|
||||
|
||||
span {
|
||||
@apply block;
|
||||
@apply break-words break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.outline-item {
|
||||
@apply cursor-pointer;
|
||||
@apply flex-grow-0 flex-shrink-0;
|
||||
@apply text-secondaryLight;
|
||||
@apply inline-flex;
|
||||
@apply items-center;
|
||||
@apply px-2;
|
||||
@apply py-1;
|
||||
@apply transition;
|
||||
@apply hover: text-secondary;
|
||||
}
|
||||
|
||||
.ts-font {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<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 rounded divide-x 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>
|
||||
<div class="relative 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>
|
||||
<span class="end-actions">
|
||||
<label
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('mqtt.color')"
|
||||
for="select-color"
|
||||
class="absolute inset-0 flex items-center justify-center group hover:cursor-pointer"
|
||||
>
|
||||
<icon-lucide-brush
|
||||
class="transition opacity-80 svg-icons group-hover:opacity-100 text-accentContrast"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
id="select-color"
|
||||
v-model="color"
|
||||
type="color"
|
||||
class="w-8 h-8 p-1 rounded bg-primary"
|
||||
/>
|
||||
</span>
|
||||
</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>
|
||||
Reference in New Issue
Block a user