// index.js function debounce(fn, delay = 500) { let timeoutId; return (...args) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn.apply(this, args), delay); }; } document.addEventListener("sidebarLoaded", () => { // Wrap in debounce and immediately call it const run = debounce(() => { waitForSystemDropdown((dropdown) => { updateSectionTitle(); // ✅ this will run once dropdown is ready dropdown.addEventListener( "change", debounce(() => { updateSectionTitle(); // ✅ run on dropdown change const now = luxon.DateTime.now().setZone("Asia/Kuala_Lumpur"); const start = now.startOf("day").toJSDate(); const end = now.endOf("day").toJSDate(); flatpickrInstance.setDate([start, end], true); updateDisplayAndCallAPI([start, end]); }, 300), // optional delay for change events ); }); }, 300); // debounce delay for sidebarLoaded run(); // ✅ actually invoke it }); const today = new Date(); // current date & time const startOfToday = new Date( today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, ); const endOfToday = today; // use 'today' itself as maxDate (current time) let loginSummaryData = null; // Global variable to store the API response const flatpickrInstance = flatpickr("#daterange", { mode: "range", dateFormat: "d M Y", defaultDate: [startOfToday, endOfToday], maxDate: today, appendTo: document.getElementById("calendar-container"), showMonths: 1, // ✅ Show 2 months side by side onReady: function (selectedDates) { document.addEventListener( "sidebarLoaded", () => { if (selectedDates.length === 2) { updateDisplayAndCallAPI(selectedDates); } }, { once: true }, ); }, onChange: function (selectedDates, dateStr, instance) { if (selectedDates.length === 2) { const [start, end] = selectedDates; // Calculate difference in days (works across months) const diffDays = Math.floor((end - start) / (1000 * 60 * 60 * 24)) + 1; if (diffDays > 31) { alert("⚠️ Please select a date range of 31 days or less."); instance.clear(); } else { updateDisplayAndCallAPI(selectedDates); } } }, }); // ⏱ Auto-refresh every 60 seconds setInterval(() => { const selectedDates = flatpickrInstance.selectedDates; if (selectedDates.length === 2) { updateDisplayAndCallAPI(selectedDates); } }, 60000); document.getElementById("date-trigger").addEventListener("click", () => { document.getElementById("daterange")._flatpickr.open(); }); function updateDisplayAndCallAPI(dates) { const formatDisplay = (d) => d.toLocaleDateString("en-MY", { day: "numeric", month: "short", year: "numeric", timeZone: "Asia/Kuala_Lumpur", }); const [start, end] = dates; const mytRangeText = start.toDateString() === end.toDateString() ? formatDisplay(start) : `${formatDisplay(start)} - ${formatDisplay(end)}`; document.getElementById("selected-range").textContent = mytRangeText; const from = luxon.DateTime.fromJSDate(start) .setZone("Asia/Kuala_Lumpur") .startOf("day") .toFormat("yyyy-MM-dd HH:mm:ss"); const to = luxon.DateTime.fromJSDate(end) .setZone("Asia/Kuala_Lumpur") .endOf("day") .toFormat("yyyy-MM-dd HH:mm:ss"); waitForSystemDropdown((dropdown) => { callLoginSummaryAPI(from, to); }); updateLastUpdatedText(); } function updateSectionTitle() { const dropdown = document.getElementById("systemDropdown"); if (!dropdown) { console.error("❌ No system dropdown found"); return; } const selectedSystem = dropdown.value; const titleEl = document.getElementById("dashboard-header"); if (titleEl) { titleEl.textContent = `Dashboard for System : ${selectedSystem}`; } } function waitForSystemDropdown(callback) { const observer = new MutationObserver(() => { const dropdown = document.getElementById("systemDropdown"); if (dropdown && dropdown.value) { // console.log("✅ systemDropdown ready with value:", dropdown.value); observer.disconnect(); callback(dropdown); dropdown.addEventListener("change", () => { // console.log("🔄 systemDropdown changed:", dropdown.value); callback(dropdown); }); } }); observer.observe(document.body, { childList: true, subtree: true }); } async function callLoginSummaryAPI(from, to) { await checkToken(); const cookieName = env.FRONTEND_APP_NAME + "AccessToken"; const accessToken = getCookie(cookieName); if (!accessToken) { console.error("❌ No access token found in cookies"); return; } const token = `Bearer ${accessToken}`; // console.log("Sini :" + document.getElementById("systemDropdown").value); const body = JSON.stringify({ clientid: document.getElementById("systemDropdown").value, from: from, to: to, }); fetch(`${env.API_URL}/api/login-summary/_search`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: token, }, body: body, }) .then(async (response) => { if (!response.ok) { const errorText = await response.text(); throw new Error(`API failed: ${response.status} ${errorText}`); } return response.json(); }) .then((data) => { const apiEntries = data.results || []; let startDateLuxon, endDateLuxon; try { startDateLuxon = luxon.DateTime.fromFormat( from, "yyyy-MM-dd HH:mm:ss", { zone: "Asia/Kuala_Lumpur", }, ); endDateLuxon = luxon.DateTime.fromFormat(to, "yyyy-MM-dd HH:mm:ss", { zone: "Asia/Kuala_Lumpur", }); if (!startDateLuxon.isValid || !endDateLuxon.isValid) { throw new Error("Invalid date format for 'from' or 'to'"); } } catch (err) { console.error("❌ Luxon date parse error:", err.message); return; } const openRequest = indexedDB.open("LoginAnalyticsDB"); openRequest.onsuccess = function (event) { const db = event.target.result; if (!db.objectStoreNames.contains("logins")) { proceedWithEntries(apiEntries); return; } const tx = db.transaction("logins", "readonly"); const store = tx.objectStore("logins"); const getAll = store.getAll(); getAll.onsuccess = function (e) { const localEntries = e.target.result || []; const filteredLocal = localEntries.filter((entry) => { try { const loginTime = luxon.DateTime.fromFormat( entry.LoginTime, "yyyy-MM-dd HH:mm:ss", { zone: "utc" }, ); return ( loginTime >= startDateLuxon.toUTC() && loginTime <= endDateLuxon.toUTC() ); } catch (err) { console.error("❌ Error parsing entry.LoginTime:", entry, err); return false; } }); const mergedEntries = [...apiEntries, ...filteredLocal]; proceedWithEntries(mergedEntries); }; getAll.onerror = function (err) { console.error("⚠️ Failed to read from IndexedDB:", err); proceedWithEntries(apiEntries); }; }; openRequest.onerror = function (err) { console.error("⚠️ Could not open IndexedDB:", err); proceedWithEntries(apiEntries); }; function proceedWithEntries(entries) { try { updateLoginsChart(entries, startDateLuxon, endDateLuxon); renderOnlineTimeChart(entries); renderOnlineStatusChart(entries); } catch (err) { console.error("❌ Chart rendering error:", err); } try { const totalLogins = getTotalLoginsCount({ results: entries }); const uniqueIPCount = getUniqueIPCount(entries); const totalUsersCount = getUniqueUserCount(entries); document.querySelector(".logins-box .b").textContent = totalLogins; document.querySelector(".ips-box .b2").textContent = uniqueIPCount; document.querySelector(".totalUsers-box .b2").textContent = totalUsersCount; } catch (err) { console.error("❌ DOM update error:", err); } } }) .catch((error) => { console.error("❌ API Error (catch):", error); }); } function updateLastUpdatedText() { const now = new Date(); const timeStr = now.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", hour12: true, }); document.querySelector(".last-update-at").textContent = `Last update at ${timeStr}`; } // "Update Now" click handler document.querySelector(".update-now").addEventListener("click", () => { const now = new Date(); // ✅ Update the datepicker selection to now flatpickrInstance.setDate(now, true); // true -> trigger onChange // ✅ Call your API/display logic with now updateDisplayAndCallAPI([now, now]); }); /* === utility functions (unchanged) === */ function getTotalLoginsCount(data) { return data.total || (data.results || []).length; } function getActiveSessionsCount(entries) { const now = new Date(); return entries.filter((entry) => { if (entry.revoked !== null) return false; const loginTime = new Date(entry.LoginTime.replace(" ", "T") + "Z"); const expiry = new Date(entry.expiry?.replace(" ", "T") + "Z"); const diffMinutes = (now - loginTime) / 60000; return expiry > now && diffMinutes < 5; }).length; } function getUniqueIPCount(entries) { const uniqueIPs = new Set(); entries.forEach((entry) => { const ip = entry.ipaddress?.trim(); if (ip && ip.toLowerCase() !== "") { uniqueIPs.add(ip.toLowerCase()); } }); return uniqueIPs.size; } function getUniqueUserCount(entries) { const uniqueEmails = new Set(); entries.forEach((entry) => { if (entry.email) { uniqueEmails.add(entry.email); } }); return uniqueEmails.size; } /* === charts + toggle functions unchanged from your original === */ let loginsPerHourChart = null; function updateLoginsChart(entries, start, end) { start = start.setZone("Asia/Kuala_Lumpur").startOf("day"); end = end.setZone("Asia/Kuala_Lumpur").endOf("day"); const totalDays = Math.ceil(end.diff(start, "days").days); let labels = []; let buckets = []; let chartData = []; if (totalDays === 1) { const interval = 2; const bucketCount = Math.ceil(24 / interval); buckets = new Array(bucketCount).fill(0); labels = Array.from({ length: bucketCount }, (_, i) => i * interval); entries.forEach((entry) => { const loginTime = luxon.DateTime.fromISO( entry.LoginTime.replace(" ", "T"), { zone: "utc" }, ).setZone("Asia/Kuala_Lumpur"); const hoursSinceStart = loginTime.diff(start, "hours").hours; const bucketIndex = Math.floor(hoursSinceStart / interval); if (bucketIndex >= 0 && bucketIndex < bucketCount) buckets[bucketIndex]++; }); chartData = labels.map((label, i) => ({ x: label, y: buckets[i] })); } else if (totalDays <= 14) { const intervalDays = 1; const bucketCount = Math.ceil(totalDays / intervalDays); buckets = new Array(bucketCount).fill(0); labels = []; for (let i = 0; i < bucketCount; i++) { labels.push(start.plus({ days: i * intervalDays }).toFormat("dd MMM")); } entries.forEach((entry) => { const loginTime = luxon.DateTime.fromISO( entry.LoginTime.replace(" ", "T"), { zone: "utc" }, ).setZone("Asia/Kuala_Lumpur"); const daysSinceStart = loginTime.diff(start, "days").days; const bucketIndex = Math.floor(daysSinceStart / intervalDays); if (bucketIndex >= 0 && bucketIndex < bucketCount) buckets[bucketIndex]++; }); chartData = labels.map((label, i) => ({ x: label, y: buckets[i] })); } else { const intervalDays = 2; const bucketCount = Math.ceil(totalDays / intervalDays); buckets = new Array(bucketCount).fill(0); labels = []; for (let i = 0; i < bucketCount; i++) { labels.push(start.plus({ days: i * intervalDays }).toFormat("dd MMM")); } entries.forEach((entry) => { const loginTime = luxon.DateTime.fromISO( entry.LoginTime.replace(" ", "T"), { zone: "utc" }, ).setZone("Asia/Kuala_Lumpur"); const daysSinceStart = loginTime.diff(start, "days").days; const bucketIndex = Math.floor(daysSinceStart / intervalDays); if (bucketIndex >= 0 && bucketIndex < bucketCount) buckets[bucketIndex]++; }); chartData = labels.map((label, i) => ({ x: label, y: buckets[i] })); } const hasData = chartData.some((d) => d.y > 0); const ctx = document.getElementById("loginsPerHourChart").getContext("2d"); const gradient = ctx.createLinearGradient(0, 0, 0, 300); gradient.addColorStop(0, "rgba(67, 57, 242, 0.2)"); gradient.addColorStop(1, "rgba(67, 57, 242, 0.05)"); const maxY = Math.max(...buckets, 5); // Ensure horizontal lines appear const stepSize = calculateStepSize(maxY); const yMax = Math.ceil(maxY / stepSize) * stepSize; // ─── Plugin for "No data available" ─── const noDataPlugin = { id: "noDataPlugin", beforeDraw(chart) { const hasData = chart.data.datasets.some((dataset) => dataset.data.some((d) => d.y > 0), ); if (!hasData) { const { ctx, chartArea: { left, right, top, bottom }, } = chart; ctx.save(); ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.font = "bold 18px Poppins"; ctx.fillStyle = "#9b9b9b"; ctx.fillText( "No data available", (left + right) / 2, (top + bottom) / 2, ); ctx.restore(); } }, }; const config = { type: "line", data: { datasets: [ { label: "Logins", data: hasData ? chartData : [], fill: true, tension: 0.4, borderColor: "#4339F2", backgroundColor: gradient, borderWidth: 3, pointRadius: hasData ? 2 : 0, pointHoverRadius: hasData ? 6 : 0, pointBackgroundColor: "#4339F2", pointHoverBackgroundColor: "#4339F2", pointBorderColor: "#fff", pointHoverBorderColor: "#fff", pointBorderWidth: 2, }, ], labels: labels, }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: "nearest", intersect: false }, plugins: { legend: { display: false }, tooltip: { callbacks: { title: (context) => totalDays === 1 ? `Hour ${parseInt(context[0].label)}:00` : `Date: ${context[0].label}`, label: (context) => `Logins: ${context.parsed.y}`, }, }, }, scales: { x: { type: "category", labels: labels, title: { display: true, text: totalDays === 1 ? "Hour (MYT)" : "Date", font: { size: 16, weight: "bold" }, color: hasData ? "#666" : "rgba(102,102,102,0.4)", // faded if no data }, ticks: { color: hasData ? "#666" : "rgba(102,102,102,0.4)", maxRotation: 0, autoSkip: false, }, grid: { display: false }, }, y: { beginAtZero: true, // max: yMax, ticks: { stepSize: stepSize, color: hasData ? "#666" : "rgba(102,102,102,0.4)", }, title: { display: true, text: "No. of Logins", font: { size: 16, weight: "bold" }, color: hasData ? "#666" : "rgba(102,102,102,0.4)", }, grid: { drawBorder: false, color: "rgba(0,0,0,0.1)", // horizontal lines remain }, }, }, }, plugins: [noDataPlugin], }; if (loginsPerHourChart) loginsPerHourChart.destroy(); loginsPerHourChart = new Chart(ctx, config); } function calculateStepSize(maxValue) { if (maxValue <= 5) return 1; if (maxValue <= 20) return 2; if (maxValue <= 50) return 5; if (maxValue <= 100) return 10; return Math.ceil(maxValue / 5 / 50) * 50; } function renderOnlineTimeChart(entries) { const revokedEntries = entries.filter((e) => parseInt(e.revoked) === 1); const bucketCounts = new Array(8).fill(0); // 8 buckets revokedEntries.forEach((entry) => { const login = new Date(entry.LoginTime.replace(" ", "T") + "Z"); const updated = new Date(entry.UpdatedTime.replace(" ", "T") + "Z"); if ( !entry.LoginTime || !entry.UpdatedTime || isNaN(login) || isNaN(updated) ) { console.warn(`⛔ Invalid or missing time for ${entry.email}`, entry); return; } const durationMin = (updated - login) / (1000 * 60); // duration in minutes let bucket = 7; // default >=8h if (durationMin < 5) bucket = 0; else if (durationMin < 15) bucket = 1; else if (durationMin < 30) bucket = 2; else if (durationMin < 60) bucket = 3; else if (durationMin < 120) bucket = 4; else if (durationMin < 240) bucket = 5; else if (durationMin < 480) bucket = 6; bucketCounts[bucket] += 1; }); const ctx = document.getElementById("onlineTimeChart").getContext("2d"); if (window.onlineTimeChartInstance) window.onlineTimeChartInstance.destroy(); const hasData = bucketCounts.some((c) => c > 0); // ─── Custom plugin for "No data available" ─── const noDataPlugin = { id: "noDataOnlineTime", beforeDraw(chart) { const hasData = chart.data.datasets.some((ds) => ds.data.some((d) => d > 0), ); if (!hasData) { const { ctx, chartArea } = chart; ctx.save(); ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.font = "bold 18px Poppins"; ctx.fillStyle = "#9b9b9b"; ctx.fillText( "No data available", (chartArea.left + chartArea.right) / 2, (chartArea.top + chartArea.bottom) / 2, ); ctx.restore(); } }, }; window.onlineTimeChartInstance = new Chart(ctx, { type: "bar", data: { labels: [ "5 min", "5–14 min", "15–29 min", "30–59 min", "1–1h59m", "2–3h59m", "4–7h59m", ">8 h", ], datasets: [ { label: "No. of Logins", data: hasData ? bucketCounts : [], backgroundColor: hasData ? "#4339F2" : "#d3d3d3", borderRadius: 4, }, ], }, options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: true, color: hasData ? "#333" : "rgba(102,102,102,0.6)", font: { size: 20, weight: "bold" }, padding: { bottom: 15 }, }, legend: { display: false }, tooltip: { callbacks: { label: (ctx) => `${ctx.parsed.y} session${ctx.parsed.y !== 1 ? "s" : ""}`, }, }, }, scales: { x: { ticks: { color: hasData ? "#444" : "rgba(102,102,102,0.4)", font: { size: 14 }, }, grid: { display: false, drawBorder: false, drawTicks: false }, title: { display: true, text: "Average Online Time", color: hasData ? "#444" : "rgba(102,102,102,0.4)", font: { size: 16, weight: "bold" }, }, }, y: { beginAtZero: true, min: 0, ticks: { stepSize: 1, color: hasData ? "#444" : "rgba(102,102,102,0.4)", font: { size: 14 }, }, grid: { drawTicks: false, drawBorder: false, color: hasData ? "#ddd" : "#ccc", borderDash: [6, 8], }, title: { display: true, text: "No. of Sessions", color: hasData ? "#444" : "rgba(102,102,102,0.4)", font: { size: 16, weight: "bold" }, }, }, }, }, plugins: [noDataPlugin], }); } function renderOnlineStatusChart(entries) { const now = new Date(); // STEP 1: Deduplicate by hashSession and filter valid sessions const sessionMap = new Map(); entries.forEach((entry) => { if (!entry.hashSession || sessionMap.has(entry.hashSession)) return; if (parseInt(entry.revoked) === 1) return; const expiry = new Date(entry.expiry.replace(" ", "T") + "Z"); if (expiry <= now) return; sessionMap.set(entry.hashSession, entry); }); // STEP 2: Classify each session as active or idle let activeCount = 0; let idleCount = 0; sessionMap.forEach((entry) => { const updated = new Date(entry.UpdatedTime.replace(" ", "T") + "Z"); const minsSinceUpdate = (now - updated) / (1000 * 60); if (minsSinceUpdate < 5) activeCount++; else idleCount++; }); const total = activeCount + idleCount; // Update DOM text const totalEl = document.querySelector(".sessions-box .b"); if (totalEl) totalEl.textContent = total; const activePercent = total ? Math.round((activeCount / total) * 100) : 0; const idlePercent = total ? 100 - activePercent : 0; // Destroy previous chart if (window.onlineStatusChartInstance) window.onlineStatusChartInstance.destroy(); const ctx = document.getElementById("onlineStatusChart").getContext("2d"); // Center text / No data plugin const centerTextPlugin = { id: "centerText", beforeDraw(chart) { const { width, height, ctx } = chart; ctx.save(); ctx.textAlign = "center"; ctx.textBaseline = "middle"; if (total === 0) { ctx.font = "bold 16px Poppins"; ctx.fillStyle = "#9b9b9b"; ctx.fillText("No data available", width / 2, height / 2); } else { ctx.font = "bold 16px Poppins"; ctx.fillStyle = "#666"; ctx.fillText("Online Sessions", width / 2, height / 2 - 10); ctx.font = "bold 18px Poppins"; ctx.fillText(`${total}`, width / 2, height / 2 + 12); } ctx.restore(); }, }; // Use all grey fade if no data const datasetColors = total ? ["#3BB77E", "#A0A0A0"] // normal colors if data : ["rgba(160,160,160,0.2)", "rgba(160,160,160,0.2)"]; // all grey fade const legendLabelColor = total ? "#666" : "rgba(102,102,102,0.4)"; window.onlineStatusChartInstance = new Chart(ctx, { type: "doughnut", data: { labels: [`Active – ${activePercent}%`, `Idle – ${idlePercent}%`], datasets: [ { data: total ? [activeCount, idleCount] : [1, 0], // dummy values for greyed chart backgroundColor: datasetColors, borderWidth: 0, borderRadius: 6, spacing: 4, }, ], }, options: { cutout: "80%", rotation: 210, circumference: 300, plugins: { legend: { position: "bottom", labels: { color: legendLabelColor, font: { size: 14, weight: "bold" }, }, }, tooltip: { enabled: total > 0, callbacks: { label: (ctx) => `${ctx.label.split("–")[0].trim()}: ${ctx.parsed} (${ctx.label .split("–")[1] .trim()})`, }, }, }, }, plugins: [centerTextPlugin], }); } function toggleNotificationPanel() { const panel = document.getElementById("notificationPanel"); if (panel) { panel.classList.toggle("hidden"); } } // function handleCurrentClientDropdown() { // const dropdown = document.getElementById("systemDropdown"); // if (!dropdown) return; // // Save current selected value on page load // localStorage.setItem("currentClient", dropdown.value); // // Update localStorage whenever the dropdown value changes // dropdown.addEventListener("change", () => { // localStorage.setItem("currentClient", dropdown.value); // }); // }