From 613cc24ab64b4d7e5278a6f71a8b845797c3cbd9 Mon Sep 17 00:00:00 2001 From: zahed316 Date: Mon, 25 May 2026 09:00:53 -0900 Subject: [PATCH 1/3] add modular wrapper dashboard --- README.MD | 12 + docs/wrapper-dashboard.md | 69 + tools/wrapper-dashboard/README.md | 97 ++ tools/wrapper-dashboard/package.json | 13 + tools/wrapper-dashboard/public/app.js | 168 +++ tools/wrapper-dashboard/public/index.html | 125 ++ tools/wrapper-dashboard/public/js/api.js | 21 + tools/wrapper-dashboard/public/js/dom.js | 11 + tools/wrapper-dashboard/public/js/events.js | 8 + tools/wrapper-dashboard/public/js/state.js | 19 + tools/wrapper-dashboard/public/js/tabs.js | 32 + .../public/js/views/ai-optimizer.js | 76 + .../public/js/views/config-optimizer.js | 31 + .../public/js/views/editor.js | 41 + .../wrapper-dashboard/public/js/views/logs.js | 8 + .../public/js/views/monitor.js | 7 + .../public/js/views/overview.js | 15 + .../public/js/views/plan-board.js | 14 + .../public/js/views/resolvers.js | 8 + .../public/js/views/validation.js | 11 + tools/wrapper-dashboard/public/styles.css | 456 ++++++ tools/wrapper-dashboard/server.js | 6 + tools/wrapper-dashboard/src/server/app.js | 1304 +++++++++++++++++ tools/wrapper-dashboard/src/server/paths.js | 21 + tools/wrapper-dashboard/src/server/static.js | 24 + .../src/server/utils/http.js | 9 + 26 files changed, 2606 insertions(+) create mode 100644 docs/wrapper-dashboard.md create mode 100644 tools/wrapper-dashboard/README.md create mode 100644 tools/wrapper-dashboard/package.json create mode 100644 tools/wrapper-dashboard/public/app.js create mode 100644 tools/wrapper-dashboard/public/index.html create mode 100644 tools/wrapper-dashboard/public/js/api.js create mode 100644 tools/wrapper-dashboard/public/js/dom.js create mode 100644 tools/wrapper-dashboard/public/js/events.js create mode 100644 tools/wrapper-dashboard/public/js/state.js create mode 100644 tools/wrapper-dashboard/public/js/tabs.js create mode 100644 tools/wrapper-dashboard/public/js/views/ai-optimizer.js create mode 100644 tools/wrapper-dashboard/public/js/views/config-optimizer.js create mode 100644 tools/wrapper-dashboard/public/js/views/editor.js create mode 100644 tools/wrapper-dashboard/public/js/views/logs.js create mode 100644 tools/wrapper-dashboard/public/js/views/monitor.js create mode 100644 tools/wrapper-dashboard/public/js/views/overview.js create mode 100644 tools/wrapper-dashboard/public/js/views/plan-board.js create mode 100644 tools/wrapper-dashboard/public/js/views/resolvers.js create mode 100644 tools/wrapper-dashboard/public/js/views/validation.js create mode 100644 tools/wrapper-dashboard/public/styles.css create mode 100644 tools/wrapper-dashboard/server.js create mode 100644 tools/wrapper-dashboard/src/server/app.js create mode 100644 tools/wrapper-dashboard/src/server/paths.js create mode 100644 tools/wrapper-dashboard/src/server/static.js create mode 100644 tools/wrapper-dashboard/src/server/utils/http.js diff --git a/README.MD b/README.MD index 75ba4964..a123752d 100644 --- a/README.MD +++ b/README.MD @@ -63,6 +63,18 @@ For the latest news, releases, and project updates, follow our Telegram channel: --- +## Optional Wrapper Dashboard 🖥️ + +A local browser dashboard is available in [`tools/wrapper-dashboard`](tools/wrapper-dashboard). +It can manage a downloaded client release package by editing `client_config.toml`, +editing `client_resolvers.txt`, starting/stopping the headless client binary, +parsing logs, and showing optimizer recommendations. + +Architecture and safety notes are documented in +[`docs/wrapper-dashboard.md`](docs/wrapper-dashboard.md). + +--- + ### If you like this project, please support it by starring it on GitHub (⭐). It helps the project get discovered. --- diff --git a/docs/wrapper-dashboard.md b/docs/wrapper-dashboard.md new file mode 100644 index 00000000..de325fa7 --- /dev/null +++ b/docs/wrapper-dashboard.md @@ -0,0 +1,69 @@ +# Wrapper Dashboard Architecture + +The wrapper dashboard is a local operational UI for the existing release +package. It is designed for users who have the prebuilt client binary and want +to edit configuration, inspect resolver health, and collect optimizer context +without changing the Go client. + +## Design Goals + +- Preserve the headless client binary interface: TOML config, resolver file, + stdout logs, and process signals. +- Keep all config edits explicit. Recommendations are previewed before writing. +- Avoid server-side encryption changes unless the matching server config is + coordinated. +- Keep the UI dense and operational, with Farsi/RTL support possible from the + same view model. + +## Runtime Model + +The dashboard server runs from `tools/wrapper-dashboard` and points at a release +package root through `MDVPN_DASHBOARD_ROOT`. + +```text +Browser + -> dashboard HTTP server + -> client_config.toml / client_resolvers.txt + -> MasterDnsVPN client child process + -> stdout log parser + -> telemetry and rule recommendations +``` + +The Go client still owns all tunnel behavior. The wrapper only controls files, +process lifecycle, and derived telemetry. + +## Modules + +- `server.js` starts the dashboard. +- `src/server/app.js` owns the current API routes and shared runtime state. +- `src/server/paths.js` separates the dashboard app root from the release + package root. +- `src/server/static.js` serves dashboard assets from the tool directory. +- `public/app.js` bootstraps the browser UI. +- `public/js/views/*` contains one renderer/controller per visible section. + +Further extraction can move `app.js` route groups into dedicated services +without changing public endpoints. + +## API Stability + +Existing local endpoints are kept stable: + +- `GET /api/state` +- `GET /api/editor` +- `POST /api/save-editor` +- `GET /api/telemetry` +- `GET /api/recommendations/dynamic` +- `POST /api/recommendations/preview-patch` +- `POST /api/resolvers/export-good` +- `GET /api/events` + +Consumers should treat these as local-only wrapper APIs, not as a remote +management API for the Go client. + +## Safety Rules + +- Never export raw `ENCRYPTION_KEY` in AI context. +- Never auto-apply `DOMAINS`, `ENCRYPTION_KEY`, or `DATA_ENCRYPTION_METHOD`. +- Do not commit local logs, resolver exports, or real client configs. +- Keep generated telemetry in the release package root, not the source repo. diff --git a/tools/wrapper-dashboard/README.md b/tools/wrapper-dashboard/README.md new file mode 100644 index 00000000..3ed26bf7 --- /dev/null +++ b/tools/wrapper-dashboard/README.md @@ -0,0 +1,97 @@ +# MasterDnsVPN Wrapper Dashboard + +Local web dashboard for managing a release-package client without modifying the +Go binary. The dashboard runs as a Node.js wrapper, edits the client TOML and +resolver files, starts/stops the client process, parses stdout logs, and shows +optimizer recommendations. + +## Run Against a Release Package + +```bash +cd tools/wrapper-dashboard +MDVPN_DASHBOARD_ROOT=/path/to/MasterDnsVPN_Client_Linux_AMD64 npm start +``` + +Open: + +```text +http://127.0.0.1:18080 +``` + +Use a different dashboard port: + +```bash +MDVPN_DASHBOARD_ROOT=/path/to/release MDVPN_DASHBOARD_PORT=18081 npm start +``` + +If `MDVPN_DASHBOARD_ROOT` is omitted, the dashboard uses the current working +directory as the release package root. + +## Required Release Package Files + +The selected root must contain: + +- `client_config.toml` +- `client_resolvers.txt` +- `.config-schema.json` +- `MasterDnsVPN_Client_Linux_AMD64_v...` + +Optional files created or read by the dashboard: + +- `dashboard-client.log` +- `optimizer-rules.json` +- `dashboard-runs.json` +- `known-good-resolvers.txt` + +These runtime files belong in the local release package and should not be +committed unless intentionally adding fixtures. + +## Features + +- Config and resolver editor with preview-before-apply behavior. +- Balanced optimizer recommendations. +- Dynamic AI optimizer from parsed logs and rules. +- Known-good resolver export. +- Process start/stop wrapper for the headless client. +- SSE-driven UI refresh. +- Tabs for Overview, Optimizer, Editor, Resolvers, and Logs. + +The dashboard never silently changes `DOMAINS`, `ENCRYPTION_KEY`, or +`DATA_ENCRYPTION_METHOD`. + +## Structure + +```text +server.js # bootstrap +src/server/ # wrapper server and services +src/server/paths.js # release root, app root, file paths +src/server/static.js # static public file serving +src/server/utils/http.js # JSON/text response helpers +public/app.js # browser bootstrap +public/js/ # browser modules +public/js/views/ # one view module per dashboard section +``` + +The project intentionally uses vanilla Node ESM and browser ESM. There is no +frontend build step. + +## Validation + +```bash +node --check server.js +node --check src/server/app.js +node --check src/server/paths.js +node --check src/server/static.js +node --check src/server/utils/http.js +node --check public/app.js +for f in public/js/*.js public/js/views/*.js; do node --check "$f"; done +``` + +Smoke-test the API: + +```bash +curl -s http://127.0.0.1:18080/api/state +curl -s http://127.0.0.1:18080/api/telemetry +curl -s http://127.0.0.1:18080/api/recommendations/dynamic +curl -s 'http://127.0.0.1:18080/api/resolvers?limit=5' +``` diff --git a/tools/wrapper-dashboard/package.json b/tools/wrapper-dashboard/package.json new file mode 100644 index 00000000..10bdb9de --- /dev/null +++ b/tools/wrapper-dashboard/package.json @@ -0,0 +1,13 @@ +{ + "name": "masterdnsvpn-wrapper-dashboard", + "version": "1.0.0", + "private": true, + "description": "Wrapper-first GUI dashboard for the MasterDnsVPN client distribution package.", + "type": "module", + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=20" + } +} diff --git a/tools/wrapper-dashboard/public/app.js b/tools/wrapper-dashboard/public/app.js new file mode 100644 index 00000000..0ef5ed5b --- /dev/null +++ b/tools/wrapper-dashboard/public/app.js @@ -0,0 +1,168 @@ +import { get, post, postJson } from "./js/api.js"; +import { $, escapeHtml } from "./js/dom.js"; +import { connectEvents } from "./js/events.js"; +import { dashboardState, setEditorDirty, setResolverFilter, setState } from "./js/state.js"; +import { initTabs } from "./js/tabs.js"; +import { renderAIOptimizer, setAIStatus } from "./js/views/ai-optimizer.js"; +import { renderRecommendations, renderProfileHint } from "./js/views/config-optimizer.js"; +import { + parseResolvers, + renderEditorFiles, + renderSaveResult, + setEditorStatus, + updateEditorResolverCount +} from "./js/views/editor.js"; +import { renderLogs } from "./js/views/logs.js"; +import { renderMonitor } from "./js/views/monitor.js"; +import { renderOverview } from "./js/views/overview.js"; +import { renderStages } from "./js/views/plan-board.js"; +import { renderResolvers } from "./js/views/resolvers.js"; +import { renderValidation } from "./js/views/validation.js"; + +window.addEventListener("DOMContentLoaded", () => { + bindControls(); + initTabs(); + connectEvents({ + refresh, + loadEditor, + isEditorDirty: () => dashboardState.editorDirty + }); + refresh(); + loadEditor(); +}); + +function bindControls() { + $("refreshBtn").addEventListener("click", refresh); + $("startBtn").addEventListener("click", () => post("/api/start").then(refresh)); + $("stopBtn").addEventListener("click", () => post("/api/stop").then(refresh)); + $("previewBtn").addEventListener("click", previewBalanced); + $("applyBtn").addEventListener("click", () => post("/api/apply-balanced").then(refresh)); + $("smokeBtn").addEventListener("click", () => post("/api/smoke").then(refresh)); + $("previewDynamicBtn").addEventListener("click", previewDynamicPatch); + $("copyAIContextBtn").addEventListener("click", copyAIContext); + $("exportGoodResolversBtn").addEventListener("click", exportGoodResolvers); + $("reloadEditorBtn").addEventListener("click", loadEditor); + $("stripCommentsBtn").addEventListener("click", stripConfigComments); + $("dedupeResolversBtn").addEventListener("click", dedupeResolverEditor); + $("saveEditorBtn").addEventListener("click", saveEditor); + $("configEditor").addEventListener("input", () => { + setEditorDirty(true); + setEditorStatus("Unsaved TOML edits.", ""); + }); + $("resolverEditor").addEventListener("input", () => { + setEditorDirty(true); + updateEditorResolverCount(); + setEditorStatus("Unsaved resolver edits.", ""); + }); + $("resolverFilter").addEventListener("input", (event) => { + setResolverFilter(event.target.value); + loadResolvers(); + }); + document.querySelectorAll(".profile-tabs button").forEach((button) => { + button.addEventListener("click", () => { + document.querySelectorAll(".profile-tabs button").forEach((item) => item.classList.remove("active")); + button.classList.add("active"); + renderProfileHint(button.dataset.profile); + }); + }); +} + +async function refresh() { + setState(await get("/api/state")); + render(); + await loadResolvers(); +} + +function render() { + const state = dashboardState.state; + $("subtitle").textContent = `${state.facts.version || "Unknown version"} | ${state.app.root}`; + renderOverview(state); + renderStages(state); + renderRecommendations(state); + renderAIOptimizer(state, { previewDynamicPatch, ignoreRecommendation }); + renderValidation(state); + renderLogs(state); + renderMonitor(state); + updateEditorResolverCount(); +} + +async function loadResolvers() { + const data = await get(`/api/resolvers?offset=${dashboardState.resolverOffset}&limit=250&q=${encodeURIComponent(dashboardState.resolverFilter)}`); + renderResolvers(data); +} + +async function loadEditor() { + const data = await get("/api/editor"); + renderEditorFiles(data); + setEditorDirty(false); +} + +async function saveEditor() { + const payload = { + configText: $("configEditor").value, + resolverText: $("resolverEditor").value, + restart: $("restartAfterSave").checked + }; + const result = await postJson("/api/save-editor", payload); + renderSaveResult(result); + if (!result.saved) return; + setEditorDirty(false); + await refresh(); + await loadResolvers(); +} + +async function stripConfigComments() { + const result = await postJson("/api/strip-config-comments", { configText: $("configEditor").value }); + $("configEditor").value = result.configText; + setEditorDirty(true); + setEditorStatus("TOML comments removed in the editor. Save to write the file.", ""); +} + +function dedupeResolverEditor() { + const resolvers = parseResolvers($("resolverEditor").value); + $("resolverEditor").value = resolvers.join("\n") + "\n"; + setEditorDirty(true); + updateEditorResolverCount(); + setEditorStatus("Resolver duplicates removed in the editor. Save to write the file.", ""); +} + +async function previewBalanced() { + const data = await get("/api/preview-balanced"); + $("diff").textContent = data.diff.length + ? data.diff.map((row) => `${row.type === "add" ? "+" : "-"}${row.line}: ${row.text}`).join("\n") + : "Balanced recommendations are already applied."; +} + +async function previewDynamicPatch(ids = null) { + const selectedIds = Array.isArray(ids) + ? ids + : (dashboardState.state.dynamicRecommendations?.recommendations || []).map((item) => item.id); + const data = await postJson("/api/recommendations/preview-patch", { ids: selectedIds }); + $("dynamicDiff").textContent = data.diff.length + ? data.diff.map((row) => `${row.type === "add" ? "+" : "-"}${row.line}: ${row.text}`).join("\n") + : "Selected dynamic recommendations do not change TOML."; + setAIStatus(data.changed ? "Preview generated. Nothing was saved." : "No TOML change for this selection.", data.changed ? "ok" : ""); +} + +async function copyAIContext() { + const data = await get("/api/ai-context"); + const text = JSON.stringify(data, null, 2); + try { + await navigator.clipboard.writeText(text); + setAIStatus("AI context copied with ENCRYPTION_KEY masked.", "ok"); + } catch { + $("dynamicDiff").textContent = text; + setAIStatus("Clipboard unavailable. AI context rendered below.", ""); + } +} + +async function exportGoodResolvers() { + const data = await post("/api/resolvers/export-good"); + setAIStatus(`Exported ${data.count} known-good resolvers to ${data.file}.`, "ok"); +} + +async function ignoreRecommendation(id) { + await postJson("/api/recommendations/ignore", { id }); + setAIStatus(`Ignored ${id}.`, ""); + await refresh(); +} diff --git a/tools/wrapper-dashboard/public/index.html b/tools/wrapper-dashboard/public/index.html new file mode 100644 index 00000000..039c211a --- /dev/null +++ b/tools/wrapper-dashboard/public/index.html @@ -0,0 +1,125 @@ + + + + + + MasterDnsVPN Dashboard + + + +
+
+

