metrics on dashboard
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
let initialized = false;
|
||||
let metricsTimer = null;
|
||||
let onlineChart = null;
|
||||
let osChart = null;
|
||||
const STATS_COLLAPSED_KEY = "overlord_dashboard_stats_collapsed";
|
||||
|
||||
const palette = {
|
||||
text: "#cbd5e1",
|
||||
muted: "#94a3b8",
|
||||
border: "rgba(100, 116, 139, 0.18)",
|
||||
panel: "rgba(15, 23, 42, 0.96)",
|
||||
cyan: "#22d3ee",
|
||||
emerald: "#34d399",
|
||||
sky: "#38bdf8",
|
||||
indigo: "#818cf8",
|
||||
amber: "#fbbf24",
|
||||
rose: "#fb7185",
|
||||
violet: "#a78bfa",
|
||||
slate: "#64748b",
|
||||
};
|
||||
|
||||
const osColors = [
|
||||
palette.sky,
|
||||
palette.emerald,
|
||||
palette.violet,
|
||||
palette.amber,
|
||||
palette.rose,
|
||||
palette.cyan,
|
||||
palette.indigo,
|
||||
palette.slate,
|
||||
];
|
||||
|
||||
function $(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function setText(id, value) {
|
||||
const el = $(id);
|
||||
if (el) el.textContent = value;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
const n = Number(value) || 0;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const n = Number(bytes) || 0;
|
||||
if (n <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const idx = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
||||
return `${(n / Math.pow(1024, idx)).toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
|
||||
}
|
||||
|
||||
function formatTime(timestamp) {
|
||||
return new Date(timestamp || Date.now()).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatWeekLabel(timestamp) {
|
||||
const date = new Date(timestamp || Date.now());
|
||||
return date.toLocaleDateString([], {
|
||||
weekday: "short",
|
||||
hour: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function aggregateOnlineHistory(history, snapshot) {
|
||||
const now = Date.now();
|
||||
const weekAgo = now - 7 * 24 * 60 * 60 * 1000;
|
||||
const source = Array.isArray(history)
|
||||
? history.filter((row) => Number(row?.timestamp) >= weekAgo)
|
||||
: [];
|
||||
const rows = source.length
|
||||
? source
|
||||
: [{ timestamp: now, clientsOnline: snapshot?.clients?.online || 0 }];
|
||||
const maxPoints = 84;
|
||||
if (rows.length <= maxPoints) return rows;
|
||||
|
||||
const bucketMs = Math.max(
|
||||
60 * 60 * 1000,
|
||||
Math.ceil((now - weekAgo) / maxPoints),
|
||||
);
|
||||
const buckets = new Map();
|
||||
for (const row of rows) {
|
||||
const ts = Number(row.timestamp) || now;
|
||||
const key = Math.floor(ts / bucketMs) * bucketMs;
|
||||
const bucket = buckets.get(key) || { timestamp: key, total: 0, count: 0, peak: 0 };
|
||||
const online = Number(row.clientsOnline) || 0;
|
||||
bucket.total += online;
|
||||
bucket.count += 1;
|
||||
bucket.peak = Math.max(bucket.peak, online);
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
|
||||
return Array.from(buckets.values())
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
.map((bucket) => ({
|
||||
timestamp: bucket.timestamp,
|
||||
clientsOnline: Math.round(bucket.total / Math.max(1, bucket.count)),
|
||||
clientsPeak: bucket.peak,
|
||||
}));
|
||||
}
|
||||
|
||||
function setDotTone(id, tone) {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
el.classList.toggle("is-live", tone === "live");
|
||||
el.classList.toggle("is-warn", tone === "warn");
|
||||
el.classList.toggle("is-bad", tone === "bad");
|
||||
el.classList.toggle("is-muted", tone === "muted");
|
||||
}
|
||||
|
||||
function topEntries(record, limit = 6) {
|
||||
return Object.entries(record || {})
|
||||
.map(([key, value]) => [key || "Unknown", Number(value) || 0])
|
||||
.filter(([, value]) => value > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function compactOsLabel(os) {
|
||||
const text = String(os || "Unknown");
|
||||
const lower = text.toLowerCase();
|
||||
if (lower.includes("windows 11")) return "Win 11";
|
||||
if (lower.includes("windows 10")) return "Win 10";
|
||||
if (lower.includes("windows")) return "Windows";
|
||||
if (lower.includes("darwin") || lower.includes("mac")) return "macOS";
|
||||
if (lower.includes("ubuntu")) return "Ubuntu";
|
||||
if (lower.includes("debian")) return "Debian";
|
||||
if (lower.includes("kali")) return "Kali";
|
||||
if (lower.includes("fedora")) return "Fedora";
|
||||
if (lower.includes("linux")) return "Linux";
|
||||
return text.length > 16 ? `${text.slice(0, 15)}...` : text;
|
||||
}
|
||||
|
||||
function configureChartDefaults() {
|
||||
if (typeof Chart === "undefined") return false;
|
||||
Chart.defaults.color = palette.text;
|
||||
Chart.defaults.borderColor = palette.border;
|
||||
Chart.defaults.font.family = "Inter, Segoe UI, system-ui, sans-serif";
|
||||
return true;
|
||||
}
|
||||
|
||||
function createGradient(ctx, color) {
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 150);
|
||||
gradient.addColorStop(0, `${color}55`);
|
||||
gradient.addColorStop(0.72, `${color}12`);
|
||||
gradient.addColorStop(1, `${color}00`);
|
||||
return gradient;
|
||||
}
|
||||
|
||||
function makeCharts() {
|
||||
if (!configureChartDefaults()) return false;
|
||||
const onlineCanvas = $("dash-online-chart");
|
||||
const osCanvas = $("dash-os-chart");
|
||||
if (!onlineCanvas || !osCanvas) return false;
|
||||
|
||||
const onlineCtx = onlineCanvas.getContext("2d");
|
||||
onlineChart = new Chart(onlineCtx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: "Online",
|
||||
data: [],
|
||||
borderColor: palette.cyan,
|
||||
backgroundColor: createGradient(onlineCtx, palette.cyan),
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.38,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 260 },
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: palette.panel,
|
||||
borderColor: "rgba(56, 189, 248, 0.32)",
|
||||
borderWidth: 1,
|
||||
titleColor: "#e2e8f0",
|
||||
bodyColor: palette.text,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
label: (ctx) => `Avg online: ${ctx.parsed.y}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: palette.muted, maxRotation: 0, maxTicksLimit: 5 },
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: "rgba(100, 116, 139, 0.1)" },
|
||||
ticks: { color: palette.muted, precision: 0, maxTicksLimit: 4 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
osChart = new Chart(osCanvas, {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: osColors,
|
||||
borderColor: "rgba(2, 6, 23, 0.9)",
|
||||
borderWidth: 3,
|
||||
hoverOffset: 5,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 260 },
|
||||
cutout: "66%",
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "right",
|
||||
labels: {
|
||||
boxWidth: 9,
|
||||
boxHeight: 9,
|
||||
color: palette.text,
|
||||
padding: 10,
|
||||
font: { size: 11 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: palette.panel,
|
||||
borderColor: "rgba(129, 140, 248, 0.32)",
|
||||
borderWidth: 1,
|
||||
titleColor: "#e2e8f0",
|
||||
bodyColor: palette.text,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateOnlineChart(history, snapshot) {
|
||||
if (!onlineChart) return;
|
||||
const rows = aggregateOnlineHistory(history, snapshot);
|
||||
|
||||
onlineChart.data.labels = rows.map((row) => formatWeekLabel(row.timestamp));
|
||||
onlineChart.data.datasets[0].data = rows.map((row) => Number(row.clientsOnline) || 0);
|
||||
onlineChart.update("none");
|
||||
}
|
||||
|
||||
function updateOsChart(byOS) {
|
||||
if (!osChart) return;
|
||||
const entries = topEntries(byOS, 7);
|
||||
if (!entries.length) {
|
||||
osChart.data.labels = ["No clients"];
|
||||
osChart.data.datasets[0].data = [1];
|
||||
osChart.data.datasets[0].backgroundColor = ["rgba(100, 116, 139, 0.35)"];
|
||||
setText("dash-os-leader", "None");
|
||||
osChart.update("none");
|
||||
return;
|
||||
}
|
||||
|
||||
osChart.data.labels = entries.map(([label]) => compactOsLabel(label));
|
||||
osChart.data.datasets[0].data = entries.map(([, count]) => count);
|
||||
osChart.data.datasets[0].backgroundColor = entries.map((_, index) => osColors[index % osColors.length]);
|
||||
setText("dash-os-leader", `${compactOsLabel(entries[0][0])} ${entries[0][1]}`);
|
||||
osChart.update("none");
|
||||
}
|
||||
|
||||
function updateSummary(snapshot) {
|
||||
const clients = snapshot?.clients || {};
|
||||
const online = Number(clients.online) || 0;
|
||||
const total = Number(clients.total) || 0;
|
||||
const ratio = total > 0 ? Math.round((online / total) * 100) : 0;
|
||||
const sessions = snapshot?.sessions || {};
|
||||
const activeSessions =
|
||||
(Number(sessions.console) || 0) +
|
||||
(Number(sessions.remoteDesktop) || 0) +
|
||||
(Number(sessions.fileBrowser) || 0) +
|
||||
(Number(sessions.process) || 0);
|
||||
|
||||
setText("dash-online-count", formatNumber(online));
|
||||
setText("dash-total-count", `${formatNumber(total)} total`);
|
||||
setText("dash-online-ratio", `${ratio}%`);
|
||||
setText("dash-session-count", formatNumber(activeSessions));
|
||||
setText("dash-command-minute", `${formatNumber(snapshot?.commands?.lastMinute)} cmd/min`);
|
||||
setText("dash-http-errors", `${formatNumber(snapshot?.http?.lastMinuteErrors)} errors`);
|
||||
|
||||
const mem = Number(snapshot?.server?.systemMemory?.usedPercent) || 0;
|
||||
setText("dash-memory-status", `${Math.round(mem)}%`);
|
||||
setDotTone("dash-memory-dot", mem >= 90 ? "bad" : mem >= 75 ? "warn" : "live");
|
||||
|
||||
const avgPing = snapshot?.ping?.avg;
|
||||
if (Number.isFinite(avgPing)) {
|
||||
const rounded = Math.round(avgPing);
|
||||
setText("dash-ping-status", `${rounded} ms`);
|
||||
setDotTone("dash-ping-dot", rounded >= 150 ? "bad" : rounded >= 80 ? "warn" : "live");
|
||||
} else {
|
||||
setText("dash-ping-status", "-");
|
||||
setDotTone("dash-ping-dot", "muted");
|
||||
}
|
||||
|
||||
setText("dash-trend-status", `${formatBytes(snapshot?.bandwidth?.sentPerSecond || 0)}/s out`);
|
||||
setText("dash-last-refresh", formatTime(Date.now()));
|
||||
}
|
||||
|
||||
export function updateDashboardStatsFromClients(data) {
|
||||
if (!data) return;
|
||||
const online = Number(data.online) || 0;
|
||||
const total = Number(data.total) || 0;
|
||||
const ratio = total > 0 ? Math.round((online / total) * 100) : 0;
|
||||
setText("dash-online-count", formatNumber(online));
|
||||
setText("dash-total-count", `${formatNumber(total)} total`);
|
||||
setText("dash-online-ratio", `${ratio}%`);
|
||||
}
|
||||
|
||||
async function fetchDashboardMetrics() {
|
||||
try {
|
||||
const res = await fetch("/api/metrics", { credentials: "include" });
|
||||
if (!res.ok) throw new Error(`metrics ${res.status}`);
|
||||
const data = await res.json();
|
||||
const snapshot = data?.snapshot || {};
|
||||
updateSummary(snapshot);
|
||||
updateOnlineChart(data?.history || [], snapshot);
|
||||
updateOsChart(snapshot?.clients?.byOS || {});
|
||||
setText("dash-api-status", "Live");
|
||||
} catch (err) {
|
||||
console.warn("dashboard stats failed", err);
|
||||
setText("dash-api-status", "Error");
|
||||
setText("dash-last-refresh", "Error");
|
||||
}
|
||||
}
|
||||
|
||||
export function initDashboardStats() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
if (!makeCharts()) {
|
||||
setTimeout(makeCharts, 150);
|
||||
}
|
||||
initStatsToggle();
|
||||
fetchDashboardMetrics();
|
||||
metricsTimer = setInterval(fetchDashboardMetrics, 5000);
|
||||
}
|
||||
|
||||
function initStatsToggle() {
|
||||
const shell = $("dashboard-stats");
|
||||
const button = $("dashboard-stats-toggle");
|
||||
if (!shell || !button) return;
|
||||
|
||||
const setCollapsed = (collapsed) => {
|
||||
shell.classList.toggle("is-collapsed", collapsed);
|
||||
button.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
button.title = collapsed ? "Show status overview" : "Hide status overview";
|
||||
button.innerHTML = collapsed
|
||||
? '<i class="fa-solid fa-eye"></i>'
|
||||
: '<i class="fa-solid fa-eye-slash"></i>';
|
||||
localStorage.setItem(STATS_COLLAPSED_KEY, collapsed ? "true" : "false");
|
||||
};
|
||||
|
||||
setCollapsed(localStorage.getItem(STATS_COLLAPSED_KEY) === "true");
|
||||
button.addEventListener("click", () => {
|
||||
const nextCollapsed = !shell.classList.contains("is-collapsed");
|
||||
setCollapsed(nextCollapsed);
|
||||
if (!nextCollapsed) {
|
||||
requestAnimationFrame(() => {
|
||||
onlineChart?.resize();
|
||||
osChart?.resize();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("beforeunload", () => {
|
||||
if (metricsTimer) clearInterval(metricsTimer);
|
||||
});
|
||||
}
|
||||
@@ -4377,6 +4377,277 @@ body.sb-ready #top-nav {
|
||||
z-index: 20 !important;
|
||||
}
|
||||
|
||||
.dashboard-stats-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-stats-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-stats-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.dashboard-stats-title i {
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.dashboard-stats-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid rgba(51, 65, 85, 0.68);
|
||||
border-radius: 8px;
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
|
||||
.dashboard-stats-toggle:hover {
|
||||
background: rgba(51, 65, 85, 0.78);
|
||||
color: #e2e8f0;
|
||||
border-color: rgba(71, 85, 105, 0.8);
|
||||
}
|
||||
|
||||
.dashboard-stats-content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 0.85fr) minmax(150px, 0.85fr) minmax(260px, 1.45fr) minmax(260px, 1.35fr) minmax(220px, 1fr);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dashboard-stats-shell.is-collapsed .dashboard-stats-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-stat-card,
|
||||
.dashboard-chart-card,
|
||||
.dashboard-health-card {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(51, 65, 85, 0.68);
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-stat-card::before,
|
||||
.dashboard-chart-card::before,
|
||||
.dashboard-health-card::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 142px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.dashboard-stat-card--online {
|
||||
border-color: rgba(34, 211, 238, 0.22);
|
||||
}
|
||||
|
||||
.dashboard-stat-card--sessions {
|
||||
border-color: rgba(129, 140, 248, 0.24);
|
||||
}
|
||||
|
||||
.dashboard-stat-topline,
|
||||
.dashboard-chart-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #94a3b8;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.dashboard-stat-topline i {
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.dashboard-stat-value {
|
||||
margin-top: 10px;
|
||||
color: #f8fafc;
|
||||
font-size: 2.4rem;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dashboard-stat-subline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
color: #9db1cc;
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dashboard-chart-card,
|
||||
.dashboard-health-card {
|
||||
min-height: 142px;
|
||||
padding: 12px 14px 10px;
|
||||
}
|
||||
|
||||
.dashboard-chart-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dashboard-chart-head strong {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.94rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dashboard-chart-pill {
|
||||
flex-shrink: 0;
|
||||
max-width: 9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border: 1px solid rgba(56, 189, 248, 0.22);
|
||||
border-radius: 999px;
|
||||
background: rgba(8, 47, 73, 0.32);
|
||||
color: #bae6fd;
|
||||
padding: 3px 8px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dashboard-chart-wrap {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 88px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-chart-wrap--donut {
|
||||
height: 92px;
|
||||
}
|
||||
|
||||
.dashboard-health-list {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.dashboard-health-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 26px;
|
||||
border: 1px solid rgba(100, 116, 139, 0.16);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.46);
|
||||
padding: 5px 8px;
|
||||
color: #9db1cc;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.dashboard-health-row strong {
|
||||
color: #e2e8f0;
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dashboard-live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #64748b;
|
||||
box-shadow: 0 0 0 4px rgba(100, 116, 139, 0.12);
|
||||
}
|
||||
|
||||
.dashboard-live-dot.is-live {
|
||||
background: #34d399;
|
||||
box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.12), 0 0 16px rgba(52, 211, 153, 0.34);
|
||||
}
|
||||
|
||||
.dashboard-live-dot.is-warn {
|
||||
background: #fbbf24;
|
||||
box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.12), 0 0 16px rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
.dashboard-live-dot.is-bad {
|
||||
background: #fb7185;
|
||||
box-shadow: 0 0 0 4px rgba(251, 113, 133, 0.12), 0 0 16px rgba(251, 113, 133, 0.3);
|
||||
}
|
||||
|
||||
.dashboard-live-dot.is-muted {
|
||||
background: #64748b;
|
||||
box-shadow: 0 0 0 4px rgba(100, 116, 139, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.dashboard-stats-content {
|
||||
grid-template-columns: repeat(2, minmax(150px, 1fr)) repeat(2, minmax(260px, 1.35fr));
|
||||
}
|
||||
|
||||
.dashboard-health-card {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 940px) {
|
||||
.dashboard-stats-content {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-chart-card,
|
||||
.dashboard-health-card {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.dashboard-stats-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-chart-card,
|
||||
.dashboard-health-card {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.dashboard-stat-value {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-menu-panel,
|
||||
.nav-dd-menu {
|
||||
z-index: 40 !important;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
markManualDisconnect,
|
||||
} from "./data.js";
|
||||
import { ThumbnailLoader } from "./thumbnail-loader.js";
|
||||
import { initDashboardStats, updateDashboardStatsFromClients } from "./dashboard-stats.js";
|
||||
|
||||
const grid = document.getElementById("grid");
|
||||
const totalPill = document.getElementById("total-pill");
|
||||
@@ -844,7 +845,11 @@ function initializeRenderer() {
|
||||
getDisplayFields: () => displayFields,
|
||||
});
|
||||
rendererSetLayout = rSetLayout;
|
||||
registerRenderer(renderMerge);
|
||||
registerRenderer((data, options) => {
|
||||
renderMerge(data, options);
|
||||
updateDashboardStatsFromClients(data);
|
||||
});
|
||||
initDashboardStats();
|
||||
setupDashboardThumbnailLoader();
|
||||
refreshGroupFilter();
|
||||
loadWithOptions();
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<link rel="stylesheet" href="/assets/main.css" />
|
||||
<link rel="stylesheet" href="/assets/custom.css" />
|
||||
<script defer src="/vendor/animejs/anime.min.js"></script>
|
||||
<script defer src="/vendor/chart.js/chart.umd.js"></script>
|
||||
<link rel="preload" href="/api/clients?page=1&pageSize=12&q=&sort=stable&status=all&os=all&country=all&group=all" as="fetch" crossorigin="use-credentials" />
|
||||
<link rel="preload" href="/api/auth/me" as="fetch" crossorigin="use-credentials" />
|
||||
<link rel="preload" href="/api/groups" as="fetch" crossorigin="use-credentials" />
|
||||
@@ -38,6 +39,91 @@
|
||||
>0 online</span
|
||||
>
|
||||
</div>
|
||||
<section id="dashboard-stats" class="dashboard-stats-shell" aria-label="Dashboard status">
|
||||
<div class="dashboard-stats-bar">
|
||||
<div class="dashboard-stats-title">
|
||||
<i class="fa-solid fa-chart-simple"></i>
|
||||
<span>Status overview</span>
|
||||
</div>
|
||||
<button id="dashboard-stats-toggle" class="dashboard-stats-toggle" type="button" aria-expanded="true" title="Hide status overview">
|
||||
<i class="fa-solid fa-eye-slash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="dashboard-stats-content" class="dashboard-stats-content">
|
||||
<div class="dashboard-stat-card dashboard-stat-card--online">
|
||||
<div class="dashboard-stat-topline">
|
||||
<span class="dashboard-live-dot is-live"></span>
|
||||
<span>Online</span>
|
||||
</div>
|
||||
<div id="dash-online-count" class="dashboard-stat-value">0</div>
|
||||
<div class="dashboard-stat-subline">
|
||||
<span id="dash-total-count">0 total</span>
|
||||
<span id="dash-online-ratio">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-stat-card dashboard-stat-card--sessions">
|
||||
<div class="dashboard-stat-topline">
|
||||
<i class="fa-solid fa-wave-square"></i>
|
||||
<span>Sessions</span>
|
||||
</div>
|
||||
<div id="dash-session-count" class="dashboard-stat-value">0</div>
|
||||
<div class="dashboard-stat-subline">
|
||||
<span id="dash-command-minute">0 cmd/min</span>
|
||||
<span id="dash-http-errors">0 errors</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-chart-card">
|
||||
<div class="dashboard-chart-head">
|
||||
<div>
|
||||
<span class="dashboard-chart-kicker">Past Week</span>
|
||||
<strong>Clients Online</strong>
|
||||
</div>
|
||||
<span id="dash-trend-status" class="dashboard-chart-pill">Live</span>
|
||||
</div>
|
||||
<div class="dashboard-chart-wrap">
|
||||
<canvas id="dash-online-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-chart-card">
|
||||
<div class="dashboard-chart-head">
|
||||
<div>
|
||||
<span class="dashboard-chart-kicker">Fleet Mix</span>
|
||||
<strong>Operating Systems</strong>
|
||||
</div>
|
||||
<span id="dash-os-leader" class="dashboard-chart-pill">None</span>
|
||||
</div>
|
||||
<div class="dashboard-chart-wrap dashboard-chart-wrap--donut">
|
||||
<canvas id="dash-os-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-health-card">
|
||||
<div class="dashboard-chart-head">
|
||||
<div>
|
||||
<span class="dashboard-chart-kicker">Status</span>
|
||||
<strong>Server Pulse</strong>
|
||||
</div>
|
||||
<span id="dash-last-refresh" class="dashboard-chart-pill">Waiting</span>
|
||||
</div>
|
||||
<div class="dashboard-health-list">
|
||||
<div class="dashboard-health-row">
|
||||
<span class="dashboard-live-dot is-live"></span>
|
||||
<span>API</span>
|
||||
<strong id="dash-api-status">Live</strong>
|
||||
</div>
|
||||
<div class="dashboard-health-row">
|
||||
<span id="dash-memory-dot" class="dashboard-live-dot is-live"></span>
|
||||
<span>Memory</span>
|
||||
<strong id="dash-memory-status">0%</strong>
|
||||
</div>
|
||||
<div class="dashboard-health-row">
|
||||
<span id="dash-ping-dot" class="dashboard-live-dot is-muted"></span>
|
||||
<span>Ping Avg</span>
|
||||
<strong id="dash-ping-status">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="dashboard-toolbar">
|
||||
<label
|
||||
id="search-shell"
|
||||
|
||||
@@ -178,13 +178,14 @@ describe("MetricsCollector", () => {
|
||||
});
|
||||
|
||||
test("history is capped at maxHistoryPoints", () => {
|
||||
for (let i = 0; i < 4500; i++) {
|
||||
const weekOfFiveSecondSamples = 7 * 24 * 60 * 12;
|
||||
for (let i = 0; i < weekOfFiveSecondSamples + 180; i++) {
|
||||
const snap = metrics.getSnapshot();
|
||||
metrics.recordHistoryEntry(snap);
|
||||
}
|
||||
|
||||
const history = metrics.getHistory();
|
||||
expect(history.length).toBeLessThanOrEqual(4320);
|
||||
expect(history.length).toBeLessThanOrEqual(weekOfFiveSecondSamples);
|
||||
});
|
||||
|
||||
test("getHistory returns a copy", () => {
|
||||
|
||||
@@ -94,7 +94,7 @@ class MetricsCollector {
|
||||
private receivedPerSecond: number = 0;
|
||||
|
||||
private history: MetricsHistory[] = [];
|
||||
private maxHistoryPoints: number = 4320;
|
||||
private maxHistoryPoints: number = 7 * 24 * 60 * 12;
|
||||
private snapshotEnricher: ((snapshot: MetricsSnapshot) => void) | null = null;
|
||||
|
||||
private pingValues: number[] = [];
|
||||
|
||||
Reference in New Issue
Block a user