feat(diagnostics): Socket 外部事件訂閱
.env.exampleDiagnosticsRoutes.cjsDocs/ChangeHistory.mdDocs/DIAGNOSTICS.mdDocs/RUNTIME.mdDocs/TEST_FILES_REFERENCE.mdScriptLib/diagnostics/socket-client.cjsServer.jsTest/diagnostics.test.cjsTest/diagnostics_ui.cjsassets/app.css.build.jsonassets/diagnostics.jsdiagnostics.htmltodo.md
commit d8d059210ce472741f755004bce33ae46198fdfaAuthor: Nuclear0709 <agp05432@gmail.com>Date: Tue Nov 10 23:37:20 2026 +0800 feat(diagnostics): Socket 外部事件訂閱 - 訂閱 ReplyKit SocketServer 的 external log batch/diagnostic - 連線目標由 DIAGNOSTICS_SOCKET_HOST/PORT 設定,/diagnostics 只保留開關 - 切換只送 subscribeExternalLogs;已建立連線不重連,取消保留連線 - 修正狀態輪詢重設訂閱 checkbox 的 dirty 狀態 - 補 socket client/API/UI 測試與文件 diff --git a/.env.example b/.env.example--- a/.env.example+++ b/.env.example@@ -120,6 +120,10 @@ SOCKET_API=http://192.168.0.101:9322 # ReplyKit/外部工具的 HTTP log bridge 可 POST 到 http://<主服務>:3332/post。 # 選填 token;設定後必須用 ?token=... 或 x-diagnostics-token/Bearer。 # DIAGNOSTICS_TOKEN=+# 選填:啟動時自動訂閱 ReplyKit SocketServer 的外部事件(也可在 /diagnostics 頁開關)+# DIAGNOSTICS_SOCKET_ENABLED=0+# DIAGNOSTICS_SOCKET_HOST=192.168.0.242+# DIAGNOSTICS_SOCKET_PORT=9322 # ─── 圖片快取 ──────────────────────────────────────── # TikTok/Twitch 頭像、禮物圖與贊助 overlay 圖片的本地快取 URL 前綴。diff --git a/DiagnosticsRoutes.cjs b/DiagnosticsRoutes.cjs--- a/DiagnosticsRoutes.cjs+++ b/DiagnosticsRoutes.cjs@@ -12,9 +12,9 @@ function json(res, status, value) { * 診斷入口:HTTP LogServer 相容 /post、帶外 diagnostic、以及唯讀診斷頁。 * 資料只存在記憶體,不寫 log 檔或聊天歷史。 * @param {import('./ScriptLib/diagnostics/store.cjs').DiagnosticsStore} store- * @param {{token?: string}} [options]+ * @param {{token?: string, socketClient?: Object, socketDefaults?: {host?: string, port?: number}}} [options] */-function createDiagnosticsRoutes(store, { token = '' } = {}) {+function createDiagnosticsRoutes(store, { token = '', socketClient = null, socketDefaults = {} } = {}) { function authorizedIntake(req) { if (!token) return true; const header = req.headers['x-diagnostics-token'];@@ -103,7 +103,61 @@ function createDiagnosticsRoutes(store, { token = '' } = {}) { return true; } - return { servePage, serveApi, serveIntake };+ function serveSocket(req, res, authorized = false) {+ const url = new URL(req.url, 'http://localhost');+ if (url.pathname !== '/api/diagnostics/socket') return false;+ if (!authorized) {+ json(res, 401, { ok: false, code: 'AUTH_REQUIRED', error: '請先登入' });+ return true;+ }+ if (!socketClient) {+ json(res, 501, { ok: false, error: 'Socket 訂閱未啟用' });+ return true;+ }+ if (req.method === 'GET') {+ json(res, 200, {+ ok: true,+ ...socketClient.snapshot(),+ defaults: { host: socketDefaults.host || '', port: socketDefaults.port || 0 }+ });+ return true;+ }+ if (req.method !== 'POST') {+ json(res, 405, { ok: false, error: '只支援 GET 或 POST' });+ return true;+ }+ let body = '';+ let tooLarge = false;+ req.on('data', chunk => {+ body += chunk;+ if (Buffer.byteLength(body) > 64 * 1024) tooLarge = true;+ });+ req.on('end', () => {+ if (tooLarge) {+ json(res, 413, { ok: false, error: 'body too large' });+ return;+ }+ try {+ const data = JSON.parse(body);+ if (data.enabled === true) {+ const host = String(socketDefaults.host || '').trim();+ const port = Number(socketDefaults.port || 0);+ if (!socketClient.start(host, port)) {+ json(res, 400, { ok: false, error: '需要有效的 host 與 port' });+ return;+ }+ } else {+ socketClient.unsubscribe();+ }+ json(res, 200, { ok: true, ...socketClient.snapshot() });+ } catch (error) {+ json(res, 400, { ok: false, error: 'JSON 解析失敗:' + error.message });+ }+ });+ return true;+ }++ return { servePage, serveApi, serveIntake, serveSocket }; } module.exports = { createDiagnosticsRoutes };diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.md--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -1,5 +1,50 @@ # ChangeHistory +## 2026.11.10 22:58 YouTube 未啟用時不再輸出斷線收尾++**類型**: 修正 · **檔案**: `ScriptLib/youtube/chat.mjs`、`TikTok.js`、`Test/youtube_stop.test.mjs`++### 問題++`handleExit` 無條件呼叫 `stopYoutubeChat()`,而後者每次都會輸出「❌ Youtube 聊天室已斷線」,即使這次執行根本沒有啟用 YouTube。`handleExit` 也可能被多個退出來源重複呼叫,造成同一段收尾與 YouTube 訊息出現兩次。++### 修改++- YouTube 模組新增 `youtubeActive`,只在原生聊天室、觀眾輪詢或瀏覽器 fallback 真正啟動後設為 true。+- `stopYoutubeChat` 先記錄是否曾啟用;未啟用時不輸出斷線訊息。+- 有啟用時改成中性「⏹️ Youtube 聊天室已停止」。+- `handleExit` 加 idempotent guard,重複退出訊號只收尾一次。++### 驗證++- `node --test Test/youtube_stop.test.mjs`+- `npm run lint`++---++## 2026.11.10 22:48 診斷 Socket 外部事件訂閱++**類型**: 功能 · **檔案**: `ScriptLib/diagnostics/socket-client.cjs`、`DiagnosticsRoutes.cjs`、`Server.js`、`diagnostics.html`、`assets/diagnostics.js`、`Test/diagnostics.test.cjs`、`Test/diagnostics_ui.cjs`、`.env.example`、`Docs/DIAGNOSTICS.md`++### 修改++- 新增 ReplyKit SocketServer 客戶端:送出 `subscribeExternalLogs`,接收 `externalLogSubscription`/`externalLogBatch`/`diagnostic`。+- 斷線自動重連並重新訂閱;取消訂閱會送 `enabled:false` 並關閉連線。+- TTWChat 只送訂閱訊息,不送 extension 專用 type,維持外部工具角色。+- 連線目標只由 `DIAGNOSTICS_SOCKET_HOST`/`PORT` 設定;`/diagnostics` 只保留訂閱開關與狀態。+- 訂閱切換只送 `subscribeExternalLogs`,同一條已建立連線不重連;取消只送 `enabled:false` 並保留連線。+- Socket 訂閱欄位有 dirty 狀態:使用者勾選後,2 秒狀態輪詢不會把 checkbox 重設回伺服器舊值。+- Log I/O 卡片只顯示 `subsystem:"logio"`;socket 外部日誌進下方應用日誌列表。+- 補 socket client、API 與 UI 測試。++### 驗證++- `node --test Test/diagnostics.test.cjs`+- `node Test/diagnostics_ui.cjs`+- `npm run lint`、`npm run check:css`++---+ ## 2026.11.10 20:42 應用診斷入口與 HTTP log bridge **類型**: 功能 · **檔案**: `diagnostics.html`、`assets/diagnostics.js`、`DiagnosticsRoutes.cjs`、`ScriptLib/diagnostics/store.cjs`、`Server.js`、`WebAssets.cjs`、`monitor.html`、`runtime.html`、`log.html`、`lang/*.json`、`.env.example`、`Test/diagnostics.test.cjs`、`Test/diagnostics_ui.cjs`、`Docs/RUNTIME.md`、`Docs/TEST_FILES_REFERENCE.md`diff --git a/Docs/DIAGNOSTICS.md b/Docs/DIAGNOSTICS.md--- /dev/null+++ b/Docs/DIAGNOSTICS.md@@ -0,0 +1,72 @@+# 應用診斷(/diagnostics)++## 目的++集中查看外部應用(目前是 ReplyKit)的診斷與日誌,用於排查錄影、推流、擴展或 log I/O 問題。資料只是暫存,不是正式 log 檔。++## 兩種來源++### HTTP log bridge++與 ReplyKit `LogServer.js` 相容,POST 到:++```text+http://<TTWChat主機>:3332/post+```++接受:++```json+{ "logs": [{ "time": "...", "title": "ReplyKit", "body": "..." }] }+```++單一 log 物件或陣列也接受。若設定 `DIAGNOSTICS_TOKEN`:++```text+http://<TTWChat主機>:3332/post?token=<token>+```++或使用 `x-diagnostics-token`/`Authorization: Bearer <token>`。++### Socket 外部事件訂閱++ReplyKit 的 SocketServer 預設 port `9322`。連線目標只在環境變數設定一次:++```text+DIAGNOSTICS_SOCKET_HOST=192.168.0.242+DIAGNOSTICS_SOCKET_PORT=9322+# 可選:主服務啟動時自動訂閱+DIAGNOSTICS_SOCKET_ENABLED=1+```++`/diagnostics` 只保留「訂閱外部事件」開關,不再要求填 host/port。TTWChat 會:++1. 第一次啟用時建立 TCP 連線到設定的 host/port。+2. 在既有連線上送 `{"type":"subscribeExternalLogs","enabled":true}`。+3. 接收 `externalLogSubscription`、`externalLogBatch`、`diagnostic`。+4. 取消時只送 `enabled:false`,保留連線;再次啟用直接在同一條連線重新訂閱。+5. 已啟用時若連線斷開,自動重連並重新訂閱。++TTWChat 只送訂閱訊息,不送 `log`/`logbatch`/`videoHealth`/`audioHealth`/`reconnectStatus` 等 extension 專用訊息,因此維持外部工具角色。++## Log I/O 是什麼++頁面上的 **Log I/O** 卡片專指 `type:"diagnostic"`、`subsystem:"logio"` 的摘要,也就是應用程式**寫入 log 檔失敗**的次數與最後錯誤,不是 socket 外部日誌。++Socket 外部日誌會出現在下方「應用日誌」,與 HTTP log bridge 合併顯示。++## 邊界++- 只存在記憶體:logs 最多 2000、diagnostic 最多 100。+- 不寫 log 檔、SQLite、聊天、traffic、AutoClip 或通知。+- 未知 socket `type` 忽略,不中斷連線。+- `/diagnostics` 需要主服務登入;`/post` 只有選填 token,請勿直接暴露到不可信網路。+- Socket 角色識別不是身份驗證。++## 驗證++```bash+node --test Test/diagnostics.test.cjs+node Test/diagnostics_ui.cjs+npm run lint+```diff --git a/Docs/RUNTIME.md b/Docs/RUNTIME.md--- a/Docs/RUNTIME.md+++ b/Docs/RUNTIME.md@@ -33,7 +33,7 @@ Socket 使用既有 SOCKET_API 設定(可再用 SOCKET_APIS 加開多個端點 工具列會顯示子程序維護的本場加入/點讚/聊天累計。數字隨 socket payload 的 `session` metadata 更新;點讚以原始 `data.count` 累加,因此與每位使用者的 60 秒顯示計數不同。點讚的 Bark/ntfy 通知會像加入事件一樣合併 3 秒,monitor 仍保留逐筆點讚列。 -應用診斷(ReplyKit/外部 HTTP log bridge 與帶外 logio)顯示在 `/diagnostics`;只存在記憶體,不寫 log/SQLite/聊天歷史。+應用診斷(ReplyKit/外部 HTTP log bridge 與帶外 logio)顯示在 `/diagnostics`;可另外訂閱 ReplyKit SocketServer 的 external logs,只存在記憶體,不寫 log/SQLite/聊天歷史。詳見 `Docs/DIAGNOSTICS.md`。 圖片(TikTok/Twitch 頭像、禮物圖與贊助 overlay 圖片)由 MediaCache 持久化到 `cache/media/` 與 `data/media.sqlite`,訊息改傳 `MEDIA_BASE_URL/media/<hash>`;詳見 `Docs/MEDIA_CACHE.md`。 diff --git a/Docs/TEST_FILES_REFERENCE.md b/Docs/TEST_FILES_REFERENCE.md--- a/Docs/TEST_FILES_REFERENCE.md+++ b/Docs/TEST_FILES_REFERENCE.md@@ -65,8 +65,9 @@ node Test/xxx.mjs | `Test/media_cache.test.cjs` | 圖片快取本地 URL、下載、SVG/私有位址拒絕、路由與 SocketHub 改寫 | | `Test/inline_image.test.cjs` | data URI 圖片格式、大小/數量限制與通知剝離 | | `Test/socket_test_routes.test.cjs` | Raw Socket 測試入口的登入、子程序檢查與 payload 轉送 |-| `Test/diagnostics.test.cjs` | 診斷記憶體 store、LogServer 相容 `/post`、diagnostic 與 token |+| `Test/diagnostics.test.cjs` | 診斷 store、`/post`、diagnostic、token、Socket 外部事件訂閱與 API | | `Test/diagnostics_ui.cjs` | `/diagnostics` 日誌順序、自動滾到最新、logio 卡片與無 page error |+| `Test/youtube_stop.test.mjs` | 未啟用 YouTube 時收尾不輸出聊天室斷線 | | `Test/notify.test.mjs` | 通知格式化與 Bark/ntfy 逐通道結果 | | `Test/traffic_planner_ui.cjs` | 人流分類、批次補分類、預覽/套用/復原與手機寬度 | | `Test/traffic_planner_collecting_ui.cjs` | 資料累積建議、時段分組提醒與一週計畫 UI |diff --git a/ScriptLib/diagnostics/socket-client.cjs b/ScriptLib/diagnostics/socket-client.cjs--- /dev/null+++ b/ScriptLib/diagnostics/socket-client.cjs@@ -0,0 +1,186 @@+const net = require('node:net');++const MAX_LINE = 4 * 1024 * 1024;++/**+ * 訂閱 ReplyKit SocketServer 的外部日誌/帶外 diagnostic。+ * 只送 subscribeExternalLogs,不送 extension 專用訊息,維持外部工具角色。+ */+class DiagnosticsSocketClient {+ constructor({ store, reconnectMs = 3000, logger = console } = {}) {+ this.store = store;+ this.reconnectMs = reconnectMs;+ this.logger = logger;+ this.socket = null;+ this.buffer = '';+ this.retryTimer = null;+ this.host = '';+ this.port = 0;+ this.enabled = false;+ this.subscribed = false;+ this.state = 'off';+ this.lastError = '';+ this.lastMessageAt = null;+ this.attempts = 0;+ }++ snapshot() {+ return {+ enabled: this.enabled,+ host: this.host,+ port: this.port,+ state: this.state,+ subscribed: this.subscribed,+ lastError: this.lastError,+ lastMessageAt: this.lastMessageAt,+ attempts: this.attempts+ };+ }++ clearRetry() {+ if (this.retryTimer) clearTimeout(this.retryTimer);+ this.retryTimer = null;+ }++ send(payload) {+ if (!this.socket || this.socket.destroyed || !this.socket.writable) return false;+ try {+ this.socket.write(JSON.stringify(payload) + '\n');+ return true;+ } catch (error) {+ this.lastError = error.message;+ return false;+ }+ }++ subscribe(enabled) {+ return this.send({ type: 'subscribeExternalLogs', enabled: enabled === true });+ }++ start(host, port) {+ const nextHost = String(host || '').trim();+ const nextPort = Number(port) || 0;+ if (!nextHost || nextPort < 1 || nextPort > 65535) {+ this.lastError = '需要有效的 host 與 port';+ this.enabled = false;+ this.state = 'error';+ return false;+ }+ const sameTarget = this.host === nextHost && this.port === nextPort;+ this.host = nextHost;+ this.port = nextPort;+ this.enabled = true;+ this.attempts = 0;+ this.lastError = '';+ // 已建立同一條連線時只重新訂閱,不重連。+ if (sameTarget && this.socket && !this.socket.destroyed && this.socket.writable) {+ this.subscribe(true);+ if (!this.subscribed) this.state = 'connected';+ return true;+ }+ this.closeSocket();+ this.connect();+ return true;+ }++ unsubscribe() {+ this.enabled = false;+ this.subscribed = false;+ this.clearRetry();+ this.subscribe(false);+ this.state = this.socket && !this.socket.destroyed ? 'connected' : 'off';+ }++ stop() {+ this.enabled = false;+ this.subscribed = false;+ this.clearRetry();+ this.subscribe(false);+ this.closeSocket();+ this.state = 'off';+ }++ closeSocket() {+ if (!this.socket) return;+ const socket = this.socket;+ this.socket = null;+ try { socket.destroy(); } catch { /* ignore */ }+ }++ connect() {+ if (!this.enabled) return;+ this.clearRetry();+ this.state = 'connecting';+ this.attempts += 1;+ const socket = net.createConnection({ host: this.host, port: this.port });+ this.socket = socket;+ socket.setNoDelay(true);+ socket.setEncoding('utf8');+ socket.on('connect', () => {+ this.state = 'connected';+ this.lastError = '';+ this.buffer = '';+ if (this.enabled) this.subscribe(true);+ });+ socket.on('data', chunk => this.onData(chunk));+ socket.on('error', error => {+ this.lastError = error.message;+ });+ socket.on('close', () => {+ if (this.socket !== socket) return;+ this.socket = null;+ this.subscribed = false;+ if (!this.enabled) {+ this.state = 'off';+ return;+ }+ this.state = 'reconnecting';+ this.retryTimer = setTimeout(() => {+ this.retryTimer = null;+ this.connect();+ }, this.reconnectMs);+ });+ }++ onData(chunk) {+ this.buffer += chunk;+ if (this.buffer.length > MAX_LINE) {+ this.buffer = '';+ this.lastError = '單行資料過大,已重置緩衝';+ return;+ }+ let index;+ while ((index = this.buffer.indexOf('\n')) >= 0) {+ const line = this.buffer.slice(0, index).trim();+ this.buffer = this.buffer.slice(index + 1);+ if (line) this.onLine(line);+ }+ }++ onLine(line) {+ let data;+ try { data = JSON.parse(line); } catch {+ this.lastError = '收到非 JSON 資料';+ return;+ }+ if (!data || typeof data !== 'object') return;+ if (data.type === 'externalLogSubscription') {+ this.subscribed = data.enabled === true;+ this.state = this.subscribed ? 'subscribed' : 'connected';+ this.lastMessageAt = Date.now();+ return;+ }+ if (data.type === 'externalLogBatch') {+ if (Array.isArray(data.entries)) this.store.ingestLogs({ logs: data.entries });+ this.lastMessageAt = Date.now();+ return;+ }+ if (data.type === 'diagnostic') {+ this.store.ingestDiagnostic(data);+ this.lastMessageAt = Date.now();+ }+ // 其他 type 忽略,不中斷連線。+ }+}++module.exports = { DiagnosticsSocketClient };diff --git a/Server.js b/Server.js--- a/Server.js+++ b/Server.js@@ -62,10 +62,23 @@ const mediaRoutes = require('./MediaRoutes.cjs'); const serveMedia = mediaRoutes.createMediaRoutes(); const serveMediaAdmin = mediaRoutes.createMediaAdminRoutes(); const { DiagnosticsStore } = require('./ScriptLib/diagnostics/store.cjs');+const { DiagnosticsSocketClient } = require('./ScriptLib/diagnostics/socket-client.cjs'); const { createDiagnosticsRoutes } = require('./DiagnosticsRoutes.cjs');-const diagnosticsRoutes = createDiagnosticsRoutes(new DiagnosticsStore(), {- token: process.env.DIAGNOSTICS_TOKEN || ''+const diagnosticsStore = new DiagnosticsStore();+const diagnosticsSocket = new DiagnosticsSocketClient({ store: diagnosticsStore });+const diagnosticsRoutes = createDiagnosticsRoutes(diagnosticsStore, {+ token: process.env.DIAGNOSTICS_TOKEN || '',+ socketClient: diagnosticsSocket,+ socketDefaults: {+ host: process.env.DIAGNOSTICS_SOCKET_HOST || '',+ port: Number(process.env.DIAGNOSTICS_SOCKET_PORT) || 9322+ } });+if (/^(1|true|yes|on)$/i.test(process.env.DIAGNOSTICS_SOCKET_ENABLED || '')+ && process.env.DIAGNOSTICS_SOCKET_HOST) {+ diagnosticsSocket.start(process.env.DIAGNOSTICS_SOCKET_HOST,+ Number(process.env.DIAGNOSTICS_SOCKET_PORT) || 9322);+} const audiencePrune = setInterval(() => { try { audience.prune(180); } catch (error) { console.error('[Audience] 清理失敗:', error.message); } }, 24 * 3600000);@@ -633,6 +646,7 @@ const server = http.createServer((req, res) => { if (diagnosticsRoutes.serveIntake(req, res)) return; if (diagnosticsRoutes.servePage(req, res)) return; if (diagnosticsRoutes.serveApi(req, res, isValidToken(parseCookies(req).authToken))) return;+ if (diagnosticsRoutes.serveSocket(req, res, isValidToken(parseCookies(req).authToken))) return; if (serveMediaAdmin(req, res, isValidToken(parseCookies(req).authToken))) return; if (serveSocketTest(req, res, isValidToken(parseCookies(req).authToken))) return; if (serveMedia(req, res)) return;diff --git a/Test/diagnostics.test.cjs b/Test/diagnostics.test.cjs--- a/Test/diagnostics.test.cjs+++ b/Test/diagnostics.test.cjs@@ -1,7 +1,9 @@ const test = require('node:test'); const assert = require('node:assert/strict');+const net = require('node:net'); const { EventEmitter } = require('node:events'); const { DiagnosticsStore } = require('../ScriptLib/diagnostics/store.cjs');+const { DiagnosticsSocketClient } = require('../ScriptLib/diagnostics/socket-client.cjs'); const { createDiagnosticsRoutes } = require('../DiagnosticsRoutes.cjs'); function request(route, { url = '/post', method = 'POST', body = '', headers = {} } = {}) {@@ -90,3 +92,89 @@ test('診斷 API 需要登入並回傳 snapshot', async () => { assert.equal(allowed.status, 200); assert.equal(allowed.body.diagnostics.length, 1); });++test('Socket client 訂閱 externalLogs 並接收 batch/diagnostic', async () => {+ const store = new DiagnosticsStore();+ const received = [];+ const server = net.createServer(socket => {+ socket.on('error', () => {});+ socket.setEncoding('utf8');+ let buffer = '';+ socket.on('data', chunk => {+ buffer += chunk;+ let index;+ while ((index = buffer.indexOf('\n')) >= 0) {+ const line = buffer.slice(0, index);+ buffer = buffer.slice(index + 1);+ if (!line) continue;+ received.push(JSON.parse(line));+ if (JSON.parse(line).type === 'subscribeExternalLogs' && !socket.destroyed) {+ try {+ socket.write(JSON.stringify({ type: 'externalLogSubscription', enabled: true }) + '\n');+ socket.write(JSON.stringify({+ type: 'externalLogBatch', source: 'mainapp',+ entries: [{ time: 'now', title: 'ReplyKit', body: 'socket log' }]+ }) + '\n');+ socket.write(JSON.stringify({+ type: 'diagnostic', subsystem: 'logio', source: 'mainapp', count: 2, lastError: 'disk'+ }) + '\n');+ } catch { /* client 已關閉 */ }+ }+ }+ });+ });+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));+ const client = new DiagnosticsSocketClient({ store, reconnectMs: 50, logger: { log() {}, error() {} } });+ try {+ assert.equal(client.start('127.0.0.1', server.address().port), true);+ await new Promise(resolve => {+ const timer = setInterval(() => {+ const snap = store.snapshot();+ if (snap.logs.length && snap.diagnostics.length) {+ clearInterval(timer);+ resolve();+ }+ }, 10);+ });+ assert.deepEqual(received[0], { type: 'subscribeExternalLogs', enabled: true });+ assert.equal(client.snapshot().subscribed, true);+ assert.equal(store.snapshot().logs[0].body, 'socket log');+ assert.equal(store.snapshot().diagnostics[0].count, 2);+ } finally {+ client.stop();+ await new Promise(resolve => server.close(resolve));+ }+});++test('Socket 訂閱 API 需登入,GET 狀態與 POST 開關', async () => {+ const calls = [];+ const fake = {+ snapshot: () => ({ enabled: true, host: 'h', port: 9322, state: 'subscribed', subscribed: true }),+ start: (host, port) => { calls.push(['start', host, port]); return true; },+ unsubscribe: () => calls.push(['unsubscribe'])+ };+ const routes = createDiagnosticsRoutes(new DiagnosticsStore(), {+ socketClient: fake, socketDefaults: { host: 'default', port: 9322 }+ });+ const call = (method, body, authorized = true) => new Promise(resolve => {+ const req = new EventEmitter();+ req.url = '/api/diagnostics/socket';+ req.method = method;+ req.headers = {};+ const res = { writeHead(status) { this.status = status; }, end(value) { resolve({ status: this.status, body: JSON.parse(value) }); } };+ const served = routes.serveSocket(req, res, authorized);+ if (!served) { resolve({ served: false }); return; }+ setImmediate(() => {+ if (body) req.emit('data', Buffer.from(JSON.stringify(body)));+ req.emit('end');+ });+ });+ assert.equal((await call('GET', null, false)).status, 401);+ const status = await call('GET');+ assert.equal(status.body.subscribed, true);+ const enabled = await call('POST', { enabled: true });+ assert.equal(enabled.status, 200);+ assert.deepEqual(calls[0], ['start', 'default', 9322]);+ await call('POST', { enabled: false });+ assert.deepEqual(calls[1], ['unsubscribe']);+});diff --git a/Test/diagnostics_ui.cjs b/Test/diagnostics_ui.cjs--- a/Test/diagnostics_ui.cjs+++ b/Test/diagnostics_ui.cjs@@ -9,6 +9,7 @@ const { browserPath } = require('./browser_path.cjs'); async function main() { const pageHtml = fs.readFileSync(path.join(__dirname, '../diagnostics.html'));+ const socketPosts = []; const server = http.createServer((req, res) => { const url = new URL(req.url, 'http://127.0.0.1'); if (url.pathname === '/diagnostics') {@@ -16,6 +17,28 @@ async function main() { res.end(pageHtml); return; }+ if (url.pathname === '/api/diagnostics/socket') {+ if (req.method === 'POST') {+ let body = '';+ req.on('data', chunk => { body += chunk; });+ req.on('end', () => {+ const data = JSON.parse(body);+ socketPosts.push(data);+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });+ res.end(JSON.stringify({+ ok: true, enabled: data.enabled, host: data.host, port: data.port,+ state: data.enabled ? 'connecting' : 'off', subscribed: false, defaults: {}+ }));+ });+ return;+ }+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });+ res.end(JSON.stringify({+ ok: true, enabled: false, host: '', port: 9322, state: 'off', subscribed: false,+ defaults: { host: '192.168.0.242', port: 9322 }+ }));+ return;+ } if (url.pathname === '/api/diagnostics') { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({@@ -60,6 +83,14 @@ async function main() { assert.equal(result.atBottom, true); assert.match(result.diagnostic, /logio/); assert.equal(result.loginHidden, true);+ await page.waitForFunction(() => document.querySelector('#diagSocketStatus').textContent.length > 0);+ assert.match(await page.$eval('#diagSocketStatus', node => node.textContent), /未啟用/);+ await page.click('#diagSocketEnabled');+ await new Promise(resolve => setTimeout(resolve, 2300));+ assert.equal(await page.$eval('#diagSocketEnabled', node => node.checked), true);+ await page.click('#diagSocketApply');+ await page.waitForFunction(() => document.querySelector('#diagSocketStatus').textContent.includes('連線中'));+ assert.deepEqual(socketPosts[0], { enabled: true }); assert.deepEqual(errors, []); console.log('Diagnostics UI passed: log stream, logio card, and no page errors.'); } finally {diff --git a/assets/app.css.build.json b/assets/app.css.build.json--- a/assets/app.css.build.json+++ b/assets/app.css.build.json@@ -1,5 +1,5 @@ { "schema": 2,- "inputHash": "ce3d9ac0160ed455da2fa5193ed580f4f819a84137146044e9628e8a941a1111",+ "inputHash": "488adc478e073ed24b999b76981e5a0ed1e2daf13394d1522e8a9df1d8ce316b", "outputHash": "5e8e8ac9b7a490acf78fc06ee9e9c0e63e69a0a32e695f93025dce6a24930dce" }diff --git a/assets/diagnostics.js b/assets/diagnostics.js--- a/assets/diagnostics.js+++ b/assets/diagnostics.js@@ -11,10 +11,14 @@ const auto = $('diagAuto'); const autoScroll = $('diagAutoScroll'); const refresh = $('diagRefresh');+ const socketEnabled = $('diagSocketEnabled');+ const socketApply = $('diagSocketApply');+ const socketStatus = $('diagSocketStatus'); let state = { logs: [], diagnostics: [], recentDiagnostics: [], stats: {}, lastReceived: null }; let timer = null; let searchTimer = null; let loading = false;+ let socketDirty = false; function text(node, value) { node.textContent = value == null ? '' : String(value);@@ -49,7 +53,7 @@ function renderCards() { cards.replaceChildren();- const diagnostics = state.diagnostics || [];+ const diagnostics = (state.diagnostics || []).filter(item => item.subsystem === 'logio'); if (!diagnostics.length) { const empty = document.createElement('div'); empty.className = 'diag-empty';@@ -154,6 +158,65 @@ statusEl.textContent = `最後收到:${relative(state.lastReceived)} · 緩衝 ${stats.bufferedLogs || 0} 則 · 診斷 ${stats.bufferedDiagnostics || 0} 筆`; } + function socketLabel(data) {+ const stateLabels = {+ off: '未啟用', connecting: '連線中', connected: '已連線', subscribed: '已訂閱',+ reconnecting: '重連中', error: '錯誤'+ };+ const label = stateLabels[data.state] || data.state || '未知';+ const where = data.host ? `${data.host}:${data.port}` : '未設定';+ const extra = data.lastError ? ` · ${data.lastError}` : '';+ return `${where} · ${label}${data.subscribed ? '(外部事件)' : ''}${extra}`;+ }++ async function loadSocketStatus() {+ try {+ const response = await fetch('/api/diagnostics/socket', { cache: 'no-store' });+ if (response.status === 401) {+ socketStatus.textContent = '需要登入';+ return;+ }+ const data = await response.json();+ if (!response.ok || !data.ok) {+ socketStatus.textContent = data.error || ('HTTP ' + response.status);+ return;+ }+ // 使用者正在編輯時不要用輪詢結果蓋掉勾選狀態。+ if (!socketDirty) socketEnabled.checked = data.enabled === true;+ socketStatus.textContent = socketLabel(data);+ } catch (error) {+ socketStatus.textContent = '狀態讀取失敗:' + error.message;+ }+ }++ async function applySocketSubscription() {+ socketApply.disabled = true;+ try {+ const body = { enabled: socketEnabled.checked };+ const response = await fetch('/api/diagnostics/socket', {+ method: 'POST',+ headers: { 'Content-Type': 'application/json' },+ body: JSON.stringify(body)+ });+ const data = await response.json().catch(() => ({}));+ if (response.status === 401) {+ showCallout('Socket 訂閱需要主服務登入。', '/login');+ return;+ }+ if (!response.ok || !data.ok) {+ socketStatus.textContent = data.error || ('HTTP ' + response.status);+ return;+ }+ socketDirty = false;+ socketEnabled.checked = data.enabled === true;+ socketStatus.textContent = socketLabel(data);+ } catch (error) {+ socketStatus.textContent = '套用失敗:' + error.message;+ } finally {+ socketApply.disabled = false;+ }+ }+ async function load() { if (loading) return; loading = true;@@ -186,7 +249,10 @@ timer = null; if (!auto.checked) return; timer = setInterval(() => {- if (document.visibilityState === 'visible') load();+ if (document.visibilityState === 'visible') {+ load();+ loadSocketStatus();+ } }, 2000); } @@ -194,14 +260,23 @@ clearTimeout(searchTimer); searchTimer = setTimeout(load, 250); });- refresh.addEventListener('click', load);+ refresh.addEventListener('click', () => {+ load();+ loadSocketStatus();+ }); auto.addEventListener('change', schedule); autoScroll.addEventListener('change', () => { if (autoScroll.checked) scrollLogsToLatest(); });+ socketApply.addEventListener('click', applySocketSubscription);+ socketEnabled.addEventListener('change', () => { socketDirty = true; }); document.addEventListener('visibilitychange', () => {- if (document.visibilityState === 'visible' && auto.checked) load();+ if (document.visibilityState === 'visible' && auto.checked) {+ load();+ loadSocketStatus();+ } }); schedule(); load();+ loadSocketStatus(); })();diff --git a/diagnostics.html b/diagnostics.html--- a/diagnostics.html+++ b/diagnostics.html@@ -34,6 +34,16 @@ <p id="diagCallout" class="diag-card mb-4" hidden></p> + <section class="diag-card mb-4">+ <h2>Socket 外部事件</h2>+ <div class="flex items-center flex-wrap gap-3 text-xs">+ <label class="inline-flex items-center gap-1"><input id="diagSocketEnabled" type="checkbox"> 訂閱外部事件</label>+ <button id="diagSocketApply" class="bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded">套用</button>+ <span id="diagSocketStatus" class="text-xs text-gray-400"></span>+ </div>+ <small class="text-xs text-gray-500">連線目標由 DIAGNOSTICS_SOCKET_HOST/PORT 設定;切換只送 subscribeExternalLogs,已建立的連線不會重連。Log I/O 是寫檔失敗摘要,Socket 外部事件是另一條日誌串流。</small>+ </section>+ <div class="grid gap-3 md:grid-cols-2 mb-4"> <section class="diag-card"> <h2>Log I/O</h2>diff --git a/todo.md b/todo.md--- a/todo.md+++ b/todo.md@@ -26,6 +26,8 @@ - [x] Python overlay 支援 img/giftImg/AdOverlay icon 與訊息內嵌 data URI。 - [x] monitor MediaCache 檢視/本地連結複製與 Raw Socket 測試入口。 - [x] 應用診斷 `/diagnostics`、LogServer 相容 `/post` 與可選 token。+- [x] 診斷頁 Socket 外部事件訂閱開關(subscribeExternalLogs、自動重連)。+- [x] 未啟用 YouTube 時不輸出假斷線;handleExit 冪等。 - [ ] 正式環境以實際 child 程序驗證 IPC 結果與倒數(目前 monitor UI 使用假 SSE/fake `/chat`)。 ## 表情映射後續(2026-11-06)