MasterDnsVPN Client

+

Wrapper dashboard

+
+
+ + + +
+
+ + + +
+
+

Overview

+
+
+ +
+

Plan Stages

+
+
+ +
+

Config Optimizer

+
+ + + + + +
+
+ + + +
+
+

+      
+ +
+

AI Optimizer

+
+
+ + + +
+
+
+

+      
+ +
+

Validation

+
+
+ +
+

Configuration Editor

+
+ + + + + +
+
+
+ + +
+
+ +
+

Resolver Pool

+
+ + +
+
+
+ +
+

Run Logs

+
+

+      
+ +
+

AI Monitor

+
+
+
Overview
Plan Stages
Config Optimizer
+
Resolver Pool
Run Logs
AI Monitor
+
Profiles
Apply Preview
Smoke Tests
+
+
پشتیبانی راست به چپ برای رابط فارسی آماده است.
+
+
+ + + + diff --git a/tools/wrapper-dashboard/public/js/api.js b/tools/wrapper-dashboard/public/js/api.js new file mode 100644 index 00000000..994553b6 --- /dev/null +++ b/tools/wrapper-dashboard/public/js/api.js @@ -0,0 +1,21 @@ +export async function get(path) { + const response = await fetch(path); + if (!response.ok) throw new Error(await response.text()); + return response.json(); +} + +export async function post(path) { + const response = await fetch(path, { method: "POST" }); + if (!response.ok) throw new Error(await response.text()); + return response.json(); +} + +export async function postJson(path, body) { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + if (!response.ok) throw new Error(await response.text()); + return response.json(); +} diff --git a/tools/wrapper-dashboard/public/js/dom.js b/tools/wrapper-dashboard/public/js/dom.js new file mode 100644 index 00000000..75e0c6db --- /dev/null +++ b/tools/wrapper-dashboard/public/js/dom.js @@ -0,0 +1,11 @@ +export const $ = (id) => document.getElementById(id); + +export function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, (char) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + }[char])); +} diff --git a/tools/wrapper-dashboard/public/js/events.js b/tools/wrapper-dashboard/public/js/events.js new file mode 100644 index 00000000..d1b00a56 --- /dev/null +++ b/tools/wrapper-dashboard/public/js/events.js @@ -0,0 +1,8 @@ +export function connectEvents({ refresh, loadEditor, isEditorDirty }) { + const events = new EventSource("/api/events"); + events.onmessage = () => { + refresh(); + if (!isEditorDirty()) loadEditor(); + }; + return events; +} diff --git a/tools/wrapper-dashboard/public/js/state.js b/tools/wrapper-dashboard/public/js/state.js new file mode 100644 index 00000000..ab44b943 --- /dev/null +++ b/tools/wrapper-dashboard/public/js/state.js @@ -0,0 +1,19 @@ +export const dashboardState = { + state: null, + resolverOffset: 0, + resolverFilter: "", + editorDirty: false +}; + +export function setState(nextState) { + dashboardState.state = nextState; +} + +export function setResolverFilter(value) { + dashboardState.resolverFilter = value; + dashboardState.resolverOffset = 0; +} + +export function setEditorDirty(value) { + dashboardState.editorDirty = value; +} diff --git a/tools/wrapper-dashboard/public/js/tabs.js b/tools/wrapper-dashboard/public/js/tabs.js new file mode 100644 index 00000000..b0c01073 --- /dev/null +++ b/tools/wrapper-dashboard/public/js/tabs.js @@ -0,0 +1,32 @@ +import { escapeHtml } from "./dom.js"; + +const tabs = [ + ["overview", "Overview"], + ["optimizer", "Optimizer"], + ["editor", "Editor"], + ["resolvers", "Resolvers"], + ["logs", "Logs"] +]; + +export function initTabs() { + const nav = document.querySelector("[data-tabs]"); + if (!nav) return; + nav.innerHTML = tabs.map(([id, label]) => ` + + `).join(""); + nav.querySelectorAll("[data-tab]").forEach((button) => { + button.addEventListener("click", () => activateTab(button.dataset.tab)); + }); + const initial = location.hash.replace("#", "") || "overview"; + activateTab(tabs.some(([id]) => id === initial) ? initial : "overview", false); +} + +export function activateTab(tab, updateHash = true) { + document.querySelectorAll("[data-tab]").forEach((button) => { + button.classList.toggle("active", button.dataset.tab === tab); + }); + document.querySelectorAll("[data-tab-panel]").forEach((panel) => { + panel.hidden = panel.dataset.tabPanel !== tab; + }); + if (updateHash) history.replaceState(null, "", `#${tab}`); +} diff --git a/tools/wrapper-dashboard/public/js/views/ai-optimizer.js b/tools/wrapper-dashboard/public/js/views/ai-optimizer.js new file mode 100644 index 00000000..c8d268d2 --- /dev/null +++ b/tools/wrapper-dashboard/public/js/views/ai-optimizer.js @@ -0,0 +1,76 @@ +import { $, escapeHtml } from "../dom.js"; + +export function renderAIOptimizer(state, actions) { + const telemetry = state.telemetry || {}; + const scores = telemetry.scores || {}; + const scoreRows = [ + ["Health", scores.healthScore], + ["Active Resolvers", scores.activeResolverScore], + ["MTU", scores.mtuScore], + ["Compression", scores.compressionScore], + ["Stream Pressure", scores.streamPressureScore] + ]; + $("aiScores").innerHTML = scoreRows.map(([label, value]) => { + const normalized = Number.isFinite(value) ? value : 0; + return ` +
+
${escapeHtml(label)}${normalized}/100
+
+
+ `; + }).join(""); + + const dynamic = state.dynamicRecommendations?.recommendations || []; + $("dynamicRecommendations").innerHTML = dynamic.length ? dynamic.map((item) => { + const evidenceId = `evidence-${item.id}`; + const patchKeys = Object.keys(item.configPatch || {}); + return ` +
+
+ ${escapeHtml(item.id)} + ${escapeHtml(item.severity)} +
+

${escapeHtml(item.recommendation || item.condition || "Recommendation generated from current telemetry.")}

+
+ confidence: ${escapeHtml(item.confidence || "medium")} + ${item.requiresRestart ? "restart required" : "live observation"} + ${patchKeys.length ? `patch: ${escapeHtml(patchKeys.join(", "))}` : "advisory"} +
+
+ + + +
+ +
+ `; + }).join("") : `
ok No dynamic recommendations yet.
`; + + $("dynamicRecommendations").querySelectorAll("[data-preview]").forEach((button) => { + button.addEventListener("click", () => actions.previewDynamicPatch([button.dataset.preview])); + }); + $("dynamicRecommendations").querySelectorAll("[data-why]").forEach((button) => { + button.addEventListener("click", () => { + const target = $(button.dataset.why); + if (target) target.hidden = !target.hidden; + }); + }); + $("dynamicRecommendations").querySelectorAll("[data-ignore]").forEach((button) => { + button.addEventListener("click", async () => { + await actions.ignoreRecommendation(button.dataset.ignore); + }); + }); +} + +export function setAIStatus(message, type) { + $("aiOptimizerStatus").textContent = message; + $("aiOptimizerStatus").className = `editor-status ${type || ""}`; +} + +function severityTag(severity) { + if (severity === "error") return "error"; + if (severity === "warn") return "warn"; + return "ok"; +} diff --git a/tools/wrapper-dashboard/public/js/views/config-optimizer.js b/tools/wrapper-dashboard/public/js/views/config-optimizer.js new file mode 100644 index 00000000..b3bc2f44 --- /dev/null +++ b/tools/wrapper-dashboard/public/js/views/config-optimizer.js @@ -0,0 +1,31 @@ +import { $, escapeHtml } from "../dom.js"; + +export function renderRecommendations(state) { + $("recommendations").innerHTML = state.recommendations.items.map((item) => { + const changed = item.current !== item.recommended; + const tag = item.requiresServerChange ? "warn" : changed ? "running" : "ok"; + const label = item.requiresServerChange ? "server coordinated" : changed ? "pending" : "applied"; + return ` +
+
+ ${escapeHtml(item.key)} + ${label} +
+ Current -> Recommended + ${escapeHtml(JSON.stringify(item.current))} -> ${escapeHtml(JSON.stringify(item.recommended))} +

${escapeHtml(item.rationale)}

+
+ `; + }).join(""); +} + +export function renderProfileHint(profile) { + const hints = { + balanced: "Balanced is the active direct-apply profile.", + speed: "Speed: use lower duplication only after logs show stable resolver behavior.", + reliability: "Reliability: increase duplication and retries when retransmits/timeouts dominate.", + scanner: "Scanner: use equal MTU bounds and clean resolver export for dedicated resolver testing.", + debug: "Debug: temporarily raise LOG_LEVEL to DEBUG while collecting troubleshooting logs." + }; + $("diff").textContent = hints[profile] || ""; +} diff --git a/tools/wrapper-dashboard/public/js/views/editor.js b/tools/wrapper-dashboard/public/js/views/editor.js new file mode 100644 index 00000000..e82902e8 --- /dev/null +++ b/tools/wrapper-dashboard/public/js/views/editor.js @@ -0,0 +1,41 @@ +import { $, escapeHtml } from "../dom.js"; + +export function setEditorStatus(message, type) { + $("editorStatus").textContent = message; + $("editorStatus").className = `editor-status ${type || ""}`; +} + +export function parseResolvers(text) { + const seen = new Set(); + const resolvers = []; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + if (seen.has(line)) continue; + seen.add(line); + resolvers.push(line); + } + return resolvers; +} + +export function updateEditorResolverCount() { + const el = $("editorResolverCount"); + if (!el) return; + el.textContent = `(${parseResolvers($("resolverEditor").value || "").length})`; +} + +export function renderEditorFiles(data) { + $("configEditor").value = data.configText; + $("resolverEditor").value = data.resolverText; + updateEditorResolverCount(); + setEditorStatus("Editor loaded from client_config.toml and client_resolvers.txt.", "ok"); +} + +export function renderSaveResult(result) { + if (!result.saved) { + setEditorStatus(result.errors.map((entry) => escapeHtml(entry)).join(" "), "error"); + return; + } + const warningText = result.warnings?.length ? ` Warnings: ${result.warnings.join(" ")}` : ""; + setEditorStatus(`${result.message} Resolvers saved: ${result.resolverCount}.${warningText}`, "ok"); +} diff --git a/tools/wrapper-dashboard/public/js/views/logs.js b/tools/wrapper-dashboard/public/js/views/logs.js new file mode 100644 index 00000000..0f3baa8f --- /dev/null +++ b/tools/wrapper-dashboard/public/js/views/logs.js @@ -0,0 +1,8 @@ +import { $, escapeHtml } from "../dom.js"; + +export function renderLogs(state) { + $("events").innerHTML = state.parsedEvents.slice(-8).reverse().map((event) => ` +
${escapeHtml(event.type)} ${escapeHtml(event.line)}
+ `).join("") || `
No parsed run events yet.
`; + $("logs").textContent = state.logs.map((entry) => `${entry.time} ${entry.source} ${entry.line}`).join("\n"); +} diff --git a/tools/wrapper-dashboard/public/js/views/monitor.js b/tools/wrapper-dashboard/public/js/views/monitor.js new file mode 100644 index 00000000..c5688a65 --- /dev/null +++ b/tools/wrapper-dashboard/public/js/views/monitor.js @@ -0,0 +1,7 @@ +import { $, escapeHtml } from "../dom.js"; + +export function renderMonitor(state) { + $("monitor").innerHTML = state.recommendations.monitor.map((item) => ` +
${escapeHtml(item)}
+ `).join(""); +} diff --git a/tools/wrapper-dashboard/public/js/views/overview.js b/tools/wrapper-dashboard/public/js/views/overview.js new file mode 100644 index 00000000..f9812b14 --- /dev/null +++ b/tools/wrapper-dashboard/public/js/views/overview.js @@ -0,0 +1,15 @@ +import { $, escapeHtml } from "../dom.js"; + +export function renderOverview(state) { + const rows = [ + ["Status", state.runtime.status], + ["PID", state.runtime.pid || "-"], + ["Uptime", `${state.runtime.uptimeSeconds}s`], + ["Proxy", state.facts.proxy], + ["Resolvers", state.facts.resolverCount], + ["Local DNS", state.facts.localDnsEnabled ? "enabled" : "disabled"] + ]; + $("overview").innerHTML = rows.map(([label, value]) => ` +
${escapeHtml(label)}${escapeHtml(String(value))}
+ `).join(""); +} diff --git a/tools/wrapper-dashboard/public/js/views/plan-board.js b/tools/wrapper-dashboard/public/js/views/plan-board.js new file mode 100644 index 00000000..4383c648 --- /dev/null +++ b/tools/wrapper-dashboard/public/js/views/plan-board.js @@ -0,0 +1,14 @@ +import { $, escapeHtml } from "../dom.js"; + +export function renderStages(state) { + $("stages").innerHTML = state.stages.map((stage) => ` +
+
+ ${escapeHtml(stage.title)} + ${escapeHtml(stage.status)} +
+

${escapeHtml(stage.target)}

+
+
+ `).join(""); +} diff --git a/tools/wrapper-dashboard/public/js/views/resolvers.js b/tools/wrapper-dashboard/public/js/views/resolvers.js new file mode 100644 index 00000000..cf8142da --- /dev/null +++ b/tools/wrapper-dashboard/public/js/views/resolvers.js @@ -0,0 +1,8 @@ +import { $, escapeHtml } from "../dom.js"; + +export function renderResolvers(data) { + $("resolverCount").textContent = `${data.filtered} shown from ${data.total}`; + $("resolvers").innerHTML = data.items.map((item) => ` +
#${item.index}${escapeHtml(item.value)}
+ `).join(""); +} diff --git a/tools/wrapper-dashboard/public/js/views/validation.js b/tools/wrapper-dashboard/public/js/views/validation.js new file mode 100644 index 00000000..c7010271 --- /dev/null +++ b/tools/wrapper-dashboard/public/js/views/validation.js @@ -0,0 +1,11 @@ +import { $, escapeHtml } from "../dom.js"; + +export function renderValidation(state) { + const entries = [ + ...state.validations.errors.map((text) => ({ type: "error", text })), + ...state.validations.warnings.map((text) => ({ type: "warn", text })) + ]; + $("validation").innerHTML = entries.length ? entries.map((entry) => ` +
${entry.type} ${escapeHtml(entry.text)}
+ `).join("") : `
ok No validation errors.
`; +} diff --git a/tools/wrapper-dashboard/public/styles.css b/tools/wrapper-dashboard/public/styles.css new file mode 100644 index 00000000..add9a580 --- /dev/null +++ b/tools/wrapper-dashboard/public/styles.css @@ -0,0 +1,456 @@ +:root { + color-scheme: light; + --bg: #f4f6f8; + --panel: #ffffff; + --text: #15202b; + --muted: #65717f; + --line: #d9e0e7; + --accent: #0f766e; + --accent-2: #2457a7; + --danger: #b42318; + --warn: #b54708; + --ok: #0b7a38; + --shadow: 0 1px 2px rgba(15, 23, 42, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 14px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 76px; + padding: 16px 24px; + background: #ffffff; + border-bottom: 1px solid var(--line); + position: sticky; + top: 0; + z-index: 10; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: 22px; + font-weight: 700; + letter-spacing: 0; +} + +h2 { + font-size: 15px; + margin-bottom: 12px; +} + +#subtitle { + margin-top: 4px; + color: var(--muted); +} + +button { + border: 1px solid var(--line); + background: #fff; + color: var(--text); + min-height: 34px; + padding: 0 12px; + border-radius: 6px; + font: inherit; + cursor: pointer; +} + +button:hover { + border-color: var(--accent); +} + +button.active, +#startBtn { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +#stopBtn { + color: var(--danger); +} + +#saveEditorBtn { + background: var(--accent-2); + border-color: var(--accent-2); + color: #fff; +} + +.top-actions, +.optimizer-actions, +.profile-tabs, +.resolver-tools, +.editor-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.tabbar { + display: flex; + gap: 8px; + padding: 12px 16px 0; + background: var(--bg); + position: sticky; + top: 76px; + z-index: 9; +} + +.tabbar button.active { + background: var(--accent-2); + border-color: var(--accent-2); + color: #fff; +} + +[hidden] { + display: none !important; +} + +.layout { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + padding: 16px; +} + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); + padding: 16px; + min-width: 0; +} + +.wide { + grid-column: span 2; +} + +.full { + grid-column: 1 / -1; +} + +.overview { + grid-column: span 1; +} + +.metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.metric, +.recommendation, +.validation-item, +.event, +.stage { + border: 1px solid var(--line); + border-radius: 6px; + padding: 10px; + background: #fbfcfd; +} + +.metric strong, +.recommendation strong { + display: block; + font-size: 12px; + color: var(--muted); + font-weight: 600; + margin-bottom: 4px; +} + +.stage-list, +.recommendations, +.event-list, +#monitor, +#validation { + display: grid; + gap: 8px; +} + +.score-grid { + display: grid; + grid-template-columns: repeat(5, minmax(120px, 1fr)); + gap: 8px; + margin-bottom: 12px; +} + +.score { + border: 1px solid var(--line); + border-radius: 6px; + padding: 10px; + background: #fbfcfd; +} + +.score header, +.rec-actions, +.rec-meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.score header { + justify-content: space-between; + margin-bottom: 8px; +} + +.score strong { + font-size: 12px; + color: var(--muted); +} + +.ai-actions { + margin-bottom: 10px; +} + +.dynamic-rec p { + margin-top: 8px; + color: var(--text); +} + +.rec-meta { + margin-top: 8px; + color: var(--muted); + font-size: 12px; +} + +.rec-actions { + margin-top: 10px; +} + +.evidence { + margin: 10px 0 0; + padding-left: 20px; + color: var(--muted); +} + +.stage header, +.recommendation header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.bar { + height: 8px; + background: #e8edf2; + border-radius: 999px; + overflow: hidden; + margin-top: 8px; +} + +.bar span { + display: block; + height: 100%; + background: var(--accent-2); +} + +.tag { + border-radius: 999px; + padding: 2px 8px; + font-size: 12px; + background: #e8edf2; + color: var(--muted); + white-space: nowrap; +} + +.tag.done, +.tag.ok { + color: var(--ok); + background: #e8f5ee; +} + +.tag.blocked, +.tag.error { + color: var(--danger); + background: #fdeceb; +} + +.tag.running, +.tag.warn { + color: var(--warn); + background: #fff3e5; +} + +.diff, +.logs { + max-height: 280px; + overflow: auto; + background: #111827; + color: #f9fafb; + border-radius: 6px; + padding: 12px; + white-space: pre-wrap; + line-height: 1.45; +} + +.diff:empty { + display: none; +} + +.editor-toolbar { + margin-bottom: 10px; +} + +.checkline { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 34px; + color: var(--muted); +} + +.editor-status { + min-height: 24px; + margin-bottom: 10px; + color: var(--muted); +} + +.editor-status.ok { + color: var(--ok); +} + +.editor-status.error { + color: var(--danger); +} + +.editor-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); + gap: 12px; +} + +.editor-pane { + display: grid; + gap: 6px; + min-width: 0; +} + +.editor-pane span { + font-weight: 600; + color: var(--muted); + font-size: 12px; +} + +.editor-pane textarea { + width: 100%; + min-height: 440px; + resize: vertical; + border: 1px solid #1f2937; + border-radius: 6px; + background: #111827; + color: #f9fafb; + padding: 12px; + font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +input[type="search"] { + min-height: 34px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0 10px; + font: inherit; + min-width: 240px; +} + +.resolver-list { + height: 360px; + overflow: auto; + margin-top: 10px; + border: 1px solid var(--line); + border-radius: 6px; +} + +.resolver-row { + display: grid; + grid-template-columns: 80px 1fr; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--line); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.resolver-row:nth-child(odd) { + background: #fbfcfd; +} + +.schematic { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin-top: 12px; +} + +.schematic div { + border: 1px solid var(--line); + border-radius: 6px; + min-height: 44px; + display: grid; + place-items: center; + background: #f7fafc; + text-align: center; +} + +.rtl-note { + margin-top: 12px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fbfcfd; + font-family: Tahoma, "Segoe UI", sans-serif; +} + +@media (max-width: 1100px) { + .layout { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .editor-grid { + grid-template-columns: 1fr; + } + + .score-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .topbar, + .layout { + display: block; + } + + .panel { + margin: 12px; + } + + .top-actions { + margin-top: 12px; + } + + .score-grid { + grid-template-columns: 1fr; + } +} diff --git a/tools/wrapper-dashboard/server.js b/tools/wrapper-dashboard/server.js new file mode 100644 index 00000000..179a065d --- /dev/null +++ b/tools/wrapper-dashboard/server.js @@ -0,0 +1,6 @@ +import { shutdownDashboard, startDashboard } from "./src/server/app.js"; + +startDashboard(); + +process.on("SIGINT", shutdownDashboard); +process.on("SIGTERM", shutdownDashboard); diff --git a/tools/wrapper-dashboard/src/server/app.js b/tools/wrapper-dashboard/src/server/app.js new file mode 100644 index 00000000..e8482db7 --- /dev/null +++ b/tools/wrapper-dashboard/src/server/app.js @@ -0,0 +1,1304 @@ +import { createServer } from "node:http"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import net from "node:net"; +import { + BINARY, + CONFIG_FILE, + GOOD_RESOLVERS_FILE, + LOG_FILE, + PORT, + RESOLVERS_FILE, + ROOT, + RULES_FILE, + RUNS_FILE, + SCHEMA_FILE +} from "./paths.js"; +import { serveStatic } from "./static.js"; +import { sendJson } from "./utils/http.js"; + +let clientProcess = null; +let startedAt = null; +let runStatus = "disconnected"; +let logLines = []; +let parsedEvents = []; +let metrics = defaultMetrics(); +let telemetry = defaultTelemetry(); +const ignoredRecommendationIds = new Set(); +const sseClients = new Set(); + +const directRecommendations = [ + { + key: "RESOLVER_BALANCING_STRATEGY", + recommended: 6, + profile: "balanced", + impact: "throughput + resolver distribution", + risk: "low", + requiresServerChange: false, + applyMode: "direct", + rationale: "Loss Then Latency balances quality and distribution better than pure least-loss on large pools." + }, + { + key: "SAVE_MTU_SERVERS_TO_FILE", + recommended: true, + profile: "balanced", + impact: "maintainability + resolver reuse", + risk: "low", + requiresServerChange: false, + applyMode: "direct", + rationale: "Exports successful MTU-tested resolvers after startup." + }, + { + key: "MTU_SERVERS_FILE_FORMAT", + recommended: "{IP}", + profile: "balanced", + impact: "clean resolver export", + risk: "low", + requiresServerChange: false, + applyMode: "direct", + rationale: "Produces a directly reusable one-IP-per-line resolver file." + }, + { + key: "DATA_ENCRYPTION_METHOD", + recommended: 2, + profile: "security", + impact: "stronger tunnel encryption", + risk: "medium", + requiresServerChange: true, + applyMode: "manual-server-coordinated", + rationale: "ChaCha20 is a better security/performance compromise than XOR, but the server must match." + } +]; + +const stages = [ + { id: "resource-load", title: "Resource Load", dependencies: [], target: "Load markdown, schema, TOML, resolvers, and binary version." }, + { id: "config-validation", title: "Config Validation", dependencies: ["resource-load"], target: "Check required keys, ranges, cross-field constraints, and port conflicts." }, + { id: "optimization-review", title: "Optimization Review", dependencies: ["config-validation"], target: "Show direct, optional, and server-coordinated recommendations." }, + { id: "apply-preview", title: "Apply Preview", dependencies: ["optimization-review"], target: "Render a diff before changing TOML." }, + { id: "run-observe", title: "Run & Observe", dependencies: ["apply-preview"], target: "Start/stop client and parse lifecycle log events." }, + { id: "feedback-loop", title: "Feedback Loop", dependencies: ["run-observe"], target: "Adapt recommendations from MTU, loss, latency, and errors." } +]; + +export const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://${req.headers.host}`); + if (url.pathname === "/api/events") return handleEvents(req, res); + if (url.pathname.startsWith("/api/")) return await handleApi(req, res, url); + return await serveStatic(req, res, url); + } catch (error) { + sendJson(res, 500, { error: error.message }); + } +}); + +export function startDashboard() { + ensureOptimizerRulesFile(); + loadExistingLogFile(); + server.listen(PORT, "127.0.0.1", () => { + console.log(`MasterDnsVPN dashboard: http://127.0.0.1:${PORT}`); + }); + return server; +} + +async function handleApi(req, res, url) { + if (req.method === "GET" && url.pathname === "/api/state") { + return sendJson(res, 200, await buildState()); + } + if (req.method === "GET" && url.pathname === "/api/config") { + return sendJson(res, 200, { config: parseToml(readText(CONFIG_FILE)), raw: readText(CONFIG_FILE) }); + } + if (req.method === "GET" && url.pathname === "/api/editor") { + return sendJson(res, 200, { + configText: readText(CONFIG_FILE), + resolverText: readText(RESOLVERS_FILE), + running: Boolean(clientProcess) + }); + } + if (req.method === "POST" && url.pathname === "/api/save-editor") { + const body = await readRequestJson(req); + const result = await saveEditor(body); + return sendJson(res, 200, result); + } + if (req.method === "POST" && url.pathname === "/api/strip-config-comments") { + const body = await readRequestJson(req); + const configText = removeTomlComments(String(body.configText || "")); + return sendJson(res, 200, { configText }); + } + if (req.method === "GET" && url.pathname === "/api/recommendations") { + return sendJson(res, 200, buildRecommendations(parseToml(readText(CONFIG_FILE)))); + } + if (req.method === "GET" && url.pathname === "/api/telemetry") { + return sendJson(res, 200, buildTelemetrySnapshot(parseToml(readText(CONFIG_FILE)))); + } + if (req.method === "GET" && url.pathname === "/api/rules") { + const config = parseToml(readText(CONFIG_FILE)); + return sendJson(res, 200, { rules: loadOptimizerRules(), evaluation: buildDynamicRecommendations(config) }); + } + if (req.method === "GET" && url.pathname === "/api/recommendations/dynamic") { + return sendJson(res, 200, buildDynamicRecommendations(parseToml(readText(CONFIG_FILE)))); + } + if (req.method === "GET" && url.pathname === "/api/ai-context") { + return sendJson(res, 200, buildAIContext(parseToml(readText(CONFIG_FILE)))); + } + if (req.method === "GET" && url.pathname === "/api/run-summaries") { + return sendJson(res, 200, { runs: loadRunSummaries() }); + } + if (req.method === "POST" && url.pathname === "/api/recommendations/preview-patch") { + const body = await readRequestJson(req); + return sendJson(res, 200, previewRecommendationPatch(body)); + } + if (req.method === "POST" && url.pathname === "/api/recommendations/ignore") { + const body = await readRequestJson(req); + if (body.id) ignoredRecommendationIds.add(String(body.id)); + return sendJson(res, 200, { ignored: Array.from(ignoredRecommendationIds) }); + } + if (req.method === "POST" && url.pathname === "/api/resolvers/export-good") { + return sendJson(res, 200, exportGoodResolvers()); + } + if (req.method === "GET" && url.pathname === "/api/preview-balanced") { + return sendJson(res, 200, previewBalanced()); + } + if (req.method === "POST" && url.pathname === "/api/apply-balanced") { + const applied = applyBalanced(); + return sendJson(res, 200, { applied, state: await buildState() }); + } + if (req.method === "POST" && url.pathname === "/api/start") { + return sendJson(res, 200, startClient()); + } + if (req.method === "POST" && url.pathname === "/api/stop") { + return sendJson(res, 200, stopClient()); + } + if (req.method === "POST" && url.pathname === "/api/smoke") { + return sendJson(res, 200, await runSmokeTest()); + } + if (req.method === "GET" && url.pathname === "/api/resolvers") { + return sendJson(res, 200, getResolvers(url)); + } + sendJson(res, 404, { error: "Not found" }); +} + +async function buildState() { + const config = parseToml(readText(CONFIG_FILE)); + const schema = JSON.parse(readText(SCHEMA_FILE)); + const docs = await markdownInventory(); + const resolverCount = lineCount(readText(RESOLVERS_FILE)); + const version = await binaryVersion(); + const validations = await validateConfig(config, schema); + const recommendations = buildRecommendations(config); + const dynamicRecommendations = buildDynamicRecommendations(config); + const telemetrySnapshot = buildTelemetrySnapshot(config); + return { + app: { root: ROOT, port: PORT, binary: BINARY ? basename(BINARY) : null }, + runtime: { + status: runStatus, + pid: clientProcess?.pid || null, + startedAt, + uptimeSeconds: startedAt ? Math.floor((Date.now() - startedAt) / 1000) : 0, + logFile: LOG_FILE + }, + facts: { + version, + resolverCount, + proxy: `${config.LISTEN_IP || "127.0.0.1"}:${config.LISTEN_PORT || 18000}`, + localDnsEnabled: Boolean(config.LOCAL_DNS_ENABLED) + }, + stages: buildStages(validations, recommendations), + config, + docs, + validations, + recommendations, + dynamicRecommendations, + telemetry: telemetrySnapshot, + metrics, + logs: logLines.slice(-200), + parsedEvents: parsedEvents.slice(-100), + schematic: [ + ["Overview", "Plan Stages", "Config Optimizer"], + ["Resolver Pool", "Run Logs", "AI Monitor"], + ["Profiles", "Apply Preview", "Smoke Tests"] + ] + }; +} + +function buildStages(validations, recommendations) { + const directRemaining = recommendations.items.filter((item) => item.applyMode === "direct" && item.current !== item.recommended); + return stages.map((stage) => { + if (stage.id === "resource-load") return stageState(stage, "done", 100); + if (stage.id === "config-validation") return stageState(stage, validations.errors.length ? "blocked" : "done", validations.errors.length ? 55 : 100); + if (stage.id === "optimization-review") return stageState(stage, "done", 100); + if (stage.id === "apply-preview") return stageState(stage, directRemaining.length ? "running" : "done", directRemaining.length ? 65 : 100); + if (stage.id === "run-observe") return stageState(stage, clientProcess ? "running" : "pending", clientProcess ? 60 : 0); + return stageState(stage, metrics.runs > 0 ? "done" : "pending", metrics.runs > 0 ? 100 : 0); + }); +} + +function stageState(stage, status, progress) { + return { ...stage, status, progress, actions: stageActions(stage.id, status) }; +} + +function stageActions(id, status) { + if (id === "apply-preview") return ["Preview Balanced", "Apply Balanced"]; + if (id === "run-observe") return clientProcess ? ["Stop Client"] : ["Start Client"]; + if (id === "feedback-loop") return ["Run Smoke Test", "Review AI Monitor"]; + return status === "blocked" ? ["Fix Validation"] : []; +} + +async function validateConfig(config, schema) { + const errors = []; + const warnings = []; + const props = schema.properties || {}; + for (const [key, spec] of Object.entries(props)) { + if ((spec.required || schema.required?.includes(key)) && config[key] === undefined) { + errors.push(`${key} is required.`); + } + if (config[key] === undefined) continue; + const value = config[key]; + if (spec.type === "integer" && !Number.isInteger(value)) errors.push(`${key} must be an integer.`); + if (spec.type === "number" && typeof value !== "number") errors.push(`${key} must be a number.`); + if (spec.type === "boolean" && typeof value !== "boolean") errors.push(`${key} must be a boolean.`); + if (spec.type === "string" && typeof value !== "string") errors.push(`${key} must be a string.`); + if (spec.type === "array" && !Array.isArray(value)) errors.push(`${key} must be an array.`); + if (typeof value === "number") { + if (spec.minimum !== undefined && value < spec.minimum) errors.push(`${key} is below minimum ${spec.minimum}.`); + if (spec.maximum !== undefined && value > spec.maximum) errors.push(`${key} is above maximum ${spec.maximum}.`); + if (spec.exclusiveMinimum !== undefined && value <= spec.exclusiveMinimum) errors.push(`${key} must be greater than ${spec.exclusiveMinimum}.`); + } + if (spec.enum && !spec.enum.includes(value)) errors.push(`${key} is not one of ${spec.enum.join(", ")}.`); + } + if ((config.MIN_UPLOAD_MTU ?? 0) > (config.MAX_UPLOAD_MTU ?? Infinity)) errors.push("MIN_UPLOAD_MTU must be <= MAX_UPLOAD_MTU."); + if ((config.MIN_DOWNLOAD_MTU ?? 0) > (config.MAX_DOWNLOAD_MTU ?? Infinity)) errors.push("MIN_DOWNLOAD_MTU must be <= MAX_DOWNLOAD_MTU."); + if ((config.PING_AGGRESSIVE_INTERVAL_SECONDS ?? 0) >= (config.PING_LAZY_INTERVAL_SECONDS ?? Infinity)) warnings.push("PING_AGGRESSIVE_INTERVAL_SECONDS should be lower than PING_LAZY_INTERVAL_SECONDS."); + if ((config.PING_WARM_THRESHOLD_SECONDS ?? 0) >= (config.PING_COOL_THRESHOLD_SECONDS ?? Infinity)) warnings.push("PING_WARM_THRESHOLD_SECONDS should be lower than PING_COOL_THRESHOLD_SECONDS."); + if (config.DATA_ENCRYPTION_METHOD === 1) warnings.push("DATA_ENCRYPTION_METHOD=1 uses XOR. ChaCha20 is recommended only with matching server config."); + if (config.LOCAL_DNS_ENABLED && await isPortOpen(config.LOCAL_DNS_IP || "127.0.0.1", config.LOCAL_DNS_PORT || 53)) { + errors.push(`LOCAL_DNS ${config.LOCAL_DNS_IP}:${config.LOCAL_DNS_PORT} appears to be in use.`); + } + return { errors, warnings }; +} + +async function validateConfigText(configText) { + const schema = JSON.parse(readText(SCHEMA_FILE)); + const config = parseToml(configText); + const validations = await validateConfig(config, schema); + if (!configText.trim()) validations.errors.push("Configuration cannot be empty."); + if (!Object.hasOwn(config, "DOMAINS")) validations.errors.push("DOMAINS is required."); + if (!Object.hasOwn(config, "ENCRYPTION_KEY")) validations.errors.push("ENCRYPTION_KEY is required."); + if (Array.isArray(config.DOMAINS) && config.DOMAINS.length === 0) validations.errors.push("DOMAINS must contain at least one domain."); + if (typeof config.ENCRYPTION_KEY === "string" && !config.ENCRYPTION_KEY.trim()) validations.errors.push("ENCRYPTION_KEY cannot be empty."); + return { config, validations }; +} + +async function saveEditor(body) { + const configText = String(body.configText ?? ""); + const resolverText = String(body.resolverText ?? ""); + const shouldRestart = Boolean(body.restart); + const resolvers = normalizeResolvers(resolverText); + const { config, validations } = await validateConfigText(configText); + + if (resolvers.length === 0) validations.errors.push("Add at least one resolver before saving."); + if (validations.errors.length) { + return { saved: false, errors: validations.errors, warnings: validations.warnings }; + } + + writeFileSync(CONFIG_FILE, ensureTrailingNewline(configText), "utf8"); + writeFileSync(RESOLVERS_FILE, `${resolvers.join("\n")}\n`, "utf8"); + + const restartResult = shouldRestart && clientProcess ? restartClient() : null; + + return { + saved: true, + config, + resolverCount: resolvers.length, + warnings: validations.warnings, + running: Boolean(clientProcess), + restartResult, + message: clientProcess && !shouldRestart + ? "Saved. Restart the client for changes to affect the running process." + : "Saved." + }; +} + +function normalizeResolvers(text) { + const seen = new Set(); + const resolvers = []; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + if (seen.has(line)) continue; + seen.add(line); + resolvers.push(line); + } + return resolvers; +} + +function buildRecommendations(config) { + const items = directRecommendations.map((item) => ({ ...item, current: config[item.key] })); + const monitor = []; + if (metrics.timeoutWarnings > 20) monitor.push("Many resolver timeout warnings detected; consider lowering MTU_TEST_PARALLELISM or increasing MTU_TEST_TIMEOUT."); + if (metrics.mtuRejected > 50 && metrics.mtuValid === 0) monitor.push("Last run rejected many resolvers with no valid MTU path. Verify DOMAINS delegation, server reachability on UDP 53, ENCRYPTION_KEY, and DATA_ENCRYPTION_METHOD before tuning performance."); + if (metrics.lastRejectReason === "UPLOAD_MTU") monitor.push("Upload MTU rejections dominated the last run; increasing MTU_TEST_TIMEOUT or reducing MTU_TEST_PARALLELISM may help only after server/domain reachability is confirmed."); + if (metrics.noValidConnections) monitor.push("The client exited because MTU testing found no valid connections."); + if (metrics.arqEvents > 10) monitor.push("ARQ retransmission/NACK activity is high; consider Reliability profile."); + if (metrics.syncedUploadMtu && metrics.syncedUploadMtu < 80) monitor.push("Synced upload MTU is low; keep AUTO_REMOVE_LOW_MTU_SERVERS enabled and export healthy resolvers."); + if (!monitor.length) monitor.push("No run-derived changes yet. Start the client or run a smoke test to collect feedback."); + return { profile: "balanced", items, monitor }; +} + +function buildTelemetrySnapshot(config) { + const validRatio = metrics.mtuTotal ? (metrics.mtuValid || 0) / metrics.mtuTotal : null; + const now = Date.now(); + const recentStreams = telemetry.streams.timestamps.filter((time) => now - time <= 60_000); + const activeResolvers = telemetry.resolvers.activeTotal ?? metrics.mtuValid ?? 0; + return { + generatedAt: new Date().toISOString(), + config: { + resolverStrategy: config.RESOLVER_BALANCING_STRATEGY, + packetDuplication: config.PACKET_DUPLICATION_COUNT, + uploadCompression: config.UPLOAD_COMPRESSION_TYPE, + downloadCompression: config.DOWNLOAD_COMPRESSION_TYPE, + minUploadMtu: config.MIN_UPLOAD_MTU, + maxUploadMtu: config.MAX_UPLOAD_MTU, + minDownloadMtu: config.MIN_DOWNLOAD_MTU, + maxDownloadMtu: config.MAX_DOWNLOAD_MTU, + mtuParallelism: config.MTU_TEST_PARALLELISM, + tunnelProcessWorkers: config.TUNNEL_PROCESS_WORKERS, + tunnelPacketTimeout: config.TUNNEL_PACKET_TIMEOUT_SECONDS + }, + mtu: { + totalResolvers: metrics.mtuTotal, + validResolvers: metrics.mtuValid, + rejectedResolvers: metrics.mtuRejected, + validRatio, + syncedUploadMtu: metrics.syncedUploadMtu, + syncedDownloadMtu: metrics.syncedDownloadMtu, + rejectsByReason: Object.fromEntries(telemetry.mtu.rejectsByReason), + validTable: telemetry.mtu.validResolvers.slice(-100) + }, + compression: telemetry.compression, + session: telemetry.session, + runtime: { + status: runStatus, + pid: clientProcess?.pid || null, + rxTxWorkers: telemetry.runtime.rxTxWorkers, + processWorkers: telemetry.runtime.processWorkers, + stateDrift: detectStateDrift() + }, + streams: { + total: telemetry.streams.total, + perMinute: recentStreams.length, + topTargets: topEntries(telemetry.streams.targets, 12) + }, + resolvers: { + activeTotal: activeResolvers, + disabled: Array.from(telemetry.resolvers.disabled.values()).slice(-100), + reactivated: Array.from(telemetry.resolvers.reactivated.values()).slice(-100), + knownGood: knownGoodResolvers().slice(0, 200) + }, + scores: buildOptimizerScores(validRatio, activeResolvers, recentStreams.length), + recentImportantEvents: importantEvents(80) + }; +} + +function buildDynamicRecommendations(config) { + const rules = loadOptimizerRules(); + const recs = []; + const validRatio = metrics.mtuTotal ? (metrics.mtuValid || 0) / metrics.mtuTotal : null; + const rejectReasons = Object.fromEntries(telemetry.mtu.rejectsByReason); + const uploadRejects = rejectReasons.UPLOAD_MTU || 0; + const downloadRejects = rejectReasons.DOWNLOAD_MTU || 0; + const recentStreamRate = telemetry.streams.timestamps.filter((time) => Date.now() - time <= 60_000).length; + const activeTotal = telemetry.resolvers.activeTotal ?? metrics.mtuValid ?? 0; + const stateDrift = detectStateDrift(); + + if (validRatio !== null && validRatio < 0.01 && telemetry.session.initialized) { + recs.push(ruleRecommendation(rules, "low-valid-ratio-session-ok", { + configPatch: { + SAVE_MTU_SERVERS_TO_FILE: true, + MTU_SERVERS_FILE_FORMAT: "{IP}" + }, + evidence: [ + `${metrics.mtuValid || 0}/${metrics.mtuTotal || 0} resolvers passed MTU testing.`, + `Session initialized successfully${telemetry.session.id ? ` with ID ${telemetry.session.id}` : ""}.` + ] + })); + } + + if (uploadRejects > downloadRejects && config.MIN_UPLOAD_MTU === config.MAX_UPLOAD_MTU && config.MIN_UPLOAD_MTU <= 64) { + recs.push(ruleRecommendation(rules, "keep-fixed-low-upload-mtu", { + configPatch: { + MIN_UPLOAD_MTU: config.MIN_UPLOAD_MTU, + MAX_UPLOAD_MTU: config.MAX_UPLOAD_MTU + }, + evidence: [ + `UPLOAD_MTU rejects dominate (${uploadRejects} upload vs ${downloadRejects} download).`, + `Upload MTU is fixed at ${config.MIN_UPLOAD_MTU}.` + ] + })); + } + + if (downloadRejects > 0 && metrics.syncedDownloadMtu && config.MAX_DOWNLOAD_MTU > metrics.syncedDownloadMtu) { + recs.push(ruleRecommendation(rules, "lower-download-mtu-upper-bound", { + configPatch: { + MAX_DOWNLOAD_MTU: metrics.syncedDownloadMtu + }, + evidence: [ + `${downloadRejects} DOWNLOAD_MTU rejects were observed.`, + `Synced download MTU settled at ${metrics.syncedDownloadMtu}, below MAX_DOWNLOAD_MTU=${config.MAX_DOWNLOAD_MTU}.` + ] + })); + } + + if (telemetry.compression.uploadDisabledReason && telemetry.compression.effectiveUpload === "OFF") { + recs.push(ruleRecommendation(rules, "disable-upload-compression-low-mtu", { + configPatch: { + UPLOAD_COMPRESSION_TYPE: 0, + DOWNLOAD_COMPRESSION_TYPE: config.DOWNLOAD_COMPRESSION_TYPE + }, + evidence: [ + `Upload compression disabled by runtime: ${telemetry.compression.uploadDisabledReason}.`, + `Effective compression is Upload=${telemetry.compression.effectiveUpload}, Download=${telemetry.compression.effectiveDownload || "unknown"}.` + ] + })); + } + + if (telemetry.resolvers.reactivated.size > 0) { + recs.push(ruleRecommendation(rules, "wait-and-export-reactivated-resolvers", { + configPatch: {}, + evidence: [ + `${telemetry.resolvers.reactivated.size} resolvers reactivated after startup.`, + `Active resolver total reached ${activeTotal}.` + ] + })); + } + + if (telemetry.resolvers.disabled.size > 0) { + recs.push(ruleRecommendation(rules, "downgrade-lossy-resolvers", { + configPatch: {}, + evidence: [ + `${telemetry.resolvers.disabled.size} resolvers disabled during runtime.`, + "Disabled resolvers will be excluded from known-good export." + ] + })); + } + + if (telemetry.session.initialized && recentStreamRate > 60 && activeTotal > 0 && activeTotal < 10) { + recs.push(ruleRecommendation(rules, "high-stream-rate-low-active-resolvers", { + configPatch: {}, + evidence: [ + `${recentStreamRate} SOCKS streams opened in the last minute.`, + `Only ${activeTotal} active resolvers are currently known.` + ] + })); + } + + if (stateDrift) { + recs.push(ruleRecommendation(rules, "wrapper-state-drift", { + configPatch: {}, + evidence: [ + `Dashboard state is ${runStatus} while PID ${clientProcess?.pid || "unknown"} still appears active.` + ] + })); + } + + return { + generatedAt: new Date().toISOString(), + ignored: Array.from(ignoredRecommendationIds), + recommendations: recs.filter((rec) => !ignoredRecommendationIds.has(rec.id)) + }; +} + +function ruleRecommendation(rules, id, override) { + const rule = rules.find((item) => item.id === id) || {}; + return { + id, + severity: rule.severity || "info", + condition: rule.condition || "", + recommendation: rule.recommendation || "", + confidence: rule.confidence || "medium", + requiresRestart: Boolean(rule.requiresRestart), + requiresServerChange: Boolean(rule.requiresServerChange), + configPatch: override.configPatch || rule.configPatch || {}, + evidence: override.evidence || [], + applyMode: Object.keys(override.configPatch || {}).length ? "preview-only" : "advisory" + }; +} + +function previewRecommendationPatch(body) { + const selectedIds = new Set((body.ids || []).map(String)); + const dynamic = buildDynamicRecommendations(parseToml(readText(CONFIG_FILE))).recommendations; + const patch = {}; + for (const rec of dynamic) { + if (selectedIds.size && !selectedIds.has(rec.id)) continue; + if (rec.requiresServerChange) continue; + for (const [key, value] of Object.entries(rec.configPatch || {})) { + if (["DOMAINS", "ENCRYPTION_KEY", "DATA_ENCRYPTION_METHOD"].includes(key)) continue; + patch[key] = value; + } + } + const original = readText(CONFIG_FILE); + const updated = updateToml(original, Object.entries(patch).map(([key, recommended]) => ({ key, recommended }))); + return { + changed: original !== updated, + patch, + diff: simpleDiff(original, updated) + }; +} + +function buildAIContext(config) { + const redactedConfig = {}; + const keys = [ + "DOMAINS", + "DATA_ENCRYPTION_METHOD", + "ENCRYPTION_KEY", + "RESOLVER_BALANCING_STRATEGY", + "PACKET_DUPLICATION_COUNT", + "SETUP_PACKET_DUPLICATION_COUNT", + "UPLOAD_COMPRESSION_TYPE", + "DOWNLOAD_COMPRESSION_TYPE", + "MIN_UPLOAD_MTU", + "MAX_UPLOAD_MTU", + "MIN_DOWNLOAD_MTU", + "MAX_DOWNLOAD_MTU", + "MTU_TEST_RETRIES", + "MTU_TEST_TIMEOUT", + "MTU_TEST_PARALLELISM", + "AUTO_REMOVE_LOW_MTU_SERVERS", + "TUNNEL_PROCESS_WORKERS", + "RX_TX_WORKERS", + "TUNNEL_PACKET_TIMEOUT_SECONDS", + "ARQ_WINDOW_SIZE", + "LOG_LEVEL" + ]; + for (const key of keys) { + if (key in config) redactedConfig[key] = key === "ENCRYPTION_KEY" ? maskSecret(config[key]) : config[key]; + } + return { + generatedAt: new Date().toISOString(), + version: BINARY ? basename(BINARY) : null, + config: redactedConfig, + telemetry: buildTelemetrySnapshot(config), + recommendations: buildDynamicRecommendations(config).recommendations, + topWarnings: importantEvents(50).filter((event) => ["mtu-reject", "mtu-failed", "resolver-disabled", "compression"].includes(event.type)).slice(-20) + }; +} + +function previewBalanced() { + const original = readText(CONFIG_FILE); + const updated = updateToml(original, directRecommendations.filter((item) => item.applyMode === "direct")); + return { changed: original !== updated, diff: simpleDiff(original, updated) }; +} + +function applyBalanced() { + const original = readText(CONFIG_FILE); + const direct = directRecommendations.filter((item) => item.applyMode === "direct"); + const updated = updateToml(original, direct); + if (updated !== original) writeFileSync(CONFIG_FILE, updated); + return direct.map((item) => item.key); +} + +function updateToml(text, recommendations) { + let next = text; + for (const item of recommendations) { + const value = formatTomlValue(item.recommended); + const pattern = new RegExp(`^(${escapeRegExp(item.key)}\\s*=\\s*).*$`, "m"); + if (pattern.test(next)) next = next.replace(pattern, `$1${value}`); + } + return next; +} + +function startClient() { + if (clientProcess) return { status: "already-running", pid: clientProcess.pid }; + if (!BINARY) return { status: "error", error: "Client binary not found." }; + logLines = []; + parsedEvents = []; + metrics = defaultMetrics(); + telemetry = defaultTelemetry(); + startedAt = Date.now(); + runStatus = "connecting"; + writeFileSync(LOG_FILE, ""); + clientProcess = spawn(BINARY, ["-config", CONFIG_FILE], { cwd: ROOT }); + attachProcessStream(clientProcess.stdout, "stdout"); + attachProcessStream(clientProcess.stderr, "stderr"); + clientProcess.on("exit", (code, signal) => { + appendLog(`[INFO] Client exited code=${code} signal=${signal || "none"}`, "system"); + persistRunSummary(code, signal); + clientProcess = null; + runStatus = "disconnected"; + metrics.runs += 1; + broadcast({ type: "state", payload: { status: runStatus } }); + }); + return { status: "started", pid: clientProcess.pid }; +} + +function stopClient() { + if (!clientProcess) return { status: "not-running" }; + clientProcess.kill("SIGTERM"); + runStatus = "stopping"; + return { status: "stopping", pid: clientProcess.pid }; +} + +function restartClient() { + if (!clientProcess) return startClient(); + const pid = clientProcess.pid; + clientProcess.once("exit", () => { + setTimeout(() => startClient(), 300); + }); + stopClient(); + return { status: "restarting", pid }; +} + +async function runSmokeTest() { + const config = parseToml(readText(CONFIG_FILE)); + const proxy = `${config.LISTEN_IP || "127.0.0.1"}:${config.LISTEN_PORT || 18000}`; + const result = await runCommand("curl", ["--socks5", proxy, "-s", "-o", "/dev/null", "-w", "%{http_code}", "--connect-timeout", "10", "http://httpbin.org/ip"], 15000); + metrics.smokeTests += 1; + metrics.lastSmoke = { proxy, ok: result.stdout.trim() === "200", output: result.stdout.trim(), error: result.stderr.trim(), code: result.code }; + broadcast({ type: "metrics", payload: metrics }); + return metrics.lastSmoke; +} + +function attachProcessStream(stream, source) { + const rl = createInterface({ input: stream }); + rl.on("line", (line) => appendLog(line, source)); +} + +function appendLog(line, source) { + const item = { time: new Date().toISOString(), source, line }; + logLines.push(item); + if (logLines.length > 1000) logLines.shift(); + parseLogLine(item); + writeFileSync(LOG_FILE, logLines.map((entry) => `${entry.time} ${entry.source} ${entry.line}`).join("\n") + "\n"); + broadcast({ type: "log", payload: item }); +} + +function loadExistingLogFile() { + if (!existsSync(LOG_FILE)) return; + const lines = readText(LOG_FILE).split(/\r?\n/).filter(Boolean).slice(-1000); + for (const line of lines) { + const match = line.match(/^(\S+)\s+(\S+)\s+(.+)$/); + const item = match + ? { time: match[1], source: match[2], line: match[3] } + : { time: new Date().toISOString(), source: "file", line }; + logLines.push(item); + parseLogLine(item, false); + } +} + +function parseLogLine(item) { + const line = item.line; + let type = null; + let details = {}; + if (/session.*init|initialized|SESSION_ACCEPT/i.test(line)) { + type = "session"; + const attempt = line.match(/Session init attempt with\s+(\S+)\s+and resolver\s+(\S+)/i); + const success = line.match(/Session Initialized Successfully \(ID:\s*(\d+)\)/i); + if (attempt) { + telemetry.session.initDomain = attempt[1]; + telemetry.session.initResolver = attempt[2]; + details = { domain: attempt[1], resolver: attempt[2], phase: "attempt" }; + } + if (success) { + runStatus = "connected"; + telemetry.session.initialized = true; + telemetry.session.id = Number(success[1]); + details = { id: telemetry.session.id, phase: "success" }; + } + } else if (/MTU discovery.*start|Starting MTU/i.test(line)) { + type = "mtu-start"; + metrics.mtuStartedAt = item.time; + } else if (/MTU discovery.*complete|Synced .*MTU/i.test(line)) { + type = "mtu"; + const upload = line.match(/Upload MTU:?\s*(\d+)/i); + const download = line.match(/Download MTU:?\s*(\d+)/i); + if (upload) metrics.syncedUploadMtu = Number(upload[1]); + if (download) metrics.syncedDownloadMtu = Number(download[1]); + details = { syncedUploadMtu: metrics.syncedUploadMtu, syncedDownloadMtu: metrics.syncedDownloadMtu }; + } else if (/^\d{4}\/\d{2}\/\d{2}.*\s+\d+\s+\d+\s+\S+\s+\S+/.test(line) && /\[INFO\]\s+\S+:\d+\s+\d+\s+\d+/.test(line)) { + type = "valid-resolver"; + const valid = line.match(/\[INFO\]\s+(\S+:\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)/); + if (valid) { + details = { + resolver: valid[1], + uploadMtu: Number(valid[2]), + downloadMtu: Number(valid[3]), + resolveTime: valid[4], + domain: valid[5] + }; + upsertByKey(telemetry.mtu.validResolvers, details, "resolver"); + } + } else if (/Session Compression Upload:.*Disabled/i.test(line)) { + type = "compression"; + const match = line.match(/Session Compression Upload:\s+(\S+)\s+\(Disabled due to ([^)]+)\)/i); + if (match) { + telemetry.compression.requestedUpload = match[1]; + telemetry.compression.uploadDisabledReason = match[2]; + details = { requestedUpload: match[1], disabledReason: match[2] }; + } + } else if (/Effective Compression Upload:/i.test(line)) { + type = "compression"; + const match = line.match(/Effective Compression Upload:\s+(\S+)\s+Download:\s+(\S+)/i); + if (match) { + telemetry.compression.effectiveUpload = match[1]; + telemetry.compression.effectiveDownload = match[2]; + details = { effectiveUpload: match[1], effectiveDownload: match[2] }; + } + } else if (/Async Runtime Initialized:/i.test(line)) { + type = "runtime"; + const match = line.match(/Async Runtime Initialized:\s+(\d+)\s+RX\/TX Workers,\s+(\d+)\s+Processors/i); + if (match) { + telemetry.runtime.rxTxWorkers = Number(match[1]); + telemetry.runtime.processWorkers = Number(match[2]); + details = { rxTxWorkers: telemetry.runtime.rxTxWorkers, processWorkers: telemetry.runtime.processWorkers }; + } + } else if (/New SOCKS5 TCP CONNECT/i.test(line)) { + type = "stream"; + const match = line.match(/CONNECT to\s+(.+?):(\d+),\s+Stream ID:\s+(\d+)/i); + if (match) { + const target = `${match[1]}:${match[2]}`; + telemetry.streams.total += 1; + telemetry.streams.timestamps.push(Date.parse(item.time) || Date.now()); + if (telemetry.streams.timestamps.length > 2000) telemetry.streams.timestamps = telemetry.streams.timestamps.slice(-2000); + telemetry.streams.targets.set(target, (telemetry.streams.targets.get(target) || 0) + 1); + details = { host: match[1], port: Number(match[2]), streamId: Number(match[3]), target }; + } + } else if (/DNS Resolver disabled/i.test(line)) { + type = "resolver-disabled"; + const match = line.match(/DNS Resolver disabled \(([^)]+)\):\s+(\S+).*Remaining:\s+(\d+)/i); + if (match) { + const entry = { resolver: match[2], reason: match[1], remaining: Number(match[3]), time: item.time }; + telemetry.resolvers.disabled.set(entry.resolver, entry); + telemetry.resolvers.activeTotal = entry.remaining; + details = entry; + } + } else if (/DNS Resolver Reactivated/i.test(line)) { + type = "resolver-reactivated"; + const match = line.match(/DNS Resolver Reactivated:\s+(\S+).*Total Active:\s+(\d+)/i); + if (match) { + const entry = { resolver: match[1], activeTotal: Number(match[2]), time: item.time }; + telemetry.resolvers.reactivated.set(entry.resolver, entry); + telemetry.resolvers.activeTotal = entry.activeTotal; + details = entry; + } + } else if (/timeout/i.test(line)) { + type = "timeout"; + metrics.timeoutWarnings += 1; + } else if (/Rejected .*reason=/i.test(line)) { + type = "mtu-reject"; + metrics.mtuRejectedEvents += 1; + const totals = line.match(/totals:\s*valid=(\d+),\s*rejected=(\d+)/i); + const total = line.match(/Rejected\s*\((\d+)\/(\d+)\)/i); + const reason = line.match(/reason=([A-Z0-9_]+)/i); + if (totals) { + metrics.mtuValid = Number(totals[1]); + metrics.mtuRejected = Math.max(metrics.mtuRejected, Number(totals[2])); + } else { + metrics.mtuRejected = Math.max(metrics.mtuRejected, metrics.mtuRejectedEvents); + } + if (total) metrics.mtuTotal = Number(total[2]); + if (reason) metrics.lastRejectReason = reason[1]; + if (reason) telemetry.mtu.rejectsByReason.set(reason[1], (telemetry.mtu.rejectsByReason.get(reason[1]) || 0) + 1); + details = { + resolver: line.match(/via\s+(\S+)/i)?.[1] || null, + domain: line.match(/Rejected \([^)]*\):\s+(\S+)/i)?.[1] || null, + reason: reason?.[1] || null, + value: Number(line.match(/value=(\d+)/i)?.[1] || 0), + valid: metrics.mtuValid, + rejected: metrics.mtuRejected, + total: metrics.mtuTotal + }; + } else if (/retransmit|NACK|ARQ/i.test(line)) { + type = "arq"; + metrics.arqEvents += 1; + } else if (/No valid connections|MTU tests failed/i.test(line)) { + type = "mtu-failed"; + metrics.errors += 1; + metrics.noValidConnections = true; + } else if (/\[ERROR\]|error/i.test(line)) { + type = "error"; + metrics.errors += 1; + } + if (type) { + const event = { ...item, type, details }; + parsedEvents.push(event); + if (parsedEvents.length > 300) parsedEvents.shift(); + telemetry.events.push(event); + if (telemetry.events.length > 1000) telemetry.events.shift(); + broadcast({ type: "parsed-event", payload: event }); + } +} + +function handleEvents(req, res) { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive" + }); + res.write(`data: ${JSON.stringify({ type: "hello", payload: { status: runStatus } })}\n\n`); + sseClients.add(res); + req.on("close", () => sseClients.delete(res)); +} + +function broadcast(message) { + for (const client of sseClients) { + client.write(`data: ${JSON.stringify(message)}\n\n`); + } +} + +function getResolvers(url) { + const query = (url.searchParams.get("q") || "").trim().toLowerCase(); + const offset = Math.max(0, Number(url.searchParams.get("offset") || 0)); + const limit = Math.min(500, Math.max(1, Number(url.searchParams.get("limit") || 100))); + const all = readText(RESOLVERS_FILE).split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const filtered = query ? all.filter((resolver) => resolver.toLowerCase().includes(query)) : all; + return { total: all.length, filtered: filtered.length, offset, limit, items: filtered.slice(offset, offset + limit).map((value, index) => ({ index: offset + index + 1, value })) }; +} + +async function markdownInventory() { + const files = await listMarkdown(ROOT); + return files.map((file) => { + const text = readText(file); + return { + path: file.replace(`${ROOT}/`, ""), + title: (text.match(/^#\s+(.+)$/m)?.[1] || basename(file)), + lines: lineCount(text) + }; + }); +} + +async function listMarkdown(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + if (entry.name === "node_modules" || entry.name === ".git") continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) files.push(...await listMarkdown(full)); + if (entry.isFile() && entry.name.endsWith(".md")) files.push(full); + } + return files.sort(); +} + +function parseToml(text) { + const config = {}; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#") || !line.includes("=")) continue; + const [rawKey, ...rest] = line.split("="); + const key = rawKey.trim(); + const value = stripInlineComment(rest.join("=").trim()); + config[key] = parseTomlValue(value); + } + return config; +} + +function removeTomlComments(text) { + const lines = text + .split(/\r?\n/) + .map(stripTomlComment) + .filter((line, index, all) => line.trim() || (index > 0 && all[index - 1].trim())); + return ensureTrailingNewline(lines.join("\n")); +} + +function stripTomlComment(line) { + let result = ""; + let inSingleQuote = false; + let inDoubleQuote = false; + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + const previous = index > 0 ? line[index - 1] : ""; + if (character === '"' && previous !== "\\" && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + } else if (character === "'" && previous !== "\\" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + } else if (character === "#" && !inSingleQuote && !inDoubleQuote) { + break; + } + result += character; + } + return result.trimEnd(); +} + +function parseTomlValue(value) { + if (value.startsWith("[") && value.endsWith("]")) { + const inner = value.slice(1, -1).trim(); + if (!inner) return []; + return inner.split(",").map((part) => parseTomlValue(part.trim())); + } + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1); + if (value === "true") return true; + if (value === "false") return false; + if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value); + return value; +} + +function stripInlineComment(value) { + let quoted = false; + let quote = ""; + for (let i = 0; i < value.length; i += 1) { + if ((value[i] === '"' || value[i] === "'") && value[i - 1] !== "\\") { + quoted = !quoted; + quote = quoted ? value[i] : ""; + } + if (!quoted && value[i] === "#") return value.slice(0, i).trim(); + } + return value.trim(); +} + +function ensureTrailingNewline(text) { + return text.endsWith("\n") ? text : `${text}\n`; +} + +function readRequestJson(req) { + return new Promise((resolveRequest, rejectRequest) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + if (body.length > 2_000_000) { + req.destroy(); + rejectRequest(new Error("Request body is too large.")); + } + }); + req.on("end", () => { + if (!body.trim()) return resolveRequest({}); + try { + resolveRequest(JSON.parse(body)); + } catch (error) { + rejectRequest(new Error(`Invalid JSON body: ${error.message}`)); + } + }); + req.on("error", rejectRequest); + }); +} + +function formatTomlValue(value) { + if (typeof value === "string") return `"${value.replaceAll('"', '\\"')}"`; + if (Array.isArray(value)) return `[${value.map(formatTomlValue).join(", ")}]`; + return String(value); +} + +function simpleDiff(before, after) { + const a = before.split(/\r?\n/); + const b = after.split(/\r?\n/); + const rows = []; + for (let i = 0; i < Math.max(a.length, b.length); i += 1) { + if (a[i] !== b[i]) { + if (a[i] !== undefined) rows.push({ type: "remove", line: i + 1, text: a[i] }); + if (b[i] !== undefined) rows.push({ type: "add", line: i + 1, text: b[i] }); + } + } + return rows; +} + +async function binaryVersion() { + if (!BINARY) return null; + const result = await runCommand(BINARY, ["-version"], 5000); + return result.stdout.trim() || result.stderr.trim() || null; +} + +function runCommand(command, args, timeoutMs) { + return new Promise((resolveResult) => { + const child = spawn(command, args, { cwd: ROOT }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => child.kill("SIGTERM"), timeoutMs); + child.stdout.on("data", (chunk) => stdout += chunk.toString()); + child.stderr.on("data", (chunk) => stderr += chunk.toString()); + child.on("close", (code) => { + clearTimeout(timer); + resolveResult({ code, stdout, stderr }); + }); + child.on("error", (error) => { + clearTimeout(timer); + resolveResult({ code: -1, stdout, stderr: error.message }); + }); + }); +} + +function isPortOpen(host, port) { + return new Promise((resolveResult) => { + const socket = net.createConnection({ host, port, timeout: 300 }); + socket.on("connect", () => { + socket.destroy(); + resolveResult(true); + }); + socket.on("timeout", () => { + socket.destroy(); + resolveResult(false); + }); + socket.on("error", () => resolveResult(false)); + }); +} + +function defaultMetrics() { + return { + runs: 0, + smokeTests: 0, + timeoutWarnings: 0, + mtuRejected: 0, + mtuRejectedEvents: 0, + mtuValid: null, + mtuTotal: null, + lastRejectReason: null, + noValidConnections: false, + arqEvents: 0, + errors: 0, + syncedUploadMtu: null, + syncedDownloadMtu: null, + mtuStartedAt: null, + lastSmoke: null + }; +} + +function defaultTelemetry() { + return { + events: [], + mtu: { + rejectsByReason: new Map(), + validResolvers: [] + }, + compression: { + requestedUpload: null, + requestedDownload: null, + effectiveUpload: null, + effectiveDownload: null, + uploadDisabledReason: null + }, + session: { + initialized: false, + id: null, + initDomain: null, + initResolver: null + }, + runtime: { + rxTxWorkers: null, + processWorkers: null + }, + streams: { + total: 0, + timestamps: [], + targets: new Map() + }, + resolvers: { + activeTotal: null, + disabled: new Map(), + reactivated: new Map() + } + }; +} + +function ensureOptimizerRulesFile() { + if (existsSync(RULES_FILE)) return; + writeFileSync(RULES_FILE, JSON.stringify(defaultOptimizerRules(), null, 2) + "\n", "utf8"); +} + +function defaultOptimizerRules() { + return [ + { + id: "low-valid-ratio-session-ok", + severity: "warn", + condition: "validResolvers / totalResolvers < 0.01 && session.initialized", + recommendation: "Export and reuse working resolvers; reduce resolver-list noise after observing recovery.", + configPatch: { SAVE_MTU_SERVERS_TO_FILE: true, MTU_SERVERS_FILE_FORMAT: "{IP}" }, + confidence: "high", + requiresRestart: false, + requiresServerChange: false + }, + { + id: "keep-fixed-low-upload-mtu", + severity: "info", + condition: "UPLOAD_MTU dominates rejects && MIN_UPLOAD_MTU == MAX_UPLOAD_MTU <= 64", + recommendation: "Keep the fixed low upload MTU profile because this network rejects most upload probes.", + configPatch: {}, + confidence: "medium", + requiresRestart: true, + requiresServerChange: false + }, + { + id: "lower-download-mtu-upper-bound", + severity: "warn", + condition: "DOWNLOAD_MTU rejects exist && MAX_DOWNLOAD_MTU > syncedDownloadMtu", + recommendation: "Lower MAX_DOWNLOAD_MTU to the observed synced download MTU to reduce failed probing.", + configPatch: {}, + confidence: "medium", + requiresRestart: true, + requiresServerChange: false + }, + { + id: "disable-upload-compression-low-mtu", + severity: "info", + condition: "upload compression disabled due to low MTU", + recommendation: "Set upload compression OFF while keeping effective download compression if it works.", + configPatch: { UPLOAD_COMPRESSION_TYPE: 0 }, + confidence: "high", + requiresRestart: true, + requiresServerChange: false + }, + { + id: "wait-and-export-reactivated-resolvers", + severity: "info", + condition: "resolver reactivation count > 0", + recommendation: "Wait for background rechecks, then export active/reactivated resolvers as a cleaner resolver file.", + configPatch: {}, + confidence: "medium", + requiresRestart: false, + requiresServerChange: false + }, + { + id: "downgrade-lossy-resolvers", + severity: "warn", + condition: "resolver disabled for runtime loss", + recommendation: "Exclude 100% loss resolvers from known-good exports and future candidate lists.", + configPatch: {}, + confidence: "high", + requiresRestart: false, + requiresServerChange: false + }, + { + id: "high-stream-rate-low-active-resolvers", + severity: "warn", + condition: "session.initialized && streamRate > 60/min && activeResolvers < 10", + recommendation: "High stream pressure with few active resolvers depends heavily on packet duplication; avoid lowering duplication yet.", + configPatch: {}, + confidence: "medium", + requiresRestart: false, + requiresServerChange: false + }, + { + id: "wrapper-state-drift", + severity: "error", + condition: "dashboard state is stopping while child process is alive", + recommendation: "Reconcile wrapper process state so dashboard status matches the actual client process.", + configPatch: {}, + confidence: "high", + requiresRestart: false, + requiresServerChange: false + } + ]; +} + +function loadOptimizerRules() { + try { + const parsed = JSON.parse(readText(RULES_FILE)); + return Array.isArray(parsed) ? parsed : defaultOptimizerRules(); + } catch { + return defaultOptimizerRules(); + } +} + +function loadRunSummaries() { + try { + const parsed = JSON.parse(readText(RUNS_FILE)); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function persistRunSummary(code, signal) { + const config = parseToml(readText(CONFIG_FILE)); + const summary = { + time: new Date().toISOString(), + code, + signal: signal || null, + runtimeSeconds: startedAt ? Math.floor((Date.now() - startedAt) / 1000) : null, + metrics: { ...metrics }, + telemetry: { + mtu: buildTelemetrySnapshot(config).mtu, + compression: telemetry.compression, + session: telemetry.session, + streams: buildTelemetrySnapshot(config).streams, + resolvers: { + activeTotal: telemetry.resolvers.activeTotal, + disabledCount: telemetry.resolvers.disabled.size, + reactivatedCount: telemetry.resolvers.reactivated.size + } + } + }; + const runs = loadRunSummaries(); + runs.push(summary); + writeFileSync(RUNS_FILE, JSON.stringify(runs.slice(-50), null, 2) + "\n", "utf8"); +} + +function buildOptimizerScores(validRatio, activeResolvers, streamRate) { + const resolverScore = validRatio === null ? 50 : clamp(Math.round(validRatio * 1000), 0, 100); + const activeScore = clamp(activeResolvers * 8, 0, 100); + const mtuScore = metrics.syncedUploadMtu && metrics.syncedDownloadMtu + ? clamp(Math.round((metrics.syncedUploadMtu / 150) * 40 + (metrics.syncedDownloadMtu / 1000) * 60), 0, 100) + : 0; + const compressionScore = telemetry.compression.effectiveDownload === "LZ4" || telemetry.compression.effectiveDownload === "ZSTD" ? 75 : 45; + const pressureScore = streamRate > 60 && activeResolvers < 10 ? 35 : streamRate > 60 ? 60 : 85; + const healthScore = Math.round((resolverScore + activeScore + mtuScore + compressionScore + pressureScore) / 5); + return { healthScore, activeResolverScore: activeScore, mtuScore, compressionScore, streamPressureScore: pressureScore }; +} + +function importantEvents(limit) { + return telemetry.events + .filter((event) => ["mtu", "mtu-failed", "valid-resolver", "compression", "session", "runtime", "resolver-disabled", "resolver-reactivated", "arq", "error"].includes(event.type)) + .slice(-limit); +} + +function topEntries(map, limit) { + return Array.from(map.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, limit) + .map(([key, count]) => ({ key, count })); +} + +function upsertByKey(list, item, key) { + const index = list.findIndex((entry) => entry[key] === item[key]); + if (index >= 0) list[index] = item; + else list.push(item); +} + +function knownGoodResolvers() { + const disabled = new Set(telemetry.resolvers.disabled.keys()); + const candidates = [ + ...telemetry.mtu.validResolvers.map((item) => item.resolver), + ...Array.from(telemetry.resolvers.reactivated.keys()) + ]; + return Array.from(new Set(candidates)).filter((resolver) => resolver && !disabled.has(resolver)); +} + +function exportGoodResolvers() { + const resolvers = knownGoodResolvers(); + writeFileSync(GOOD_RESOLVERS_FILE, `${resolvers.join("\n")}${resolvers.length ? "\n" : ""}`, "utf8"); + return { file: GOOD_RESOLVERS_FILE, count: resolvers.length, resolvers }; +} + +function detectStateDrift() { + if (!clientProcess || runStatus !== "stopping") return false; + try { + process.kill(clientProcess.pid, 0); + return true; + } catch { + return false; + } +} + +function maskSecret(value) { + const text = String(value || ""); + if (text.length <= 8) return "***"; + return `${text.slice(0, 4)}...${text.slice(-4)}`; +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function readText(file) { + return readFileSync(file, "utf8"); +} + +function lineCount(text) { + return text.split(/\r?\n/).filter(Boolean).length; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export async function shutdownDashboard() { + if (clientProcess) clientProcess.kill("SIGTERM"); + server.close(() => process.exit(0)); +} diff --git a/tools/wrapper-dashboard/src/server/paths.js b/tools/wrapper-dashboard/src/server/paths.js new file mode 100644 index 00000000..5b742fb3 --- /dev/null +++ b/tools/wrapper-dashboard/src/server/paths.js @@ -0,0 +1,21 @@ +import { readdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +export const ROOT = resolve(process.env.MDVPN_DASHBOARD_ROOT || process.cwd()); +export const PORT = Number(process.env.MDVPN_DASHBOARD_PORT || 18080); +export const PUBLIC_DIR = join(APP_ROOT, "public"); +export const CONFIG_FILE = join(ROOT, "client_config.toml"); +export const RESOLVERS_FILE = join(ROOT, "client_resolvers.txt"); +export const SCHEMA_FILE = join(ROOT, ".config-schema.json"); +export const LOG_FILE = join(ROOT, "dashboard-client.log"); +export const RULES_FILE = join(ROOT, "optimizer-rules.json"); +export const RUNS_FILE = join(ROOT, "dashboard-runs.json"); +export const GOOD_RESOLVERS_FILE = join(ROOT, "known-good-resolvers.txt"); +export const BINARY = findBinary(); + +function findBinary() { + const found = readdirSync(ROOT).find((file) => file.startsWith("MasterDnsVPN_Client_Linux_AMD64_v")); + return found ? join(ROOT, found) : null; +} diff --git a/tools/wrapper-dashboard/src/server/static.js b/tools/wrapper-dashboard/src/server/static.js new file mode 100644 index 00000000..d60d705d --- /dev/null +++ b/tools/wrapper-dashboard/src/server/static.js @@ -0,0 +1,24 @@ +import { createReadStream, existsSync } from "node:fs"; +import { extname, join, resolve } from "node:path"; +import { PUBLIC_DIR } from "./paths.js"; +import { sendText } from "./utils/http.js"; + +export async function serveStatic(req, res, url) { + const requested = url.pathname === "/" ? "/index.html" : url.pathname; + const target = resolve(join(PUBLIC_DIR, requested)); + if (!target.startsWith(PUBLIC_DIR)) return sendText(res, 403, "Forbidden"); + if (!existsSync(target)) return sendText(res, 404, "Not found"); + const type = contentType(extname(target)); + res.writeHead(200, { "Content-Type": type }); + createReadStream(target).pipe(res); +} + +function contentType(ext) { + return { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml" + }[ext] || "application/octet-stream"; +} diff --git a/tools/wrapper-dashboard/src/server/utils/http.js b/tools/wrapper-dashboard/src/server/utils/http.js new file mode 100644 index 00000000..23e59350 --- /dev/null +++ b/tools/wrapper-dashboard/src/server/utils/http.js @@ -0,0 +1,9 @@ +export function sendJson(res, status, body) { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(body, null, 2)); +} + +export function sendText(res, status, body) { + res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" }); + res.end(body); +} From 572c27c21c395702524caaf2700e3df6098647e9 Mon Sep 17 00:00:00 2001 From: zahed316 Date: Mon, 25 May 2026 09:55:22 -0900 Subject: [PATCH 2/3] separate network contexts in dashboard telemetry --- docs/wrapper-dashboard.md | 4 + tools/wrapper-dashboard/README.md | 2 + tools/wrapper-dashboard/public/app.js | 17 +- tools/wrapper-dashboard/public/index.html | 14 ++ .../public/js/views/ai-optimizer.js | 12 ++ tools/wrapper-dashboard/public/styles.css | 33 ++++ tools/wrapper-dashboard/src/server/app.js | 178 +++++++++++++++--- 7 files changed, 226 insertions(+), 34 deletions(-) diff --git a/docs/wrapper-dashboard.md b/docs/wrapper-dashboard.md index de325fa7..af2233ce 100644 --- a/docs/wrapper-dashboard.md +++ b/docs/wrapper-dashboard.md @@ -67,3 +67,7 @@ management API for the Go client. - Never auto-apply `DOMAINS`, `ENCRYPTION_KEY`, or `DATA_ENCRYPTION_METHOD`. - Do not commit local logs, resolver exports, or real client configs. - Keep generated telemetry in the release package root, not the source repo. +- Treat filtered and unfiltered network logs as separate optimization contexts. + If one log contains harsh-network failures followed by mass resolver + reactivation, the dashboard marks it as mixed and defaults recommendations to + the filtered-safe view until the user explicitly selects another context. diff --git a/tools/wrapper-dashboard/README.md b/tools/wrapper-dashboard/README.md index 3ed26bf7..217b907d 100644 --- a/tools/wrapper-dashboard/README.md +++ b/tools/wrapper-dashboard/README.md @@ -51,6 +51,8 @@ committed unless intentionally adding fixtures. - Config and resolver editor with preview-before-apply behavior. - Balanced optimizer recommendations. - Dynamic AI optimizer from parsed logs and rules. +- Filtered/unfiltered/unknown network-context tagging so mixed logs do not + accidentally tune the filtered profile from unfiltered resolver recovery. - Known-good resolver export. - Process start/stop wrapper for the headless client. - SSE-driven UI refresh. diff --git a/tools/wrapper-dashboard/public/app.js b/tools/wrapper-dashboard/public/app.js index 0ef5ed5b..06ac74ec 100644 --- a/tools/wrapper-dashboard/public/app.js +++ b/tools/wrapper-dashboard/public/app.js @@ -40,7 +40,10 @@ function bindControls() { $("smokeBtn").addEventListener("click", () => post("/api/smoke").then(refresh)); $("previewDynamicBtn").addEventListener("click", previewDynamicPatch); $("copyAIContextBtn").addEventListener("click", copyAIContext); - $("exportGoodResolversBtn").addEventListener("click", exportGoodResolvers); + $("exportGoodResolversBtn").addEventListener("click", () => exportGoodResolvers()); + $("exportFilteredResolversBtn").addEventListener("click", () => exportGoodResolvers("filtered")); + $("exportUnfilteredResolversBtn").addEventListener("click", () => exportGoodResolvers("unfiltered")); + $("networkContextSelect").addEventListener("change", setNetworkContext); $("reloadEditorBtn").addEventListener("click", loadEditor); $("stripCommentsBtn").addEventListener("click", stripConfigComments); $("dedupeResolversBtn").addEventListener("click", dedupeResolverEditor); @@ -156,9 +159,15 @@ async function copyAIContext() { } } -async function exportGoodResolvers() { - const data = await post("/api/resolvers/export-good"); - setAIStatus(`Exported ${data.count} known-good resolvers to ${data.file}.`, "ok"); +async function exportGoodResolvers(context = null) { + const data = await postJson("/api/resolvers/export-good", { context }); + setAIStatus(`Exported ${data.count} ${data.context} known-good resolvers to ${data.file}.`, "ok"); +} + +async function setNetworkContext(event) { + const data = await postJson("/api/network-context", { context: event.target.value }); + setAIStatus(`Network context set to ${data.selected}.`, data.mixed ? "warn" : "ok"); + await refresh(); } async function ignoreRecommendation(id) { diff --git a/tools/wrapper-dashboard/public/index.html b/tools/wrapper-dashboard/public/index.html index 039c211a..87f1b858 100644 --- a/tools/wrapper-dashboard/public/index.html +++ b/tools/wrapper-dashboard/public/index.html @@ -52,11 +52,25 @@

Config Optimizer

AI Optimizer

+
+ + unknown +
+
+ +
diff --git a/tools/wrapper-dashboard/public/js/views/ai-optimizer.js b/tools/wrapper-dashboard/public/js/views/ai-optimizer.js index c8d268d2..a36eb207 100644 --- a/tools/wrapper-dashboard/public/js/views/ai-optimizer.js +++ b/tools/wrapper-dashboard/public/js/views/ai-optimizer.js @@ -2,6 +2,18 @@ import { $, escapeHtml } from "../dom.js"; export function renderAIOptimizer(state, actions) { const telemetry = state.telemetry || {}; + const context = telemetry.networkContext || state.dynamicRecommendations?.networkContext || {}; + const selector = $("networkContextSelect"); + if (selector) selector.value = context.selected || "unknown"; + const badge = $("networkContextBadge"); + if (badge) { + badge.textContent = context.mixed ? "mixed log context detected" : (context.detected || "unknown"); + badge.className = `tag ${context.mixed ? "warn" : context.detected === "filtered" ? "running" : context.detected === "unfiltered" ? "ok" : ""}`; + } + const notes = context.notes || []; + $("networkContextNotes").innerHTML = notes.length + ? notes.map((note) => `
${context.mixed ? "warn" : "info"} ${escapeHtml(note)}
`).join("") + : ""; const scores = telemetry.scores || {}; const scoreRows = [ ["Health", scores.healthScore], diff --git a/tools/wrapper-dashboard/public/styles.css b/tools/wrapper-dashboard/public/styles.css index add9a580..7633ce3d 100644 --- a/tools/wrapper-dashboard/public/styles.css +++ b/tools/wrapper-dashboard/public/styles.css @@ -224,6 +224,39 @@ button.active, margin-bottom: 10px; } +.context-tools { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.context-tools label { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 12px; + font-weight: 600; +} + +.context-tools select { + min-height: 34px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; + color: var(--text); + padding: 0 8px; + font: inherit; +} + +.context-notes { + display: grid; + gap: 8px; + margin-bottom: 10px; +} + .dynamic-rec p { margin-top: 8px; color: var(--text); diff --git a/tools/wrapper-dashboard/src/server/app.js b/tools/wrapper-dashboard/src/server/app.js index e8482db7..c1269d63 100644 --- a/tools/wrapper-dashboard/src/server/app.js +++ b/tools/wrapper-dashboard/src/server/app.js @@ -139,6 +139,10 @@ async function handleApi(req, res, url) { if (req.method === "GET" && url.pathname === "/api/recommendations/dynamic") { return sendJson(res, 200, buildDynamicRecommendations(parseToml(readText(CONFIG_FILE)))); } + if (req.method === "POST" && url.pathname === "/api/network-context") { + const body = await readRequestJson(req); + return sendJson(res, 200, setNetworkContext(body.context)); + } if (req.method === "GET" && url.pathname === "/api/ai-context") { return sendJson(res, 200, buildAIContext(parseToml(readText(CONFIG_FILE)))); } @@ -155,7 +159,8 @@ async function handleApi(req, res, url) { return sendJson(res, 200, { ignored: Array.from(ignoredRecommendationIds) }); } if (req.method === "POST" && url.pathname === "/api/resolvers/export-good") { - return sendJson(res, 200, exportGoodResolvers()); + const body = await readRequestJson(req); + return sendJson(res, 200, exportGoodResolvers(body.context)); } if (req.method === "GET" && url.pathname === "/api/preview-balanced") { return sendJson(res, 200, previewBalanced()); @@ -350,9 +355,12 @@ function buildTelemetrySnapshot(config) { const validRatio = metrics.mtuTotal ? (metrics.mtuValid || 0) / metrics.mtuTotal : null; const now = Date.now(); const recentStreams = telemetry.streams.timestamps.filter((time) => now - time <= 60_000); - const activeResolvers = telemetry.resolvers.activeTotal ?? metrics.mtuValid ?? 0; + const context = contextSummary(); + const evaluationContext = effectiveNetworkContext(context.selected); + const activeResolvers = contextActiveResolvers(evaluationContext); return { generatedAt: new Date().toISOString(), + networkContext: context, config: { resolverStrategy: config.RESOLVER_BALANCING_STRATEGY, packetDuplication: config.PACKET_DUPLICATION_COUNT, @@ -394,7 +402,7 @@ function buildTelemetrySnapshot(config) { activeTotal: activeResolvers, disabled: Array.from(telemetry.resolvers.disabled.values()).slice(-100), reactivated: Array.from(telemetry.resolvers.reactivated.values()).slice(-100), - knownGood: knownGoodResolvers().slice(0, 200) + knownGood: knownGoodResolvers(evaluationContext).slice(0, 200) }, scores: buildOptimizerScores(validRatio, activeResolvers, recentStreams.length), recentImportantEvents: importantEvents(80) @@ -404,15 +412,17 @@ function buildTelemetrySnapshot(config) { function buildDynamicRecommendations(config) { const rules = loadOptimizerRules(); const recs = []; + const context = contextSummary(); + const selectedContext = effectiveNetworkContext(context.selected); const validRatio = metrics.mtuTotal ? (metrics.mtuValid || 0) / metrics.mtuTotal : null; const rejectReasons = Object.fromEntries(telemetry.mtu.rejectsByReason); const uploadRejects = rejectReasons.UPLOAD_MTU || 0; const downloadRejects = rejectReasons.DOWNLOAD_MTU || 0; const recentStreamRate = telemetry.streams.timestamps.filter((time) => Date.now() - time <= 60_000).length; - const activeTotal = telemetry.resolvers.activeTotal ?? metrics.mtuValid ?? 0; + const activeTotal = contextActiveResolvers(selectedContext); const stateDrift = detectStateDrift(); - if (validRatio !== null && validRatio < 0.01 && telemetry.session.initialized) { + if (selectedContext !== "unfiltered" && validRatio !== null && validRatio < 0.01 && telemetry.session.initialized) { recs.push(ruleRecommendation(rules, "low-valid-ratio-session-ok", { configPatch: { SAVE_MTU_SERVERS_TO_FILE: true, @@ -425,7 +435,7 @@ function buildDynamicRecommendations(config) { })); } - if (uploadRejects > downloadRejects && config.MIN_UPLOAD_MTU === config.MAX_UPLOAD_MTU && config.MIN_UPLOAD_MTU <= 64) { + if (selectedContext !== "unfiltered" && uploadRejects > downloadRejects && config.MIN_UPLOAD_MTU === config.MAX_UPLOAD_MTU && config.MIN_UPLOAD_MTU <= 64) { recs.push(ruleRecommendation(rules, "keep-fixed-low-upload-mtu", { configPatch: { MIN_UPLOAD_MTU: config.MIN_UPLOAD_MTU, @@ -438,7 +448,7 @@ function buildDynamicRecommendations(config) { })); } - if (downloadRejects > 0 && metrics.syncedDownloadMtu && config.MAX_DOWNLOAD_MTU > metrics.syncedDownloadMtu) { + if (selectedContext !== "unfiltered" && downloadRejects > 0 && metrics.syncedDownloadMtu && config.MAX_DOWNLOAD_MTU > metrics.syncedDownloadMtu) { recs.push(ruleRecommendation(rules, "lower-download-mtu-upper-bound", { configPatch: { MAX_DOWNLOAD_MTU: metrics.syncedDownloadMtu @@ -450,7 +460,7 @@ function buildDynamicRecommendations(config) { })); } - if (telemetry.compression.uploadDisabledReason && telemetry.compression.effectiveUpload === "OFF") { + if (selectedContext !== "unfiltered" && telemetry.compression.uploadDisabledReason && telemetry.compression.effectiveUpload === "OFF") { recs.push(ruleRecommendation(rules, "disable-upload-compression-low-mtu", { configPatch: { UPLOAD_COMPRESSION_TYPE: 0, @@ -463,12 +473,12 @@ function buildDynamicRecommendations(config) { })); } - if (telemetry.resolvers.reactivated.size > 0) { + if (telemetry.resolvers.reactivated.size > 0 && selectedContext !== "filtered") { recs.push(ruleRecommendation(rules, "wait-and-export-reactivated-resolvers", { configPatch: {}, evidence: [ `${telemetry.resolvers.reactivated.size} resolvers reactivated after startup.`, - `Active resolver total reached ${activeTotal}.` + `Active resolver total reached ${telemetry.context.reactivationBurst.maxActive || activeTotal}.` ] })); } @@ -504,6 +514,7 @@ function buildDynamicRecommendations(config) { return { generatedAt: new Date().toISOString(), + networkContext: context, ignored: Array.from(ignoredRecommendationIds), recommendations: recs.filter((rec) => !ignoredRecommendationIds.has(rec.id)) }; @@ -577,6 +588,7 @@ function buildAIContext(config) { return { generatedAt: new Date().toISOString(), version: BINARY ? basename(BINARY) : null, + networkContext: contextSummary(), config: redactedConfig, telemetry: buildTelemetrySnapshot(config), recommendations: buildDynamicRecommendations(config).recommendations, @@ -690,14 +702,23 @@ function parseLogLine(item) { const line = item.line; let type = null; let details = {}; - if (/session.*init|initialized|SESSION_ACCEPT/i.test(line)) { + if (/Async Runtime Initialized:/i.test(line)) { + type = "runtime"; + const match = line.match(/Async Runtime Initialized:\s+(\d+)\s+RX\/TX Workers,\s+(\d+)\s+Processors/i); + if (match) { + telemetry.runtime.rxTxWorkers = Number(match[1]); + telemetry.runtime.processWorkers = Number(match[2]); + details = { rxTxWorkers: telemetry.runtime.rxTxWorkers, processWorkers: telemetry.runtime.processWorkers }; + } + } else if (/session.*init|initialized|SESSION_ACCEPT/i.test(line)) { type = "session"; const attempt = line.match(/Session init attempt with\s+(\S+)\s+and resolver\s+(\S+)/i); const success = line.match(/Session Initialized Successfully \(ID:\s*(\d+)\)/i); if (attempt) { + telemetry.session.attempts += 1; telemetry.session.initDomain = attempt[1]; telemetry.session.initResolver = attempt[2]; - details = { domain: attempt[1], resolver: attempt[2], phase: "attempt" }; + details = { domain: attempt[1], resolver: attempt[2], phase: "attempt", attempts: telemetry.session.attempts }; } if (success) { runStatus = "connected"; @@ -705,6 +726,15 @@ function parseLogLine(item) { telemetry.session.id = Number(success[1]); details = { id: telemetry.session.id, phase: "success" }; } + if (/Session initialization failed/i.test(line)) { + telemetry.session.failures += 1; + details = { phase: "failure", failures: telemetry.session.failures }; + } + const retry = line.match(/Session init retry backoff:\s+(\S+)/i); + if (retry) { + telemetry.session.retries += 1; + details = { phase: "retry", retries: telemetry.session.retries, backoff: retry[1] }; + } } else if (/MTU discovery.*start|Starting MTU/i.test(line)) { type = "mtu-start"; metrics.mtuStartedAt = item.time; @@ -744,14 +774,6 @@ function parseLogLine(item) { telemetry.compression.effectiveDownload = match[2]; details = { effectiveUpload: match[1], effectiveDownload: match[2] }; } - } else if (/Async Runtime Initialized:/i.test(line)) { - type = "runtime"; - const match = line.match(/Async Runtime Initialized:\s+(\d+)\s+RX\/TX Workers,\s+(\d+)\s+Processors/i); - if (match) { - telemetry.runtime.rxTxWorkers = Number(match[1]); - telemetry.runtime.processWorkers = Number(match[2]); - details = { rxTxWorkers: telemetry.runtime.rxTxWorkers, processWorkers: telemetry.runtime.processWorkers }; - } } else if (/New SOCKS5 TCP CONNECT/i.test(line)) { type = "stream"; const match = line.match(/CONNECT to\s+(.+?):(\d+),\s+Stream ID:\s+(\d+)/i); @@ -770,6 +792,9 @@ function parseLogLine(item) { const entry = { resolver: match[2], reason: match[1], remaining: Number(match[3]), time: item.time }; telemetry.resolvers.disabled.set(entry.resolver, entry); telemetry.resolvers.activeTotal = entry.remaining; + telemetry.context.lowestActiveAfterDisable = telemetry.context.lowestActiveAfterDisable === null + ? entry.remaining + : Math.min(telemetry.context.lowestActiveAfterDisable, entry.remaining); details = entry; } } else if (/DNS Resolver Reactivated/i.test(line)) { @@ -779,6 +804,7 @@ function parseLogLine(item) { const entry = { resolver: match[1], activeTotal: Number(match[2]), time: item.time }; telemetry.resolvers.reactivated.set(entry.resolver, entry); telemetry.resolvers.activeTotal = entry.activeTotal; + recordReactivationBurst(entry); details = entry; } } else if (/timeout/i.test(line)) { @@ -825,6 +851,7 @@ function parseLogLine(item) { if (parsedEvents.length > 300) parsedEvents.shift(); telemetry.events.push(event); if (telemetry.events.length > 1000) telemetry.events.shift(); + updateContextDetection(); broadcast({ type: "parsed-event", payload: event }); } } @@ -1068,7 +1095,10 @@ function defaultTelemetry() { initialized: false, id: null, initDomain: null, - initResolver: null + initResolver: null, + attempts: 0, + failures: 0, + retries: 0 }, runtime: { rxTxWorkers: null, @@ -1083,6 +1113,19 @@ function defaultTelemetry() { activeTotal: null, disabled: new Map(), reactivated: new Map() + }, + context: { + selected: "unknown", + detected: "unknown", + mixed: false, + notes: [], + lowestActiveAfterDisable: null, + reactivationBurst: { + count: 0, + firstAt: null, + lastAt: null, + maxActive: null + } } }; } @@ -1203,6 +1246,7 @@ function persistRunSummary(code, signal) { signal: signal || null, runtimeSeconds: startedAt ? Math.floor((Date.now() - startedAt) / 1000) : null, metrics: { ...metrics }, + networkContext: contextSummary(), telemetry: { mtu: buildTelemetrySnapshot(config).mtu, compression: telemetry.compression, @@ -1251,19 +1295,93 @@ function upsertByKey(list, item, key) { else list.push(item); } -function knownGoodResolvers() { +function setNetworkContext(context) { + const normalized = normalizeNetworkContext(context); + telemetry.context.selected = normalized; + return contextSummary(); +} + +function normalizeNetworkContext(context) { + return ["filtered", "unfiltered", "unknown"].includes(context) ? context : "unknown"; +} + +function recordReactivationBurst(entry) { + const burst = telemetry.context.reactivationBurst; + burst.count += 1; + burst.firstAt ||= entry.time; + burst.lastAt = entry.time; + burst.maxActive = Math.max(burst.maxActive || 0, entry.activeTotal); +} + +function updateContextDetection() { + const validRatio = metrics.mtuTotal ? (metrics.mtuValid || 0) / metrics.mtuTotal : null; + const filteredSignals = [ + validRatio !== null && validRatio < 0.01, + telemetry.session.failures > 0, + telemetry.resolvers.disabled.size > 0, + telemetry.context.lowestActiveAfterDisable !== null && telemetry.context.lowestActiveAfterDisable <= 10 + ].filter(Boolean).length; + const unfilteredSignals = [ + telemetry.context.reactivationBurst.count >= 20, + (telemetry.context.reactivationBurst.maxActive || 0) >= 50 + ].filter(Boolean).length; + telemetry.context.mixed = filteredSignals >= 2 && unfilteredSignals >= 1; + telemetry.context.detected = telemetry.context.mixed + ? "mixed" + : filteredSignals >= 2 + ? "filtered" + : unfilteredSignals >= 1 + ? "unfiltered" + : "unknown"; + const notes = []; + if (filteredSignals >= 2) notes.push("Filtered-network signals: low valid ratio, session retries/failures, resolver loss, or very low active count."); + if (unfilteredSignals >= 1) notes.push(`Unfiltered-network signals: ${telemetry.context.reactivationBurst.count} resolver reactivations and active total up to ${telemetry.context.reactivationBurst.maxActive || 0}.`); + if (telemetry.context.mixed) notes.push("Mixed log context detected; do not use unfiltered reactivation bursts to tune the filtered profile."); + telemetry.context.notes = notes; +} + +function contextSummary() { + updateContextDetection(); + return { + selected: telemetry.context.selected, + detected: telemetry.context.detected, + mixed: telemetry.context.mixed, + notes: telemetry.context.notes, + lowestActiveAfterDisable: telemetry.context.lowestActiveAfterDisable, + reactivationBurst: telemetry.context.reactivationBurst + }; +} + +function contextActiveResolvers(context = telemetry.context.selected) { + context = effectiveNetworkContext(context); + if (context === "filtered") return telemetry.context.lowestActiveAfterDisable ?? metrics.mtuValid ?? 0; + if (context === "unfiltered") return telemetry.context.reactivationBurst.maxActive ?? telemetry.resolvers.activeTotal ?? metrics.mtuValid ?? 0; + return telemetry.resolvers.activeTotal ?? metrics.mtuValid ?? 0; +} + +function knownGoodResolvers(context = telemetry.context.selected) { + context = effectiveNetworkContext(context); const disabled = new Set(telemetry.resolvers.disabled.keys()); - const candidates = [ - ...telemetry.mtu.validResolvers.map((item) => item.resolver), - ...Array.from(telemetry.resolvers.reactivated.keys()) - ]; + const baseCandidates = telemetry.mtu.validResolvers.map((item) => item.resolver); + const candidates = context === "filtered" + ? baseCandidates + : [...baseCandidates, ...Array.from(telemetry.resolvers.reactivated.keys())]; return Array.from(new Set(candidates)).filter((resolver) => resolver && !disabled.has(resolver)); } -function exportGoodResolvers() { - const resolvers = knownGoodResolvers(); - writeFileSync(GOOD_RESOLVERS_FILE, `${resolvers.join("\n")}${resolvers.length ? "\n" : ""}`, "utf8"); - return { file: GOOD_RESOLVERS_FILE, count: resolvers.length, resolvers }; +function exportGoodResolvers(context) { + const selectedContext = effectiveNetworkContext(normalizeNetworkContext(context || telemetry.context.selected)); + const resolvers = knownGoodResolvers(selectedContext); + const target = selectedContext === "unknown" + ? GOOD_RESOLVERS_FILE + : GOOD_RESOLVERS_FILE.replace(/\.txt$/i, `-${selectedContext}.txt`); + writeFileSync(target, `${resolvers.join("\n")}${resolvers.length ? "\n" : ""}`, "utf8"); + return { file: target, context: selectedContext, count: resolvers.length, resolvers }; +} + +function effectiveNetworkContext(context = telemetry.context.selected) { + const normalized = normalizeNetworkContext(context); + return normalized === "unknown" && telemetry.context.mixed ? "filtered" : normalized; } function detectStateDrift() { From 913808c8d6260d3cd713852968a8c50f44a94ef4 Mon Sep 17 00:00:00 2001 From: zahed316 Date: Mon, 25 May 2026 10:06:55 -0900 Subject: [PATCH 3/3] clarify optimizer patch status --- tools/wrapper-dashboard/README.md | 2 + tools/wrapper-dashboard/public/app.js | 7 +++- tools/wrapper-dashboard/public/index.html | 1 + .../public/js/views/ai-optimizer.js | 22 ++++++++++- .../public/js/views/config-optimizer.js | 9 +++++ tools/wrapper-dashboard/public/styles.css | 39 +++++++++++++++++++ tools/wrapper-dashboard/src/server/app.js | 39 ++++++++++++++++++- 7 files changed, 115 insertions(+), 4 deletions(-) diff --git a/tools/wrapper-dashboard/README.md b/tools/wrapper-dashboard/README.md index 217b907d..5579964a 100644 --- a/tools/wrapper-dashboard/README.md +++ b/tools/wrapper-dashboard/README.md @@ -53,6 +53,8 @@ committed unless intentionally adding fixtures. - Dynamic AI optimizer from parsed logs and rules. - Filtered/unfiltered/unknown network-context tagging so mixed logs do not accidentally tune the filtered profile from unfiltered resolver recovery. +- Live patch status for each recommendation, showing whether a TOML change is + pending, already applied, or advisory-only. - Known-good resolver export. - Process start/stop wrapper for the headless client. - SSE-driven UI refresh. diff --git a/tools/wrapper-dashboard/public/app.js b/tools/wrapper-dashboard/public/app.js index 06ac74ec..35f075c7 100644 --- a/tools/wrapper-dashboard/public/app.js +++ b/tools/wrapper-dashboard/public/app.js @@ -29,6 +29,9 @@ window.addEventListener("DOMContentLoaded", () => { }); refresh(); loadEditor(); + setInterval(() => { + if (!document.hidden) refresh(); + }, 5000); }); function bindControls() { @@ -143,8 +146,8 @@ async function previewDynamicPatch(ids = null) { const data = await postJson("/api/recommendations/preview-patch", { ids: selectedIds }); $("dynamicDiff").textContent = data.diff.length ? data.diff.map((row) => `${row.type === "add" ? "+" : "-"}${row.line}: ${row.text}`).join("\n") - : "Selected dynamic recommendations do not change TOML."; - setAIStatus(data.changed ? "Preview generated. Nothing was saved." : "No TOML change for this selection.", data.changed ? "ok" : ""); + : data.explanation || "Selected dynamic recommendations do not change TOML."; + setAIStatus(data.explanation || (data.changed ? "Preview generated. Nothing was saved." : "No TOML change for this selection."), data.changed ? "ok" : ""); } async function copyAIContext() { diff --git a/tools/wrapper-dashboard/public/index.html b/tools/wrapper-dashboard/public/index.html index 87f1b858..eb87af42 100644 --- a/tools/wrapper-dashboard/public/index.html +++ b/tools/wrapper-dashboard/public/index.html @@ -46,6 +46,7 @@

Config Optimizer

+

       
diff --git a/tools/wrapper-dashboard/public/js/views/ai-optimizer.js b/tools/wrapper-dashboard/public/js/views/ai-optimizer.js index a36eb207..00aec34c 100644 --- a/tools/wrapper-dashboard/public/js/views/ai-optimizer.js +++ b/tools/wrapper-dashboard/public/js/views/ai-optimizer.js @@ -46,8 +46,9 @@ export function renderAIOptimizer(state, actions) {
confidence: ${escapeHtml(item.confidence || "medium")} ${item.requiresRestart ? "restart required" : "live observation"} - ${patchKeys.length ? `patch: ${escapeHtml(patchKeys.join(", "))}` : "advisory"} + ${patchLabel(item, patchKeys)}
+ ${renderPatchPreview(item.patchPreview || [])}
@@ -86,3 +87,22 @@ function severityTag(severity) { if (severity === "warn") return "warn"; return "ok"; } + +function patchLabel(item, patchKeys) { + if (!patchKeys.length || item.patchState === "advisory") return "advisory only"; + if (item.patchState === "already-applied") return `already applied: ${escapeHtml(patchKeys.join(", "))}`; + return `will change: ${escapeHtml(patchKeys.join(", "))}`; +} + +function renderPatchPreview(items) { + if (!items.length) return ""; + return ` +
+ ${items.map((item) => ` + + ${escapeHtml(item.key)}: ${escapeHtml(JSON.stringify(item.current))} -> ${escapeHtml(JSON.stringify(item.recommended))} + + `).join("")} +
+ `; +} diff --git a/tools/wrapper-dashboard/public/js/views/config-optimizer.js b/tools/wrapper-dashboard/public/js/views/config-optimizer.js index b3bc2f44..124a6efc 100644 --- a/tools/wrapper-dashboard/public/js/views/config-optimizer.js +++ b/tools/wrapper-dashboard/public/js/views/config-optimizer.js @@ -1,6 +1,15 @@ import { $, escapeHtml } from "../dom.js"; export function renderRecommendations(state) { + const pending = state.recommendations.items.filter((item) => item.current !== item.recommended && !item.requiresServerChange).length; + const coordinated = state.recommendations.items.filter((item) => item.requiresServerChange).length; + $("optimizerSummary").innerHTML = ` +
+ ${pending ? "pending" : "applied"} + Balanced direct settings ${pending ? `have ${pending} pending TOML change(s)` : "are already applied"}. + ${coordinated ? `${coordinated} security recommendation requires matching server config.` : ""} +
+ `; $("recommendations").innerHTML = state.recommendations.items.map((item) => { const changed = item.current !== item.recommended; const tag = item.requiresServerChange ? "warn" : changed ? "running" : "ok"; diff --git a/tools/wrapper-dashboard/public/styles.css b/tools/wrapper-dashboard/public/styles.css index 7633ce3d..3b1b6ef1 100644 --- a/tools/wrapper-dashboard/public/styles.css +++ b/tools/wrapper-dashboard/public/styles.css @@ -70,10 +70,12 @@ button { border-radius: 6px; font: inherit; cursor: pointer; + transition: border-color 140ms ease, background-color 140ms ease, color 140ms ease, transform 140ms ease; } button:hover { border-color: var(--accent); + transform: translateY(-1px); } button.active, @@ -138,6 +140,19 @@ button.active, box-shadow: var(--shadow); padding: 16px; min-width: 0; + animation: panel-in 160ms ease; +} + +@keyframes panel-in { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: translateY(0); + } } .wide { @@ -272,6 +287,30 @@ button.active, margin-top: 10px; } +.patch-preview { + display: grid; + gap: 6px; + margin-top: 10px; +} + +.patch-preview code { + display: block; + width: fit-content; + max-width: 100%; + white-space: normal; + border: 1px solid var(--line); + border-radius: 6px; + padding: 5px 8px; + background: #eef6f5; + color: var(--ok); + font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.patch-preview code.pending { + background: #fff7ed; + color: var(--warn); +} + .evidence { margin: 10px 0 0; padding-left: 20px; diff --git a/tools/wrapper-dashboard/src/server/app.js b/tools/wrapper-dashboard/src/server/app.js index c1269d63..bab109d6 100644 --- a/tools/wrapper-dashboard/src/server/app.js +++ b/tools/wrapper-dashboard/src/server/app.js @@ -516,7 +516,9 @@ function buildDynamicRecommendations(config) { generatedAt: new Date().toISOString(), networkContext: context, ignored: Array.from(ignoredRecommendationIds), - recommendations: recs.filter((rec) => !ignoredRecommendationIds.has(rec.id)) + recommendations: recs + .map((rec) => enrichRecommendation(rec, config)) + .filter((rec) => !ignoredRecommendationIds.has(rec.id)) }; } @@ -536,12 +538,33 @@ function ruleRecommendation(rules, id, override) { }; } +function enrichRecommendation(rec, config) { + const patchPreview = Object.entries(rec.configPatch || {}).map(([key, recommended]) => { + const current = config[key]; + return { + key, + current, + recommended, + changes: JSON.stringify(current) !== JSON.stringify(recommended) + }; + }); + const changeCount = patchPreview.filter((item) => item.changes).length; + return { + ...rec, + patchPreview, + hasConfigChange: changeCount > 0, + patchState: patchPreview.length === 0 ? "advisory" : changeCount > 0 ? "pending" : "already-applied" + }; +} + function previewRecommendationPatch(body) { const selectedIds = new Set((body.ids || []).map(String)); const dynamic = buildDynamicRecommendations(parseToml(readText(CONFIG_FILE))).recommendations; const patch = {}; + const selectedRecommendations = []; for (const rec of dynamic) { if (selectedIds.size && !selectedIds.has(rec.id)) continue; + selectedRecommendations.push(rec); if (rec.requiresServerChange) continue; for (const [key, value] of Object.entries(rec.configPatch || {})) { if (["DOMAINS", "ENCRYPTION_KEY", "DATA_ENCRYPTION_METHOD"].includes(key)) continue; @@ -553,10 +576,24 @@ function previewRecommendationPatch(body) { return { changed: original !== updated, patch, + selected: selectedRecommendations.map((rec) => ({ + id: rec.id, + patchState: rec.patchState, + patchPreview: rec.patchPreview + })), + explanation: previewExplanation(selectedRecommendations, original !== updated), diff: simpleDiff(original, updated) }; } +function previewExplanation(recommendations, changed) { + if (changed) return "Preview generated. Nothing was saved."; + if (!recommendations.length) return "No selected recommendations were found."; + if (recommendations.every((rec) => rec.patchState === "advisory")) return "Selected recommendations are advisory and do not include TOML changes."; + if (recommendations.every((rec) => rec.patchState !== "pending")) return "Selected TOML recommendations are already applied."; + return "Selected dynamic recommendations do not change TOML."; +} + function buildAIContext(config) { const redactedConfig = {}; const keys = [