commit 50f7445c1c8024ec6b55cafb72ee03721bb7ecfd Author: I-Fun Date: Fri Mar 27 14:46:28 2026 +0700 initial commit diff --git a/index.php b/index.php new file mode 100644 index 0000000..6fe13f8 --- /dev/null +++ b/index.php @@ -0,0 +1,204 @@ + [ + 'header' => "Authorization: Basic " . base64_encode("user:$apiToken") . "\r\n", + 'method' => 'GET', + 'timeout' => 5 + ] +]; +$context = stream_context_create($options); + + +// Require the new functions.php for PHP logic. +require_once __DIR__ . '/src/functions.php'; + +// Handle AJAX request +if (isset($_GET['fetch']) && $_GET['fetch'] === 'metrics') { + header('Content-Type: application/json'); + echo json_encode(fetchMetrics($metricsUrl, $context)); + exit; +} + +// --- AJAX handler for alerts --- +if (isset($_GET['fetch']) && $_GET['fetch'] === 'alerts') { + header('Content-Type: application/json'); + echo json_encode(fetchGrafanaAlerts($grafanaUrl, $grafanaApiKey)); + exit; +} + +// --- AJAX handler for holiday info --- +if (isset($_GET['fetch']) && $_GET['fetch'] === 'holiday') { + header('Content-Type: application/json'); + $date = isset($_GET['date']) ? $_GET['date'] : date('Y-m-d'); + $holiday = getIndonesianHoliday($date); + echo json_encode(['holiday' => $holiday]); + exit; +} + +// --- AJAX handler for tasks --- +if (isset($_GET['fetch']) && $_GET['fetch'] === 'tasks') { + header('Content-Type: application/json'); + echo json_encode(fetchTasks($taskApiUrl, $useMockTaskApi, $mockTaskFile)); + exit; +} + +// Initial data load +$todayHoliday = getIndonesianHoliday(date('Y-m-d')); +$data = fetchMetrics($metricsUrl, $context); +if ($data['error']) { + http_response_code(500); + die($data['message']); +} + +// Extract data +$up = $data['up']; +$down = $data['down']; +$pending = $data['pending']; +$maintenance = $data['maintenance']; +$downMonitors = $data['downMonitors']; +$mtMonitors = $data['mtMonitors']; +$expiringCerts = $data['expiringCerts']; // <-- Add this line to make $expiringCerts available for the template +$lastRefresh = $data['lastRefresh']; + + +?> + + + + + + Services Monitoring + + + + +
+
+
+
> + +
+
+
+
+ +

Thamrin Net - Monitoring Summary

+

Monitoring Summary

+ + + +
+
+

Online

+ +
+
+

Offline

+ +
+
+

Maintenance

+ +
+
+

Alert

+ - +
+
+

Pending Task

+ - +
+
+ +
+
+
+ + + + + +
+ +
0 ? 'style="display:none;"' : '' ?>> +
โœ… All is good โ€” No services are down.
+
+ +
> +

Offline Services

+
    + +
  • + +
+
+ + + +
> +

Under Maintenance

+
    + +
  • + +
+
+ +
> +

Upcoming SSL Expiry

+
    + +
  • - days remaining
  • + +
+
+ + +
+ +
+
+ + + + +
+

Tasks

