Commit 6c19868e by Dedy Kurniawan

update

1 parent e4019636
......@@ -4,9 +4,9 @@ Dashboard manajemen PXE Server untuk lab komputer, dibangun dengan Node.js + Exp
## Fitur
- ✅ Login dengan username & password
- 📝 Editor teks untuk file `boot.uefi` (dengan backup otomatis)
- 📝 Editor teks untuk file `boot.ipxe` (dengan backup otomatis)
- 📤 Upload/replace file: `vmlinuz`, `initrd.img`, `filesystem.squashfs`
- ⚙️ Monitor & kontrol service: tftpd-hpa, nginx, isc-dhcp-server, nfs-kernel-server
- ⚙️ Monitor & kontrol service: dnsmaq, nginx, ipxe
- 🔄 Auto-refresh status service tiap 10 detik
## Struktur Direktori
......@@ -51,18 +51,15 @@ npm install
### 3. Konfigurasi sudo (untuk kontrol service)
Tambahkan ke `/etc/sudoers` (gunakan `visudo`):
```
www-data ALL=(ALL) NOPASSWD: /bin/systemctl start tftpd-hpa
www-data ALL=(ALL) NOPASSWD: /bin/systemctl stop tftpd-hpa
www-data ALL=(ALL) NOPASSWD: /bin/systemctl restart tftpd-hpa
www-data ALL=(ALL) NOPASSWD: /bin/systemctl start dnsmaq
www-data ALL=(ALL) NOPASSWD: /bin/systemctl stop dnsmaq
www-data ALL=(ALL) NOPASSWD: /bin/systemctl restart dnsmaq
www-data ALL=(ALL) NOPASSWD: /bin/systemctl start nginx
www-data ALL=(ALL) NOPASSWD: /bin/systemctl stop nginx
www-data ALL=(ALL) NOPASSWD: /bin/systemctl restart nginx
www-data ALL=(ALL) NOPASSWD: /bin/systemctl start isc-dhcp-server
www-data ALL=(ALL) NOPASSWD: /bin/systemctl stop isc-dhcp-server
www-data ALL=(ALL) NOPASSWD: /bin/systemctl restart isc-dhcp-server
www-data ALL=(ALL) NOPASSWD: /bin/systemctl start nfs-kernel-server
www-data ALL=(ALL) NOPASSWD: /bin/systemctl stop nfs-kernel-server
www-data ALL=(ALL) NOPASSWD: /bin/systemctl restart nfs-kernel-server
www-data ALL=(ALL) NOPASSWD: /bin/systemctl start ipxe
www-data ALL=(ALL) NOPASSWD: /bin/systemctl stop ipxe
www-data ALL=(ALL) NOPASSWD: /bin/systemctl restart ipxe
```
### 4. Pastikan direktori PXE ada
......@@ -96,7 +93,7 @@ Buka browser: **http://localhost:3000**
## Environment Variables
```bash
PORT=3000 # Port server (default: 3000)
PXE_ROOT=/var/www/html # Root direktori PXE (default: /var/www/html)
PXE_ROOT=/var/www/pxe-dashboard # Root direktori PXE (default: /var/www/pxe-dashboard)
```
## Menjalankan sebagai Service (systemd)
......@@ -113,7 +110,7 @@ WorkingDirectory=/path/to/pxe-dashboard
ExecStart=/usr/bin/node app.js
Restart=on-failure
Environment=PORT=3000
Environment=PXE_ROOT=/var/www/html
Environment=PXE_ROOT=/var/www/pxe-dashboard
[Install]
WantedBy=multi-user.target
......@@ -128,7 +125,7 @@ sudo systemctl start pxe-dashboard
## Jalur File yang Dikelola
| File | Path |
|------|------|
| boot.uefi | `/var/www/html/boot.uefi` |
| boot.uefi | `/var/www/html/boot.ipxe` |
| vmlinuz | `/var/www/html/debian12/live/vmlinuz` |
| initrd.img | `/var/www/html/debian12/live/initrd.img` |
| filesystem.squashfs | `/var/www/html/debian12/live/filesystem.squashfs` |
......@@ -39,11 +39,13 @@ const authRoutes = require('./routes/auth');
const dashboardRoutes = require('./routes/dashboard');
const fileRoutes = require('./routes/files');
const serviceRoutes = require('./routes/service');
const monitorRoutes = require('./routes/monitor');
app.use('/', authRoutes);
app.use('/dashboard', dashboardRoutes);
app.use('/files', fileRoutes);
app.use('/service', serviceRoutes);
app.use('/monitor', monitorRoutes);
app.listen(PORT, () => {
console.log(`PXE Dashboard berjalan di http://localhost:${PORT}`);
......
......@@ -1026,3 +1026,225 @@ body {
opacity: 0.3;
font-size: 10px;
}
/* ===== MONITOR PAGE ===== */
.mon-stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 4px;
}
.mon-stat-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 18px 20px;
}
.msc-label {
font-size: 9px;
letter-spacing: 2px;
color: var(--text3);
font-family: var(--font-mono);
margin-bottom: 8px;
}
.msc-value {
font-size: 26px;
font-weight: 700;
font-family: var(--font-mono);
color: #fff;
}
.msc-sep { color: var(--text3); margin: 0 4px; font-size: 18px; }
.accent-green { color: var(--accent3); }
.accent-blue { color: var(--accent); }
.accent-red { color: var(--danger); }
/* Bandwidth */
.bw-container {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 20px;
}
.bw-iface-selector {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.bw-iface-btn {
padding: 5px 14px;
border: 1px solid var(--border2);
border-radius: 4px;
background: var(--bg3);
color: var(--text2);
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.bw-iface-btn.active,
.bw-iface-btn:hover {
border-color: var(--accent);
color: var(--accent);
background: rgba(0,212,255,0.08);
}
.bw-stats-row {
display: flex;
gap: 32px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.bw-stat { display: flex; flex-direction: column; gap: 4px; }
.bw-stat-label {
font-size: 9px;
letter-spacing: 2px;
color: var(--text3);
font-family: var(--font-mono);
}
.bw-stat-val {
font-size: 20px;
font-weight: 700;
font-family: var(--font-mono);
}
.chart-wrap {
position: relative;
height: 160px;
}
/* Two col layout */
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) { .two-col { grid-template-columns: 1fr; } }
/* Top lists */
.top-list {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.top-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
transition: background 0.15s;
}
.top-item:last-child { border-bottom: none; }
.top-item:hover { background: rgba(255,255,255,0.02); }
.top-rank {
font-size: 10px;
color: var(--text3);
font-family: var(--font-mono);
width: 24px;
flex-shrink: 0;
}
.top-name {
flex: 1;
font-size: 12px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.top-count {
font-size: 11px;
font-family: var(--font-mono);
color: var(--accent);
flex-shrink: 0;
}
/* Log Table */
.log-filter-wrap { margin-left: auto; }
.log-filter-input {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 4px;
padding: 4px 12px;
color: var(--text);
font-family: var(--font-mono);
font-size: 11px;
outline: none;
width: 200px;
transition: border-color 0.2s;
}
.log-filter-input:focus { border-color: var(--accent); }
.log-filter-input::placeholder { color: var(--text3); }
.section-title {
display: flex;
align-items: center;
gap: 10px;
}
.log-table-wrap {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 6px;
overflow: auto;
max-height: 400px;
}
.log-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.log-table thead {
position: sticky;
top: 0;
background: var(--bg2);
z-index: 1;
}
.log-table th {
padding: 10px 14px;
text-align: left;
font-size: 9px;
letter-spacing: 2px;
color: var(--text3);
font-family: var(--font-mono);
border-bottom: 1px solid var(--border);
font-weight: 400;
}
.log-table td {
padding: 8px 14px;
border-bottom: 1px solid rgba(30,45,61,0.5);
font-size: 11px;
color: var(--text2);
}
.log-row:hover td { background: rgba(255,255,255,0.02); }
.log-row.status-err td { color: rgba(255,61,90,0.7); }
.log-time { color: var(--text3) !important; font-size: 10px !important; }
.log-ip { color: var(--accent) !important; }
.log-file { color: var(--text) !important; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 10px;
font-family: var(--font-mono);
font-weight: 700;
}
.status-badge.status-ok {
background: rgba(0,255,157,0.1);
color: var(--accent3);
border: 1px solid rgba(0,255,157,0.2);
}
.status-badge.status-err {
background: rgba(255,61,90,0.1);
color: var(--danger);
border: 1px solid rgba(255,61,90,0.2);
}
.empty-state {
text-align: center;
padding: 32px;
color: var(--text3);
font-family: var(--font-mono);
font-size: 12px;
}
const express = require('express');
const fs = require('fs');
const readline = require('readline');
const { exec } = require('child_process');
const { isAuthenticated } = require('../middleware/auth');
const router = express.Router();
const NGINX_LOG = process.env.NGINX_LOG || '/var/log/nginx/access.log';
// ─── Bandwidth dari /proc/net/dev ───────────────────────────────────────────
function getNetStats() {
try {
const raw = fs.readFileSync('/proc/net/dev', 'utf8');
const lines = raw.trim().split('\n').slice(2);
const result = {};
lines.forEach(line => {
const parts = line.trim().split(/\s+/);
const iface = parts[0].replace(':', '');
if (iface === 'lo') return;
result[iface] = {
rxBytes: parseInt(parts[1]),
txBytes: parseInt(parts[9]),
};
});
return result;
} catch(e) { return {}; }
}
// Cache untuk hitung delta bps
let prevStats = {};
let prevTime = Date.now();
function getBandwidth() {
const now = Date.now();
const current = getNetStats();
const elapsed = (now - prevTime) / 1000; // detik
const bandwidth = {};
Object.keys(current).forEach(iface => {
const prev = prevStats[iface];
if (prev && elapsed > 0) {
bandwidth[iface] = {
rxBps: Math.max(0, (current[iface].rxBytes - prev.rxBytes) / elapsed),
txBps: Math.max(0, (current[iface].txBytes - prev.txBytes) / elapsed),
rxBytes: current[iface].rxBytes,
txBytes: current[iface].txBytes,
};
} else {
bandwidth[iface] = { rxBps: 0, txBps: 0, ...current[iface] };
}
});
prevStats = current;
prevTime = now;
return bandwidth;
}
// ─── Parse nginx access log ──────────────────────────────────────────────────
// Format nginx default: IP - - [date] "METHOD /path HTTP/x.x" status bytes "ref" "ua"
function parseNginxLog(limit = 100) {
return new Promise((resolve) => {
const entries = [];
try {
if (!fs.existsSync(NGINX_LOG)) return resolve([]);
const content = fs.readFileSync(NGINX_LOG, 'utf8');
const lines = content.trim().split('\n').filter(Boolean);
// Ambil dari bawah (terbaru)
const recent = lines.slice(-limit).reverse();
recent.forEach(line => {
// Hanya tampilkan request ke /debian12/live/
if (!line.includes('/debian12/live/')) return;
// Parse format nginx combined log
const m = line.match(/^(\S+)\s+-\s+-\s+\[([^\]]+)\]\s+"(\S+)\s+(\S+)\s+\S+"\s+(\d+)\s+(\d+)/);
if (!m) return;
const [, ip, time, method, path, status, bytes] = m;
const filename = path.split('/').pop().split('?')[0] || path;
entries.push({
ip,
time,
method,
path,
filename,
status: parseInt(status),
bytes: parseInt(bytes),
});
});
} catch(e) { console.error('Gagal baca nginx log:', e.message); }
resolve(entries);
});
}
// ─── Routes ──────────────────────────────────────────────────────────────────
// Halaman monitoring
router.get('/', isAuthenticated, async (req, res) => {
const logs = await parseNginxLog(100);
const bandwidth = getBandwidth();
// Statistik ringkasan dari log
const stats = {
totalRequests: logs.length,
uniqueClients: new Set(logs.map(l => l.ip)).size,
totalBytes: logs.reduce((s, l) => s + (l.bytes || 0), 0),
success: logs.filter(l => l.status >= 200 && l.status < 300).length,
errors: logs.filter(l => l.status >= 400).length,
};
// Top file yang paling sering diakses
const fileCounts = {};
logs.forEach(l => {
fileCounts[l.filename] = (fileCounts[l.filename] || 0) + 1;
});
const topFiles = Object.entries(fileCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([name, count]) => ({ name, count }));
// Top client IP
const ipCounts = {};
logs.forEach(l => { ipCounts[l.ip] = (ipCounts[l.ip] || 0) + 1; });
const topClients = Object.entries(ipCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([ip, count]) => ({ ip, count }));
res.render('monitor', {
title: 'Traffic Monitor',
logs: logs.slice(0, 50),
bandwidth,
stats,
topFiles,
topClients,
nginxLog: NGINX_LOG,
});
});
// ─── API endpoint untuk polling realtime ─────────────────────────────────────
router.get('/api/bandwidth', isAuthenticated, (req, res) => {
res.json({ bandwidth: getBandwidth(), timestamp: Date.now() });
});
router.get('/api/logs', isAuthenticated, async (req, res) => {
const logs = await parseNginxLog(50);
const stats = {
totalRequests: logs.length,
uniqueClients: new Set(logs.map(l => l.ip)).size,
totalBytes: logs.reduce((s, l) => s + (l.bytes || 0), 0),
success: logs.filter(l => l.status >= 200 && l.status < 300).length,
errors: logs.filter(l => l.status >= 400).length,
};
res.json({ logs: logs.slice(0, 50), stats, timestamp: Date.now() });
});
module.exports = router;
\ No newline at end of file
<!DOCTYPE html>
<html lang="id">
<head><%- include('partials/head') %></head>
<body class="app-page">
<%- include('partials/nav') %>
<main class="main-content">
<div class="page-header">
<div>
<h1 class="page-title">TRAFFIC MONITOR</h1>
<p class="page-sub">Bandwidth & HTTP akses client PXE — <code class="path-inline"><%= nginxLog %></code></p>
</div>
<div class="header-time">
<span class="status-dot active pulse"></span>
<span>Live — update tiap 3 detik</span>
</div>
</div>
<%- include('partials/flash') %>
<!-- Stat Cards -->
<section class="section">
<h2 class="section-title">RINGKASAN (100 REQUEST TERAKHIR)</h2>
<div class="mon-stat-grid">
<div class="mon-stat-card">
<div class="msc-label">TOTAL REQUEST</div>
<div class="msc-value" id="stat-total"><%= stats.totalRequests || 0 %></div>
</div>
<div class="mon-stat-card">
<div class="msc-label">CLIENT UNIK</div>
<div class="msc-value accent-green" id="stat-clients"><%= stats.uniqueClients || 0 %></div>
</div>
<div class="mon-stat-card">
<div class="msc-label">DATA TERKIRIM</div>
<div class="msc-value accent-blue" id="stat-bytes">0 B</div>
</div>
<div class="mon-stat-card">
<div class="msc-label">SUKSES / ERROR</div>
<div class="msc-value">
<span class="accent-green" id="stat-ok"><%= stats.success || 0 %></span>
<span class="msc-sep">/</span>
<span class="accent-red" id="stat-err"><%= stats.errors || 0 %></span>
</div>
</div>
</div>
</section>
<!-- Bandwidth Chart -->
<section class="section">
<h2 class="section-title">BANDWIDTH REALTIME</h2>
<div class="bw-container">
<div class="bw-iface-selector" id="iface-selector">
<% if (bandwidth && Object.keys(bandwidth).length > 0) { %>
<% Object.keys(bandwidth).forEach(function(iface, i) { %>
<button class="bw-iface-btn <%= i === 0 ? 'active' : '' %>" data-iface="<%= iface %>">
<%= iface %>
</button>
<% }); %>
<% } else { %>
<span class="bw-no-iface">Mendeteksi interface...</span>
<% } %>
</div>
<div class="bw-stats-row">
<div class="bw-stat">
<span class="bw-stat-label">↓ RX</span>
<span class="bw-stat-val accent-green" id="bw-rx">0 bps</span>
</div>
<div class="bw-stat">
<span class="bw-stat-label">↑ TX</span>
<span class="bw-stat-val accent-blue" id="bw-tx">0 bps</span>
</div>
<div class="bw-stat">
<span class="bw-stat-label">TOTAL RX</span>
<span class="bw-stat-val" id="bw-total-rx"></span>
</div>
<div class="bw-stat">
<span class="bw-stat-label">TOTAL TX</span>
<span class="bw-stat-val" id="bw-total-tx"></span>
</div>
</div>
<div class="chart-wrap">
<canvas id="bw-chart"></canvas>
</div>
</div>
</section>
<!-- Top Files & Top Clients -->
<div class="two-col">
<section class="section">
<h2 class="section-title">TOP FILE DIAKSES</h2>
<div class="top-list" id="top-files">
<% if (topFiles && topFiles.length > 0) { %>
<% topFiles.forEach(function(f, i) { %>
<div class="top-item">
<span class="top-rank">#<%= i+1 %></span>
<span class="top-name"><%= f.name || '(root)' %></span>
<span class="top-count"><%= f.count %>x</span>
</div>
<% }); %>
<% } else { %>
<div class="empty-state">Belum ada data</div>
<% } %>
</div>
</section>
<section class="section">
<h2 class="section-title">TOP CLIENT IP</h2>
<div class="top-list" id="top-clients">
<% if (topClients && topClients.length > 0) { %>
<% topClients.forEach(function(c, i) { %>
<div class="top-item">
<span class="top-rank">#<%= i+1 %></span>
<span class="top-name mono"><%= c.ip %></span>
<span class="top-count"><%= c.count %>x</span>
</div>
<% }); %>
<% } else { %>
<div class="empty-state">Belum ada data</div>
<% } %>
</div>
</section>
</div>
<!-- Live Log Table -->
<section class="section">
<h2 class="section-title">LOG HTTP LIVE
<span class="log-filter-wrap">
<input type="text" id="log-filter" class="log-filter-input" placeholder="Filter IP / file...">
</span>
</h2>
<div class="log-table-wrap">
<table class="log-table">
<thead>
<tr>
<th>WAKTU</th>
<th>CLIENT IP</th>
<th>FILE</th>
<th>STATUS</th>
<th>UKURAN</th>
</tr>
</thead>
<tbody id="log-body">
<% if (logs && logs.length > 0) { %>
<% logs.forEach(function(log) { %>
<tr class="log-row status-<%= log.status >= 400 ? 'err' : 'ok' %>"
data-ip="<%= log.ip %>" data-file="<%= log.filename %>">
<td class="mono log-time"><%= log.time %></td>
<td class="mono log-ip"><%= log.ip %></td>
<td class="mono log-file" title="<%= log.path %>"><%= log.filename %></td>
<td><span class="status-badge status-<%= log.status >= 400 ? 'err' : 'ok' %>"><%= log.status %></span></td>
<td class="mono" data-bytes="<%= log.bytes %>"></td>
</tr>
<% }); %>
<% } else { %>
<tr><td colspan="5" class="empty-state">Tidak ada request ke /debian12/live/ ditemukan di log</td></tr>
<% } %>
</tbody>
</table>
</div>
</section>
</main>
<%- include('partials/footer') %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<script>
// Helpers
function fmtBytes(b) {
b = parseInt(b) || 0;
if (b === 0) return '0 B';
const k = 1024, s = ['B','KB','MB','GB','TB'];
const i = Math.floor(Math.log(b) / Math.log(k));
return (b / Math.pow(k, i)).toFixed(2) + ' ' + s[i];
}
function fmtBps(b) {
b = parseFloat(b) || 0;
if (b === 0) return '0 bps';
const k = 1000, s = ['bps','Kbps','Mbps','Gbps'];
const i = Math.floor(Math.log(Math.max(b, 1)) / Math.log(k));
return (b / Math.pow(k, i)).toFixed(1) + ' ' + s[i];
}
// Format ukuran di tabel saat load
document.querySelectorAll('td[data-bytes]').forEach(td => {
td.textContent = fmtBytes(td.dataset.bytes);
});
// Update stat bytes saat load
document.getElementById('stat-bytes').textContent = fmtBytes(<%= stats.totalBytes || 0 %>);
// Chart
const MAX_POINTS = 30;
const rxData = Array(MAX_POINTS).fill(0);
const txData = Array(MAX_POINTS).fill(0);
let activeIface = '';
const ctx = document.getElementById('bw-chart').getContext('2d');
const bwChart = new Chart(ctx, {
type: 'line',
data: {
labels: Array(MAX_POINTS).fill(''),
datasets: [
{
label: '↓ RX',
data: rxData,
borderColor: '#00ff9d',
backgroundColor: 'rgba(0,255,157,0.08)',
borderWidth: 2,
pointRadius: 0,
fill: true,
tension: 0.4,
},
{
label: '↑ TX',
data: txData,
borderColor: '#00d4ff',
backgroundColor: 'rgba(0,212,255,0.08)',
borderWidth: 2,
pointRadius: 0,
fill: true,
tension: 0.4,
}
]
},
options: {
animation: false,
responsive: true,
maintainAspectRatio: false,
scales: {
x: { display: false },
y: {
beginAtZero: true,
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: {
color: '#4a6478',
font: { family: 'Share Tech Mono', size: 10 },
callback: v => fmtBps(v)
}
}
},
plugins: {
legend: {
labels: { color: '#7a94aa', font: { family: 'Share Tech Mono', size: 11 } }
},
tooltip: {
callbacks: { label: c => fmtBps(c.raw) }
}
}
}
});
// Poll bandwidth
async function pollBandwidth() {
try {
const r = await fetch('/monitor/api/bandwidth');
if (!r.ok) return;
const d = await r.json();
const ifaces = Object.keys(d.bandwidth || {});
if (ifaces.length === 0) return;
// Auto-set activeIface pertama kali
if (!activeIface || !d.bandwidth[activeIface]) {
activeIface = ifaces[0];
// Update selector buttons
const sel = document.getElementById('iface-selector');
sel.innerHTML = ifaces.map((iface, i) =>
`<button class="bw-iface-btn ${i === 0 ? 'active' : ''}" data-iface="${iface}">${iface}</button>`
).join('');
sel.querySelectorAll('.bw-iface-btn').forEach(btn => {
btn.addEventListener('click', () => {
sel.querySelectorAll('.bw-iface-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeIface = btn.dataset.iface;
rxData.fill(0); txData.fill(0);
bwChart.update();
});
});
}
const iface = d.bandwidth[activeIface];
if (!iface) return;
rxData.push(iface.rxBps || 0); rxData.shift();
txData.push(iface.txBps || 0); txData.shift();
bwChart.update();
document.getElementById('bw-rx').textContent = fmtBps(iface.rxBps);
document.getElementById('bw-tx').textContent = fmtBps(iface.txBps);
document.getElementById('bw-total-rx').textContent = fmtBytes(iface.rxBytes);
document.getElementById('bw-total-tx').textContent = fmtBytes(iface.txBytes);
} catch(e) { console.error('Bandwidth poll error:', e); }
}
// Poll logs
async function pollLogs() {
try {
const r = await fetch('/monitor/api/logs');
if (!r.ok) return;
const d = await r.json();
document.getElementById('stat-total').textContent = d.stats.totalRequests || 0;
document.getElementById('stat-clients').textContent = d.stats.uniqueClients || 0;
document.getElementById('stat-bytes').textContent = fmtBytes(d.stats.totalBytes || 0);
document.getElementById('stat-ok').textContent = d.stats.success || 0;
document.getElementById('stat-err').textContent = d.stats.errors || 0;
const filter = document.getElementById('log-filter').value.toLowerCase();
const tbody = document.getElementById('log-body');
tbody.innerHTML = '';
const logs = d.logs || [];
if (logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">Tidak ada request ke /debian12/live/ di log</td></tr>';
return;
}
logs.forEach(log => {
if (filter && !log.ip.includes(filter) && !(log.filename || '').toLowerCase().includes(filter)) return;
const isErr = log.status >= 400;
const tr = document.createElement('tr');
tr.className = `log-row status-${isErr ? 'err' : 'ok'}`;
tr.innerHTML = `
<td class="mono log-time">${log.time || '—'}</td>
<td class="mono log-ip">${log.ip || '—'}</td>
<td class="mono log-file" title="${log.path || ''}">${log.filename || '—'}</td>
<td><span class="status-badge status-${isErr ? 'err' : 'ok'}">${log.status}</span></td>
<td class="mono">${fmtBytes(log.bytes)}</td>
`;
tbody.appendChild(tr);
});
} catch(e) { console.error('Log poll error:', e); }
}
// Filter
document.getElementById('log-filter').addEventListener('input', pollLogs);
// Clock
setInterval(() => {
document.getElementById('footer-clock').textContent = new Date().toLocaleString('id-ID');
}, 1000);
document.getElementById('footer-clock').textContent = new Date().toLocaleString('id-ID');
// Start
pollBandwidth();
pollLogs();
setInterval(pollBandwidth, 3000);
setInterval(pollLogs, 5000);
</script>
</body>
</html>
......@@ -19,6 +19,9 @@
<li><a href="/service/status" class="nav-link <%= title === 'Status Service' ? 'active' : '' %>">
<span class="nav-icon">◈</span> Services
</a></li>
<li><a href="/monitor" class="nav-link <%= title === 'Traffic Monitor' ? 'active' : '' %>">
<span class="nav-icon">◈</span> Monitor
</a></li>
</ul>
<div class="nav-user">
<span class="user-badge"><%= user.username %></span>
......
Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!