Files
NGO/dasshboard.html
T
Your Name 2a0af148c8 block
2026-03-30 08:21:13 -03:00

578 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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.