// app.js 管理协议调试器列表筛选、详情编辑器和实时事件交互。 import { getLocale, setLocale, t, translateDocument } from "./i18n.js"; import { bindEvents, renderPauseState } from "./app_events.js"; import { escapeHTML, formatBytes, formatDuration, formatHex, formatState, renderDecodeError, renderTruncated } from "./view_helpers.js"; const monacoReady = loadMonaco(); const editorSlots = { request: { editor: null, model: null, host: null, token: 0, value: "", language: "plaintext" }, response: { editor: null, model: null, host: null, token: 0, value: "", language: "plaintext" }, }; const state = { status: null, exchanges: [], conversations: [], selectedId: null, selected: null, search: "", requestId: "", conversationId: "", endpoint: "all", bidiMessageKinds: new Set(), showOptions: false, sortOrder: "desc", paused: false, pendingRefresh: false, connection: { connected: false, key: "status.connecting", values: {} }, tabs: { request: "body", response: "body", }, }; const elements = { statusDot: document.querySelector("#status-dot"), statusText: document.querySelector("#status-text"), serviceAddress: document.querySelector("#service-address"), upstreamURL: document.querySelector("#upstream-url"), connectionLabel: document.querySelector("#connection-label"), trafficSummary: document.querySelector("#traffic-summary"), searchInput: document.querySelector("#search-input"), requestIdInput: document.querySelector("#request-id-input"), conversationSelect: document.querySelector("#conversation-select"), endpointFilter: document.querySelector("#endpoint-filter"), bidiMessageFilter: document.querySelector("#bidi-message-filter"), bidiMessageOptions: document.querySelector("#bidi-message-options"), showOptionsCheckbox: document.querySelector("#show-options-checkbox"), sortOrder: document.querySelector("#sort-order"), requestCount: document.querySelector("#request-count"), requestList: document.querySelector("#request-list"), emptyState: document.querySelector("#empty-state"), selectionSummary: document.querySelector("#selection-summary"), requestContent: document.querySelector("#request-content"), responseContent: document.querySelector("#response-content"), pauseButton: document.querySelector("#pause-button"), clearButton: document.querySelector("#clear-button"), localeSelect: document.querySelector("#locale-select"), workspace: document.querySelector("#workspace"), splitter: document.querySelector("#horizontal-splitter"), }; async function fetchJSON(url, options) { const response = await fetch(url, options); if (!response.ok) { throw new Error(`${response.status} ${response.statusText}`); } if (response.status === 204) return null; return response.json(); } async function loadStatus() { state.status = await fetchJSON("api/status"); elements.statusDot.classList.toggle("online", Boolean(state.status.running)); renderRuntimeStatus(); elements.serviceAddress.textContent = `http://${state.status.serviceAddr}`; elements.upstreamURL.textContent = state.status.upstreamURL; elements.showOptionsCheckbox.checked = state.showOptions; } async function refreshList() { const query = state.conversationId ? `?conversation_id=${encodeURIComponent(state.conversationId)}` : ""; [state.exchanges, state.conversations] = await Promise.all([ fetchJSON(`api/exchanges${query}`), fetchJSON("api/conversations"), ]); renderConversationOptions(); renderBidiMessageFilter(); renderList(); renderTrafficSummary(); if (state.selectedId && state.exchanges.some((item) => item.id === state.selectedId)) { await refreshDetail(state.selectedId); } else if (state.selectedId) { state.selectedId = null; state.selected = null; renderDetail(); } } function renderConversationOptions() { const selected = state.conversationId; const options = [``]; for (const conversation of state.conversations) { if (!conversation.conversationId) continue; const label = `${conversation.conversationId} (${conversation.exchangeCount})`; options.push(``); } elements.conversationSelect.innerHTML = options.join(""); elements.conversationSelect.value = selected; } async function refreshDetail(id) { if (!id) return; try { const detail = await fetchJSON(`api/exchanges/${encodeURIComponent(id)}`); if (state.selectedId !== id) return; state.selected = detail; renderDetail(); } catch (error) { if (state.selectedId === id) { state.selected = null; renderDetailError(error); } } } function scheduleRefresh() { if (state.paused) { state.pendingRefresh = true; return; } if (state.pendingRefresh) return; state.pendingRefresh = true; window.setTimeout(async () => { state.pendingRefresh = false; try { await refreshList(); } catch (error) { setConnectionState(false, "connection.refreshFailed", { message: error.message }); } }, 90); } function connectEvents() { const events = new EventSource("api/events"); events.addEventListener("open", () => setConnectionState(true, "connection.live")); events.addEventListener("update", scheduleRefresh); events.addEventListener("error", () => setConnectionState(false, "connection.retrying")); } function setConnectionState(connected, key, values = {}) { state.connection = { connected, key, values }; renderConnectionState(); } function renderRuntimeStatus() { if (!state.status) { elements.statusText.textContent = t("status.connecting"); return; } elements.statusText.textContent = t(state.status.running ? "status.running" : "status.stopped"); } function renderConnectionState() { const { connected, key, values } = state.connection; elements.connectionLabel.textContent = t(key, values); elements.statusDot.classList.toggle("online", connected && Boolean(state.status?.running)); } function filteredExchanges() { const query = state.search.trim().toLowerCase(); const requestId = state.requestId.trim().toLowerCase(); const direction = state.sortOrder === "asc" ? 1 : -1; return state.exchanges .filter((item) => { if (!state.showOptions && String(item.method || "").toUpperCase() === "OPTIONS") return false; if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false; if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false; if (state.endpoint === "bidiappend" && state.bidiMessageKinds.size > 0 && !state.bidiMessageKinds.has(item.requestKind || "")) return false; if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false; if (!query) return true; return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)] .filter(Boolean) .some((value) => String(value).toLowerCase().includes(query)); }) .sort((left, right) => { const startedAtDelta = new Date(left.startedAt).getTime() - new Date(right.startedAt).getTime(); if (startedAtDelta !== 0) return startedAtDelta * direction; return left.id.localeCompare(right.id, undefined, { numeric: true }) * direction; }); } function renderBidiMessageFilter() { const visible = state.endpoint === "bidiappend"; elements.bidiMessageFilter.hidden = !visible; if (!visible) elements.bidiMessageFilter.open = false; const availableKinds = state.exchanges .filter((item) => item.path.toLowerCase().includes("bidiappend") && item.requestKind) .map((item) => item.requestKind); const kinds = [...new Set([...availableKinds, ...state.bidiMessageKinds])].sort((left, right) => left.localeCompare(right)); elements.bidiMessageFilter.querySelector("summary").textContent = state.bidiMessageKinds.size ? t("filters.selectedMessageTypes", { count: state.bidiMessageKinds.size }) : t("filters.allMessageTypes"); elements.bidiMessageOptions.innerHTML = [ ``, ...kinds.map((kind) => ``), ].join(""); } function renderList() { const exchanges = filteredExchanges(); elements.requestCount.textContent = t("count.requests", { count: exchanges.length }); elements.emptyState.classList.toggle("hidden", exchanges.length > 0); const groups = new Map(); for (const item of exchanges) { const conversationID = item.conversationId || ""; if (!groups.has(conversationID)) groups.set(conversationID, []); groups.get(conversationID).push(item); } elements.requestList.innerHTML = [...groups.entries()] .map(([conversationID, items]) => { const label = conversationID || t("groups.unassigned"); const header = `${escapeHTML(t("groups.conversation"))}${escapeHTML(label)}${items.length}`; const rows = items.map((item) => { const selected = item.id === state.selectedId ? " selected" : ""; const statusClass = item.status >= 400 ? "error" : item.status ? "success" : ""; const kind = item.responseKind || item.requestKind || "-"; return ` ${escapeHTML(item.id)} ${escapeHTML(item.url)} ${escapeHTML(item.requestId || "-")} ${escapeHTML(kind)} ${escapeHTML(item.method)} ${item.status || "-"} ${formatBytes(item.responseBytes)} ${formatDuration(item.durationMs)} `; }).join(""); return header + rows; }) .join(""); } function renderTrafficSummary() { const totals = state.exchanges.reduce( (result, item) => { result.up += item.requestBytes || 0; result.down += item.responseBytes || 0; return result; }, { up: 0, down: 0 }, ); elements.trafficSummary.textContent = `↑ ${formatBytes(totals.up)} ↓ ${formatBytes(totals.down)}`; } function renderDetail() { if (!state.selected) { disposeEditor("request"); disposeEditor("response"); elements.selectionSummary.innerHTML = `POST${escapeHTML(t("selection.waiting"))}${escapeHTML(t("selection.prompt"))}`; elements.requestContent.classList.remove("editor-active"); elements.responseContent.classList.remove("editor-active"); elements.requestContent.innerHTML = `
${escapeHTML(t("notices.noRequest"))}
`; elements.responseContent.innerHTML = `
${escapeHTML(t("notices.noResponse"))}
`; return; } const item = state.selected; const statusClass = item.status >= 200 && item.status < 400 ? "success" : ""; elements.selectionSummary.innerHTML = `${escapeHTML(item.method)}${escapeHTML(item.status || formatState(item.state))}${escapeHTML(item.url)}`; renderPayload("request", item.request, state.tabs.request); renderPayload("response", item.response, state.tabs.response); } function renderDetailError(error) { disposeEditor("request"); disposeEditor("response"); elements.requestContent.classList.remove("editor-active"); elements.responseContent.classList.remove("editor-active"); elements.requestContent.innerHTML = `
${escapeHTML(error.message)}
`; elements.responseContent.innerHTML = `
${escapeHTML(error.message)}
`; } function renderPayload(side, payload, tab) { const container = elements[`${side}Content`]; if (!payload) { renderStaticPayload(side, `
${escapeHTML(t("notices.noContent"))}
`); return; } if (tab === "headers") { renderStaticPayload(side, renderHeaders(payload.headers)); return; } if (tab === "raw") { if (!payload.rawHex) { renderStaticPayload(side, `
${escapeHTML(t("notices.noRaw"))}
`); return; } renderEditorPayload(side, formatHex(payload.rawHex), "plaintext", renderTruncated(payload.rawTruncated)); return; } if (tab === "frames") { const document = frameEditorDocument(payload.frames); if (!document) { renderStaticPayload(side, `
${escapeHTML(t("notices.noFrames"))}
`); return; } renderEditorPayload(side, document, "json", ""); return; } if (payload.decodedJson) { renderEditorPayload(side, payload.decodedJson, payload.decodedLanguage || "json", `${renderDecodeError(payload.decodeError)}${renderTruncated(payload.rawTruncated)}`); return; } if (payload.frames?.length) { renderEditorPayload(side, frameEditorDocument(payload.frames), "json", ""); return; } if (payload.decodeError) { renderStaticPayload(side, `
${escapeHTML(payload.decodeError)}
`); return; } container.classList.remove("editor-active"); renderStaticPayload(side, `
${escapeHTML(t("notices.noBody"))}
`); } function renderHeaders(headers = []) { const items = Array.isArray(headers) ? headers : []; if (!items.length) return `
${escapeHTML(t("notices.noHeaders"))}
`; return `${items .map((header) => ``) .join("")}
${escapeHTML(header.name)}${escapeHTML(header.value)}
`; } function frameEditorDocument(frames = []) { const items = Array.isArray(frames) ? frames : []; if (!items.length) return ""; const normalized = items.map((frame) => { let message = frame.rawHex || null; if (frame.json) { try { message = JSON.parse(frame.json); } catch { message = frame.json; } } return { index: frame.index, kind: frame.kind || frame.messageType || t("notices.unknown"), messageType: frame.messageType || undefined, flags: `0x${Number(frame.flags || 0).toString(16).padStart(2, "0")}`, length: frame.length, compressed: Boolean(frame.compressed), endStream: Boolean(frame.endStream), requestId: frame.requestId || undefined, error: frame.error || undefined, message, }; }); return JSON.stringify(normalized, null, 2); } function renderStaticPayload(side, markup) { disposeEditor(side); const container = elements[`${side}Content`]; container.classList.remove("editor-active"); container.innerHTML = markup; } function renderEditorPayload(side, value, language, notices) { const container = elements[`${side}Content`]; const slot = editorSlots[side]; slot.value = value; slot.language = language; container.classList.add("editor-active"); let host = container.querySelector(".editor-host"); if (!host || slot.host !== host) { disposeEditor(side); slot.value = value; slot.language = language; container.innerHTML = `
${escapeHTML(value)}
${notices}
`; host = container.querySelector(".editor-host"); void createEditor(side, host, value, language); return; } container.querySelector(".editor-notices").innerHTML = notices; const fallback = host.querySelector(".editor-fallback"); if (fallback) fallback.textContent = value; updateEditor(slot, value, language); } async function createEditor(side, host, value, language) { const slot = editorSlots[side]; const token = ++slot.token; slot.host = host; try { const monaco = await monacoReady; if (token !== slot.token || !host.isConnected) return; host.textContent = ""; const model = monaco.editor.createModel(slot.value || value, slot.language || language); const editor = monaco.editor.create(host, { model, theme: "vs-dark", readOnly: true, domReadOnly: true, automaticLayout: true, fontFamily: "SFMono-Regular, Consolas, Liberation Mono, monospace", fontSize: 12, lineHeight: 19, minimap: { enabled: false }, glyphMargin: false, folding: true, lineNumbersMinChars: 3, overviewRulerLanes: 0, overviewRulerBorder: false, renderLineHighlight: "none", scrollBeyondLastLine: false, smoothScrolling: true, wordWrap: "off", padding: { top: 8, bottom: 16 }, stickyScroll: { enabled: false }, contextmenu: true, }); slot.editor = editor; slot.model = model; slot.host = host; } catch { // Monaco 初始化失败时保留文本回退视图。 } } function updateEditor(slot, value, language) { if (!slot.editor || !slot.model) return; const monaco = window.monaco; if (monaco && slot.model.getLanguageId() !== language) monaco.editor.setModelLanguage(slot.model, language); if (slot.model.getValue() === value) return; const viewState = slot.editor.saveViewState(); slot.model.setValue(value); if (viewState) slot.editor.restoreViewState(viewState); } function disposeEditor(side) { const slot = editorSlots[side]; slot.token += 1; slot.editor?.dispose(); slot.model?.dispose(); slot.editor = null; slot.model = null; slot.host = null; slot.value = ""; slot.language = "plaintext"; } function loadMonaco() { return new Promise((resolve, reject) => { const amdRequire = window.require; if (typeof amdRequire !== "function" || typeof amdRequire.config !== "function") { reject(new Error("Monaco loader is unavailable")); return; } amdRequire.config({ paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.56.0/min/vs" } }); amdRequire(["vs/editor/editor.main"], () => resolve(window.monaco), reject); }); } function applyLocale() { translateDocument(); elements.localeSelect.value = getLocale(); renderRuntimeStatus(); renderConnectionState(); renderPauseState(state, elements); renderConversationOptions(); renderBidiMessageFilter(); renderList(); renderTrafficSummary(); renderDetail(); } bindEvents({ state, elements, fetchJSON, refreshList, refreshDetail, renderList, renderDetail, renderBidiMessageFilter, setConnectionState, applyLocale, }); async function bootstrap() { applyLocale(); renderDetail(); try { await Promise.all([loadStatus(), refreshList()]); connectEvents(); } catch (error) { setConnectionState(false, "connection.connectFailed", { message: error.message }); } } void bootstrap();