This commit is contained in:
Your Name
2026-03-30 09:14:06 -03:00
parent 2a0af148c8
commit bd319d05a5
2 changed files with 521 additions and 1047 deletions
+213 -162
View File
@@ -1,9 +1,9 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Dashboard</title>
<style>
<head>
<meta charset="UTF-8" />
<title>Dashboard</title>
<style>
body {
margin: 0;
width: 1920px;
@@ -30,7 +30,7 @@
border-radius: 14px;
padding: 18px;
box-sizing: border-box;
box-shadow: 0 6px 14px rgba(0,0,0,0.25);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.25);
position: relative;
overflow: hidden;
}
@@ -52,13 +52,25 @@
flex-shrink: 0;
}
.green { background: #3ad29f; }
.yellow { background: #f5c542; }
.red { background: #e05d5d; }
.green {
background: #3ad29f;
}
.yellow {
background: #f5c542;
}
.red {
background: #e05d5d;
}
.green-text { color: #3ad29f; }
.yellow-text { color: #f5c542; }
.red-text { color: #e05d5d; }
.green-text {
color: #3ad29f;
}
.yellow-text {
color: #f5c542;
}
.red-text {
color: #e05d5d;
}
/* SERVER GRID */
.servers {
@@ -116,7 +128,7 @@
.meeting.soon {
border: 2px solid #e05d5d;
box-shadow: 0 0 8px rgba(224,93,93,0.4);
box-shadow: 0 0 8px rgba(224, 93, 93, 0.4);
}
.meeting-header {
@@ -146,8 +158,14 @@
white-space: nowrap;
}
.badge-odoo { background: #714b67; color: #e6edf3; }
.badge-brandize { background: #1a6fa0; color: #e6edf3; }
.badge-odoo {
background: #714b67;
color: #e6edf3;
}
.badge-brandize {
background: #1a6fa0;
color: #e6edf3;
}
.time {
font-size: 11px;
@@ -247,7 +265,7 @@
.test-row {
display: flex;
justify-content: space-between;
border-bottom: 1px solid rgba(255,255,255,0.06);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 3px 0;
}
@@ -295,55 +313,71 @@
opacity: 0.85;
padding: 12px;
}
</style>
</head>
<body>
</style>
</head>
<body>
<div class="dashboard" id="dashboard">
<div class="loading" style="grid-column: 1/-1; grid-row: 1/-1">
Loading dashboard…
</div>
</div>
<div class="dashboard" id="dashboard">
<div class="loading" style="grid-column:1/-1;grid-row:1/-1;">Loading dashboard…</div>
</div>
<script>
const WEBHOOK_URL =
"https://002-001-5dd6e535-4d1c-46bc-9bd9-42ad4bc5f082.odoo4projects.com/webhook/52781c84-9f12-4a61-96b2-9e1e3933148d";
const REFRESH_INTERVAL_MS = 60 * 1000; // refresh every 60 seconds
<script>
const WEBHOOK_URL = 'https://002-001-5dd6e535-4d1c-46bc-9bd9-42ad4bc5f082.odoo4projects.com/webhook/52781c84-9f12-4a61-96b2-9e1e3933148d';
const REFRESH_INTERVAL_MS = 60 * 1000; // refresh every 60 seconds
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mbToGb(mb) {
return (mb / 1024).toFixed(1);
}
function pct(free, total) {
return total > 0 ? (free / total) * 100 : 0;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mbToGb(mb) { return (mb / 1024).toFixed(1); }
function pct(free, total) { return total > 0 ? (free / total * 100) : 0; }
function metricColor(freePct) {
if (freePct < 10) return "red";
if (freePct < 20) return "yellow";
return "green";
}
function metricColor(freePct) {
if (freePct < 10) return 'red';
if (freePct < 20) return 'yellow';
return 'green';
}
function worstColor(colors) {
if (colors.includes("red")) return "red";
if (colors.includes("yellow")) return "yellow";
return "green";
}
function worstColor(colors) {
if (colors.includes('red')) return 'red';
if (colors.includes('yellow')) return 'yellow';
return 'green';
}
function formatLocalTime(dateTimeStr) {
function formatLocalTime(dateTimeStr) {
const d = new Date(dateTimeStr);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
}
return d.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
function formatLocalDate(dateTimeStr) {
function formatLocalDate(dateTimeStr) {
const d = new Date(dateTimeStr);
return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
}
return d.toLocaleDateString([], {
weekday: "short",
month: "short",
day: "numeric",
});
}
function isToday(dateTimeStr) {
function isToday(dateTimeStr) {
const now = new Date();
const d = new Date(dateTimeStr);
return d.getFullYear() === now.getFullYear() &&
return (
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
}
d.getDate() === now.getDate()
);
}
function isSoonOrOngoing(startStr, endStr) {
function isSoonOrOngoing(startStr, endStr) {
const now = new Date();
const start = new Date(startStr);
const end = new Date(endStr);
@@ -351,12 +385,12 @@ function isSoonOrOngoing(startStr, endStr) {
if (now >= start && now <= end) return true;
if (diffMin >= 0 && diffMin <= 30) return true;
return false;
}
}
// ---------------------------------------------------------------------------
// Server cards
// ---------------------------------------------------------------------------
function buildServerCard(s) {
// ---------------------------------------------------------------------------
// Server cards
// ---------------------------------------------------------------------------
function buildServerCard(s) {
const ramPct = pct(s.free_ram, s.available_ram);
const hddPct = pct(s.free_hdd, s.available_hdd);
const ramColor = metricColor(ramPct);
@@ -364,14 +398,16 @@ function buildServerCard(s) {
const lightColor = worstColor([ramColor, hddColor]);
const displayNames = {
saopaulo: 'São Paulo',
manchester: 'Manchester',
mumbai: 'Mumbai',
sydney: 'Sydney',
meppel: 'Meppel',
boston: 'Boston'
saopaulo: "São Paulo",
manchester: "Manchester",
mumbai: "Mumbai",
sydney: "Sydney",
meppel: "Meppel",
boston: "Boston",
};
const displayName = displayNames[s.Server] || (s.Server.charAt(0).toUpperCase() + s.Server.slice(1));
const displayName =
displayNames[s.Server] ||
s.Server.charAt(0).toUpperCase() + s.Server.slice(1);
return {
lightColor,
@@ -390,14 +426,14 @@ function buildServerCard(s) {
</div>
</div>
</div>
`
`,
};
}
}
// ---------------------------------------------------------------------------
// Meetings
// ---------------------------------------------------------------------------
function parseMeetings(calEvents, calName) {
// ---------------------------------------------------------------------------
// Meetings
// ---------------------------------------------------------------------------
function parseMeetings(calEvents, calName) {
const seen = new Set();
const result = [];
for (const e of calEvents) {
@@ -407,86 +443,100 @@ function parseMeetings(calEvents, calName) {
result.push({ ...ev, _calName: calName });
}
return result;
}
}
function buildMeetingCard(m) {
const startStr = (m.start && (m.start.dateTime || m.start.date)) || '';
const endStr = (m.end && (m.end.dateTime || m.end.date)) || '';
const soon = startStr && endStr ? isSoonOrOngoing(startStr, endStr) : false;
function buildMeetingCard(m) {
const startStr =
(m.start && (m.start.dateTime || m.start.date)) || "";
const endStr = (m.end && (m.end.dateTime || m.end.date)) || "";
const soon =
startStr && endStr
? isSoonOrOngoing(startStr, endStr)
: false;
const today = startStr ? isToday(startStr) : false;
const badges = (m._cals || []).map(c => {
if (c === 'ODOO4projects') return `<span class="badge badge-odoo">ODOO4</span>`;
if (c === 'Brandize') return `<span class="badge badge-brandize">Brandize</span>`;
return '';
}).join('');
const badges = (m._cals || [])
.map((c) => {
if (c === "ODOO4projects")
return `<span class="badge badge-odoo">ODOO4</span>`;
if (c === "Brandize")
return `<span class="badge badge-brandize">Brandize</span>`;
return "";
})
.join("");
const dateLabel = today || !startStr
? ''
const dateLabel =
today || !startStr
? ""
: `<span style="font-size:10px;opacity:0.55;margin-right:4px;">${formatLocalDate(startStr)}</span>`;
const timeLabel = startStr && endStr
const timeLabel =
startStr && endStr
? `${formatLocalTime(startStr)} ${formatLocalTime(endStr)}`
: '';
: "";
return `
<div class="meeting${soon ? ' soon' : ''}">
<div class="meeting${soon ? " soon" : ""}">
<div class="meeting-header">
<div class="meeting-name">${m.summary || 'Meeting'}</div>
<div class="meeting-name">${m.summary || "Meeting"}</div>
<div class="meeting-badges">${badges}</div>
</div>
<div class="time">${dateLabel}${timeLabel}</div>
</div>
`;
}
}
// ---------------------------------------------------------------------------
// Todos
// ---------------------------------------------------------------------------
function buildTodoItem(t) {
// ---------------------------------------------------------------------------
// Todos
// ---------------------------------------------------------------------------
function buildTodoItem(t) {
const done = !!t.done;
const checkInner = done
? `<svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2.5" stroke="#fff" stroke-width="1.8" fill="none" stroke-linecap="round"/></svg>`
: '';
: "";
return `
<div class="todo${done ? ' done-todo' : ''}">
<div class="todo-check${done ? ' checked' : ''}">${checkInner}</div>
<span>${t.Todotodo || ''}</span>
<div class="todo${done ? " done-todo" : ""}">
<div class="todo-check${done ? " checked" : ""}">${checkInner}</div>
<span>${t.Todotodo || ""}</span>
</div>
`;
}
}
// ---------------------------------------------------------------------------
// Latest Signup item
// ---------------------------------------------------------------------------
function buildSignupItem(ct) {
if (!ct) return '';
const tagBadges = (ct.tags || '').split(',').map(t => t.trim()).filter(Boolean)
.map(t => `<span class="signup-tag">${t}</span>`).join('');
// ---------------------------------------------------------------------------
// Latest Signup item
// ---------------------------------------------------------------------------
function buildSignupItem(ct) {
if (!ct) return "";
const tagBadges = (ct.tags || "")
.split(",")
.map((t) => t.trim())
.filter(Boolean)
.map((t) => `<span class="signup-tag">${t}</span>`)
.join("");
return `
<div class="signup-item">
<div class="signup-email">${ct.email || ''}</div>
<div class="signup-email">${ct.email || ""}</div>
<div class="signup-meta">
<span class="signup-server">&#128421; ${ct.server || ''}</span>
<span class="signup-server">&#128421; ${ct.server || ""}</span>
${tagBadges}
<span style="opacity:0.5;font-size:10px;">${ct.status || ''}</span>
<span style="opacity:0.5;font-size:10px;">${ct.status || ""}</span>
</div>
<div style="margin-top:4px;opacity:0.55;font-size:10px;">
${ct.Domains ? ct.Domains : ''}${ct.expires ? ' &middot; expires ' + ct.expires : ''}
${ct.Domains ? ct.Domains : ""}${ct.expires ? " &middot; expires " + ct.expires : ""}
</div>
</div>
`;
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
function render(data) {
const serverStatusRaw = data['Server Status'] || [];
const calOdoo = data['Calender ODOO4projects'] || [];
const calBrandize = data['Calender Brandize'] || [];
const todos = data['Todos'] || [];
const currentTest = data['Current Tests'] || null;
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
function render(data) {
const serverStatusRaw = data["Server Status"] || [];
const calOdoo = data["Calender ODOO4projects"] || [];
const calBrandize = data["Calender Brandize"] || [];
const todos = data["Todos"] || [];
const currentTest = data["Current Tests"] || null;
// Deduplicate servers by Server key
const serverMap = {};
@@ -497,12 +547,14 @@ function render(data) {
// Server cards
const serverCards = servers.map(buildServerCard);
const overallColor = worstColor(serverCards.map(c => c.lightColor));
const serverCardsHtml = serverCards.map(c => c.html).join('');
const overallColor = worstColor(
serverCards.map((c) => c.lightColor),
);
const serverCardsHtml = serverCards.map((c) => c.html).join("");
// Meetings merge & deduplicate across both calendars
const odooMeetings = parseMeetings(calOdoo, 'ODOO4projects');
const brandizeMeetings = parseMeetings(calBrandize, 'Brandize');
const odooMeetings = parseMeetings(calOdoo, "ODOO4projects");
const brandizeMeetings = parseMeetings(calBrandize, "Brandize");
const allMeetings = [...odooMeetings, ...brandizeMeetings];
const meetingById = {};
@@ -514,17 +566,25 @@ function render(data) {
}
}
const dedupedMeetings = Object.values(meetingById).sort((a, b) => {
const aStart = (a.start && (a.start.dateTime || a.start.date)) || '';
const bStart = (b.start && (b.start.dateTime || b.start.date)) || '';
const dedupedMeetings = Object.values(meetingById).sort(
(a, b) => {
const aStart =
(a.start && (a.start.dateTime || a.start.date)) ||
"";
const bStart =
(b.start && (b.start.dateTime || b.start.date)) ||
"";
return new Date(aStart) - new Date(bStart);
});
},
);
const meetingsHtml = dedupedMeetings.map(buildMeetingCard).join('');
const todosHtml = todos.map(buildTodoItem).join('');
const meetingsHtml = dedupedMeetings
.map(buildMeetingCard)
.join("");
const todosHtml = todos.map(buildTodoItem).join("");
const signupsHtml = buildSignupItem(currentTest);
document.getElementById('dashboard').innerHTML = `
document.getElementById("dashboard").innerHTML = `
<!-- SERVER STATUS -->
<div class="box" style="grid-column:1; grid-row:1/3;">
@@ -562,57 +622,48 @@ function render(data) {
<!-- CURRENT TESTS -->
<div class="box" style="grid-column:3; grid-row:2;">
<div class="title">Current Test</div>
${currentTest ? `
${
currentTest
? `
<div class="test-box">
<div class="test-row"><span class="test-label">UUID</span><span class="test-value" style="font-size:10px;">${currentTest.UUID || ''}</span></div>
<div class="test-row"><span class="test-label">Email</span><span class="test-value">${currentTest.email || ''}</span></div>
<div class="test-row"><span class="test-label">Server</span><span class="test-value">${currentTest.server || ''}</span></div>
<div class="test-row"><span class="test-label">Tags</span><span class="test-value"><span class="signup-tag">${currentTest.tags || ''}</span></span></div>
<div class="test-row"><span class="test-label">Status</span><span class="test-value"><span class="status-badge dev">${currentTest.status || ''}</span></span></div>
<div class="test-row"><span class="test-label">Expires</span><span class="test-value">${currentTest.expires || ''}</span></div>
<div class="test-row"><span class="test-label">Domains</span><span class="test-value" style="font-size:10px;">${currentTest.Domains || ''}</span></div>
<div class="test-row"><span class="test-label">Created</span><span class="test-value">${currentTest.created || ''}</span></div>
<div class="test-row"><span class="test-label">UUID</span><span class="test-value" style="font-size:10px;">${currentTest.UUID || ""}</span></div>
<div class="test-row"><span class="test-label">Email</span><span class="test-value">${currentTest.email || ""}</span></div>
<div class="test-row"><span class="test-label">Server</span><span class="test-value">${currentTest.server || ""}</span></div>
<div class="test-row"><span class="test-label">Tags</span><span class="test-value"><span class="signup-tag">${currentTest.tags || ""}</span></span></div>
<div class="test-row"><span class="test-label">Status</span><span class="test-value"><span class="status-badge dev">${currentTest.status || ""}</span></span></div>
<div class="test-row"><span class="test-label">Expires</span><span class="test-value">${currentTest.expires || ""}</span></div>
<div class="test-row"><span class="test-label">Domains</span><span class="test-value" style="font-size:10px;">${currentTest.Domains || ""}</span></div>
<div class="test-row"><span class="test-label">Created</span><span class="test-value">${currentTest.created || ""}</span></div>
</div>
` : '<div style="opacity:0.4;font-size:13px;">No active test</div>'}
`
: '<div style="opacity:0.4;font-size:13px;">No active test</div>'
}
</div>
`;
}
}
// ---------------------------------------------------------------------------
// Fetch & refresh loop
// ---------------------------------------------------------------------------
async function loadData() {
// ---------------------------------------------------------------------------
// Fetch & refresh loop
// ---------------------------------------------------------------------------
async function loadData() {
try {
const res = await fetch(WEBHOOK_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
render(data);
} catch (err) {
document.getElementById('dashboard').innerHTML = `
document.getElementById("dashboard").innerHTML = `
<div class="error-msg" style="grid-column:1/-1;grid-row:1/-1;padding:40px;">
&#9888; Failed to load dashboard data: ${err.message}<br>
<span style="font-size:11px;opacity:0.6;">Retrying in ${REFRESH_INTERVAL_MS / 1000}s…</span>
</div>
`;
}
}
}
loadData();
setInterval(loadData, REFRESH_INTERVAL_MS);
</script>
</body>
loadData();
setInterval(loadData, REFRESH_INTERVAL_MS);
</script>
</body>
</html>
```
Here's what changed from the original:
1. **Webhook fetch** — replaced the `{{ $json.info }}` n8n template variable with a proper `fetch()` call to the webhook URL. Data loads dynamically on page open.
2. **Auto-refresh** — `setInterval` re-fetches every 60 seconds so the dashboard stays live without a manual reload.
3. **Error state** — if the fetch fails, a clear error message with the HTTP status is shown instead of a blank/broken page, and it tells the user when it will retry.
4. **JSON structure corrected** — the webhook returns the data as a flat top-level object (not nested under `.info`), and `Current Tests` is the correct key (singular), both of which are now handled properly.
5. **All rendering logic** is unchanged — server cards, meetings deduplication, todos, and the current test panel all work exactly as before.
-577
View File
@@ -1,577 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Dashboard</title>
<style>
body {
margin: 0;
width: 1920px;
height: 1200px;
font-family: Arial, sans-serif;
background: #2f3e4e;
color: #e6edf3;
overflow: hidden;
}
.dashboard {
width: 100%;
height: 100%;
padding: 20px;
box-sizing: border-box;
display: grid;
grid-template-columns: 2fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 20px;
}
.box {
background: #3b4c5e;
border-radius: 14px;
padding: 18px;
box-sizing: border-box;
box-shadow: 0 6px 14px rgba(0,0,0,0.25);
position: relative;
overflow: hidden;
}
.title {
font-size: 20px;
font-weight: bold;
margin-bottom: 12px;
opacity: 0.9;
}
.status-light {
position: absolute;
top: 18px;
right: 18px;
width: 14px;
height: 14px;
border-radius: 50%;
flex-shrink: 0;
}
.green { background: #3ad29f; }
.yellow { background: #f5c542; }
.red { background: #e05d5d; }
.green-text { color: #3ad29f; }
.yellow-text { color: #f5c542; }
.red-text { color: #e05d5d; }
/* SERVER GRID */
.servers {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.server {
background: #44586d;
border-radius: 10px;
padding: 12px;
position: relative;
}
.server-name {
font-size: 15px;
margin-bottom: 8px;
opacity: 0.85;
font-weight: bold;
padding-right: 22px;
}
.server .status-light {
top: 12px;
right: 12px;
}
.metrics {
font-size: 12px;
line-height: 1.7;
opacity: 0.85;
}
.metric-row {
display: flex;
justify-content: space-between;
align-items: center;
}
/* MEETINGS */
.meetings-scroll {
overflow-y: auto;
max-height: calc(100% - 45px);
}
.meeting {
background: #44586d;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
border: 2px solid transparent;
position: relative;
}
.meeting.soon {
border: 2px solid #e05d5d;
box-shadow: 0 0 8px rgba(224,93,93,0.4);
}
.meeting-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.meeting-name {
font-size: 13px;
font-weight: bold;
opacity: 0.95;
flex: 1;
}
.meeting-badges {
display: flex;
gap: 4px;
margin-left: 6px;
}
.badge {
font-size: 9px;
padding: 2px 5px;
border-radius: 4px;
font-weight: bold;
white-space: nowrap;
}
.badge-odoo { background: #714b67; color: #e6edf3; }
.badge-brandize { background: #1a6fa0; color: #e6edf3; }
.time {
font-size: 11px;
opacity: 0.65;
margin-top: 3px;
}
/* TODOS */
.todos-scroll {
overflow-y: auto;
max-height: calc(100% - 45px);
}
.todo {
background: #44586d;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
font-size: 13px;
opacity: 0.9;
display: flex;
align-items: center;
gap: 8px;
}
.todo.done-todo {
opacity: 0.5;
text-decoration: line-through;
}
.todo-check {
width: 14px;
height: 14px;
border-radius: 3px;
border: 2px solid #8aa0b5;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.todo-check.checked {
background: #3ad29f;
border-color: #3ad29f;
}
/* LATEST SIGNUPS */
.signups-scroll {
overflow-y: auto;
max-height: calc(100% - 45px);
}
.signup-item {
background: #44586d;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
font-size: 12px;
}
.signup-email {
font-size: 13px;
font-weight: bold;
margin-bottom: 4px;
opacity: 0.95;
}
.signup-meta {
display: flex;
gap: 8px;
opacity: 0.7;
flex-wrap: wrap;
align-items: center;
}
.signup-tag {
background: #2f3e4e;
border-radius: 4px;
padding: 1px 6px;
font-size: 10px;
color: #3ad29f;
}
.signup-server {
font-size: 11px;
opacity: 0.7;
}
/* CURRENT TESTS */
.test-box {
background: #44586d;
border-radius: 10px;
padding: 12px;
font-size: 12px;
line-height: 1.8;
}
.test-row {
display: flex;
justify-content: space-between;
border-bottom: 1px solid rgba(255,255,255,0.06);
padding: 3px 0;
}
.test-label {
opacity: 0.6;
font-size: 11px;
}
.test-value {
font-size: 11px;
text-align: right;
max-width: 65%;
word-break: break-all;
}
.status-badge {
display: inline-block;
padding: 1px 7px;
border-radius: 5px;
font-size: 10px;
font-weight: bold;
background: #3ad29f33;
color: #3ad29f;
}
.status-badge.dev {
background: #f5c54233;
color: #f5c542;
}
</style>
</head>
<body>
<div class="dashboard" id="dashboard"></div>
<script>
const WEBHOOK_URL = 'https://002-001-5dd6e535-4d1c-46bc-9bd9-42ad4bc5f082.odoo4projects.com/webhook/52781c84-9f12-4a61-96b2-9e1e3933148d';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mbToGb(mb) { return (mb / 1024).toFixed(1); }
function pct(free, total) { return total > 0 ? (free / total * 100) : 0; }
function metricColor(freePct) {
if (freePct < 10) return 'red';
if (freePct < 20) return 'yellow';
return 'green';
}
function worstColor(colors) {
if (colors.includes('red')) return 'red';
if (colors.includes('yellow')) return 'yellow';
return 'green';
}
function formatLocalTime(dateTimeStr) {
return new Date(dateTimeStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
}
function formatLocalDate(dateTimeStr) {
return new Date(dateTimeStr).toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
}
function isToday(dateTimeStr) {
const now = new Date(), d = new Date(dateTimeStr);
return d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
}
function isSoonOrOngoing(startStr, endStr) {
const now = new Date();
const start = new Date(startStr);
const end = new Date(endStr);
const diffMin = (start - now) / 60000;
if (now >= start && now <= end) return true;
if (diffMin >= 0 && diffMin <= 30) return true;
return false;
}
// ---------------------------------------------------------------------------
// Server cards
// ---------------------------------------------------------------------------
function buildServerCard(s) {
const ramPct = pct(s.free_ram, s.available_ram);
const hddPct = pct(s.free_hdd, s.available_hdd);
const ramColor = metricColor(ramPct);
const hddColor = metricColor(hddPct);
const light = worstColor([ramColor, hddColor]);
const names = { saopaulo: 'São Paulo', manchester: 'Manchester', mumbai: 'Mumbai', sydney: 'Sydney', meppel: 'Meppel', boston: 'Boston' };
const displayName = names[s.Server] || (s.Server.charAt(0).toUpperCase() + s.Server.slice(1));
return {
light,
html: `
<div class="server">
<div class="server-name">${displayName}</div>
<div class="status-light ${light}"></div>
<div class="metrics">
<div class="metric-row"><span>RAM:</span><span class="${ramColor}-text">${ramPct.toFixed(0)}% free (${mbToGb(s.free_ram)} GB)</span></div>
<div class="metric-row"><span>HDD:</span><span class="${hddColor}-text">${hddPct.toFixed(0)}% free (${mbToGb(s.free_hdd)} GB)</span></div>
</div>
</div>`
};
}
// ---------------------------------------------------------------------------
// Meetings
// ---------------------------------------------------------------------------
function parseMeetings(calEvents, calName) {
const seen = new Set(), result = [];
for (const e of calEvents) {
const ev = e.json || e;
if (seen.has(ev.id)) continue;
seen.add(ev.id);
result.push({ ...ev, _calName: calName });
}
return result;
}
function buildMeetingCard(m) {
const startStr = (m.start && (m.start.dateTime || m.start.date)) || '';
const endStr = (m.end && (m.end.dateTime || m.end.date)) || '';
const soon = startStr && endStr ? isSoonOrOngoing(startStr, endStr) : false;
const today = startStr ? isToday(startStr) : false;
const badges = (m._cals || []).map(c => {
if (c === 'ODOO4projects') return `<span class="badge badge-odoo">ODOO4</span>`;
if (c === 'Brandize') return `<span class="badge badge-brandize">Brandize</span>`;
return '';
}).join('');
const dateLabel = (!today && startStr)
? `<span style="font-size:10px;opacity:0.55;margin-right:4px;">${formatLocalDate(startStr)}</span>`
: '';
const timeLabel = startStr && endStr ? `${formatLocalTime(startStr)} ${formatLocalTime(endStr)}` : '';
return `
<div class="meeting${soon ? ' soon' : ''}">
<div class="meeting-header">
<div class="meeting-name">${m.summary || 'Meeting'}</div>
<div class="meeting-badges">${badges}</div>
</div>
<div class="time">${dateLabel}${timeLabel}</div>
</div>`;
}
// ---------------------------------------------------------------------------
// Todos
// ---------------------------------------------------------------------------
function buildTodoItem(t) {
const done = !!t.done;
const check = done
? `<svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2.5" stroke="#fff" stroke-width="1.8" fill="none" stroke-linecap="round"/></svg>`
: '';
return `
<div class="todo${done ? ' done-todo' : ''}">
<div class="todo-check${done ? ' checked' : ''}">${check}</div>
<span>${t.Todotodo || ''}</span>
</div>`;
}
// ---------------------------------------------------------------------------
// Signup items — accepts a single object OR an array
// ---------------------------------------------------------------------------
function buildSignupItem(ct) {
if (!ct) return '';
const tagBadges = (ct.tags || '').split(',').map(t => t.trim()).filter(Boolean)
.map(t => `<span class="signup-tag">${t}</span>`).join('');
return `
<div class="signup-item">
<div class="signup-email">${ct.email || ''}</div>
<div class="signup-meta">
<span class="signup-server">&#128421; ${ct.server || ''}</span>
${tagBadges}
<span style="opacity:0.5;font-size:10px;">${ct.status || ''}</span>
</div>
<div style="margin-top:4px;opacity:0.55;font-size:10px;">
${ct.Domains ? ct.Domains : ''}${ct.expires ? ' &middot; expires ' + ct.expires : ''}
</div>
</div>`;
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
function render(data) {
// --- Servers ---
const serverStatusRaw = data['Server Status'] || [];
const serverMap = {};
for (const s of serverStatusRaw) { if (!serverMap[s.Server]) serverMap[s.Server] = s; }
const servers = Object.values(serverMap);
const serverCards = servers.map(buildServerCard);
const overallColor = worstColor(serverCards.map(c => c.light));
const serverCardsHtml = serverCards.map(c => c.html).join('');
// --- Meetings ---
const calOdoo = data['Calender ODOO4projects'] || [];
const calBrandize = data['Calender Brandize'] || [];
const allMeetings = [
...parseMeetings(calOdoo, 'ODOO4projects'),
...parseMeetings(calBrandize, 'Brandize')
];
const meetingById = {};
for (const m of allMeetings) {
if (!meetingById[m.id]) {
meetingById[m.id] = { ...m, _cals: [m._calName] };
} else if (!meetingById[m.id]._cals.includes(m._calName)) {
meetingById[m.id]._cals.push(m._calName);
}
}
const dedupedMeetings = Object.values(meetingById).sort((a, b) => {
const aS = (a.start && (a.start.dateTime || a.start.date)) || '';
const bS = (b.start && (b.start.dateTime || b.start.date)) || '';
return new Date(aS) - new Date(bS);
});
// --- Todos ---
const todos = data['Todos'] || [];
// --- Latest Signups: may be array or single object ---
const signupsRaw = data['Latest Signups'] || data['Signups'] || data['signups'] || [];
const signupsArr = Array.isArray(signupsRaw)
? signupsRaw
: (signupsRaw && typeof signupsRaw === 'object' ? [signupsRaw] : []);
const signupsHtml = signupsArr.length
? signupsArr.map(buildSignupItem).join('')
: '<div style="opacity:0.4;font-size:13px;">No signups</div>';
// --- Current Test: object ---
const currentTest = data['Current Tests'] || null;
document.getElementById('dashboard').innerHTML = `
<!-- SERVER STATUS -->
<div class="box" style="grid-column:1; grid-row:1/3;">
<div class="title">Server Status</div>
<div class="status-light ${overallColor}"></div>
<div class="servers">${serverCardsHtml}</div>
</div>
<!-- MEETINGS -->
<div class="box" style="grid-column:2; grid-row:1;">
<div class="title">Meetings</div>
<div class="meetings-scroll">
${dedupedMeetings.length
? dedupedMeetings.map(buildMeetingCard).join('')
: '<div style="opacity:0.4;font-size:13px;">No meetings</div>'}
</div>
</div>
<!-- LATEST SIGNUPS -->
<div class="box" style="grid-column:3; grid-row:1;">
<div class="title">Latest Signups</div>
<div class="signups-scroll">${signupsHtml}</div>
</div>
<!-- TODOS -->
<div class="box" style="grid-column:2; grid-row:2;">
<div class="title">Today's Todos</div>
<div class="todos-scroll">
${todos.length
? todos.map(buildTodoItem).join('')
: '<div style="opacity:0.4;font-size:13px;">No todos</div>'}
</div>
</div>
<!-- CURRENT TEST -->
<div class="box" style="grid-column:3; grid-row:2;">
<div class="title">Current Test</div>
${currentTest ? `
<div class="test-box">
<div class="test-row"><span class="test-label">UUID</span><span class="test-value" style="font-size:10px;">${currentTest.UUID || ''}</span></div>
<div class="test-row"><span class="test-label">Email</span><span class="test-value">${currentTest.email || ''}</span></div>
<div class="test-row"><span class="test-label">Server</span><span class="test-value">${currentTest.server || ''}</span></div>
<div class="test-row"><span class="test-label">Tags</span><span class="test-value"><span class="signup-tag">${currentTest.tags || ''}</span></span></div>
<div class="test-row"><span class="test-label">Status</span><span class="test-value"><span class="status-badge dev">${currentTest.status || ''}</span></span></div>
<div class="test-row"><span class="test-label">Expires</span><span class="test-value">${currentTest.expires || ''}</span></div>
<div class="test-row"><span class="test-label">Domains</span><span class="test-value" style="font-size:10px;">${currentTest.Domains || ''}</span></div>
<div class="test-row"><span class="test-label">Created</span><span class="test-value">${currentTest.created || ''}</span></div>
</div>` : '<div style="opacity:0.4;font-size:13px;">No active test</div>'}
</div>
`;
// Signal to Puppeteer that rendering is complete
window.__dashboardReady = true;
}
// ---------------------------------------------------------------------------
// Fetch once, then signal ready
// ---------------------------------------------------------------------------
fetch(WEBHOOK_URL)
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(data => render(data))
.catch(err => {
document.getElementById('dashboard').innerHTML =
`<div style="grid-column:1/-1;grid-row:1/-1;padding:40px;color:#e05d5d;font-size:14px;">
&#9888; Failed to load: ${err.message}
</div>`;
window.__dashboardReady = true;
});
</script>
</body>
</html>
```
Here's what changed and why:
**Puppeteer fix — `window.__dashboardReady` flag**
Instead of relying on `networkidle` (which races against the async fetch), Puppeteer should now wait for this flag:
```/dev/null/puppeteer-example.js#L1-4
await page.goto('file:///.../dasshboard.html');
await page.waitForFunction(() => window.__dashboardReady === true);
await page.screenshot({ path: 'dashboard.png' });
```
The flag is set to `true` at the end of `render()` (and also in the catch block), so Puppeteer only screenshots once the DOM is fully painted — never the loading screen.
**Latest Signups fix**
The code now tries three key names — `Latest Signups`, `Signups`, `signups` — and handles both an **array** and a **single object** (wraps it in an array). Once your webhook returns the signups as an array under one of those keys, all items will render. Right now the webhook has no array of signups, only `Current Tests` as a single object, so the box will show "No signups" until the correct key is available in the webhook response.