dashboard
This commit is contained in:
+686
@@ -0,0 +1,686 @@
|
|||||||
|
<!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";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the wall-clock date/time directly from an ISO-8601 string that
|
||||||
|
// already carries an offset (e.g. "2026-03-31T10:00:00-03:00").
|
||||||
|
// We never let the browser re-interpret it into its own timezone.
|
||||||
|
function parseISOParts(dateTimeStr) {
|
||||||
|
// Matches: YYYY-MM-DDTHH:MM:SS(±HH:MM or Z)
|
||||||
|
const m = dateTimeStr.match(
|
||||||
|
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2})?/,
|
||||||
|
);
|
||||||
|
if (!m) return null;
|
||||||
|
return {
|
||||||
|
year: parseInt(m[1], 10),
|
||||||
|
month: parseInt(m[2], 10),
|
||||||
|
day: parseInt(m[3], 10),
|
||||||
|
hour: parseInt(m[4], 10),
|
||||||
|
minute: parseInt(m[5], 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocalTime(dateTimeStr) {
|
||||||
|
const p = parseISOParts(dateTimeStr);
|
||||||
|
if (!p) return "";
|
||||||
|
const h = String(p.hour).padStart(2, "0");
|
||||||
|
const m = String(p.minute).padStart(2, "0");
|
||||||
|
return `${h}:${m}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocalDate(dateTimeStr) {
|
||||||
|
const p = parseISOParts(dateTimeStr);
|
||||||
|
if (!p) return "";
|
||||||
|
// Build a UTC date just for the locale label so day/month names are correct
|
||||||
|
const d = new Date(Date.UTC(p.year, p.month - 1, p.day));
|
||||||
|
return d.toLocaleDateString([], {
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
timeZone: "UTC",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToday(dateTimeStr) {
|
||||||
|
const now = new Date();
|
||||||
|
const p = parseISOParts(dateTimeStr);
|
||||||
|
if (!p) return false;
|
||||||
|
return (
|
||||||
|
p.year === now.getFullYear() &&
|
||||||
|
p.month === now.getMonth() + 1 &&
|
||||||
|
p.day === 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.name || ""}</span>
|
||||||
|
${t.Minutes != null ? `<span style="margin-left:auto;opacity:0.55;font-size:12px;flex-shrink:0;">${t.Minutes}</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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSignupItems(raw) {
|
||||||
|
if (!raw) return { html: "", count: 0 };
|
||||||
|
const items = Array.isArray(raw) ? raw : [raw];
|
||||||
|
const html = items.map(buildSignupItem).join("");
|
||||||
|
return { html, count: items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function render(data) {
|
||||||
|
const serverStatusRaw = data["Server Status"] || [];
|
||||||
|
const calOdoo = data["Calender ODOO4projects"] || [];
|
||||||
|
const calBrandize = data["Calender Brandize"] || [];
|
||||||
|
const todos = data["Todos"] || [];
|
||||||
|
const currentTestsRaw = 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 totalMinutes = todos.reduce(
|
||||||
|
(sum, t) => sum + (t.Minutes || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalHours = (totalMinutes / 60)
|
||||||
|
.toFixed(1)
|
||||||
|
.replace(/\.0$/, "");
|
||||||
|
const signups = buildSignupItems(currentTestsRaw);
|
||||||
|
|
||||||
|
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/3;">
|
||||||
|
<div class="title">Latest Signups <span style="font-size:13px;font-weight:normal;opacity:0.6;background:#2f3e4e;border-radius:10px;padding:1px 9px;margin-left:6px;">${signups.count}</span></div>
|
||||||
|
<div class="signups-scroll">
|
||||||
|
${signups.html || '<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" style="display:flex;align-items:center;">Today's Todos<span style="margin-left:auto;font-size:13px;font-weight:normal;opacity:0.6;background:#2f3e4e;border-radius:10px;padding:1px 9px;">${totalHours}h</span></div>
|
||||||
|
<div class="todos-scroll">
|
||||||
|
${todosHtml || '<div style="opacity:0.4;font-size:13px;">No todos</div>'}
|
||||||
|
</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>
|
||||||
|
|
||||||
Reference in New Issue
Block a user