websocket.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. <template>
  2. <AppPaneLayout layout-id="websocket">
  3. <template #primary>
  4. <div
  5. class="sticky top-0 z-10 flex flex-shrink-0 p-4 overflow-x-auto space-x-2 bg-primary hide-scrollbar"
  6. >
  7. <div class="inline-flex flex-1 space-x-2">
  8. <input
  9. id="websocket-url"
  10. v-model="url"
  11. class="w-full px-4 py-2 border rounded bg-primaryLight border-divider text-secondaryDark"
  12. type="url"
  13. autocomplete="off"
  14. spellcheck="false"
  15. :class="{ error: !isUrlValid }"
  16. :placeholder="`${t('websocket.url')}`"
  17. :disabled="
  18. connectionState === 'CONNECTED' ||
  19. connectionState === 'CONNECTING'
  20. "
  21. @keyup.enter="isUrlValid ? toggleConnection() : null"
  22. />
  23. <ButtonPrimary
  24. id="connect"
  25. :disabled="!isUrlValid"
  26. class="w-32"
  27. name="connect"
  28. :label="
  29. connectionState === 'DISCONNECTED'
  30. ? t('action.connect')
  31. : t('action.disconnect')
  32. "
  33. :loading="connectionState === 'CONNECTING'"
  34. @click.native="toggleConnection"
  35. />
  36. </div>
  37. </div>
  38. <SmartTabs
  39. v-model="selectedTab"
  40. styles="sticky bg-primary top-upperPrimaryStickyFold z-10"
  41. render-inactive-tabs
  42. >
  43. <SmartTab
  44. :id="'communication'"
  45. :label="`${$t('websocket.communication')}`"
  46. >
  47. <RealtimeCommunication
  48. :is-connected="connectionState === 'CONNECTED'"
  49. @send-message="sendMessage($event)"
  50. ></RealtimeCommunication>
  51. </SmartTab>
  52. <SmartTab :id="'protocols'" :label="`${$t('websocket.protocols')}`">
  53. <div
  54. class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight top-upperSecondaryStickyFold"
  55. >
  56. <label class="font-semibold text-secondaryLight">
  57. {{ t("websocket.protocols") }}
  58. </label>
  59. <div class="flex">
  60. <ButtonSecondary
  61. v-tippy="{ theme: 'tooltip' }"
  62. :title="t('action.clear_all')"
  63. svg="trash-2"
  64. @click.native="clearContent"
  65. />
  66. <ButtonSecondary
  67. v-tippy="{ theme: 'tooltip' }"
  68. :title="t('add.new')"
  69. svg="plus"
  70. @click.native="addProtocol"
  71. />
  72. </div>
  73. </div>
  74. <draggable
  75. v-model="protocols"
  76. animation="250"
  77. handle=".draggable-handle"
  78. draggable=".draggable-content"
  79. ghost-class="cursor-move"
  80. chosen-class="bg-primaryLight"
  81. drag-class="cursor-grabbing"
  82. >
  83. <div
  84. v-for="(protocol, index) of protocols"
  85. :key="`protocol-${index}`"
  86. class="flex border-b divide-x divide-dividerLight border-dividerLight draggable-content group"
  87. >
  88. <span>
  89. <ButtonSecondary
  90. svg="grip-vertical"
  91. class="cursor-auto text-primary hover:text-primary"
  92. :class="{
  93. 'draggable-handle group-hover:text-secondaryLight !cursor-grab':
  94. index !== protocols?.length - 1,
  95. }"
  96. tabindex="-1"
  97. />
  98. </span>
  99. <input
  100. v-model="protocol.value"
  101. class="flex flex-1 px-4 py-2 bg-transparent"
  102. :placeholder="`${t('count.protocol', { count: index + 1 })}`"
  103. name="message"
  104. type="text"
  105. autocomplete="off"
  106. @change="
  107. updateProtocol(index, {
  108. value: $event.target.value,
  109. active: protocol.active,
  110. })
  111. "
  112. />
  113. <span>
  114. <ButtonSecondary
  115. v-tippy="{ theme: 'tooltip' }"
  116. :title="
  117. protocol.hasOwnProperty('active')
  118. ? protocol.active
  119. ? t('action.turn_off')
  120. : t('action.turn_on')
  121. : t('action.turn_off')
  122. "
  123. :svg="
  124. protocol.hasOwnProperty('active')
  125. ? protocol.active
  126. ? 'check-circle'
  127. : 'circle'
  128. : 'check-circle'
  129. "
  130. color="green"
  131. @click.native="
  132. updateProtocol(index, {
  133. value: protocol.value,
  134. active: !protocol.active,
  135. })
  136. "
  137. />
  138. </span>
  139. <span>
  140. <ButtonSecondary
  141. v-tippy="{ theme: 'tooltip' }"
  142. :title="t('action.remove')"
  143. svg="trash"
  144. color="red"
  145. @click.native="deleteProtocol(index)"
  146. />
  147. </span>
  148. </div>
  149. </draggable>
  150. <div
  151. v-if="protocols.length === 0"
  152. class="flex flex-col items-center justify-center p-4 text-secondaryLight"
  153. >
  154. <img
  155. :src="`/images/states/${$colorMode.value}/add_category.svg`"
  156. loading="lazy"
  157. class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
  158. :alt="`${t('empty.protocols')}`"
  159. />
  160. <span class="mb-4 text-center">
  161. {{ t("empty.protocols") }}
  162. </span>
  163. </div>
  164. </SmartTab>
  165. </SmartTabs>
  166. </template>
  167. <template #secondary>
  168. <RealtimeLog
  169. :title="$t('websocket.log')"
  170. :log="log"
  171. @delete="clearLogEntries()"
  172. />
  173. </template>
  174. </AppPaneLayout>
  175. </template>
  176. <script setup lang="ts">
  177. import { ref, watch, onUnmounted, onMounted } from "@nuxtjs/composition-api"
  178. import debounce from "lodash/debounce"
  179. import draggable from "vuedraggable"
  180. import {
  181. setWSEndpoint,
  182. WSEndpoint$,
  183. WSProtocols$,
  184. setWSProtocols,
  185. addWSProtocol,
  186. deleteWSProtocol,
  187. updateWSProtocol,
  188. deleteAllWSProtocols,
  189. addWSLogLine,
  190. WSLog$,
  191. setWSLog,
  192. HoppWSProtocol,
  193. setWSSocket,
  194. WSSocket$,
  195. } from "~/newstore/WebSocketSession"
  196. import {
  197. useI18n,
  198. useStream,
  199. useToast,
  200. useNuxt,
  201. useStreamSubscriber,
  202. useReadonlyStream,
  203. } from "~/helpers/utils/composables"
  204. import { WSConnection, WSErrorMessage } from "~/helpers/realtime/WSConnection"
  205. const nuxt = useNuxt()
  206. const t = useI18n()
  207. const toast = useToast()
  208. const { subscribeToStream } = useStreamSubscriber()
  209. const selectedTab = ref<"communication" | "protocols">("communication")
  210. const url = useStream(WSEndpoint$, "", setWSEndpoint)
  211. const protocols = useStream(WSProtocols$, [], setWSProtocols)
  212. const socket = useStream(WSSocket$, new WSConnection(), setWSSocket)
  213. const connectionState = useReadonlyStream(
  214. socket.value.connectionState$,
  215. "DISCONNECTED"
  216. )
  217. const log = useStream(WSLog$, [], setWSLog)
  218. // DATA
  219. const isUrlValid = ref(true)
  220. const activeProtocols = ref<string[]>([])
  221. let worker: Worker
  222. watch(url, (newUrl) => {
  223. if (newUrl) debouncer()
  224. })
  225. watch(
  226. protocols,
  227. (newProtocols) => {
  228. activeProtocols.value = newProtocols
  229. .filter((item) =>
  230. Object.prototype.hasOwnProperty.call(item, "active")
  231. ? item.active === true
  232. : true
  233. )
  234. .map(({ value }) => value)
  235. },
  236. { deep: true }
  237. )
  238. const workerResponseHandler = ({
  239. data,
  240. }: {
  241. data: { url: string; result: boolean }
  242. }) => {
  243. if (data.url === url.value) isUrlValid.value = data.result
  244. }
  245. const getErrorPayload = (error: WSErrorMessage): string => {
  246. if (error instanceof SyntaxError) {
  247. return error.message
  248. }
  249. return t("error.something_went_wrong").toString()
  250. }
  251. onMounted(() => {
  252. worker = nuxt.value.$worker.createRejexWorker()
  253. worker.addEventListener("message", workerResponseHandler)
  254. subscribeToStream(socket.value.event$, (event) => {
  255. switch (event?.type) {
  256. case "CONNECTING":
  257. log.value = [
  258. {
  259. payload: `${t("state.connecting_to", { name: url.value })}`,
  260. source: "info",
  261. color: "var(--accent-color)",
  262. ts: undefined,
  263. },
  264. ]
  265. break
  266. case "CONNECTED":
  267. log.value = [
  268. {
  269. payload: `${t("state.connected_to", { name: url.value })}`,
  270. source: "info",
  271. color: "var(--accent-color)",
  272. ts: Date.now(),
  273. },
  274. ]
  275. toast.success(`${t("state.connected")}`)
  276. break
  277. case "MESSAGE_SENT":
  278. addWSLogLine({
  279. payload: event.message,
  280. source: "client",
  281. ts: Date.now(),
  282. })
  283. break
  284. case "MESSAGE_RECEIVED":
  285. addWSLogLine({
  286. payload: event.message,
  287. source: "server",
  288. ts: event.time,
  289. })
  290. break
  291. case "ERROR":
  292. addWSLogLine({
  293. payload: getErrorPayload(event.error),
  294. source: "info",
  295. color: "#ff5555",
  296. ts: event.time,
  297. })
  298. break
  299. case "DISCONNECTED":
  300. addWSLogLine({
  301. payload: t("state.disconnected_from", { name: url.value }).toString(),
  302. source: "info",
  303. color: "#ff5555",
  304. ts: event.time,
  305. })
  306. toast.error(`${t("state.disconnected")}`)
  307. break
  308. }
  309. })
  310. })
  311. onUnmounted(() => {
  312. if (worker) worker.terminate()
  313. })
  314. const clearContent = () => {
  315. deleteAllWSProtocols()
  316. }
  317. const debouncer = debounce(function () {
  318. worker.postMessage({ type: "ws", url: url.value })
  319. }, 1000)
  320. const toggleConnection = () => {
  321. // If it is connecting:
  322. if (connectionState.value === "DISCONNECTED") {
  323. return socket.value.connect(url.value, activeProtocols.value)
  324. }
  325. // Otherwise, it's disconnecting.
  326. socket.value.disconnect()
  327. }
  328. const sendMessage = (event: { message: string; eventName: string }) => {
  329. socket.value.sendMessage(event)
  330. }
  331. const addProtocol = () => {
  332. addWSProtocol({ value: "", active: true })
  333. }
  334. const deleteProtocol = (index: number) => {
  335. const oldProtocols = protocols.value.slice()
  336. deleteWSProtocol(index)
  337. toast.success(`${t("state.deleted")}`, {
  338. duration: 4000,
  339. action: {
  340. text: `${t("action.undo")}`,
  341. onClick: (_, toastObject) => {
  342. protocols.value = oldProtocols
  343. toastObject.goAway()
  344. },
  345. },
  346. })
  347. }
  348. const updateProtocol = (index: number, updated: HoppWSProtocol) => {
  349. updateWSProtocol(index, updated)
  350. }
  351. const clearLogEntries = () => {
  352. log.value = []
  353. }
  354. </script>