154 lines
7.3 KiB
JavaScript
154 lines
7.3 KiB
JavaScript
(() => {
|
|
const root = document.querySelector("[data-dashboard-metrics]");
|
|
if (!root) return;
|
|
const memoryChart = root.querySelector("[data-memory-chart]");
|
|
const logChart = root.querySelector("[data-log-chart]");
|
|
const logSummary = root.querySelector("[data-log-summary]");
|
|
const status = root.querySelector("[data-metrics-status]");
|
|
const memoryScopeLabel = root.querySelector("[data-memory-scope-label]");
|
|
const logScopeLabel = root.querySelector("[data-log-scope-label]");
|
|
const scopeButtons = Array.from(root.querySelectorAll("[data-dashboard-scope]"));
|
|
let activeScope = window.localStorage.getItem("lumi-dashboard-scope") || "5m";
|
|
|
|
const scopeLabels = { "5m": "5 minutes", "24h": "24 hours", "7d": "7 days" };
|
|
const levels = [
|
|
["error", "Errors", "danger"],
|
|
["warn", "Warnings", "warning"],
|
|
["info", "Info", "info"],
|
|
["debug", "Debug", "text-muted"]
|
|
];
|
|
|
|
const bytes = (value) => {
|
|
const mb = Number(value || 0) / 1048576;
|
|
return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
|
|
};
|
|
|
|
const duration = (seconds) => {
|
|
const total = Number(seconds || 0);
|
|
const hours = Math.floor(total / 3600);
|
|
const minutes = Math.floor((total % 3600) / 60);
|
|
return hours ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
};
|
|
|
|
const setMetric = (name, value) => {
|
|
const target = root.querySelector(`[data-metric="${name}"]`);
|
|
if (target) target.textContent = value;
|
|
};
|
|
|
|
const escapeAttr = (value) => String(value ?? "").replace(/[&"<]/g, (char) => ({
|
|
"&": "&",
|
|
"\"": """,
|
|
"<": "<"
|
|
})[char]);
|
|
|
|
const scaleX = (sample, samples, scopeMs, now) => {
|
|
if (samples.length <= 1) return 290;
|
|
const min = now - scopeMs;
|
|
return 10 + Math.max(0, Math.min(1, (sample.sampled_at - min) / scopeMs)) * 250;
|
|
};
|
|
|
|
const scaleY = (value, max) => 104 - (Number(value || 0) / Math.max(max, 1)) * 82;
|
|
|
|
const drawMemory = (data) => {
|
|
if (!memoryChart) return;
|
|
const samples = data.memory_scopes?.[activeScope] || [];
|
|
const scopeMs = data.scopes?.[activeScope]?.duration_ms || 5 * 60 * 1000;
|
|
const now = Number(data.sampled_at || Date.now());
|
|
const max = Math.max(...samples.map((sample) => sample.rss), data.memory?.rss || 0, 1);
|
|
const gridValues = [0, 0.25, 0.5, 0.75, 1].map((ratio) => Math.round(max * ratio));
|
|
const points = samples.map((sample) => `${scaleX(sample, samples, scopeMs, now).toFixed(1)},${scaleY(sample.rss, max).toFixed(1)}`).join(" ");
|
|
const labelSamples = samples.length <= 3
|
|
? samples
|
|
: [samples[0], samples[Math.floor(samples.length / 2)], samples[samples.length - 1]];
|
|
const grid = gridValues.map((value) => {
|
|
const y = scaleY(value, max);
|
|
return `<line x1="10" x2="285" y1="${y.toFixed(1)}" y2="${y.toFixed(1)}" stroke="var(--lumi-border)" stroke-width="1"></line><text x="288" y="${(y + 3).toFixed(1)}" fill="var(--lumi-text-muted)" font-size="8">${bytes(value)}</text>`;
|
|
}).join("");
|
|
const vertical = [0, 0.5, 1].map((ratio) => {
|
|
const x = 10 + ratio * 250;
|
|
const label = ratio === 0 ? `-${scopeLabels[activeScope] || activeScope}` : ratio === 1 ? "now" : "mid";
|
|
return `<line x1="${x}" x2="${x}" y1="16" y2="104" stroke="var(--lumi-border)" stroke-width="1" stroke-dasharray="3 4"></line><text x="${x}" y="116" fill="var(--lumi-text-muted)" font-size="8" text-anchor="middle">${label}</text>`;
|
|
}).join("");
|
|
const labels = labelSamples.map((sample) => {
|
|
const x = scaleX(sample, samples, scopeMs, now);
|
|
const y = scaleY(sample.rss, max);
|
|
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="var(--lumi-primary)"></circle><text x="${x.toFixed(1)}" y="${Math.max(10, y - 7).toFixed(1)}" fill="var(--lumi-text)" font-size="8" text-anchor="middle">${bytes(sample.rss)}</text>`;
|
|
}).join("");
|
|
memoryChart.innerHTML = `
|
|
${grid}
|
|
${vertical}
|
|
${points ? `<polyline points="${points}" fill="none" stroke="var(--lumi-primary)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"></polyline>` : ""}
|
|
${labels}
|
|
`;
|
|
};
|
|
|
|
const drawLogs = (logs) => {
|
|
if (!logChart) return;
|
|
const counts = logs.levels || logs;
|
|
const entries = levels.map(([level, label, token]) => [level, label, token, Number(counts[level] || 0)]);
|
|
const max = Math.max(...entries.map(([, , , value]) => value), 1);
|
|
const range = encodeURIComponent(String(logs.scope_ms || 5 * 60 * 1000));
|
|
logChart.innerHTML = entries.map(([level, label, token, value], index) => {
|
|
const height = Math.max(4, (value / max) * 76);
|
|
const x = 22 + index * 68;
|
|
const y = 92 - height;
|
|
const href = `/admin/logs?range=${range}&level=${encodeURIComponent(level)}&limit=500`;
|
|
return `<a href="${href}" aria-label="${escapeAttr(label)}: ${value} logs"><rect x="${x}" y="${y}" width="38" height="${height}" rx="6" fill="var(--lumi-${token})"></rect><text x="${x + 19}" y="${Math.max(14, y - 5)}" text-anchor="middle" fill="var(--lumi-text)" font-size="10">${value}</text><text x="${x + 19}" y="112" text-anchor="middle" fill="var(--lumi-text-muted)" font-size="10">${level}</text></a>`;
|
|
}).join("");
|
|
if (logSummary) {
|
|
logSummary.innerHTML = entries.map(([level, label, , value]) => {
|
|
const href = `/admin/logs?range=${range}&level=${encodeURIComponent(level)}&limit=500`;
|
|
const badge = level === "error" ? "danger" : level === "warn" ? "warning" : level === "info" ? "info" : "muted";
|
|
return `<a class="badge ${badge}" href="${href}">${label}: ${value}</a>`;
|
|
}).join("");
|
|
}
|
|
};
|
|
|
|
const syncScopeButtons = () => {
|
|
scopeButtons.forEach((button) => {
|
|
const active = button.dataset.dashboardScope === activeScope;
|
|
button.classList.toggle("active", active);
|
|
button.setAttribute("aria-pressed", active ? "true" : "false");
|
|
});
|
|
if (memoryScopeLabel) memoryScopeLabel.textContent = scopeLabels[activeScope] || activeScope;
|
|
if (logScopeLabel) logScopeLabel.textContent = scopeLabels[activeScope] || activeScope;
|
|
};
|
|
|
|
const refresh = async () => {
|
|
try {
|
|
syncScopeButtons();
|
|
const response = await fetch(`/api/admin/dashboard-metrics?scope=${encodeURIComponent(activeScope)}`, { cache: "no-store" });
|
|
const data = await response.json();
|
|
if (!response.ok) throw new Error(data.error || "Metrics unavailable.");
|
|
setMetric("uptime", duration(data.uptime_seconds));
|
|
setMetric("rss", bytes(data.memory.rss));
|
|
setMetric("heap", `${bytes(data.memory.heap_used)} / ${bytes(data.memory.heap_total)}`);
|
|
setMetric("plugins", `${data.plugins.enabled} / ${data.plugins.total}`);
|
|
setMetric("users", data.counts.users);
|
|
setMetric("commands", data.counts.commands);
|
|
if (status) {
|
|
status.textContent = "Live";
|
|
status.className = "status-indicator status-success";
|
|
}
|
|
drawMemory(data);
|
|
drawLogs(data.logs);
|
|
} catch (error) {
|
|
if (status) {
|
|
status.textContent = error.message;
|
|
status.className = "status-indicator status-danger";
|
|
}
|
|
}
|
|
};
|
|
|
|
scopeButtons.forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
activeScope = button.dataset.dashboardScope || "5m";
|
|
window.localStorage.setItem("lumi-dashboard-scope", activeScope);
|
|
refresh();
|
|
});
|
|
});
|
|
|
|
refresh();
|
|
window.setInterval(refresh, 10000);
|
|
})();
|