+
+
+
+
+
+ +
+ Last updated: (refresh every 10 Seconds) +
+ +
+ + + +
+ + + + + + + diff --git a/src/audio/Down3.wav b/src/audio/Down3.wav new file mode 100644 index 0000000..57c8275 Binary files /dev/null and b/src/audio/Down3.wav differ diff --git a/src/audio/Up3.wav b/src/audio/Up3.wav new file mode 100644 index 0000000..efd2dd9 Binary files /dev/null and b/src/audio/Up3.wav differ diff --git a/src/functions.php b/src/functions.php new file mode 100644 index 0000000..f4bf887 --- /dev/null +++ b/src/functions.php @@ -0,0 +1,187 @@ + [ + 'header' => "Authorization: Basic " . base64_encode("user:$apiToken") . "\r\n", + 'method' => 'GET', + 'timeout' => 5 + ] + ]; + // Fetch metrics + $metricsData = @file_get_contents($metricsUrl, false, $context); + + if ($metricsData === false) { + return [ + 'error' => true, + 'message' => "Error: Unable to fetch metrics from Thamrin Monitoring." + ]; + } + + // Initialize counters and lists + $up = 0; + $down = 0; + $pending = 0; + $maintenance = 0; + $downMonitors = []; + $mtMonitors = []; + $expiringCerts = []; + $total = 0; + + // Parse Prometheus metrics + // Monitor Status + foreach (explode("\n", $metricsData) as $line) { + if (strpos($line, 'monitor_status{') === 0) { + if (preg_match('/monitor_name="([^"]+)".*?}\s([0-3])$/', trim($line), $matches)) { + $monitorName = $matches[1]; + // Only count if monitor_name contains a dash + if (strpos($monitorName, '-') === false) continue; + $status = (int)$matches[2]; + $total++; + + switch ($status) { + case 0: + $down++; + $downMonitors[] = $monitorName; + break; + case 1: $up++; break; + case 2: $pending++; break; + case 3: + $maintenance++; + $mtMonitors[] = $monitorName; + break; + } + } + } + // Process monitor_cert_days_remaining inside the loop + if (strpos($line, 'monitor_cert_days_remaining{') === 0) { + if (preg_match('/monitor_name="([^"]+)".*?}\s(\d+)$/', trim($line), $matches)) { + $monitorName = $matches[1]; + $daysRemaining = (int)$matches[2]; + if ($daysRemaining < 30) { // strictly below 30 + $expiringCerts[] = ['name' => $monitorName, 'days' => $daysRemaining]; + } + } + } + } + + // Sort expiring certificates by days ascending + usort($expiringCerts, function($a, $b) { + return $a['days'] <=> $b['days']; + }); + + // Get last refresh time + $lastRefresh = date('Y-m-d H:i:s'); + + return [ + 'error' => false, + 'up' => $up, + 'down' => $down, + 'pending' => $pending, + 'maintenance' => $maintenance, + 'downMonitors' => $downMonitors, + 'mtMonitors' => $mtMonitors, + 'expiringCerts' => $expiringCerts, + 'lastRefresh' => $lastRefresh + ]; +} + +// Indonesian Holidays Function +function getIndonesianHoliday($date = null) { + if ($date === null) $date = date('Y-m-d'); + + $url = "https://libur.deno.dev/api"; + + // Call API + $response = @file_get_contents($url); + if ($response === false) return null; + + $data = json_decode($response, true); + if (!is_array($data)) return null; + + // Search for matching date + foreach ($data as $holiday) { + if (isset($holiday['date']) && $holiday['date'] === $date) { + return $holiday['name']; + } + } + + return null; +} + +// Function to fetch Grafana alerts +function fetchGrafanaAlerts($grafanaUrl, $grafanaApiKey = null) { + $url = rtrim($grafanaUrl, '/') . '/api/alertmanager/grafana/api/v2/alerts'; + $headers = [ + 'Accept: application/json', + ]; + if ($grafanaApiKey) { + $headers[] = 'Authorization: Bearer ' . $grafanaApiKey; + } + $opts = [ + 'http' => [ + 'method' => 'GET', + 'header' => implode("\r\n", $headers) . "\r\n", + 'timeout' => 5 + ] + ]; + $context = stream_context_create($opts); + $result = @file_get_contents($url, false, $context); + if ($result === false) { + return [ 'error' => true, 'alerts' => [], 'message' => 'Unable to fetch alerts from Grafana.' ]; + } + $alerts = json_decode($result, true); + if (!is_array($alerts)) { + return [ 'error' => true, 'alerts' => [], 'message' => 'Invalid response from Grafana.' ]; + } + // Only return firing alerts + $activeAlerts = array_filter($alerts, function($alert) { + return isset($alert['status']['state']) && $alert['status']['state'] === 'active'; + }); +// Format for frontend โ€” only show summary/description +$summary = array_map(function($alert) { + return trim( + $alert['annotations']['summary'] + ?? $alert['annotations']['description'] + ?? 'No description' + ); +}, $activeAlerts); + return [ 'error' => false, 'alerts' => array_values($summary) ]; +} + +// Function to fetch Pending Tasks +function fetchTasks($url, $useMock = false, $mockFile = '') { + // Check if we are mocking the API + if ($useMock && file_exists($mockFile)) { + $result = @file_get_contents($mockFile); + if ($result !== false) { + $tasks = json_decode($result, true); + if (is_array($tasks)) { + return ['error' => false, 'tasks' => $tasks]; + } + } + return ['error' => true, 'tasks' => [], 'message' => 'Failed to read valid JSON from mock file.']; + } + + // If you haven't set the URL properly, return empty gracefully. + if (strpos($url, '#TODO') !== false) { + return ['error' => true, 'tasks' => [], 'message' => 'Cannot connect to task API URL.']; + } + $opts = [ + 'http' => [ + 'method' => 'GET', + 'timeout' => 5 + ] + ]; + $context = stream_context_create($opts); + $result = @file_get_contents($url, false, $context); + if ($result === false) { + return ['error' => true, 'tasks' => [], 'message' => 'Unable to fetch tasks from server.']; + } + $tasks = json_decode($result, true); + if (!is_array($tasks)) { + return ['error' => true, 'tasks' => [], 'message' => 'Invalid tasks response format.']; + } + return ['error' => false, 'tasks' => $tasks]; +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..7e297ee --- /dev/null +++ b/src/main.js @@ -0,0 +1,450 @@ +// main.js - extracted from index.php +window.paginationState = {}; + +function renderListWithPagination(listId, data, renderItemFn, emptyMsgHtml = '', itemsPerPage = 5) { + const listEl = document.getElementById(listId); + if (!listEl) return; + + if (!data || data.length === 0) { + listEl.innerHTML = emptyMsgHtml; + const pageContainer = document.getElementById(listId + '-pagination'); + if (pageContainer) pageContainer.style.display = 'none'; + return; + } + + listEl.innerHTML = ''; + const totalPages = Math.ceil(data.length / itemsPerPage); + + let currentPage = window.paginationState[listId] || 1; + if (currentPage > totalPages) currentPage = totalPages; + window.paginationState[listId] = currentPage; + + const startIdx = (currentPage - 1) * itemsPerPage; + const currentData = data.slice(startIdx, startIdx + itemsPerPage); + + currentData.forEach(item => { + const itemEl = renderItemFn(item); + if (itemEl) listEl.appendChild(itemEl); + }); + + let pageContainer = document.getElementById(listId + '-pagination'); + if (!pageContainer) { + pageContainer = document.createElement('div'); + pageContainer.id = listId + '-pagination'; + pageContainer.className = 'pagination-controls'; + listEl.parentNode.insertBefore(pageContainer, listEl.nextSibling); + } + + if (totalPages > 1) { + pageContainer.innerHTML = ''; + const prevBtn = document.createElement('button'); + prevBtn.textContent = 'ยซ Prev'; + prevBtn.disabled = currentPage === 1; + prevBtn.onclick = () => { + window.paginationState[listId]--; + renderListWithPagination(listId, data, renderItemFn, emptyMsgHtml, itemsPerPage); + }; + + const info = document.createElement('span'); + info.className = 'pag-info'; + info.textContent = ` Page ${currentPage} of ${totalPages} `; + + const nextBtn = document.createElement('button'); + nextBtn.textContent = 'Next ยป'; + nextBtn.disabled = currentPage === totalPages; + nextBtn.onclick = () => { + window.paginationState[listId]++; + renderListWithPagination(listId, data, renderItemFn, emptyMsgHtml, itemsPerPage); + }; + + pageContainer.appendChild(prevBtn); + pageContainer.appendChild(info); + pageContainer.appendChild(nextBtn); + pageContainer.style.display = 'flex'; + } else { + pageContainer.style.display = 'none'; + } +} + +function updateClock() { + const now = new Date(); + const pad = n => n.toString().padStart(2, '0'); + const timeStr = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())} `; + const clock = document.getElementById('digital-clock'); + if (clock) clock.textContent = timeStr; + const dateStr = now.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); + const date = document.getElementById('digital-date'); + if (date) date.textContent = dateStr; +} + +function updateHolidayInfo(dateObj) { + const y = dateObj.getFullYear(); + const m = String(dateObj.getMonth() + 1).padStart(2, '0'); + const d = String(dateObj.getDate()).padStart(2, '0'); + const ymd = `${y}-${m}-${d}`; + fetch(`?fetch=holiday&date=${ymd}`) + .then(resp => resp.json()) + .then(data => { + const info = document.getElementById('holiday-info'); + if (data.holiday) { + info.textContent = `(${data.holiday})`; + info.style.display = 'block'; + } else { + info.style.display = 'none'; + } + }) + .catch(() => { + const info = document.getElementById('holiday-info'); + if (info) info.style.display = 'none'; + }); +} +setInterval(updateClock, 1000); +updateClock(); + +let refreshCountdown; +const refreshInterval = 10; +function startCountdown() { + let secondsLeft = refreshInterval; + const refreshInfoElement = document.getElementById('refresh-info'); + const originalText = refreshInfoElement.textContent; + refreshCountdown = setInterval(() => { + secondsLeft--; + if (secondsLeft <= 0) { + fetchMetrics(); + fetchTasks(); + secondsLeft = refreshInterval; + } + refreshInfoElement.textContent = `${originalText.split('(')[0]} `; + }, 1000); +} +let previousDownMonitors = []; +const offlineAudio = new Audio('src/audio/Down3.wav'); +const onlineAudio = new Audio('src/audio/Up3.wav'); +offlineAudio.volume = 1.0; +onlineAudio.volume = 1.0; +function playOfflineSound() { offlineAudio.currentTime = 0; offlineAudio.play(); } +function playOnlineSound() { onlineAudio.currentTime = 0; onlineAudio.play(); } +function arrayDiff(a, b) { return a.filter(x => !b.includes(x)); } +function showStatusPopup(names, status) { + const popup = document.getElementById('status-popup'); + popup.className = ''; + popup.classList.add('show'); + popup.classList.add(status === 'down' ? 'down' : 'up'); + let label = status === 'down' ? 'DOWN' : 'UP'; + let icon = status === 'down' ? '๐Ÿ”ป' : 'โœ…'; + + let textHtml = ''; + if (Array.isArray(names) && names.length > 1) { + let listItems = names.map(name => `
  • ${name}
  • `).join(''); + textHtml = ``; + } else { + let singleText = Array.isArray(names) ? names[0] : names; + textHtml = `${singleText}`; + } + + popup.innerHTML = `${icon}
    ${textHtml}is ${label}`; + popup.style.display = 'block'; + + // Automatically increase the display time if there's a huge scrollable list! + const duration = Array.isArray(names) && names.length > 1 ? 10000 : 5000; + + setTimeout(() => { + popup.classList.remove('show'); + setTimeout(() => { popup.style.display = 'none'; }, 300); + }, duration); +} +function fetchMetrics() { + const refreshButton = document.getElementById('refresh-button'); + if (refreshButton.disabled) return; + refreshButton.innerHTML = '
    '; + refreshButton.disabled = true; + fetch('?fetch=metrics') + .then(response => response.json()) + .then(data => { + if (data.error) { + console.error(data.message); + return; + } + document.getElementById('up-count').textContent = data.up; + document.getElementById('down-count').textContent = data.down; + document.getElementById('maintenance-count').textContent = data.maintenance; + const refreshInfoElement = document.getElementById('refresh-info'); + refreshInfoElement.textContent = `Last updated: ${data.lastRefresh}`; + const downMonitorsList = document.getElementById('down-monitors-list'); + if (data.down === 0) { + document.getElementById('down-list-container').style.display = 'none'; + document.getElementById('all-good-container').style.display = 'block'; + renderListWithPagination('down-monitors-list', [], null); + } else { + document.getElementById('down-list-container').style.display = 'block'; + document.getElementById('all-good-container').style.display = 'none'; + renderListWithPagination('down-monitors-list', data.downMonitors, name => { + const li = document.createElement('li'); + li.textContent = name; + return li; + }); + } + const mtMonitorsList = document.getElementById('mt-monitors-list'); + if (data.mtMonitors.length === 0) { + document.getElementById('mt-list-container').style.display = 'none'; + renderListWithPagination('mt-monitors-list', [], null); + } else { + document.getElementById('mt-list-container').style.display = 'block'; + renderListWithPagination('mt-monitors-list', data.mtMonitors, name => { + const li = document.createElement('li'); + li.textContent = name; + return li; + }); + } + const expiringCertList = document.getElementById('expiring-cert-list'); + if (data.expiringCerts.length === 0) { + document.getElementById('cert-warning-container').style.display = 'none'; + renderListWithPagination('expiring-cert-list', [], null); + } else { + document.getElementById('cert-warning-container').style.display = 'block'; + renderListWithPagination('expiring-cert-list', data.expiringCerts, cert => { + const li = document.createElement('li'); + li.textContent = `${cert.name} - ${cert.days} days remaining`; + return li; + }); + } + const currentDown = data.downMonitors || []; + const newDown = arrayDiff(currentDown, previousDownMonitors); + const backOnline = arrayDiff(previousDownMonitors, currentDown); + if (newDown.length > 0) { + playOfflineSound(); + showStatusPopup(newDown, 'down'); + } + if (backOnline.length > 0) { + playOnlineSound(); + showStatusPopup(backOnline, 'up'); + } + previousDownMonitors = currentDown; + document.querySelectorAll('.rrd-graph img').forEach(img => { + let src = img.getAttribute('src').split('?')[0]; + img.setAttribute('src', src + '?_t=' + Date.now()); + }); + clearInterval(refreshCountdown); + startCountdown(); + }) + .catch(error => { + console.error('Error fetching metrics:', error); + }) + .finally(() => { + refreshButton.innerHTML = '๐Ÿ”„'; + refreshButton.disabled = false; + }); + // --- Fetch and update Grafana alerts --- + fetch('?fetch=alerts') + .then(resp => resp.json()) + .then(alertData => { + const alertContainer = document.getElementById('alert-list-container'); + alertContainer.style.display = 'block'; + if (alertData.error) { + renderListWithPagination('alert-list', [], null, `
  • ${alertData.message || 'Error fetching alerts from Grafana.'}
  • `); + document.getElementById('alert-count').textContent = '0'; + } else if (!alertData.alerts.length) { + renderListWithPagination('alert-list', [], null, `
  • No active alerts.
  • `); + document.getElementById('alert-count').textContent = '0'; + } else { + document.getElementById('alert-count').textContent = alertData.alerts.length; + renderListWithPagination('alert-list', alertData.alerts, alert => { + const li = document.createElement('li'); + li.textContent = alert; + return li; + }, '
  • No active alerts.
  • '); + } + }) + .catch(err => { + console.error('Alert fetch error:', err); + const alertContainer = document.getElementById('alert-list-container'); + if (alertContainer) alertContainer.style.display = 'block'; + renderListWithPagination('alert-list', [], null, `
  • Error fetching alerts from Grafana (network or server error).
  • `); + const alertCount = document.getElementById('alert-count'); + if (alertCount) alertCount.textContent = '0'; + }); +} +let currentFilter = 'all'; +let currentTaskFilter = 'all'; +window.lastFetchedTasks = []; + +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.unified-lists-container .filter-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + document.querySelectorAll('.unified-lists-container .filter-btn').forEach(b => b.classList.remove('active')); + e.target.classList.add('active'); + currentFilter = e.target.getAttribute('data-filter'); + applyFilters(); + }); + }); + + document.querySelectorAll('.task-filter-buttons .filter-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + document.querySelectorAll('.task-filter-buttons .filter-btn').forEach(b => b.classList.remove('active')); + e.target.classList.add('active'); + currentTaskFilter = e.target.getAttribute('data-task-filter'); + applyTaskFilters(); + }); + }); +}); + +function applyFilters() { + const containers = { + 'offline': document.getElementById('down-list-container'), + 'maintenance': document.getElementById('mt-list-container'), + 'ssl': document.getElementById('cert-warning-container'), + 'alerts': document.getElementById('alert-list-container') + }; + + for (const [key, element] of Object.entries(containers)) { + if (!element) continue; + if (currentFilter === 'all' || currentFilter === key) { + element.classList.remove('hidden-by-filter'); + } else { + element.classList.add('hidden-by-filter'); + } + } + + const allGood = document.getElementById('all-good-container'); + if (allGood) { + if (currentFilter === 'all' || currentFilter === 'offline') { + allGood.classList.remove('hidden-by-filter'); + } else { + allGood.classList.add('hidden-by-filter'); + } + } +} + +window.addEventListener('load', () => { + fetchMetrics(); + fetchTasks(); + startCountdown(); + updateHolidayInfo(new Date()); + // Set up midnight check to update holiday info + setInterval(() => { + const now = new Date(); + if (now.getHours() === 0 && now.getMinutes() === 0 && now.getSeconds() < 2) { + updateHolidayInfo(now); + } + }, 1000); +}); +document.getElementById('refresh-button').addEventListener('click', () => { + clearInterval(refreshCountdown); + fetchMetrics(); + fetchTasks(); +}); + +function applyTaskFilters() { + const data = window.lastFetchedTasks; + const taskList = document.getElementById('task-list'); + if (!taskList) return; + + if (!data || data.length === 0) { + renderListWithPagination('task-list', [], null, `
    No tasks available.
    `, 5); + document.getElementById('task-count').textContent = '0'; + return; + } + + // Filter out "Done" tasks completely, and exclusively count "Pending" tasks for the dashboard + let activeTasks = data.filter(t => (t.current_status || t.status || '').toLowerCase() !== 'done'); + let pendingCount = data.filter(t => (t.current_status || t.status || 'Pending').toLowerCase() === 'pending').length; + document.getElementById('task-count').textContent = pendingCount; + + let filteredTasks = activeTasks; + if (currentTaskFilter !== 'all') { + filteredTasks = activeTasks.filter(task => { + let statusClass = (task.current_status || task.status || 'Pending').toLowerCase().replace(/\s+/g, '-'); + const dueDate = task.due_date || task.deadline || task.task_deadline || 'N/A'; + if (dueDate !== 'N/A') { + const due = new Date(dueDate); + const now = new Date(); + const diffTime = due - now; + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + if (diffDays < 0 && statusClass !== 'done') { + statusClass = 'overdue'; + } + } + return statusClass === currentTaskFilter; + }); + } + + // Sort ascending by remaining days (closest/most overdue first), put N/A at bottom + filteredTasks.sort((a, b) => { + const getDueTime = (task) => { + const dueDate = task.due_date || task.deadline || task.task_deadline || 'N/A'; + if (dueDate === 'N/A' || !dueDate) return Infinity; // Push N/A to bottom + const time = new Date(dueDate).getTime(); + return isNaN(time) ? Infinity : time; + }; + return getDueTime(a) - getDueTime(b); + }); + + if (!filteredTasks || filteredTasks.length === 0) { + renderListWithPagination('task-list', [], null, `
    No matching tasks found.
    `, 5); + return; + } + + renderListWithPagination('task-list', filteredTasks, task => { + const item = document.createElement('div'); + let currentStatus = task.current_status || task.status || 'Pending'; + let statusClass = currentStatus.toLowerCase().replace(/\s+/g, '-'); + + /* Adjust field names according to your API response JSON */ + const desc = task.task_description || task.description || task.desc || 'Unknown Task'; + const fromUser = task.from || task.assigner || task.assigned_by || task.who_assign || 'N/A'; + const toUser = task.to || task.receiver || task.assigned_to || task.who_receive || 'N/A'; + const createdDate = task.task_created_date || task.date_assigned || task.assigned_date || 'N/A'; + const dueDate = task.due_date || task.deadline || task.task_deadline || 'N/A'; + + let daysRemainingText = ''; + if (dueDate !== 'N/A') { + const due = new Date(dueDate); + const now = new Date(); + const diffTime = due - now; + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + if (diffDays < 0) { + statusClass = 'overdue'; + currentStatus = 'Overdue'; + daysRemainingText = ` (${Math.abs(diffDays)} days ago)`; + } else if (diffDays === 0) { + daysRemainingText = ` (Due Today)`; + } else { + daysRemainingText = ` (${diffDays} days left)`; + } + } + + item.className = `task-item task-item-${statusClass}`; + + item.innerHTML = ` +
    +

    ${desc}

    + ${currentStatus} +
    +
    +
    From: ${fromUser}  |  To: ${toUser}
    +
    Created: ${createdDate}  |  Due: ${dueDate}${daysRemainingText}
    +
    + `; + return item; + }, '', 5); +} + +function fetchTasks() { + const taskList = document.getElementById('task-list'); + if (!taskList) return; // Prevent errors if DOM isn't ready + fetch('?fetch=tasks') + .then(resp => resp.json()) + .then(data => { + if (data.error) { + renderListWithPagination('task-list', [], null, `
    ${data.message || 'Error fetching tasks.'}
    `, 5); + document.getElementById('task-count').textContent = '0'; + return; + } + window.lastFetchedTasks = data.tasks || []; + applyTaskFilters(); + }) + .catch(err => { + document.getElementById('task-count').textContent = '0'; + renderListWithPagination('task-list', [], null, `
    Error fetching tasks. API not accessible or JSON error.
    `, 5); + }); +} diff --git a/src/mock_tasks.json b/src/mock_tasks.json new file mode 100644 index 0000000..cbbe021 --- /dev/null +++ b/src/mock_tasks.json @@ -0,0 +1,34 @@ +[ + { + "task_created_date": "2026-03-17 08:00", + "from": "Putut", + "to": "Ops-Server", + "task_description": "Install OS dan Aplikasi untuk PC Admin", + "due_date": "2026-03-25 20:00", + "current_status": "Pending" + }, + { + "task_created_date": "2026-03-16 15:30", + "from": "Farizal", + "to": "SE-Server", + "task_description": "Pembelian Domain pokegocoin.com", + "due_date": "2026-03-11 12:00", + "current_status": "Pending" + }, + { + "task_created_date": "2026-03-16 10:15", + "from": "Finance", + "to": "Irfan", + "task_description": "Pembuatan Server domain accfi.lyto.net untuk tool system approval", + "due_date": "2026-03-26 12:00", + "current_status": "Pending" + }, + { + "task_created_date": "2026-03-15 10:15", + "from": "Test 2", + "to": "Irfan 2", + "task_description": "Update OS Web Server gnjoy.asia dan upgrade PHP", + "due_date": "2026-03-29 12:00", + "current_status": "Pending" + } +] \ No newline at end of file diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..f9025bc --- /dev/null +++ b/src/style.css @@ -0,0 +1,594 @@ +/* style.css - extracted from index.php */ +#time-container { + position: absolute; + top: 10px; + left: 10px; + z-index: 10000; + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} + +.date-holiday-row { + display: flex; + flex-direction: row; + gap: 10px; + align-items: center; +} + +#digital-clock { + color: #fff; + font-size: 2.5rem; + font-family: 'Segoe UI', monospace; + letter-spacing: 2px; + text-shadow: 1px 1px 4px rgba(0, 0, 0, 0.8); +} + +#digital-date { + color: #fff; + font-size: 1.3rem; + font-family: 'Segoe UI', monospace; + letter-spacing: 1px; + text-shadow: 1px 1px 4px rgba(0, 0, 0, 0.8); +} + +#holiday-info { + color: #ff4d4d; + font-size: 1rem; + font-weight: bold; + font-family: 'Segoe UI', sans-serif; + letter-spacing: 1px; + text-shadow: 1px 1px 4px rgba(0, 0, 0, 0.8); +} + +.desktop-title { + display: block; +} + +.mobile-title { + display: none; +} + +body { + font-family: 'Segoe UI', sans-serif; + background: #121212; + color: #f1f1f1; + margin: 0; + padding: 20px; + position: relative; +} + +h1 { + text-align: center; + color: #ffffff; + margin-bottom: 5px; +} + +.refresh-info { + text-align: center; + color: #888; + font-size: 12px; + margin-bottom: 15px; +} + +.refresh-button { + position: absolute; + top: 20px; + right: 20px; + background: #2c3e50; + color: white; + border: none; + width: 36px; + height: 36px; + border-radius: 50%; + cursor: pointer; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.3s, transform 0.2s; +} + +.refresh-button:hover { + background: #34495e; +} + +.refresh-button:active { + transform: scale(0.95); +} + +.dashboard { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 20px; + margin-top: 20px; +} + +.box { + box-sizing: border-box; + background: #1e1e1e; + padding: 20px; + border-radius: 12px; + flex: 1 1 160px; + max-width: 260px; + height: 160px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + box-shadow: 0 0 15px rgba(0, 0, 0, 0.5); + transition: 0.3s ease; +} + +.box:hover { + transform: scale(1.05); +} + +.box h2 { + font-size: clamp(16px, 2vw + 6px, 24px); + margin-bottom: 5px; + color: #ccc; + white-space: nowrap; +} + +.box span { + font-size: clamp(36px, 5vw, 68px); + font-weight: bold; +} + +.up { + border-top: 4px solid #28a745; + color: #28a745; +} + +.down { + border-top: 4px solid #dc3545; + color: #dc3545; +} + +.alert-box { + border-top: 4px solid #fd7e14; + color: #fd7e14; +} + +.task-box { + border-top: 4px solid #ffc107; + color: #ffc107; +} + +.maintenance { + border-top: 4px solid #17a2b8; + color: #17a2b8; +} + +.all-good { + text-align: center; + margin-top: 30px; + color: #28a745; + font-size: 18px; + font-weight: bold; + background: #1e1e1e; + border: 2px solid #28a745; + border-radius: 8px; + padding: 10px; + max-width: 500px; + margin-left: auto; + margin-right: auto; +} + +.main-content-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 30px; + max-width: 1600px; + margin: 50px auto 20px; + align-items: start; +} + +.unified-lists-container { + background: #1e1e1e; + border-radius: 10px; + padding: 30px; + box-shadow: 0 0 15px rgba(0, 0, 0, 0.5); + max-height: 80vh; + overflow-y: auto; +} + +.unified-lists-container h3 { + margin-top: 10px; + margin-bottom: 5px; + border-bottom: 1px solid #333; + padding-bottom: 5px; + font-size: 1.1em; +} + +.unified-lists-container h3:first-child { + margin-top: 0; +} + +.filter-buttons { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 15px; + border-bottom: 1px solid #333; + padding-bottom: 15px; +} + +.filter-btn { + background: #2c3e50; + color: #fff; + border: none; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 0.9em; + transition: background 0.3s; +} + +.filter-btn:hover { + background: #34495e; +} + +.filter-btn.active { + background: #4CAF50; +} + +.hidden-by-filter { + display: none !important; +} + +.unified-lists-container ul { + list-style: none; + padding-left: 0; + margin-top: 0; + margin-bottom: 10px; +} + +.unified-lists-container li { + border-bottom: 1px solid #333; + padding: 6px 0; + font-size: 0.95em; +} + +.unified-lists-container .alert-message { + color: #aaa; + margin-bottom: 10px; +} + +/* Task Container Styles */ +.task-container { + background: #1e1e1e; + border-radius: 10px; + padding: 30px; + box-shadow: 0 0 15px rgba(0, 0, 0, 0.5); + max-height: 80vh; + overflow-y: auto; +} + +.task-container h3 { + margin-top: 0; + margin-bottom: 8px; + border-bottom: 1px solid #333; + padding-bottom: 5px; + color: #4CAF50; + font-size: 1.1em; +} + +.task-item { + background: #2a2a2a; + border-radius: 6px; + padding: 10px 12px; + margin-bottom: 10px; + border-left: 4px solid #555; + /* Default border */ + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.task-item-pending { + border-left-color: #ffc107; +} + +.task-item-in-progress { + border-left-color: #17A2B8; +} + +.task-item-done, +.task-item-completed { + border-left-color: #28a745; +} + +.task-item-overdue { + border-left-color: #dc3545; +} + +.task-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 5px; +} + +.task-item h4 { + margin: 0; + color: #fff; + font-size: 1em; + flex: 1; + padding-right: 10px; +} + +.task-status { + font-size: 0.75em; + padding: 2px 6px; + border-radius: 4px; + background: #333; + white-space: nowrap; + border: 1px solid #555; + font-weight: bold; +} + +.badge-pending { + color: #ffc107; + border-color: #ffc107; +} + +.badge-in-progress { + color: #17A2B8; + border-color: #17A2B8; +} + +.badge-done, +.badge-completed { + color: #28a745; + border-color: #28a745; +} + +.badge-overdue { + color: #dc3545; + border-color: #dc3545; +} + +.task-body { + display: flex; + flex-direction: column; + gap: 3px; +} + +.task-detail { + font-size: 0.85em; + color: #bbb; + margin-bottom: 0; +} + +.task-detail strong { + color: #ddd; + margin-right: 2px; +} + +.loading { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid rgba(255, 255, 255, .3); + border-radius: 50%; + border-top-color: #fff; + animation: spin 1s ease-in-out infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 1024px) { + .main-content-grid { + grid-template-columns: 1fr; + } +} + +.pagination-controls { + display: flex; + justify-content: center; + align-items: center; + margin-top: 15px; + gap: 15px; +} + +.pagination-controls button { + background: #34495e; + color: #fff; + border: none; + padding: 6px 14px; + border-radius: 4px; + cursor: pointer; + font-size: 0.9em; + transition: background 0.2s; +} + +.pagination-controls button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pagination-controls button:hover:not(:disabled) { + background: #4CAF50; +} + +.pagination-controls .pag-info { + color: #ccc; + font-size: 0.9em; + font-family: 'Segoe UI', sans-serif; +} + + +@media (max-width: 768px) { + .desktop-title { + display: none; + } + + .mobile-title { + display: block; + } + + #digital-clock, + #digital-date, + #holiday-info { + display: none !important; + } +} + +@media (max-width: 600px) { + .box { + flex: 1 1 100%; + max-width: 100%; + height: auto; + padding: 10px; + } + + .total-box span { + font-size: 32px; + } + + .box span { + font-size: 24px; + } + + .refresh-button { + top: 10px; + right: 10px; + width: 30px; + height: 30px; + font-size: 14px; + } +} + +#status-popup { + display: none; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0.7); + background: rgba(30, 30, 30, 0.97); + color: #fff; + padding: 40px 60px; + border-radius: 20px; + box-shadow: 0 0 40px 10px #000a; + z-index: 9999; + font-size: 2rem; + font-weight: bold; + text-align: center; + opacity: 0; + transition: opacity 0.3s, transform 0.3s; + pointer-events: auto; + /* Re-enabled so user can scroll */ + width: 90vw; + max-width: 700px; +} + +.popup-list-container { + max-height: 35vh; + overflow-y: auto; + margin: 15px 0; + background: rgba(0, 0, 0, 0.2); + border-radius: 12px; + padding: 15px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.popup-list { + font-size: 0.55em; + /* Drops text slightly relative to 2rem parent */ + text-align: left; + margin: 0; + padding-left: 20px; + list-style-type: square; + font-weight: normal; +} + +.popup-list li { + margin-bottom: 8px; + word-break: break-word; + /* Prevent text overflow */ + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + /* Soft divider */ + padding-bottom: 6px; + line-height: 1.4; +} + +.popup-list li:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +#status-popup.show { + display: block; + opacity: 1; + transform: translate(-50%, -50%) scale(1.1); + animation: popup-zoom 0.3s cubic-bezier(.68, -0.55, .27, 1.55) forwards, popup-hide 0.3s 4.7s forwards; +} + +@keyframes popup-zoom { + 0% { + transform: translate(-50%, -50%) scale(0.7); + opacity: 0; + } + + 80% { + transform: translate(-50%, -50%) scale(1.15); + opacity: 1; + } + + 100% { + transform: translate(-50%, -50%) scale(1.1); + opacity: 1; + } +} + +@keyframes popup-hide { + to { + opacity: 0; + transform: translate(-50%, -50%) scale(0.7); + } +} + +#status-popup.down { + border: 4px solid #dc3545; + color: #dc3545; + background: #1e1e1e; +} + +#status-popup.up { + border: 4px solid #28a745; + color: #28a745; + background: #1e1e1e; +} + +/* Custom Thin Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: #1e1e1e; +} + +::-webkit-scrollbar-thumb { + background: #444; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: #666; +} + +/* Firefox compatibility */ +* { + scrollbar-width: thin; + scrollbar-color: #444 #1e1e1e; +} \ No newline at end of file