feat(stage-*): devtool for websocket (#932)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
gemini-code-assist[bot]
autofix-ci[bot]
parent
a7d57935b6
commit
a7d7c1631e
@@ -80,6 +80,12 @@ const menu = computed(() => [
|
||||
icon: 'i-solar:sledgehammer-bold-duotone',
|
||||
to: '/devtools/notifications',
|
||||
},
|
||||
{
|
||||
title: 'WebSocket Inspector',
|
||||
description: 'Inspect raw WebSocket traffic',
|
||||
icon: 'i-solar:transfer-horizontal-bold-duotone',
|
||||
to: '/devtools/websocket-inspector',
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
|
||||
@@ -59,6 +59,12 @@ const menu = computed(() => [
|
||||
icon: 'i-solar:chart-bold-duotone',
|
||||
to: '/devtools/beat-sync',
|
||||
},
|
||||
{
|
||||
title: 'WebSocket Inspector',
|
||||
description: 'Inspect raw WebSocket traffic',
|
||||
icon: 'i-solar:transfer-horizontal-bold-duotone',
|
||||
to: '/devtools/websocket-inspector',
|
||||
},
|
||||
])
|
||||
|
||||
const openDevTools = useElectronEventaInvoke(electronOpenMainDevtools)
|
||||
|
||||
@@ -68,6 +68,12 @@ const menu = computed(() => [
|
||||
icon: 'i-solar:chat-square-call-bold-duotone',
|
||||
to: '/devtools/context-flow',
|
||||
},
|
||||
{
|
||||
title: 'WebSocket Inspector',
|
||||
description: 'Inspect raw WebSocket traffic',
|
||||
icon: 'i-solar:transfer-horizontal-bold-duotone',
|
||||
to: '/devtools/websocket-inspector',
|
||||
},
|
||||
{
|
||||
title: t('settings.pages.system.sections.section.developer.sections.section.use-magic-keys.title'),
|
||||
description: t('settings.pages.system.sections.section.developer.sections.section.use-magic-keys.description'),
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface ClientOptions<C = undefined> {
|
||||
autoConnect?: boolean
|
||||
autoReconnect?: boolean
|
||||
maxReconnectAttempts?: number
|
||||
onAnyMessage?: (data: WebSocketEvent<C>) => void
|
||||
onAnySend?: (data: WebSocketEvent<C>) => void
|
||||
}
|
||||
|
||||
function createInstanceId() {
|
||||
@@ -60,6 +62,8 @@ export class Client<C = undefined> {
|
||||
|
||||
this.opts = {
|
||||
url: 'ws://localhost:6121/ws',
|
||||
onAnyMessage: () => {},
|
||||
onAnySend: () => {},
|
||||
possibleEvents: [],
|
||||
onError: () => {},
|
||||
onClose: () => {},
|
||||
@@ -246,6 +250,7 @@ export class Client<C = undefined> {
|
||||
private async handleMessage(event: MessageEvent) {
|
||||
try {
|
||||
const data = JSON.parse(event.data as string) as WebSocketEvent<C>
|
||||
this.opts.onAnyMessage?.(data)
|
||||
const listeners = this.eventListeners.get(data.type)
|
||||
if (!listeners?.size) {
|
||||
return
|
||||
@@ -299,11 +304,15 @@ export class Client<C = undefined> {
|
||||
|
||||
send(data: WebSocketEventOptionalSource<C>): void {
|
||||
if (this.websocket && this.connected) {
|
||||
this.websocket.send(JSON.stringify({
|
||||
const payload = {
|
||||
source: this.opts.name as WebSocketEventSource | string,
|
||||
metadata: { source: this.identity },
|
||||
...data,
|
||||
} as WebSocketEvent<C>))
|
||||
} as WebSocketEvent<C>
|
||||
|
||||
this.opts.onAnySend?.(payload)
|
||||
|
||||
this.websocket.send(JSON.stringify(payload))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,10 @@ const routeHeaderMetadataMap = computed(() => {
|
||||
subtitle: t('tamagotchi.settings.devtools.title'),
|
||||
title: t('tamagotchi.settings.devtools.pages.context-flow.title'),
|
||||
},
|
||||
'/devtools/websocket-inspector': {
|
||||
subtitle: t('tamagotchi.settings.devtools.title'),
|
||||
title: 'WebSocket Inspector',
|
||||
},
|
||||
'/devtools/performance-visualizer': {
|
||||
subtitle: t('settings.title'),
|
||||
title: t('settings.pages.system.sections.section.developer.sections.section.performance-visualizer.title'),
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { useWebSocketInspectorStore } from '@proj-airi/stage-ui/stores/devtools/websocket-inspector'
|
||||
import { Button, FieldCheckbox, Input } from '@proj-airi/ui'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
const store = useWebSocketInspectorStore()
|
||||
|
||||
const filter = ref('')
|
||||
const showIncoming = ref(true)
|
||||
const showOutgoing = ref(true)
|
||||
const showHeartbeats = ref(true)
|
||||
const streamContainer = ref<HTMLDivElement>()
|
||||
const showingDetails = ref<string>('')
|
||||
const filteredHistory = computed(() => {
|
||||
return store.history.filter((item) => {
|
||||
if (!showIncoming.value && item.direction === 'incoming')
|
||||
return false
|
||||
if (!showOutgoing.value && item.direction === 'outgoing')
|
||||
return false
|
||||
if (!showHeartbeats.value && item.event.type === 'transport:connection:heartbeat')
|
||||
return false
|
||||
if (filter.value && !JSON.stringify(item.event).toLowerCase().includes(filter.value.toLowerCase()))
|
||||
return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
function formatTime(ts: number) {
|
||||
return new Date(ts).toLocaleTimeString()
|
||||
}
|
||||
|
||||
async function scrollToTop() {
|
||||
await nextTick()
|
||||
if (streamContainer.value)
|
||||
streamContainer.value.scrollTop = 0
|
||||
}
|
||||
|
||||
watch(() => filteredHistory.value.length, scrollToTop)
|
||||
|
||||
const directionBadgeClassMap: Record<'incoming' | 'outgoing', string[]> = {
|
||||
incoming: [
|
||||
'bg-pink-100 dark:bg-pink-900',
|
||||
'text-pink-600',
|
||||
'dark:text-pink-300',
|
||||
'border-pink-300 dark:border-pink-900',
|
||||
],
|
||||
outgoing: [
|
||||
'bg-blue-100 dark:bg-blue-900',
|
||||
'text-blue-600',
|
||||
'dark:text-blue-300',
|
||||
'border-blue-300 dark:border-blue-900',
|
||||
],
|
||||
}
|
||||
|
||||
const directionIconClassMap: Record<'incoming' | 'outgoing', string> = {
|
||||
incoming: 'i-solar:arrow-down-linear',
|
||||
outgoing: 'i-solar:arrow-up-linear',
|
||||
}
|
||||
|
||||
function directionBadgeClasses(direction: 'incoming' | 'outgoing') {
|
||||
return directionBadgeClassMap[direction]
|
||||
}
|
||||
|
||||
function directionIconClass(direction: 'incoming' | 'outgoing') {
|
||||
return directionIconClassMap[direction]
|
||||
}
|
||||
|
||||
const cardClassMap: Record<'incoming' | 'outgoing', string[]> = {
|
||||
incoming: [
|
||||
'bg-pink-50',
|
||||
'dark:bg-pink-950',
|
||||
'border-pink-300',
|
||||
'text-pink-700',
|
||||
'dark:border-pink-800',
|
||||
'dark:text-pink-100',
|
||||
],
|
||||
outgoing: [
|
||||
'bg-blue-50',
|
||||
'dark:bg-blue-950',
|
||||
'border-blue-300',
|
||||
'text-blue-700',
|
||||
'dark:border-blue-800',
|
||||
'dark:text-blue-100',
|
||||
],
|
||||
}
|
||||
|
||||
function cardClasses(direction: 'incoming' | 'outgoing') {
|
||||
return cardClassMap[direction]
|
||||
}
|
||||
|
||||
const payloadClassMap: Record<'incoming' | 'outgoing', string[]> = {
|
||||
incoming: [
|
||||
'bg-pink-100',
|
||||
'border-pink-300 border-1 border-solid',
|
||||
'dark:bg-pink-900',
|
||||
'dark:border-pink-700',
|
||||
],
|
||||
outgoing: [
|
||||
'bg-blue-100',
|
||||
'border-blue-300 border-1 border-solid',
|
||||
'dark:bg-blue-900',
|
||||
'dark:border-blue-700',
|
||||
],
|
||||
}
|
||||
|
||||
function payloadClasses(direction: 'incoming' | 'outgoing') {
|
||||
return payloadClassMap[direction]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col gap-4 overflow-hidden p-4">
|
||||
<!-- Header / Filters -->
|
||||
<div class="flex flex-col gap-4 rounded-xl bg-neutral-50 p-4 dark:bg-[rgba(0,0,0,0.3)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<FieldCheckbox v-model="showIncoming" label="Incoming" />
|
||||
<FieldCheckbox v-model="showOutgoing" label="Outgoing" />
|
||||
<FieldCheckbox v-model="showHeartbeats" label="Heartbeats" />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
v-model="filter"
|
||||
placeholder="Filter payload..."
|
||||
class="w-64"
|
||||
/>
|
||||
<Button
|
||||
label="Clear"
|
||||
icon="i-solar:trash-bin-trash-bold-duotone"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="store.clear()"
|
||||
/>
|
||||
<div class="flex flex-shrink-0 items-center text-xs">
|
||||
{{ filteredHistory.length }} / {{ store.history.length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stream -->
|
||||
<div
|
||||
ref="streamContainer"
|
||||
class="flex-1 overflow-y-auto rounded-xl bg-white/70 dark:bg-neutral-950/50"
|
||||
>
|
||||
<div
|
||||
v-if="filteredHistory.length === 0"
|
||||
class="h-full w-full flex justify-center p-3 text-sm"
|
||||
>
|
||||
No messages found.
|
||||
</div>
|
||||
<div v-else class="grid gap-3">
|
||||
<div
|
||||
v-for="item in filteredHistory"
|
||||
:key="item.id"
|
||||
class="border rounded-xl p-4 transition-colors"
|
||||
:class="cardClasses(item.direction)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span :class="['rounded-full', 'border', 'px-2', 'py-0.5', 'text-xs', 'flex', 'items-center', 'justify-center', ...directionBadgeClasses(item.direction)]">
|
||||
<span :class="['size-3.5', directionIconClass(item.direction)]" :aria-label="item.direction" />
|
||||
<span class="ml-1 font-bold tracking-wider uppercase">{{ item.direction }}</span>
|
||||
</span>
|
||||
<span class="font-semibold">
|
||||
{{ item.event.type }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm font-mono">
|
||||
{{ formatTime(item.timestamp) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<details class="group mt-2" :open="showingDetails === item.id">
|
||||
<summary class="cursor-pointer select-none text-sm font-medium" @click="showingDetails = showingDetails === item.id ? '' : item.id">
|
||||
Payload
|
||||
</summary>
|
||||
<pre
|
||||
class="mt-2 w-full overflow-auto whitespace-pre-wrap rounded-lg p-3 text-sm"
|
||||
:class="payloadClasses(item.direction)"
|
||||
>{{ JSON.stringify(item.event, null, 2) }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: settings
|
||||
</route>
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { WebSocketEvent } from '@proj-airi/server-sdk'
|
||||
|
||||
import { nanoid } from 'nanoid'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface WebSocketHistoryItem {
|
||||
id: string
|
||||
timestamp: number
|
||||
direction: 'incoming' | 'outgoing'
|
||||
event: WebSocketEvent
|
||||
}
|
||||
|
||||
export const useWebSocketInspectorStore = defineStore('devtools:websocket-inspector', () => {
|
||||
const history = ref<WebSocketHistoryItem[]>([])
|
||||
const isEnabled = ref(true)
|
||||
const maxHistory = ref(1000)
|
||||
|
||||
function add(direction: 'incoming' | 'outgoing', event: WebSocketEvent) {
|
||||
if (!isEnabled.value)
|
||||
return
|
||||
|
||||
history.value.unshift({
|
||||
id: nanoid(),
|
||||
timestamp: Date.now(),
|
||||
direction,
|
||||
event,
|
||||
})
|
||||
|
||||
if (history.value.length > maxHistory.value) {
|
||||
history.value.pop()
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
history.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
history,
|
||||
isEnabled,
|
||||
maxHistory,
|
||||
add,
|
||||
clear,
|
||||
}
|
||||
})
|
||||
@@ -6,6 +6,8 @@ import { nanoid } from 'nanoid'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useWebSocketInspectorStore } from '../../devtools/websocket-inspector'
|
||||
|
||||
export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:server', () => {
|
||||
const connected = ref(false)
|
||||
const client = ref<Client>()
|
||||
@@ -48,6 +50,12 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
|
||||
url: import.meta.env.VITE_AIRI_WS_URL || 'ws://localhost:6121/ws',
|
||||
token: options?.token,
|
||||
possibleEvents,
|
||||
onAnyMessage: (event) => {
|
||||
useWebSocketInspectorStore().add('incoming', event)
|
||||
},
|
||||
onAnySend: (event) => {
|
||||
useWebSocketInspectorStore().add('outgoing', event)
|
||||
},
|
||||
onError: (error) => {
|
||||
connected.value = false
|
||||
initializing.value = null
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isStageTamagotchi, isStageWeb } from '@proj-airi/stage-shared'
|
||||
import { useBroadcastChannel } from '@vueuse/core'
|
||||
import { Mutex } from 'es-toolkit'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch, toRaw } from 'vue'
|
||||
import { ref, toRaw, watch } from 'vue'
|
||||
|
||||
import { CHAT_STREAM_CHANNEL_NAME, CONTEXT_CHANNEL_NAME, useChatStore } from '../../chat'
|
||||
import { useModsServerChannelStore } from './channel-server'
|
||||
|
||||
Generated
+3
-9
@@ -2919,7 +2919,7 @@ importers:
|
||||
version: 66.5.11
|
||||
'@wxt-dev/module-vue':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 1.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
vue-tsc:
|
||||
specifier: ^3.1.8
|
||||
version: 3.2.1(typescript@5.9.3)
|
||||
@@ -22215,12 +22215,6 @@ snapshots:
|
||||
vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vue: 3.5.25(typescript@5.9.3)
|
||||
|
||||
'@vitejs/plugin-vue@6.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.53
|
||||
vite: 8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vue: 3.5.25(typescript@5.9.3)
|
||||
|
||||
'@vitest/browser-playwright@4.0.16(bufferutil@4.1.0)(playwright@1.57.0)(utf-8-validate@5.0.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)':
|
||||
dependencies:
|
||||
'@vitest/browser': 4.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)
|
||||
@@ -22967,9 +22961,9 @@ snapshots:
|
||||
'@types/filesystem': 0.0.36
|
||||
'@types/har-format': 1.2.16
|
||||
|
||||
'@wxt-dev/module-vue@1.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@wxt-dev/module-vue@1.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@vitejs/plugin-vue': 6.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
|
||||
'@vitejs/plugin-vue': 6.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
|
||||
wxt: 0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- vite
|
||||
|
||||
Reference in New Issue
Block a user