mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-11-02 20:30:15 -06:00
Feat: restore TreeGuard status and activity dashlets
This commit is contained in:
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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Reference in new issue
Block a user