typo
This commit is contained in:
+618
@@ -0,0 +1,618 @@
|
||||
<!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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* LOADING / ERROR */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 18px;
|
||||
opacity: 0.5;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: #e05d5d;
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<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
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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) {
|
||||
const d = new Date(dateTimeStr);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
function formatLocalDate(dateTimeStr) {
|
||||
const d = new Date(dateTimeStr);
|
||||
return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function isToday(dateTimeStr) {
|
||||
const now = new Date();
|
||||
const 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 lightColor = worstColor([ramColor, hddColor]);
|
||||
|
||||
const displayNames = {
|
||||
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));
|
||||
|
||||
return {
|
||||
lightColor,
|
||||
html: `
|
||||
<div class="server">
|
||||
<div class="server-name">${displayName}</div>
|
||||
<div class="status-light ${lightColor}"></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();
|
||||
const 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 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>
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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-meta">
|
||||
<span class="signup-server">🖥 ${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 ? ' · 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;
|
||||
|
||||
// Deduplicate servers by Server key
|
||||
const serverMap = {};
|
||||
for (const s of serverStatusRaw) {
|
||||
if (!serverMap[s.Server]) serverMap[s.Server] = s;
|
||||
}
|
||||
const servers = Object.values(serverMap);
|
||||
|
||||
// Server cards
|
||||
const serverCards = servers.map(buildServerCard);
|
||||
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 allMeetings = [...odooMeetings, ...brandizeMeetings];
|
||||
|
||||
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 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 signupsHtml = buildSignupItem(currentTest);
|
||||
|
||||
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>
|
||||
|
||||
<!-- TODAY'S MEETINGS -->
|
||||
<div class="box" style="grid-column:2; grid-row:1;">
|
||||
<div class="title">Meetings</div>
|
||||
<div class="meetings-scroll">
|
||||
${meetingsHtml || '<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 style="opacity:0.4;font-size:13px;">No signups</div>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TODOS -->
|
||||
<div class="box" style="grid-column:2; grid-row:2;">
|
||||
<div class="title">Today's Todos</div>
|
||||
<div class="todos-scroll">
|
||||
${todosHtml || '<div style="opacity:0.4;font-size:13px;">No todos</div>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CURRENT TESTS -->
|
||||
<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>
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 = `
|
||||
<div class="error-msg" style="grid-column:1/-1;grid-row:1/-1;padding:40px;">
|
||||
⚠ 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>
|
||||
</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.
|
||||
Reference in New Issue
Block a user