Feat: restore TreeGuard status and activity dashlets

This commit is contained in:
Herbert Wolverson committed 2026-03-09 14:09:11 -05:00
1 parent 5b5b90730f
commit 00ccecfb08
5 files changed
+933 -138

No files matched your search

@@ -28,7 +28,9 @@ import {LtsLast24Hours} from "./ltsLast24Hours";
import {TcpRetransmitsDash} from "./total_retransmits";
import {StormguardStatusDashlet} from "./stormguard_status";
import {BakeryStatusDashlet} from "./bakery_status";
import {TreeguardStatusDashlet} from "./treeguard_status";
import {TreeGuardStatusDashlet} from "./treeguard_status";
import {TreeGuardActivityDashlet} from "./treeguard_activity";
import {TreeguardControlsDashlet} from "./treeguard_controls";
import {Top10UploadersVisual} from "./top10_uploads_graphic";
import {Top10Uploaders} from "./top10_uploaders";
// New Traffic Overview dashlets
@@ -80,8 +82,10 @@ export const DashletMenu = [
{ name: "(Insight) Shaper Status", tag: "ltsShaperStatus", size: 3, category: "Insight" },
{ name: "(Insight) Last 24 Hours", tag: "ltsLast24", size: 3, category: "Insight" },
{ name: "Stormguard Bandwidth Adjustments", tag: "stormguardStatus", size: 6, category: "Queue Management" },
{ name: "TreeGuard Status", tag: "treeguardStatus", size: 12, category: "Queue Management" },
{ name: "Bakery Circuit Activity", tag: "bakeryStatus", size: 6, category: "Queue Management" },
{ name: "TreeGuard Controls", tag: "treeguardControls", size: 6, category: "TreeGuard" },
{ name: "TreeGuard Status", tag: "treeguardStatus", size: 6, category: "TreeGuard" },
{ name: "TreeGuard Activity", tag: "treeguardActivity", size: 12, category: "TreeGuard" },
// Traffic Overview (Insight-like)
{ name: "Shaper Top ASN (Download)", tag: "shaperTopAsnDown", size: 6, category: "Traffic" },
{ name: "Shaper Top ASN (Upload)", tag: "shaperTopAsnUp", size: 6, category: "Traffic" },
@@ -132,8 +136,10 @@ export function widgetFactory(widgetName, count) {
case "ltsShaperStatus" : widget = new LtsShaperStatus(count); break;
case "ltsLast24" : widget = new LtsLast24Hours(count); break;
case "stormguardStatus" : widget = new StormguardStatusDashlet(count); break;
case "treeguardStatus" : widget = new TreeguardStatusDashlet(count); break;
case "bakeryStatus" : widget = new BakeryStatusDashlet(count); break;
case "treeguardControls": widget = new TreeguardControlsDashlet(count); break;
case "treeguardStatus" : widget = new TreeGuardStatusDashlet(count); break;
case "treeguardActivity": widget = new TreeGuardActivityDashlet(count); break;
// Traffic Overview
case "shaperTopAsnDown" : widget = new ShaperTopAsnDownload(count); break;
case "shaperTopAsnUp" : widget = new ShaperTopAsnUpload(count); break;
@@ -0,0 +1,366 @@
import {BaseDashlet} from "../lq_js_common/dashboard/base_dashlet";
import {get_ws_client} from "../pubsub/ws";
function formatUnixSecondsToLocalTime(unixSeconds) {
const n = typeof unixSeconds === "number" ? unixSeconds : parseInt(unixSeconds, 10);
if (!Number.isFinite(n) || n <= 0) {
return "";
}
return new Date(n * 1000).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function parseCircuitEntityId(entityIdRaw) {
const s = (entityIdRaw ?? "").toString().trim();
if (!s) return { circuitId: "", display: "", hasName: false };
const m = s.match(/^(.*)\s*\(([^)]+)\)\s*$/);
if (m) {
const name = (m[1] ?? "").toString().trim();
const id = (m[2] ?? "").toString().trim();
if (id) {
return { circuitId: id, display: name || id, hasName: !!name };
}
}
return { circuitId: s, display: s, hasName: false };
}
function splitOnce(s, sep) {
const str = (s ?? "").toString();
const idx = str.indexOf(sep);
if (idx === -1) return [str, ""];
return [str.slice(0, idx), str.slice(idx + sep.length)];
}
function parseDirectionalSqmToken(token) {
const t = (token ?? "").toString().trim();
if (!t) return { down: "", up: "" };
if (!t.includes("/")) return { down: t, up: t };
const [down, up] = splitOnce(t, "/");
return { down: (down ?? "").toString().trim(), up: (up ?? "").toString().trim() };
}
function formatSqmLabel(prefix, token) {
const { down, up } = parseDirectionalSqmToken(token);
if (!down && !up) return prefix;
if (down === up) return `${prefix}: ${down}`;
return `${prefix}: DL ${down}, UL ${up}`;
}
function mkIcon(iconClass, extraClasses = []) {
const icon = document.createElement("i");
icon.classList.add("fa", "fa-fw", iconClass);
extraClasses.forEach((c) => icon.classList.add(c));
return icon;
}
function formatReason(reasonRaw) {
const raw = (reasonRaw ?? "").toString().trim();
if (!raw) return { label: "", title: "" };
const lower = raw.toLowerCase();
if (!lower.includes("next_allowed_unix=")) {
return { label: raw, title: "" };
}
const m = raw.match(/next_allowed_unix=(\d+)/i);
if (!m) return { label: raw, title: "" };
const next = formatUnixSecondsToLocalTime(parseInt(m[1], 10));
if (!next) return { label: raw, title: "" };
if (lower.startsWith("reload cooldown active")) {
return { label: `Reload on cooldown - next allowed ${next}`, title: raw };
}
return { label: raw.replace(m[0], `next allowed ${next}`), title: raw };
}
function renderAction(actionRaw) {
const raw = (actionRaw ?? "").toString();
const [verbRaw, payloadRaw] = splitOnce(raw, ":");
const verb = (verbRaw ?? "").toString().trim();
const payload = (payloadRaw ?? "").toString().trim();
const lowerVerb = verb.toLowerCase();
let iconClass = "fa-question-circle";
let iconExtra = ["text-muted"];
let label = raw;
const isFailed = lowerVerb.endsWith("_failed") || lowerVerb.includes("failed");
if (lowerVerb === "virtualize") {
iconClass = "fa-compress";
iconExtra = [];
label = "Virtualize";
} else if (lowerVerb === "unvirtualize") {
iconClass = "fa-expand";
iconExtra = [];
label = "Unvirtualize";
} else if (lowerVerb === "dry_run_toggled") {
iconClass = "fa-toggle-on";
iconExtra = ["text-muted"];
label = "Dry-run toggled";
} else if (lowerVerb === "reload_success") {
iconClass = "fa-refresh";
iconExtra = ["text-success"];
label = "Reload success";
} else if (lowerVerb === "reload_skipped") {
iconClass = "fa-refresh";
iconExtra = ["text-muted"];
label = "Reload skipped";
} else if (lowerVerb === "reload_failed") {
iconClass = "fa-refresh";
iconExtra = ["text-danger"];
label = "Reload failed";
} else if (lowerVerb.startsWith("clear_virtual_override")) {
iconClass = "fa-eraser";
iconExtra = ["text-warning"];
label = "Clear virtual override";
if (lowerVerb.endsWith("_conflict")) label += " (conflict)";
if (lowerVerb.endsWith("_failed")) label += " failed";
} else if (lowerVerb.startsWith("set_virtual_override")) {
iconClass = "fa-compress";
iconExtra = isFailed ? ["text-danger"] : [];
label = isFailed ? "Set virtual override failed" : "Set virtual override";
} else if (lowerVerb.startsWith("clear_sqm_overrides")) {
iconClass = "fa-eraser";
iconExtra = ["text-warning"];
label = "Clear SQM overrides";
if (lowerVerb.endsWith("_conflict")) label += " (conflict)";
} else if (lowerVerb === "set_sqm_override_failed") {
iconClass = "fa-exclamation-circle";
iconExtra = ["text-danger"];
label = "SQM override failed";
} else if (lowerVerb === "apply_sqm_live_failed") {
iconClass = "fa-exclamation-circle";
iconExtra = ["text-danger"];
label = formatSqmLabel("SQM live apply failed", payload);
} else if (lowerVerb === "would_set_sqm_override") {
iconClass = "fa-eye";
iconExtra = ["text-muted"];
label = formatSqmLabel("Dry-run SQM override", payload);
} else if (lowerVerb === "set_sqm_override") {
if (payload.toLowerCase().includes("cake")) {
iconClass = "fa-birthday-cake";
} else if (payload.toLowerCase().includes("fq_codel")) {
iconClass = "fa-tachometer";
} else {
iconClass = "fa-sliders";
}
iconExtra = [];
label = formatSqmLabel("SQM override", payload);
} else if (lowerVerb === "set_sqm_live") {
iconClass = "fa-bolt";
iconExtra = [];
label = formatSqmLabel("SQM live apply", payload);
} else if (isFailed) {
iconClass = "fa-exclamation-circle";
iconExtra = ["text-danger"];
label = verb;
}
return { raw, label, iconClass, iconExtra };
}
export class TreeGuardActivityDashlet extends BaseDashlet {
constructor(slot) {
super(slot);
this.size = 12;
this.circuitNameById = new Map();
this.nodeIdByName = new Map();
}
title() {
return "TreeGuard Activity";
}
tooltip() {
return "<h5>TreeGuard Activity</h5><p>Recent TreeGuard actions, including dry-run entries and persisted changes.</p>";
}
subscribeTo() {
return ["TreeGuardActivity"];
}
setup() {
const wsClient = get_ws_client();
const shapedWrapped = (msg) => {
wsClient.off("AllShapedDevices", shapedWrapped);
const devices = msg && Array.isArray(msg.data) ? msg.data : [];
devices.forEach((d) => {
const id = (d && d.circuit_id ? String(d.circuit_id) : "").trim();
const name = (d && d.circuit_name ? String(d.circuit_name) : "").trim();
if (!id || !name) return;
if (!this.circuitNameById.has(id)) {
this.circuitNameById.set(id, name);
}
});
};
wsClient.on("AllShapedDevices", shapedWrapped);
wsClient.send({ AllShapedDevices: {} });
const treeWrapped = (msg) => {
wsClient.off("NetworkTree", treeWrapped);
const data = msg && Array.isArray(msg.data) ? msg.data : [];
data.forEach((entry) => {
if (!Array.isArray(entry) || entry.length < 2) return;
const id = entry[0];
const node = entry[1];
const name = (node && node.name ? String(node.name) : "").trim();
if (!name) return;
if (!this.nodeIdByName.has(name)) {
this.nodeIdByName.set(name, id);
}
});
};
wsClient.on("NetworkTree", treeWrapped);
wsClient.send({ NetworkTree: {} });
}
buildContainer() {
const base = super.buildContainer();
const wrap = document.createElement("div");
wrap.classList.add("p-2");
const table = document.createElement("table");
table.classList.add("table", "table-sm", "table-striped", "mb-0", "small");
const thead = document.createElement("thead");
thead.classList.add("small");
const headRow = document.createElement("tr");
["Local Time", "Entity", "Action", "Persisted", "Reason"].forEach((header) => {
const th = document.createElement("th");
th.textContent = header;
headRow.appendChild(th);
});
thead.appendChild(headRow);
this.tbody = document.createElement("tbody");
table.appendChild(thead);
table.appendChild(this.tbody);
wrap.appendChild(table);
base.appendChild(wrap);
return base;
}
onMessage(msg) {
if (msg.event !== "TreeGuardActivity") {
return;
}
const entries = Array.isArray(msg.data) ? msg.data : [];
this.tbody.innerHTML = "";
if (entries.length === 0) {
const tr = document.createElement("tr");
const td = document.createElement("td");
td.colSpan = 5;
td.classList.add("text-muted");
td.textContent = "No recent activity";
tr.appendChild(td);
this.tbody.appendChild(tr);
return;
}
entries.slice(0, 50).forEach((entry) => {
const tr = document.createElement("tr");
tr.classList.add("small");
const tdTime = document.createElement("td");
tdTime.textContent = formatUnixSecondsToLocalTime(entry.time);
const tdEntity = document.createElement("td");
const entityTypeRaw = (entry.entity_type ?? "").toString();
const entityIdRaw = (entry.entity_id ?? "").toString();
const entityType = entityTypeRaw.toLowerCase().trim();
const entityId = entityIdRaw.trim();
const prefix = document.createElement("span");
prefix.classList.add("text-muted");
prefix.textContent = entityTypeRaw ? `${entityTypeRaw}: ` : "";
tdEntity.appendChild(prefix);
const mkLink = (href, text, title = "") => {
const a = document.createElement("a");
a.href = href;
a.textContent = text;
a.classList.add("redactable");
if (title) a.title = title;
return a;
};
if (entityType === "circuit" && entityId) {
const parsed = parseCircuitEntityId(entityId);
const circuitId = parsed.circuitId;
let display = parsed.display;
let title = parsed.hasName ? circuitId : "";
if (!parsed.hasName && circuitId) {
const name = this.circuitNameById.get(circuitId);
if (name) {
display = name;
title = circuitId;
}
}
tdEntity.appendChild(
mkLink(`circuit.html?id=${encodeURIComponent(circuitId)}`, display, title),
);
} else if (entityType === "node" && entityId) {
const nodeId = this.nodeIdByName.get(entityId);
if (nodeId !== undefined && nodeId !== null) {
tdEntity.appendChild(
mkLink(
`tree.html?parent=${encodeURIComponent(String(nodeId))}`,
entityId,
`Node ID: ${nodeId}`,
),
);
} else {
const span = document.createElement("span");
span.textContent = entityId;
span.classList.add("redactable");
tdEntity.appendChild(span);
}
} else {
const span = document.createElement("span");
span.textContent = entityId || entityTypeRaw || "";
span.classList.add("redactable");
tdEntity.appendChild(span);
}
const tdAction = document.createElement("td");
const action = renderAction(entry.action);
tdAction.title = action.raw;
tdAction.appendChild(mkIcon(action.iconClass, action.iconExtra));
const actionText = document.createElement("span");
actionText.textContent = ` ${action.label}`;
tdAction.appendChild(actionText);
const tdPersisted = document.createElement("td");
tdPersisted.classList.add("text-center");
const persisted = !!entry.persisted;
const persistedIcon = persisted
? mkIcon("fa-check", ["text-success"])
: mkIcon("fa-times", ["text-muted"]);
persistedIcon.setAttribute("aria-label", persisted ? "Persisted" : "Not persisted");
persistedIcon.title = persisted ? "Persisted" : "Not persisted";
tdPersisted.appendChild(persistedIcon);
const tdReason = document.createElement("td");
const reason = formatReason(entry.reason);
tdReason.textContent = reason.label;
if (reason.title) tdReason.title = reason.title;
tr.appendChild(tdTime);
tr.appendChild(tdEntity);
tr.appendChild(tdAction);
tr.appendChild(tdPersisted);
tr.appendChild(tdReason);
this.tbody.appendChild(tr);
});
}
}
@@ -0,0 +1,167 @@
import {loadConfig, saveConfig} from "../config/config_helper";
import {defaultTreeguardConfig, ensureTreeguardConfig} from "../config/treeguard_defaults";
import {BaseDashlet} from "../lq_js_common/dashboard/base_dashlet";
export class TreeguardControlsDashlet extends BaseDashlet {
constructor(slot) {
super(slot);
this.size = 6;
this.treeguard = defaultTreeguardConfig();
this.isSaving = false;
}
title() {
return "TreeGuard Controls";
}
tooltip() {
return "<h5>TreeGuard Controls</h5><p>Quick access to the default TreeGuard controls plus the current config-derived rollout status.</p>";
}
subscribeTo() {
return [];
}
statusLineId() {
return `${this.id}_status`;
}
controlsId() {
return `${this.id}_controls`;
}
saveStateId() {
return `${this.id}_save_state`;
}
enabledId() {
return `${this.id}_enabled`;
}
dryRunId() {
return `${this.id}_dry_run`;
}
buildContainer() {
const base = super.buildContainer();
const statusLine = document.createElement("div");
statusLine.id = this.statusLineId();
statusLine.className = "text-muted small mb-3";
statusLine.innerText = "Loading TreeGuard status...";
base.appendChild(statusLine);
const controls = document.createElement("div");
controls.id = this.controlsId();
controls.className = "d-flex flex-column flex-md-row gap-3 align-items-start align-items-md-center";
controls.innerHTML = `
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="${this.enabledId()}">
<label class="form-check-label" for="${this.enabledId()}">Enable TreeGuard</label>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="${this.dryRunId()}">
<label class="form-check-label" for="${this.dryRunId()}">Dry Run Mode</label>
</div>
`;
base.appendChild(controls);
const saveState = document.createElement("div");
saveState.id = this.saveStateId();
saveState.className = "small mt-2 text-muted";
base.appendChild(saveState);
return base;
}
setup() {
this.bindControls();
this.refreshConfig();
}
bindControls() {
const enabled = document.getElementById(this.enabledId());
const dryRun = document.getElementById(this.dryRunId());
enabled.addEventListener("change", () => this.persistToggle("enabled", enabled.checked));
dryRun.addEventListener("change", () => this.persistToggle("dry_run", dryRun.checked));
}
refreshConfig() {
this.setSaveState("Loading config...", false);
loadConfig(
() => {
this.treeguard = ensureTreeguardConfig(window.config);
this.syncControls();
this.renderStatusLine();
this.setSaveState("", false);
},
() => {
this.treeguard = ensureTreeguardConfig(window.config || {});
this.syncControls();
this.renderStatusLine();
this.setSaveState("Unable to refresh TreeGuard config.", true);
},
);
}
syncControls() {
const enabled = document.getElementById(this.enabledId());
const dryRun = document.getElementById(this.dryRunId());
enabled.checked = !!this.treeguard.enabled;
dryRun.checked = !!this.treeguard.dry_run;
enabled.disabled = this.isSaving;
dryRun.disabled = this.isSaving;
}
renderStatusLine() {
const statusLine = document.getElementById(this.statusLineId());
const liveState = this.treeguard.dry_run ? "dry run" : "live";
const mode = this.treeguard.cpu?.mode === "cpu_aware" ? "CPU-aware" : "Traffic/RTT only";
const linkScope = this.treeguard.links?.all_nodes ? "all links" : "allowlisted links";
const circuitScope = this.treeguard.circuits?.all_circuits ? "all circuits" : "allowlisted circuits";
const enabled = this.treeguard.enabled ? "Enabled" : "Disabled";
statusLine.innerHTML = `
Status: <strong>${enabled}</strong>, ${liveState}, ${mode}, ${linkScope}, ${circuitScope}.
<a href="config_treeguard.html" class="ms-1">Open configuration</a>
`;
}
persistToggle(field, nextValue) {
const previousValue = this.treeguard[field];
this.treeguard[field] = nextValue;
window.config = window.config || {};
window.config.treeguard = ensureTreeguardConfig(window.config);
window.config.treeguard[field] = nextValue;
this.isSaving = true;
this.syncControls();
this.renderStatusLine();
this.setSaveState("Saving...", false);
saveConfig(
() => {
this.isSaving = false;
this.syncControls();
this.renderStatusLine();
this.setSaveState("Saved.", false);
},
() => {
this.treeguard[field] = previousValue;
window.config.treeguard[field] = previousValue;
this.isSaving = false;
this.syncControls();
this.renderStatusLine();
this.setSaveState("Save failed.", true);
},
);
}
setSaveState(message, isError) {
const saveState = document.getElementById(this.saveStateId());
if (!saveState) {
return;
}
saveState.innerText = message;
saveState.classList.toggle("text-danger", !!message && isError);
saveState.classList.toggle("text-muted", !message || !isError);
}
}
@@ -1,166 +1,420 @@
import {loadConfig, saveConfig} from "../config/config_helper";
import {defaultTreeguardConfig, ensureTreeguardConfig} from "../config/treeguard_defaults";
import {BaseDashlet} from "../lq_js_common/dashboard/base_dashlet";
import {get_ws_client} from "../pubsub/ws";
export class TreeguardStatusDashlet extends BaseDashlet {
function clamp(n, min, max) {
return Math.min(Math.max(n, min), max);
}
function splitOnce(s, sep) {
const str = (s ?? "").toString();
const idx = str.indexOf(sep);
if (idx === -1) return [str, ""];
return [str.slice(0, idx), str.slice(idx + sep.length)];
}
function parseDirectionalSqmToken(token) {
const t = (token ?? "").toString().trim();
if (!t) return { down: "", up: "" };
if (!t.includes("/")) return { down: t, up: t };
const [down, up] = splitOnce(t, "/");
return { down: (down ?? "").toString().trim(), up: (up ?? "").toString().trim() };
}
function formatSqmLabel(token) {
const { down, up } = parseDirectionalSqmToken(token);
if (!down && !up) return "";
if (down === up) return down;
return `DL ${down}, UL ${up}`;
}
function mkIcon(iconClass, extraClasses = []) {
const icon = document.createElement("i");
icon.classList.add("fa", "fa-fw", iconClass);
extraClasses.forEach((c) => icon.classList.add(c));
return icon;
}
function setBgClass(el, cls) {
[
"bg-primary",
"bg-secondary",
"bg-success",
"bg-info",
"bg-warning",
"bg-danger",
"bg-light",
"bg-dark",
].forEach((c) => el.classList.remove(c));
if (cls) el.classList.add(cls);
}
function mkProgressMetric() {
const container = document.createElement("div");
container.classList.add("d-flex", "align-items-center", "gap-2");
const progress = document.createElement("div");
progress.classList.add("progress", "flex-grow-1");
progress.style.height = "0.6rem";
const bar = document.createElement("div");
bar.classList.add("progress-bar");
bar.setAttribute("role", "progressbar");
bar.style.width = "0%";
progress.appendChild(bar);
const text = document.createElement("span");
text.classList.add("small");
text.textContent = "—";
container.appendChild(progress);
container.appendChild(text);
return { container, progress, bar, text };
}
function updateProgressMetric(metric, { value, max, text, bgClass, title }) {
const safeMax = Number.isFinite(max) && max > 0 ? max : 1;
const safeValue = Number.isFinite(value) ? value : 0;
const pct = clamp((safeValue / safeMax) * 100.0, 0, 100);
metric.bar.style.width = `${pct.toFixed(1)}%`;
metric.bar.setAttribute("aria-valuemin", "0");
metric.bar.setAttribute("aria-valuemax", safeMax.toString());
metric.bar.setAttribute("aria-valuenow", safeValue.toString());
setBgClass(metric.bar, bgClass);
metric.text.textContent = text ?? "—";
metric.container.title = title ?? "";
}
function renderLastAction(summaryRaw) {
const raw = (summaryRaw ?? "").toString().trim();
const wrap = document.createElement("div");
wrap.classList.add("d-flex", "align-items-start", "gap-2");
wrap.style.whiteSpace = "normal";
wrap.style.wordBreak = "break-word";
if (!raw) {
wrap.appendChild(mkIcon("fa-minus-circle", ["text-muted"]));
const span = document.createElement("span");
span.textContent = "—";
wrap.appendChild(span);
return wrap;
}
wrap.title = raw;
let iconClass = "fa-info-circle";
let iconExtra = ["text-muted"];
let label = raw;
let m = raw.match(/^Reloaded LibreQoS:\s*(.+)$/);
if (m) {
iconClass = "fa-refresh";
iconExtra = ["text-success"];
label = `Reloaded LibreQoS: ${m[1]}`;
}
m = raw.match(/^(Virtualized|Unvirtualized) node '(.+)'$/);
if (m) {
iconClass = m[1] === "Virtualized" ? "fa-compress" : "fa-expand";
iconExtra = [];
label = `${m[1]} node: ${m[2]}`;
}
m = raw.match(/^Would set SQM override for circuit '(.+)' -> (.+)$/);
if (m) {
iconClass = "fa-eye";
iconExtra = ["text-muted"];
const tokenLabel = formatSqmLabel(m[2]);
label = tokenLabel
? `Dry-run SQM override: ${m[1]} → ${tokenLabel}`
: `Dry-run SQM override: ${m[1]}`;
}
m = raw.match(/^SQM override for circuit '(.+)' -> (.+)$/);
if (m) {
const token = (m[2] ?? "").toString();
const tokenLower = token.toLowerCase();
if (tokenLower.includes("cake")) {
iconClass = "fa-birthday-cake";
iconExtra = [];
} else if (tokenLower.includes("fq_codel")) {
iconClass = "fa-tachometer";
iconExtra = [];
} else {
iconClass = "fa-sliders";
iconExtra = [];
}
const tokenLabel = formatSqmLabel(token);
label = tokenLabel ? `SQM override: ${m[1]} → ${tokenLabel}` : `SQM override: ${m[1]}`;
}
wrap.appendChild(mkIcon(iconClass, iconExtra));
const span = document.createElement("span");
span.textContent = label;
wrap.appendChild(span);
return wrap;
}
export class TreeGuardStatusDashlet extends BaseDashlet {
constructor(slot) {
super(slot);
this.treeguard = defaultTreeguardConfig();
this.isSaving = false;
this.size = 6;
this.totalNodes = null;
this.totalCircuits = null;
this.estimatedVirtualizedNodes = null;
this.estimatedFqCodelCircuits = null;
this.lastStatusData = null;
}
title() {
return "TreeGuard";
return "TreeGuard Status";
}
tooltip() {
return "<h5>TreeGuard</h5><p>Quick access to the default TreeGuard controls plus the current config-derived rollout status.</p>";
return "<h5>TreeGuard Status</h5><p>Shows TreeGuard enablement, dry-run state, CPU pressure, managed allowlists, current virtualization/SQM states, and warnings.</p>";
}
subscribeTo() {
return [];
return ["TreeGuardStatus"];
}
statusLineId() {
return `${this.id}_status`;
setup() {
const wsClient = get_ws_client();
const treeWrapped = (msg) => {
wsClient.off("NetworkTree", treeWrapped);
const data = msg && Array.isArray(msg.data) ? msg.data : [];
let count = 0;
let virtualCount = 0;
data.forEach((entry) => {
if (!Array.isArray(entry) || entry.length < 2) return;
const node = entry[1];
const name = (node && node.name ? String(node.name) : "").trim();
if (!name || name === "Root") return;
count += 1;
if (node && (node.virtual === true || node.is_virtual === true)) {
virtualCount += 1;
}
});
this.totalNodes = count;
this.estimatedVirtualizedNodes = virtualCount;
this.rerenderWithEstimates();
};
wsClient.on("NetworkTree", treeWrapped);
wsClient.send({ NetworkTree: {} });
const shapedWrapped = (msg) => {
wsClient.off("AllShapedDevices", shapedWrapped);
const devices = msg && Array.isArray(msg.data) ? msg.data : [];
const circuits = new Set();
const fqCodelCircuits = new Set();
devices.forEach((d) => {
const id = (d && d.circuit_id ? String(d.circuit_id) : "").trim();
if (id) circuits.add(id);
const override = (d && d.sqm_override !== undefined ? String(d.sqm_override) : "").trim();
if (!override || !id) return;
const { down, up } = parseDirectionalSqmToken(override);
const downLower = (down ?? "").toString().trim().toLowerCase();
const upLower = (up ?? "").toString().trim().toLowerCase();
if (downLower === "fq_codel" || upLower === "fq_codel") {
fqCodelCircuits.add(id);
}
});
this.totalCircuits = circuits.size;
this.estimatedFqCodelCircuits = fqCodelCircuits.size;
this.rerenderWithEstimates();
};
wsClient.on("AllShapedDevices", shapedWrapped);
wsClient.send({ AllShapedDevices: {} });
}
controlsId() {
return `${this.id}_controls`;
}
saveStateId() {
return `${this.id}_save_state`;
}
enabledId() {
return `${this.id}_enabled`;
}
dryRunId() {
return `${this.id}_dry_run`;
rerenderWithEstimates() {
if (this.lastStatusData) {
this.renderStatus(this.lastStatusData);
}
}
buildContainer() {
const base = super.buildContainer();
const wrap = document.createElement("div");
wrap.classList.add("p-2");
const statusLine = document.createElement("div");
statusLine.id = this.statusLineId();
statusLine.className = "text-muted small mb-3";
statusLine.innerText = "Loading TreeGuard status...";
base.appendChild(statusLine);
const table = document.createElement("table");
table.classList.add("table", "table-sm", "mb-2");
const tbody = document.createElement("tbody");
const controls = document.createElement("div");
controls.id = this.controlsId();
controls.className = "d-flex flex-column flex-md-row gap-3 align-items-start align-items-md-center";
controls.innerHTML = `
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="${this.enabledId()}">
<label class="form-check-label" for="${this.enabledId()}">Enable TreeGuard</label>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="${this.dryRunId()}">
<label class="form-check-label" for="${this.dryRunId()}">Dry Run Mode</label>
</div>
`;
base.appendChild(controls);
const mkRow = (label, valueEl) => {
const tr = document.createElement("tr");
const tdL = document.createElement("td");
tdL.classList.add("text-muted");
tdL.style.width = "45%";
tdL.textContent = label;
const tdV = document.createElement("td");
tdV.appendChild(valueEl);
tr.appendChild(tdL);
tr.appendChild(tdV);
return tr;
};
const saveState = document.createElement("div");
saveState.id = this.saveStateId();
saveState.className = "small mt-2 text-muted";
base.appendChild(saveState);
this.enabledEl = document.createElement("span");
this.dryRunEl = document.createElement("span");
this.cpuMetric = mkProgressMetric();
this.nodesMetric = mkProgressMetric();
this.circuitsMetric = mkProgressMetric();
this.virtualizedMetric = mkProgressMetric();
this.fqCodelMetric = mkProgressMetric();
this.lastActionEl = document.createElement("div");
this.lastActionEl.classList.add("small");
tbody.appendChild(mkRow("Enabled", this.enabledEl));
tbody.appendChild(mkRow("Dry Run", this.dryRunEl));
tbody.appendChild(mkRow("CPU Max", this.cpuMetric.container));
tbody.appendChild(mkRow("Managed Nodes", this.nodesMetric.container));
tbody.appendChild(mkRow("Managed Circuits", this.circuitsMetric.container));
tbody.appendChild(mkRow("Virtualized Nodes", this.virtualizedMetric.container));
tbody.appendChild(mkRow("fq_codel Circuits", this.fqCodelMetric.container));
tbody.appendChild(mkRow("Last Action", this.lastActionEl));
table.appendChild(tbody);
wrap.appendChild(table);
const warningsHeader = document.createElement("div");
warningsHeader.classList.add("text-muted", "small", "mb-1");
warningsHeader.textContent = "Warnings";
wrap.appendChild(warningsHeader);
this.warningsEl = document.createElement("div");
this.warningsEl.classList.add("small");
wrap.appendChild(this.warningsEl);
base.appendChild(wrap);
return base;
}
setup() {
this.bindControls();
this.refreshConfig();
}
bindControls() {
const enabled = document.getElementById(this.enabledId());
const dryRun = document.getElementById(this.dryRunId());
enabled.addEventListener("change", () => this.persistToggle("enabled", enabled.checked));
dryRun.addEventListener("change", () => this.persistToggle("dry_run", dryRun.checked));
}
refreshConfig() {
this.setSaveState("Loading config...", false);
loadConfig(
() => {
this.treeguard = ensureTreeguardConfig(window.config);
this.syncControls();
this.renderStatusLine();
this.setSaveState("", false);
},
() => {
this.treeguard = ensureTreeguardConfig(window.config || {});
this.syncControls();
this.renderStatusLine();
this.setSaveState("Unable to refresh TreeGuard config.", true);
},
);
}
syncControls() {
const enabled = document.getElementById(this.enabledId());
const dryRun = document.getElementById(this.dryRunId());
enabled.checked = !!this.treeguard.enabled;
dryRun.checked = !!this.treeguard.dry_run;
enabled.disabled = this.isSaving;
dryRun.disabled = this.isSaving;
}
renderStatusLine() {
const statusLine = document.getElementById(this.statusLineId());
const liveState = this.treeguard.dry_run ? "dry run" : "live";
const mode = this.treeguard.cpu?.mode === "cpu_aware" ? "CPU-aware" : "Traffic/RTT only";
const linkScope = this.treeguard.links?.all_nodes ? "all links" : "allowlisted links";
const circuitScope = this.treeguard.circuits?.all_circuits ? "all circuits" : "allowlisted circuits";
const enabled = this.treeguard.enabled ? "Enabled" : "Disabled";
statusLine.innerHTML = `
Status: <strong>${enabled}</strong>, ${liveState}, ${mode}, ${linkScope}, ${circuitScope}.
<a href="config_treeguard.html" class="ms-1">Open configuration</a>
`;
}
persistToggle(field, nextValue) {
const previousValue = this.treeguard[field];
this.treeguard[field] = nextValue;
window.config = window.config || {};
window.config.treeguard = ensureTreeguardConfig(window.config);
window.config.treeguard[field] = nextValue;
this.isSaving = true;
this.syncControls();
this.renderStatusLine();
this.setSaveState("Saving...", false);
saveConfig(
() => {
this.isSaving = false;
this.syncControls();
this.renderStatusLine();
this.setSaveState("Saved.", false);
},
() => {
this.treeguard[field] = previousValue;
window.config.treeguard[field] = previousValue;
this.isSaving = false;
this.syncControls();
this.renderStatusLine();
this.setSaveState("Save failed.", true);
},
);
}
setSaveState(message, isError) {
const saveState = document.getElementById(this.saveStateId());
if (!saveState) {
onMessage(msg) {
if (msg.event !== "TreeGuardStatus") {
return;
}
saveState.innerText = message;
saveState.classList.toggle("text-danger", !!message && isError);
saveState.classList.toggle("text-muted", !message || !isError);
const data = msg.data || {};
this.lastStatusData = data;
this.renderStatus(data);
}
renderStatus(data) {
this.enabledEl.textContent = data.enabled ? "Yes" : "No";
this.dryRunEl.textContent = data.dry_run ? "Yes" : "No";
const cpuRaw = data.cpu_max_pct;
if (cpuRaw === null || cpuRaw === undefined) {
updateProgressMetric(this.cpuMetric, {
value: 0,
max: 100,
text: "N/A",
bgClass: "bg-secondary",
title: "CPU usage unavailable",
});
} else {
const cpu = clamp(Number(cpuRaw), 0, 100);
const bg = cpu >= 90 ? "bg-danger" : cpu >= 70 ? "bg-warning" : "bg-success";
updateProgressMetric(this.cpuMetric, {
value: cpu,
max: 100,
text: `${cpu.toFixed(0)}%`,
bgClass: bg,
title: `CPU max: ${cpu.toFixed(0)}%`,
});
}
const managedNodes = Math.max(0, Math.trunc(Number(data.managed_nodes ?? 0) || 0));
const managedCircuits = Math.max(0, Math.trunc(Number(data.managed_circuits ?? 0) || 0));
const virtualizedNodesExact = Math.max(0, Math.trunc(Number(data.virtualized_nodes ?? 0) || 0));
const fqCodelCircuitsExact = Math.max(0, Math.trunc(Number(data.fq_codel_circuits ?? 0) || 0));
const virtualizedEstimate = Math.max(0, Math.trunc(Number(this.estimatedVirtualizedNodes ?? 0) || 0));
const fqCodelEstimate = Math.max(0, Math.trunc(Number(this.estimatedFqCodelCircuits ?? 0) || 0));
const usingVirtualEstimate = virtualizedNodesExact === 0 && virtualizedEstimate > 0;
const usingFqCodelEstimate = fqCodelCircuitsExact === 0 && fqCodelEstimate > 0;
const virtualizedNodes = usingVirtualEstimate ? virtualizedEstimate : virtualizedNodesExact;
const fqCodelCircuits = usingFqCodelEstimate ? fqCodelEstimate : fqCodelCircuitsExact;
const totalNodes = Number(this.totalNodes ?? 0);
const nodesMax = totalNodes > 0 ? totalNodes : Math.max(1, managedNodes);
const nodesPct = nodesMax ? clamp((managedNodes / nodesMax) * 100, 0, 100) : 0;
updateProgressMetric(this.nodesMetric, {
value: managedNodes,
max: nodesMax,
text: managedNodes.toString(),
bgClass: "bg-info",
title: totalNodes > 0 ? `${managedNodes} / ${totalNodes} (${nodesPct.toFixed(0)}%)` : `${managedNodes}`,
});
const totalCircuits = Number(this.totalCircuits ?? 0);
const circuitsMax = totalCircuits > 0 ? totalCircuits : Math.max(1, managedCircuits);
const circuitsPct = circuitsMax ? clamp((managedCircuits / circuitsMax) * 100, 0, 100) : 0;
updateProgressMetric(this.circuitsMetric, {
value: managedCircuits,
max: circuitsMax,
text: managedCircuits.toString(),
bgClass: "bg-info",
title: totalCircuits > 0
? `${managedCircuits} / ${totalCircuits} (${circuitsPct.toFixed(0)}%)`
: `${managedCircuits}`,
});
const virtMax = Math.max(1, managedNodes);
const virtPct = managedNodes > 0 ? clamp((virtualizedNodes / virtMax) * 100, 0, 100) : 0;
updateProgressMetric(this.virtualizedMetric, {
value: virtualizedNodes,
max: virtMax,
text: usingVirtualEstimate ? `≈${virtualizedNodes}` : virtualizedNodes.toString(),
bgClass: "bg-primary",
title: (() => {
const core = managedNodes > 0
? `${virtualizedNodes} / ${managedNodes} (${virtPct.toFixed(0)}%)`
: `${virtualizedNodes}`;
return usingVirtualEstimate ? `Estimated from NetworkTree snapshot: ${core}` : core;
})(),
});
const fqMax = Math.max(1, managedCircuits);
const fqPct = managedCircuits > 0 ? clamp((fqCodelCircuits / fqMax) * 100, 0, 100) : 0;
updateProgressMetric(this.fqCodelMetric, {
value: fqCodelCircuits,
max: fqMax,
text: usingFqCodelEstimate ? `≈${fqCodelCircuits}` : fqCodelCircuits.toString(),
bgClass: "bg-warning",
title: (() => {
const core = managedCircuits > 0
? `${fqCodelCircuits} / ${managedCircuits} (${fqPct.toFixed(0)}%)`
: `${fqCodelCircuits}`;
return usingFqCodelEstimate ? `Estimated from ShapedDevices SQM overrides: ${core}` : core;
})(),
});
this.lastActionEl.innerHTML = "";
this.lastActionEl.appendChild(renderLastAction(data.last_action_summary));
const warnings = Array.isArray(data.warnings) ? data.warnings : [];
if (warnings.length === 0) {
this.warningsEl.textContent = "—";
return;
}
const ul = document.createElement("ul");
ul.classList.add("mb-0");
warnings.slice(0, 8).forEach((warning) => {
const li = document.createElement("li");
li.textContent = warning;
ul.appendChild(li);
});
this.warningsEl.innerHTML = "";
this.warningsEl.appendChild(ul);
}
}
@@ -75,7 +75,9 @@ const defaultLayout = {
{
name: "TreeGuard",
dashlets: [
{ tag: "treeguardStatus", size: 12 }
{ tag: "treeguardControls", size: 6 },
{ tag: "treeguardStatus", size: 6 },
{ tag: "treeguardActivity", size: 12 }
]
}
]