Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
73 changes: 73 additions & 0 deletions docs/wrapper-dashboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# 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.
- 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.
101 changes: 101 additions & 0 deletions tools/wrapper-dashboard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# 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.
- 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.
- 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'
```
13 changes: 13 additions & 0 deletions tools/wrapper-dashboard/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
180 changes: 180 additions & 0 deletions tools/wrapper-dashboard/public/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { get, post, postJson } from "./js/api.js";
import { $, escapeHtml } from "./js/dom.js";
import { connectEvents } from "./js/events.js";
Comment on lines +1 to +3
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();
setInterval(() => {
if (!document.hidden) refresh();
}, 5000);
});

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());
$("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);
$("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")
: 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() {
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(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) {
await postJson("/api/recommendations/ignore", { id });
setAIStatus(`Ignored ${id}.`, "");
await refresh();
}
Loading