feat: implement websocket-stream for journal server

Integrate `websocket-stream` to handle MQTT broker upgrades in the
CLI and add granular connection status tracking to the UI.

- Add `websocket-stream` dependency
- Update CLI to use `websocket-stream` for socket upgrades
- Enhance `journalStream` store with `connectionStatus` and
  `connectionError` states
- Add visual connection status indicators to `App` and `JournalPanel`
This commit is contained in:
2026-07-06 15:38:28 +08:00
parent 862ba9c01b
commit 34d647d611
6 changed files with 116 additions and 12 deletions
+14 -2
View File
@@ -1,5 +1,6 @@
import { Component, createEffect, createMemo, createSignal } from "solid-js";
import { useLocation } from "@solidjs/router";
import { useJournalStream } from "./components/stores/journalStream";
// 导入组件以注册自定义元素
import "./components";
@@ -15,6 +16,7 @@ import { JournalPanel } from "./components/journal";
const App: Component = () => {
const location = useLocation();
const stream = useJournalStream();
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
const [isDocOpen, setIsDocOpen] = createSignal(false);
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
@@ -84,13 +86,23 @@ const App: Component = () => {
<div class="flex-1" />
<button
onClick={() => setIsJournalOpen((v) => !v)}
class={`w-8 h-8 rounded flex items-center justify-center text-sm ${
class={`relative w-8 h-8 rounded flex items-center justify-center text-sm ${
isJournalOpen()
? "bg-blue-100 text-blue-600"
: "text-gray-500 hover:bg-gray-100"
}`}
title="Journal"
title={`Journal (${stream.connectionStatus})`}
>
<span
class="absolute top-0.5 right-0.5 w-2 h-2 rounded-full border border-white"
classList={{
"bg-gray-400": stream.connectionStatus === "disconnected",
"bg-yellow-400 animate-pulse":
stream.connectionStatus === "connecting",
"bg-green-500": stream.connectionStatus === "connected",
"bg-red-500": stream.connectionStatus === "error",
}}
/>
📋
</button>
<button
+6 -1
View File
@@ -20,6 +20,7 @@ import {
} from "fs";
import type { Server as HttpServer } from "http";
import type { AedesPublishPacket } from "aedes";
import websocketStream from "websocket-stream";
// ---------------------------------------------------------------------------
// Types
@@ -106,7 +107,11 @@ export async function createJournalServer(
httpServer.on("upgrade", (req, socket, head) => {
wsServer.handleUpgrade(req, socket, head, (ws) => {
broker.handle(ws as unknown as import("net").Socket);
// websocket-stream's types reference an older ws version; cast works.
const stream = websocketStream(
ws as unknown as Parameters<typeof websocketStream>[0],
);
broker.handle(stream);
});
});
+15 -7
View File
@@ -38,12 +38,17 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<div class="flex items-center gap-2">
<h2 class="text-sm font-semibold text-gray-700">Journal</h2>
<Show when={stream.connected}>
<span
class="w-2 h-2 rounded-full bg-green-500"
title="Connected"
/>
</Show>
<span
class="w-2 h-2 rounded-full"
classList={{
"bg-gray-400": stream.connectionStatus === "disconnected",
"bg-yellow-400 animate-pulse":
stream.connectionStatus === "connecting",
"bg-green-500": stream.connectionStatus === "connected",
"bg-red-500": stream.connectionStatus === "error",
}}
title={stream.connectionStatus}
/>
</div>
<button
onClick={props.onClose}
@@ -104,7 +109,10 @@ const ConnectDialog: Component = () => {
await hydrateFromServer(sessionId);
await connectStream(sessionId, brokerUrl);
} catch (e) {
setError(e instanceof Error ? e.message : "Connection failed");
const msg = e instanceof Error ? e.message : "Connection failed";
setError(msg);
// If connectStream threw, the error handler also sets connectionStatus to error.
// Make sure the error message is captured.
} finally {
setConnecting(false);
}
+15
View File
@@ -33,6 +33,10 @@ export interface JournalStreamState {
revealedPaths: Set<string>;
/** MQTT connection status */
connected: boolean;
/** Granular connection state for UI indicators */
connectionStatus: "disconnected" | "connecting" | "connected" | "error";
/** Last connection error message, if any */
connectionError: string | null;
/** This client's identity */
myName: string;
/** Broker URL, set after connect */
@@ -84,6 +88,8 @@ const [state, setState] = createStore<JournalStreamState>({
senderSeq: {},
revealedPaths: new Set(),
connected: false,
connectionStatus: "disconnected",
connectionError: null,
myName: persisted.myName,
brokerUrl: persisted.brokerUrl,
});
@@ -189,6 +195,9 @@ export async function connectStream(
): Promise<void> {
const { default: mqtt } = await import("mqtt");
setState("connectionStatus", "connecting");
setState("connectionError", null);
const client = mqtt.connect(brokerUrl, {
clientId: `${state.myName}-${Date.now()}`,
protocol: brokerUrl.startsWith("wss") ? "wss" : "ws",
@@ -200,6 +209,8 @@ export async function connectStream(
client.on("connect", () => {
_mqttConnected = true;
setState("connected", true);
setState("connectionStatus", "connected");
setState("connectionError", null);
setState("brokerUrl", brokerUrl);
// Persist connection info for next time
@@ -251,10 +262,13 @@ export async function connectStream(
client.on("close", () => {
_mqttConnected = false;
setState("connected", false);
setState("connectionStatus", "disconnected");
});
client.on("error", (err) => {
console.error("[stream] mqtt error:", err);
setState("connectionStatus", "error");
setState("connectionError", err.message);
});
}
@@ -416,6 +430,7 @@ export function disconnectStream(): void {
_mqttConnected = false;
}
setState("connected", false);
setState("connectionStatus", "disconnected");
}
// ---------------------------------------------------------------------------