initial commit
This commit is contained in:
204
index.php
Normal file
204
index.php
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
// Configuration
|
||||||
|
$metricsUrl = 'http://192.168.88.111:5002/metrics';
|
||||||
|
$apiToken = 'uk3_ehosMbWkvQvG_182svHdnSLb5kQG6HdeA166j9_p';
|
||||||
|
// Set your Grafana URL and API key here
|
||||||
|
$grafanaUrl = 'https://corvus.thamrin.net.id'; // TODO: change to your actual Grafana URL
|
||||||
|
$grafanaApiKey = 'glsa_VXhpKUk4bOqJSgfLPzqNgO76LkPjfJBx_9cce4338'; // Or set your API key if needed
|
||||||
|
$taskApiUrl = 'http://#TODO_INSERT_TASK_API_URL#/tasks'; // TODO: Change to your TASK-API URL
|
||||||
|
$useMockTaskApi = true; // Set to true to use mock data for tasks
|
||||||
|
$mockTaskFile = __DIR__ . '/src/mock_tasks.json';
|
||||||
|
|
||||||
|
// Create HTTP context with Authorization header
|
||||||
|
$options = [
|
||||||
|
'http' => [
|
||||||
|
'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'];
|
||||||
|
|
||||||
|
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Services Monitoring</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="src/style.css?v=<?= time() ?>">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="time-container">
|
||||||
|
<div class="date-holiday-row">
|
||||||
|
<div id="digital-date"></div>
|
||||||
|
<div id="holiday-info" <?= $todayHoliday ? '' : 'style="display:none;"' ?>>
|
||||||
|
<?= $todayHoliday ? htmlspecialchars('(' . $todayHoliday . ')') : '' ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="digital-clock"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="desktop-title">Thamrin Net - Monitoring Summary</h1>
|
||||||
|
<h1 class="mobile-title">Monitoring Summary</h1>
|
||||||
|
|
||||||
|
<button class="refresh-button" id="refresh-button" title="Refresh Now">
|
||||||
|
🔄
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="dashboard">
|
||||||
|
<div class="box up">
|
||||||
|
<h2>Online</h2>
|
||||||
|
<span id="up-count"><?= $up ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="box down">
|
||||||
|
<h2>Offline</h2>
|
||||||
|
<span id="down-count"><?= $down ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="box maintenance">
|
||||||
|
<h2>Maintenance</h2>
|
||||||
|
<span id="maintenance-count"><?= $maintenance ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="box alert-box">
|
||||||
|
<h2>Alert</h2>
|
||||||
|
<span id="alert-count">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="box task-box">
|
||||||
|
<h2>Pending Task</h2>
|
||||||
|
<span id="task-count">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-content-grid">
|
||||||
|
<div class="unified-lists-container">
|
||||||
|
<div class="filter-buttons">
|
||||||
|
<button class="filter-btn active" data-filter="all">All</button>
|
||||||
|
<button class="filter-btn" data-filter="offline">Offline Services</button>
|
||||||
|
<button class="filter-btn" data-filter="alerts">Active Alerts</button>
|
||||||
|
<button class="filter-btn" data-filter="maintenance">Maintenance</button>
|
||||||
|
<button class="filter-btn" data-filter="ssl">Upcoming SSL Expiry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="all-good-container" <?= $down > 0 ? 'style="display:none;"' : '' ?>>
|
||||||
|
<div class="all-good" id="all-good-message">✅ All is good — No services are down.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="down-list-container" <?= $down === 0 ? 'style="display:none;"' : '' ?>>
|
||||||
|
<h3 style="color: #dc3545;">Offline Services</h3>
|
||||||
|
<ul id="down-monitors-list">
|
||||||
|
<?php foreach (array_slice($downMonitors, 0, 5) as $name): ?>
|
||||||
|
<li><?= htmlspecialchars($name) ?></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="alert-list-container" style="display:none;">
|
||||||
|
<h3 style="color: #dc3545;">Active Alerts</h3>
|
||||||
|
<ul id="alert-list"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mt-list-container" <?= empty($mtMonitors) ? 'style="display:none;"' : '' ?>>
|
||||||
|
<h3 style="color: #17A2B8;">Under Maintenance</h3>
|
||||||
|
<ul id="mt-monitors-list">
|
||||||
|
<?php foreach (array_slice($mtMonitors, 0, 5) as $name): ?>
|
||||||
|
<li><?= htmlspecialchars($name) ?></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cert-warning-container" <?= empty($expiringCerts) ? 'style="display:none;"' : '' ?>>
|
||||||
|
<h3 style="color: #FF7F55;">Upcoming SSL Expiry</h3>
|
||||||
|
<ul id="expiring-cert-list">
|
||||||
|
<?php foreach (array_slice($expiringCerts, 0, 5) as $cert): ?>
|
||||||
|
<li><?= htmlspecialchars($cert['name']) ?> - <?= $cert['days'] ?> days remaining</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-container">
|
||||||
|
<div class="filter-buttons task-filter-buttons">
|
||||||
|
<button class="filter-btn active" data-task-filter="all">All</button>
|
||||||
|
<button class="filter-btn" data-task-filter="pending">Pending</button>
|
||||||
|
<button class="filter-btn" data-task-filter="in-progress">In Progress</button>
|
||||||
|
<button class="filter-btn" data-task-filter="overdue">Overdue</button>
|
||||||
|
</div>
|
||||||
|
<h3>Tasks</h3>
|
||||||
|
<div id="task-list">
|
||||||
|
<div class="loading"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="refresh-info" id="refresh-info">
|
||||||
|
Last updated: <?= $lastRefresh ?> (refresh every 10 Seconds)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Popup for status changes -->
|
||||||
|
<div id="status-popup"></div>
|
||||||
|
<script src="src/main.js?v=<?= time() ?>"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
BIN
src/audio/Down3.wav
Normal file
BIN
src/audio/Down3.wav
Normal file
Binary file not shown.
BIN
src/audio/Up3.wav
Normal file
BIN
src/audio/Up3.wav
Normal file
Binary file not shown.
187
src/functions.php
Normal file
187
src/functions.php
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
// Function to fetch and parse metrics
|
||||||
|
function fetchMetrics($metricsUrl, $context) {
|
||||||
|
// Create HTTP context with Authorization header
|
||||||
|
$options = [
|
||||||
|
'http' => [
|
||||||
|
'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];
|
||||||
|
}
|
||||||
450
src/main.js
Normal file
450
src/main.js
Normal file
@@ -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 => `<li>${name}</li>`).join('');
|
||||||
|
textHtml = `<div class="popup-list-container"><ul class="popup-list">${listItems}</ul></div>`;
|
||||||
|
} else {
|
||||||
|
let singleText = Array.isArray(names) ? names[0] : names;
|
||||||
|
textHtml = `<span style="font-size:1.2em; display:block; margin:10px 0;">${singleText}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
popup.innerHTML = `${icon} <br>${textHtml}<span style="font-size:1em; display:block; margin-top:10px;">is ${label}</span>`;
|
||||||
|
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 = '<div class="loading"></div>';
|
||||||
|
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, `<li style="color:#dc3545;">${alertData.message || 'Error fetching alerts from Grafana.'}</li>`);
|
||||||
|
document.getElementById('alert-count').textContent = '0';
|
||||||
|
} else if (!alertData.alerts.length) {
|
||||||
|
renderListWithPagination('alert-list', [], null, `<li style="color:#aaa;">No active alerts.</li>`);
|
||||||
|
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;
|
||||||
|
}, '<li style="color:#aaa;">No active alerts.</li>');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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, `<li style="color:#dc3545;">Error fetching alerts from Grafana (network or server error).</li>`);
|
||||||
|
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, `<div style="color:#aaa; padding: 20px;">No tasks available.</div>`, 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, `<div style="color:#aaa; padding: 20px;">No matching tasks found.</div>`, 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 = `<span style="color:#dc3545; font-style:italic;"> (${Math.abs(diffDays)} days ago)</span>`;
|
||||||
|
} else if (diffDays === 0) {
|
||||||
|
daysRemainingText = `<span style="color:#ffc107; font-style:italic;"> (Due Today)</span>`;
|
||||||
|
} else {
|
||||||
|
daysRemainingText = `<span style="color:#17A2B8; font-style:italic;"> (${diffDays} days left)</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item.className = `task-item task-item-${statusClass}`;
|
||||||
|
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="task-header">
|
||||||
|
<h4>${desc}</h4>
|
||||||
|
<span class="task-status badge-${statusClass}">${currentStatus}</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-body">
|
||||||
|
<div class="task-detail"><strong>From:</strong> ${fromUser} | <strong>To:</strong> ${toUser}</div>
|
||||||
|
<div class="task-detail"><strong>Created:</strong> ${createdDate} | <strong>Due:</strong> ${dueDate}${daysRemainingText}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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, `<div style="color:#aaa; padding: 20px;">${data.message || 'Error fetching tasks.'}</div>`, 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, `<div style="color:#aaa; padding: 20px;">Error fetching tasks. API not accessible or JSON error.</div>`, 5);
|
||||||
|
});
|
||||||
|
}
|
||||||
34
src/mock_tasks.json
Normal file
34
src/mock_tasks.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
594
src/style.css
Normal file
594
src/style.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user