main
js 249 lines 6.48 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { getNamespacedClient } from "/js/websocket.js";
3 import { store as notificationStore } from "/components/notifications/notification-store.js";
4 import { getCurrentUserISOString } from "/js/time-utils.js";
5
6 const websocket = getNamespacedClient("/ws");
7 websocket.addHandlers(["ws_dev_test"]);
8
9 const DIAGNOSTIC_EVENT = "ws_dev_console_event";
10 const SUBSCRIBE_EVENT = "ws_event_console_subscribe";
11 const UNSUBSCRIBE_EVENT = "ws_event_console_unsubscribe";
12 const MAX_ENTRIES = 200;
13 const CAPTURE_ENABLED_KEY = "a0.websocket_event_console.capture_enabled";
14
15 const model = {
16 entries: [],
17 isEnabled: false,
18 captureEnabled: false,
19 subscriptionActive: false,
20 showHandledOnly: false,
21 lastError: null,
22 _consoleCallback: null,
23 _lifecycleBound: false,
24 _entrySeq: 0,
25
26 init() {
27 this.isEnabled = Boolean(window.runtimeInfo?.isDevelopment);
28 if (!this.isEnabled) {
29 this.captureEnabled = false;
30 return;
31 }
32
33 this._bindLifecycle();
34 this.captureEnabled = this._loadCaptureEnabled();
35 },
36
37 onOpen() {
38 // `init()` is called once when the store is registered; `onOpen()` is called
39 // every time the component is displayed (modal open).
40 this.init();
41 if (!this.isEnabled) return;
42 if (this.captureEnabled) {
43 this.attach({ notify: false });
44 }
45 },
46
47 _bindLifecycle() {
48 if (this._lifecycleBound) return;
49 this._lifecycleBound = true;
50
51 websocket.onDisconnect(() => {
52 // Watcher subscriptions are per-sid and cleared server-side on disconnect.
53 this.subscriptionActive = false;
54 });
55
56 websocket.onConnect(() => {
57 if (!this.captureEnabled) return;
58 // Re-subscribe after reconnect (server watcher set is per-sid).
59 this._subscribe({ notify: false });
60 });
61 },
62
63 _loadCaptureEnabled() {
64 try {
65 const raw = window.localStorage?.getItem(CAPTURE_ENABLED_KEY);
66 return raw === "1" || raw === "true";
67 } catch (error) {
68 return false;
69 }
70 },
71
72 _persistCaptureEnabled(enabled) {
73 try {
74 window.localStorage?.setItem(CAPTURE_ENABLED_KEY, enabled ? "1" : "0");
75 } catch (error) {
76 // Ignore storage failures (private mode, etc).
77 }
78 },
79
80 async startCapture() {
81 await this.setCaptureEnabled(true, { notify: true });
82 },
83
84 async stopCapture() {
85 await this.setCaptureEnabled(false, { notify: true });
86 },
87
88 async setCaptureEnabled(enabled, { notify = true } = {}) {
89 if (!this.isEnabled) return;
90
91 const desired = Boolean(enabled);
92 if (this.captureEnabled === desired) {
93 if (desired) {
94 await this.attach({ notify: false });
95 }
96 return;
97 }
98
99 this.captureEnabled = desired;
100 this._persistCaptureEnabled(desired);
101
102 if (desired) {
103 await this.attach({ notify });
104 return;
105 }
106
107 await this.detach({ notify });
108 },
109
110 async _subscribe({ notify = true } = {}) {
111 if (!this.isEnabled) return;
112 if (this.subscriptionActive) return;
113
114 try {
115 await websocket.request(SUBSCRIBE_EVENT, {
116 requestedAt: getCurrentUserISOString(),
117 });
118 this.subscriptionActive = true;
119 this.lastError = null;
120
121 if (notify) {
122 notificationStore.frontendInfo(
123 "WebSocket diagnostics capture enabled",
124 "Event Console",
125 4,
126 );
127 }
128 } catch (error) {
129 this.handleError(error);
130 throw error;
131 }
132 },
133
134 async attach({ notify = true } = {}) {
135 if (!this.isEnabled) return;
136 if (this.subscriptionActive && this._consoleCallback) return;
137
138 try {
139 await websocket.connect();
140
141 if (!this._consoleCallback) {
142 this._consoleCallback = (envelope) => {
143 try {
144 this.addEntry(envelope);
145 } catch (error) {
146 this.handleError(error);
147 }
148 };
149
150 await websocket.on(DIAGNOSTIC_EVENT, this._consoleCallback);
151 }
152
153 await this._subscribe({ notify });
154 } catch (error) {
155 this.handleError(error);
156 throw error;
157 }
158 },
159
160 async detach({ notify = false } = {}) {
161 if (this._consoleCallback) {
162 websocket.off(DIAGNOSTIC_EVENT, this._consoleCallback);
163 this._consoleCallback = null;
164 }
165 if (this.subscriptionActive) {
166 try {
167 await websocket.request(UNSUBSCRIBE_EVENT, {});
168 } catch (error) {
169 this.handleError(error);
170 }
171 }
172 this.subscriptionActive = false;
173
174 if (notify) {
175 notificationStore.frontendInfo(
176 "WebSocket diagnostics capture disabled",
177 "Event Console",
178 3,
179 );
180 }
181 },
182
183 async reconnect() {
184 if (!this.isEnabled) return;
185 if (!this.captureEnabled) {
186 await this.startCapture();
187 return;
188 }
189
190 await this.detach({ notify: false });
191 await this.attach({ notify: true });
192 },
193
194 handleError(error) {
195 const message = error?.message || String(error || "Unknown error");
196 this.lastError = message;
197 notificationStore.frontendError(message, "WebSocket Event Console", 6);
198 },
199
200 addEntry(envelope) {
201 const payload = envelope?.data || {};
202 const entry = {
203 kind: payload.kind || "unknown",
204 sourceNamespace: payload.sourceNamespace || payload.namespace || null,
205 eventType: payload.eventType || payload.event || "unknown",
206 eventId: envelope?.eventId || null,
207 sid: payload.sid || null,
208 correlationId: payload.correlationId || envelope?.correlationId || null,
209 timestamp: payload.timestamp || envelope?.ts || getCurrentUserISOString(),
210 handlerId: payload.handlerId || envelope?.handlerId || "WsManager",
211 resultSummary: payload.resultSummary || {},
212 payloadSummary: payload.payloadSummary || {},
213 delivered: payload.delivered ?? null,
214 buffered: payload.buffered ?? null,
215 targets: Array.isArray(payload.targets) ? payload.targets : [],
216 targetCount: payload.targetCount ?? null,
217 };
218 if (!entry.eventId) {
219 this._entrySeq += 1;
220 entry.eventId = `evt_${this._entrySeq}`;
221 }
222 entry.hasHandlers =
223 (entry.resultSummary?.handlerCount ?? entry.resultSummary?.ok ?? 0) > 0;
224
225 this.entries.push(entry);
226 if (this.entries.length > MAX_ENTRIES) {
227 this.entries.shift();
228 }
229 },
230
231 filteredEntries() {
232 if (!this.showHandledOnly) {
233 return this.entries;
234 }
235 return this.entries.filter(
236 (entry) =>
237 entry.kind !== "inbound" ||
238 entry.hasHandlers ||
239 entry.resultSummary?.error > 0,
240 );
241 },
242
243 clear() {
244 this.entries = [];
245 },
246 };
247
248 const store = createStore("websocketEventConsoleStore", model);
249 export { store };