1206 lines
67 KiB
Plaintext
1206 lines
67 KiB
Plaintext
<!DOCTYPE html>
|
|
<html lang="<%= lang || 'id' %>">
|
|
<head>
|
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title><%= title %> | <%= company %></title>
|
|
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
|
|
<link rel="stylesheet" href="/css/admin.css">
|
|
<style>
|
|
/* ── ONU STATUS DOT ── */
|
|
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
|
|
.dot-on{background:var(--success);box-shadow:0 0 6px var(--success)}
|
|
.dot-off{background:var(--danger)}
|
|
|
|
/* ── PULSE ANIMATION (online) ── */
|
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
|
.pulse{animation:pulse 2s infinite}
|
|
|
|
/* ── MONITORING SECTION ── */
|
|
#monitoring-section{display:none}
|
|
#dashboard-section{display:block}
|
|
|
|
/* ── NAV TABS ── */
|
|
.tab-bar{display:flex;gap:4px;background:var(--bg3);padding:4px;border-radius:10px;margin-bottom:20px;width:fit-content}
|
|
.tab-btn{padding:8px 20px;border-radius:7px;border:none;background:transparent;color:var(--muted);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;transition:all .2s}
|
|
.tab-btn.active{background:var(--bg2);color:var(--text);box-shadow:0 2px 8px rgba(0,0,0,.3)}
|
|
.tab-btn i{margin-right:6px}
|
|
|
|
/* ── DEVICE STATUS BADGE ── */
|
|
.ds-on{background:rgba(63,185,80,.15);color:#3fb950;border:1px solid rgba(63,185,80,.3)}
|
|
.ds-off{background:rgba(248,81,73,.15);color:#f85149;border:1px solid rgba(248,81,73,.3)}
|
|
|
|
/* ── LIVE INDICATOR ── */
|
|
.live-dot{width:8px;height:8px;border-radius:50%;background:var(--success);display:inline-block;animation:pulse 1.5s infinite}
|
|
.realtime-bar{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}
|
|
|
|
/* ── CHART WRAPPER ── */
|
|
#donut-wrap{position:relative;width:160px;height:160px;margin:0 auto}
|
|
.donut-center{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center}
|
|
.donut-num{font-size:28px;font-weight:700;line-height:1}
|
|
.donut-lbl{font-size:11px;color:var(--muted);margin-top:2px}
|
|
.dash-sec-title{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin:0 0 10px;display:flex;align-items:center;gap:6px}
|
|
.dash-sec-title i{color:var(--primary)}
|
|
.dash-2col{display:grid;grid-template-columns:1.5fr 1fr;gap:18px;margin-bottom:18px}
|
|
@media(max-width:900px){.dash-2col{grid-template-columns:1fr}}
|
|
</style>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
</head>
|
|
<body>
|
|
<%- include('partials/sidebar', { activePage: activePage || 'dashboard', company }) %>
|
|
|
|
<div class="mw">
|
|
<!-- TOPBAR -->
|
|
<div class="topbar">
|
|
<div class="tb-title">
|
|
<button class="hb-menu" onclick="toggleSidebar()"><i class="bi bi-list"></i></button>
|
|
<i class="bi bi-speedometer2"></i>
|
|
<span id="page-title"><%= t('admin.nav.dashboard', 'Dashboard') %></span>
|
|
</div>
|
|
<div class="tb-right">
|
|
<div class="realtime-bar"><span class="live-dot"></span> <%= t('admin.dashboard.live_update', 'Live Update') %></div>
|
|
<span class="adm-badge">
|
|
<i class="bi bi-person-fill"></i>
|
|
<%= (typeof session !== 'undefined' && session && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %>
|
|
</span>
|
|
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i> <%= t('common.logout', 'Logout') %></a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="page">
|
|
<!-- TAB NAVIGATION -->
|
|
<div class="tab-bar">
|
|
<button class="tab-btn active" id="tab-dash" onclick="showTab('dashboard')">
|
|
<i class="bi bi-speedometer2"></i><%= t('admin.nav.dashboard', 'Dashboard') %>
|
|
</button>
|
|
<button class="tab-btn" id="tab-mon" onclick="showTab('monitoring')">
|
|
<i class="bi bi-router"></i><%= t('admin.dashboard.monitoring_onu', 'Monitoring ONU') %>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ═══════════════════════════════════════════════════════ -->
|
|
<!-- DASHBOARD SECTION -->
|
|
<!-- ═══════════════════════════════════════════════════════ -->
|
|
<div id="dashboard-section">
|
|
|
|
<!-- RINGKASAN PELANGGAN -->
|
|
<div class="dash-sec-title"><i class="bi bi-people-fill"></i> <%= t('admin.dashboard.customer_summary','Ringkasan Pelanggan') %></div>
|
|
<div class="stat-grid">
|
|
<div class="sc p" style="cursor:pointer" onclick="location.href='/admin/customers'">
|
|
<i class="bi bi-people-fill sc-icon"></i>
|
|
<div class="sc-label"><%= t('admin.dashboard.total_customers','Total Pelanggan') %></div>
|
|
<div class="sc-val"><%= custStats ? custStats.total : 0 %></div>
|
|
<div class="sc-sub"><%= t('admin.dashboard.registered','Terdaftar') %></div>
|
|
</div>
|
|
<div class="sc s" style="cursor:pointer" onclick="location.href='/admin/customers?status=active'">
|
|
<i class="bi bi-check-circle-fill sc-icon"></i>
|
|
<div class="sc-label"><%= t('admin.customers.filter.active','Aktif') %></div>
|
|
<div class="sc-val"><%= custStats ? custStats.active : 0 %></div>
|
|
<div class="sc-sub"><%= t('admin.dashboard.service_running','Layanan berjalan') %></div>
|
|
</div>
|
|
<div class="sc w" style="cursor:pointer" onclick="location.href='/admin/customers?status=suspended'">
|
|
<i class="bi bi-slash-circle-fill sc-icon"></i>
|
|
<div class="sc-label"><%= t('admin.dashboard.isolated','Isolir / Suspend') %></div>
|
|
<div class="sc-val"><%= custStats ? (custStats.suspended + custStats.ditangguhkan) : 0 %></div>
|
|
<div class="sc-sub"><%= t('admin.dashboard.needs_followup','Perlu tindak lanjut') %></div>
|
|
</div>
|
|
<div class="sc d" style="cursor:pointer" onclick="location.href='/admin/customers?status=inactive'">
|
|
<i class="bi bi-person-dash-fill sc-icon"></i>
|
|
<div class="sc-label"><%= t('admin.dashboard.inactive','Nonaktif / Pendaftar') %></div>
|
|
<div class="sc-val"><%= custStats ? custStats.inactive : 0 %></div>
|
|
<div class="sc-sub"><%= t('admin.dashboard.not_active_yet','Belum aktif') %></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- RINGKASAN BILLING -->
|
|
<div class="dash-sec-title"><i class="bi bi-receipt"></i> <%= t('admin.dashboard.billing_summary','Ringkasan Billing') %></div>
|
|
<% if (typeof billing !== 'undefined' && billing) { %>
|
|
<div class="stat-grid">
|
|
<div class="sc s" style="cursor:pointer" onclick="location.href='/admin/billing'">
|
|
<i class="bi bi-cash-stack sc-icon"></i>
|
|
<div class="sc-label"><%= t('admin.dashboard.revenue_month','Pendapatan Bulan Ini') %></div>
|
|
<div class="sc-val" style="font-size:20px">Rp <%= billing.thisMonth.toLocaleString('id-ID') %></div>
|
|
<div class="sc-sub"><%= t('admin.dashboard.collected','Sudah terkumpul') %></div>
|
|
</div>
|
|
<div class="sc i" style="cursor:pointer" onclick="location.href='/admin/billing'">
|
|
<i class="bi bi-graph-up-arrow sc-icon"></i>
|
|
<div class="sc-label"><%= t('admin.dashboard.revenue_today','Pendapatan Hari Ini') %></div>
|
|
<div class="sc-val" style="font-size:20px">Rp <%= ((typeof todayRevenue!=='undefined' && todayRevenue && todayRevenue.total) || 0).toLocaleString('id-ID') %></div>
|
|
<div class="sc-sub"><%= t('admin.dashboard.transactions','Transaksi') %>: <%= (typeof todayRevenue!=='undefined' && todayRevenue && todayRevenue.count) || 0 %></div>
|
|
</div>
|
|
<div class="sc d" style="cursor:pointer" onclick="location.href='/admin/billing?status=unpaid'">
|
|
<i class="bi bi-hourglass-split sc-icon"></i>
|
|
<div class="sc-label"><%= t('admin.dashboard.unpaid_invoices','Tagihan Belum Bayar') %></div>
|
|
<div class="sc-val"><%= billing.unpaidCount %></div>
|
|
<div class="sc-sub"><%= t('admin.dashboard.awaiting_payment','Menunggu pembayaran') %></div>
|
|
</div>
|
|
<div class="sc w" style="cursor:pointer" onclick="location.href='/admin/reports'">
|
|
<i class="bi bi-wallet2 sc-icon"></i>
|
|
<div class="sc-label"><%= t('admin.dashboard.total_receivable','Total Piutang') %></div>
|
|
<div class="sc-val" style="font-size:20px">Rp <%= billing.pendingAmount.toLocaleString('id-ID') %></div>
|
|
<div class="sc-sub"><%= t('admin.dashboard.not_collected','Belum tertagih') %></div>
|
|
</div>
|
|
</div>
|
|
<% } %>
|
|
|
|
<!-- GRAFIK PENDAPATAN + AKSI CEPAT -->
|
|
<div class="dash-2col">
|
|
<div class="card" style="margin:0">
|
|
<div class="card-hd"><h6><i class="bi bi-bar-chart-line"></i> <%= t('admin.dashboard.revenue_chart','Pendapatan per Bulan') %> <span style="color:var(--muted);font-weight:500"><%= (typeof chartYear!=='undefined' ? chartYear : new Date().getFullYear()) %></span></h6></div>
|
|
<div class="card-bd" style="height:260px"><canvas id="revenueChart"></canvas></div>
|
|
</div>
|
|
<div class="card" style="margin:0">
|
|
<div class="card-hd"><h6><i class="bi bi-lightning-charge"></i> <%= t('admin.dashboard.quick_actions','Aksi Cepat') %></h6></div>
|
|
<div class="card-bd quick-actions-grid">
|
|
<a href="/admin/customers" class="btn btn-g" style="flex-direction:column;gap:5px;padding:14px 8px;text-align:center"><i class="bi bi-person-plus" style="font-size:20px;color:var(--primary)"></i><span style="font-size:11.5px"><%= t('admin.dashboard.qa_add_customer','Tambah Pelanggan') %></span></a>
|
|
<a href="/admin/billing" class="btn btn-g" style="flex-direction:column;gap:5px;padding:14px 8px;text-align:center"><i class="bi bi-receipt" style="font-size:20px;color:var(--warning)"></i><span style="font-size:11.5px"><%= t('admin.dashboard.qa_create_invoice','Buat Tagihan') %></span></a>
|
|
<a href="/admin/packages" class="btn btn-g" style="flex-direction:column;gap:5px;padding:14px 8px;text-align:center"><i class="bi bi-box-seam" style="font-size:20px;color:var(--success)"></i><span style="font-size:11.5px"><%= t('admin.dashboard.qa_manage_packages','Kelola Paket') %></span></a>
|
|
<a href="/admin/whatsapp/broadcast" class="btn btn-g" style="flex-direction:column;gap:5px;padding:14px 8px;text-align:center"><i class="bi bi-megaphone" style="font-size:20px;color:var(--info)"></i><span style="font-size:11.5px"><%= t('admin.dashboard.qa_broadcast','Broadcast WA') %></span></a>
|
|
<a href="/admin/reports" class="btn btn-g" style="flex-direction:column;gap:5px;padding:14px 8px;text-align:center"><i class="bi bi-bar-chart-line" style="font-size:20px;color:var(--primary)"></i><span style="font-size:11.5px"><%= t('admin.dashboard.qa_reports','Laporan') %></span></a>
|
|
<button class="btn btn-g" onclick="showTab('monitoring')" style="flex-direction:column;gap:5px;padding:14px 8px;text-align:center"><i class="bi bi-router" style="font-size:20px;color:var(--danger)"></i><span style="font-size:11.5px"><%= t('admin.dashboard.qa_monitor_onu','Monitor ONU') %></span></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- STATUS JARINGAN (ONU) -->
|
|
<div class="dash-sec-title"><i class="bi bi-router"></i> <%= t('admin.dashboard.network_status','Status Jaringan (ONU)') %></div>
|
|
<div class="stat-grid">
|
|
<div class="sc p">
|
|
<i class="bi bi-router sc-icon"></i>
|
|
<div class="sc-label"><%= t('admin.dashboard.total_onu','Total ONU') %></div>
|
|
<div class="sc-val" id="stat-total"><span class="text-muted" style="font-size:16px">...</span></div>
|
|
<div class="sc-sub"><%= t('admin.dashboard.onu_registered','Perangkat terdaftar') %></div>
|
|
</div>
|
|
<div class="sc s">
|
|
<i class="bi bi-wifi sc-icon"></i>
|
|
<div class="sc-label"><%= t('admin.dashboard.online','Online') %></div>
|
|
<div class="sc-val" id="stat-online"><span class="text-muted" style="font-size:16px">...</span></div>
|
|
<div class="sc-sub"><%= t('admin.dashboard.active_now','Aktif saat ini') %></div>
|
|
</div>
|
|
<div class="sc d">
|
|
<i class="bi bi-wifi-off sc-icon"></i>
|
|
<div class="sc-label"><%= t('admin.dashboard.offline','Offline') %></div>
|
|
<div class="sc-val" id="stat-offline"><span class="text-muted" style="font-size:16px">...</span></div>
|
|
<div class="sc-sub"><%= t('admin.dashboard.no_response','Tidak merespons') %></div>
|
|
</div>
|
|
<div class="sc w">
|
|
<i class="bi bi-exclamation-triangle sc-icon"></i>
|
|
<div class="sc-label"><%= t('admin.dashboard.warning','Warning') %></div>
|
|
<div class="sc-val" id="stat-warning"><span class="text-muted" style="font-size:16px">...</span></div>
|
|
<div class="sc-sub"><%= t('admin.dashboard.needs_attention','Perlu perhatian') %></div>
|
|
</div>
|
|
</div>
|
|
<div class="dash-main-grid">
|
|
<div class="card" style="margin:0">
|
|
<div class="card-hd"><h6><i class="bi bi-pie-chart"></i> <%= t('admin.dashboard.onu_status_title','Status ONU') %></h6></div>
|
|
<div class="card-bd" style="display:flex;flex-direction:column;align-items:center;gap:16px">
|
|
<div id="donut-wrap">
|
|
<canvas id="donutChart"></canvas>
|
|
<div class="donut-center">
|
|
<div class="donut-num" id="donut-pct">-</div>
|
|
<div class="donut-lbl"><%= t('admin.dashboard.online','Online') %></div>
|
|
</div>
|
|
</div>
|
|
<div style="width:100%;display:flex;flex-direction:column;gap:8px">
|
|
<div style="display:flex;justify-content:space-between;font-size:12px">
|
|
<span><span class="dot dot-on"></span><%= t('admin.dashboard.online','Online') %></span>
|
|
<span id="leg-online" class="fw6" style="color:var(--success)">-</span>
|
|
</div>
|
|
<div style="display:flex;justify-content:space-between;font-size:12px">
|
|
<span><span class="dot dot-off"></span><%= t('admin.dashboard.offline','Offline') %></span>
|
|
<span id="leg-offline" class="fw6" style="color:var(--danger)">-</span>
|
|
</div>
|
|
</div>
|
|
<div style="font-size:11px;color:var(--muted);text-align:center" id="last-update-time">—</div>
|
|
</div>
|
|
</div>
|
|
<div class="card" style="margin:0">
|
|
<div class="card-hd">
|
|
<h6><i class="bi bi-exclamation-circle"></i> <%= t('admin.dashboard.latest_offline','ONU Offline Terbaru') %></h6>
|
|
<button class="btn btn-g btn-sm" onclick="showTab('monitoring')"><i class="bi bi-arrow-right"></i> <%= t('common.view_all','Lihat Semua') %></button>
|
|
</div>
|
|
<div id="offline-list" style="padding:0">
|
|
<div class="text-center text-muted" style="padding:24px;font-size:13px"><%= t('common.loading','Memuat data...') %></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- MONITORING ONU SECTION -->
|
|
<!-- ═══════════════════════════════════════════════════════ -->
|
|
<div id="monitoring-section" style="display:none">
|
|
|
|
<!-- Stats mini -->
|
|
<div class="stat-grid" style="margin-bottom:18px">
|
|
<div class="sc p"><i class="bi bi-router sc-icon"></i><div class="sc-label"><%= t('admin.dashboard.total_onu', 'Total ONU') %></div><div class="sc-val" id="m-total">-</div></div>
|
|
<div class="sc s"><i class="bi bi-wifi sc-icon"></i><div class="sc-label"><%= t('admin.dashboard.online', 'Online') %></div><div class="sc-val" id="m-online">-</div></div>
|
|
<div class="sc d"><i class="bi bi-wifi-off sc-icon"></i><div class="sc-label"><%= t('admin.dashboard.offline', 'Offline') %></div><div class="sc-val" id="m-offline">-</div></div>
|
|
<div class="sc i"><i class="bi bi-percent sc-icon"></i><div class="sc-label"><%= t('admin.dashboard.uptime', 'Uptime') %></div><div class="sc-val" id="m-pct">-%</div></div>
|
|
</div>
|
|
|
|
<!-- Search & Filter -->
|
|
<div class="srow" style="margin-bottom:16px">
|
|
<div class="sinput" style="max-width:320px">
|
|
<i class="bi bi-search"></i>
|
|
<input class="fc" id="search-device" placeholder="<%= t('admin.dashboard.search_placeholder', 'Cari tag, SN, atau username pppoe...') %>">
|
|
</div>
|
|
<select class="fs" id="filter-status" style="width:150px" onchange="loadDevices()">
|
|
<option value="all"><%= t('common.all_status', 'Semua Status') %></option>
|
|
<option value="online"><%= t('admin.dashboard.online', 'Online') %></option>
|
|
<option value="offline"><%= t('admin.dashboard.offline', 'Offline') %></option>
|
|
</select>
|
|
<button class="btn btn-g btn-sm" onclick="openGenieACSSettings()" title="<%= t('admin.dash.genieacs_title','Pengaturan GenieACS') %>">
|
|
<i class="bi bi-gear"></i> GenieACS
|
|
</button>
|
|
<button class="btn btn-p btn-sm" id="btn-refresh" onclick="refreshAll()">
|
|
<i class="bi bi-arrow-clockwise"></i> <%= t('common.refresh', 'Refresh') %>
|
|
</button>
|
|
<div class="realtime-bar" style="margin-left:auto">
|
|
<span class="live-dot"></span> <%= t('admin.dashboard.realtime', 'Realtime Monitoring') %>
|
|
</div>
|
|
<div class="realtime-bar">
|
|
<i class="bi bi-arrow-repeat"></i> <%= t('admin.dash.last_tr069','TR-069 terakhir:') %> <strong id="monitor-sync-time">-</strong>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bulk Actions Panel -->
|
|
<div id="monBulkActionsPanel" style="display: none; background: rgba(59,130,246,0.05); border: 1px solid rgba(59,130,246,0.15); padding: 12px 20px; border-radius: 12px; margin-bottom: 16px; align-items: center; justify-content: space-between;">
|
|
<div style="font-size: 13px; font-weight: 600; color: var(--text);">
|
|
<span id="monSelectedCount">0</span> perangkat terpilih
|
|
</div>
|
|
<div style="display: flex; gap: 8px;">
|
|
<button class="btn btn-p btn-sm" onclick="bulkMonSummon()" style="background: var(--primary);"><i class="bi bi-lightning-charge"></i> <%= t('admin.dash.summon_selected','Summon Terpilih') %></button>
|
|
<button class="btn btn-d btn-sm" onclick="bulkMonDelete()" style="background: var(--danger);"><i class="bi bi-trash"></i> <%= t('admin.dash.del_selected','Hapus Terpilih') %></button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Device Table -->
|
|
<div class="card">
|
|
<div class="card-hd">
|
|
<h6><i class="bi bi-list-ul"></i> <%= t('admin.dashboard.onu_device_list', 'Daftar Perangkat ONU') %></h6>
|
|
<span id="device-count" class="badge bm"><%= t('common.loading_short', 'memuat...') %></span>
|
|
</div>
|
|
<div id="loading-devices" style="text-align:center;padding:32px;display:none">
|
|
<div style="display:inline-block;width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--primary);border-radius:50%;animation:spin .8s linear infinite"></div>
|
|
<div class="text-muted" style="margin-top:10px;font-size:13px"><%= t('admin.dashboard.loading_onu', 'Memuat data ONU...') %></div>
|
|
</div>
|
|
<div class="tbl-wrap">
|
|
<table class="dtbl">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 40px; text-align: center;"><input type="checkbox" id="selectAllMonDevices" onchange="toggleSelectAllMon(this)"></th>
|
|
<th>#</th>
|
|
<th><%= t('admin.dashboard.table.tag_customer', 'Tag / Pelanggan') %></th>
|
|
<th><%= t('admin.dashboard.table.device', 'Perangkat') %></th>
|
|
<th><%= t('admin.dashboard.table.network', 'Jaringan') %></th>
|
|
<th><%= t('admin.dashboard.table.status_info', 'Status / Info') %></th>
|
|
<th><%= t('admin.dashboard.table.actions', 'Aksi') %></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="devices-tbody">
|
|
<tr><td colspan="7" class="text-center text-muted" style="padding:32px"><%= t('admin.dashboard.loading_devices', 'Memuat perangkat...') %></td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── MODAL DETAIL DEVICE ── -->
|
|
<div class="mo" id="deviceModal">
|
|
<div class="mb" style="max-width:920px">
|
|
<div class="mh">
|
|
<span class="mt"><i class="bi bi-router"></i> <%= t('admin.dashboard.device_detail', 'Detail Perangkat') %></span>
|
|
<button class="mc" onclick="closeModal('deviceModal')">×</button>
|
|
</div>
|
|
<div class="mbody" id="device-detail" style="max-height:72vh;overflow:auto"><div class="text-muted text-center" style="padding:20px"><%= t('common.loading_short', 'Memuat...') %></div></div>
|
|
<div class="mf"><button class="btn btn-g" onclick="closeModal('deviceModal')"><%= t('common.close', 'Tutup') %></button></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── MODAL EDIT SSID ── -->
|
|
<div class="mo" id="ssidModal">
|
|
<div class="mb">
|
|
<div class="mh"><span class="mt"><i class="bi bi-wifi"></i> <%= t('admin.dashboard.edit_ssid', 'Ubah SSID') %></span><button class="mc" onclick="closeModal('ssidModal')">×</button></div>
|
|
<div class="mbody">
|
|
<input type="hidden" id="edit-tag">
|
|
<div class="fg"><label class="fl"><%= t('admin.dashboard.new_ssid', 'SSID Baru') %></label><input class="fc" id="new-ssid" placeholder="<%= t('admin.dashboard.new_ssid_placeholder', 'Nama WiFi baru') %>"></div>
|
|
</div>
|
|
<div class="mf">
|
|
<button class="btn btn-g" onclick="closeModal('ssidModal')"><%= t('common.cancel', 'Batal') %></button>
|
|
<button class="btn btn-p" id="btn-save-ssid">
|
|
<span class="btn-text"><i class="bi bi-check-lg"></i> <%= t('common.save', 'Simpan') %></span>
|
|
<span class="btn-loader" style="display:none"><i class="bi bi-arrow-clockwise spin"></i> <%= t('common.processing', 'Memproses...') %></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── MODAL EDIT PASSWORD ── -->
|
|
<div class="mo" id="passwordModal">
|
|
<div class="mb">
|
|
<div class="mh"><span class="mt"><i class="bi bi-key"></i> <%= t('admin.dashboard.edit_wifi_password', 'Ubah Password WiFi') %></span><button class="mc" onclick="closeModal('passwordModal')">×</button></div>
|
|
<div class="mbody">
|
|
<input type="hidden" id="edit-pass-tag">
|
|
<div class="fg"><label class="fl"><%= t('admin.dashboard.new_password', 'Password Baru') %></label><input class="fc" id="new-password" type="text" placeholder="<%= t('admin.dashboard.new_password_placeholder', 'Minimal 8 karakter') %>"></div>
|
|
</div>
|
|
<div class="mf">
|
|
<button class="btn btn-g" onclick="closeModal('passwordModal')"><%= t('common.cancel', 'Batal') %></button>
|
|
<button class="btn btn-s" id="btn-save-password">
|
|
<span class="btn-text"><i class="bi bi-check-lg"></i> <%= t('common.save', 'Simpan') %></span>
|
|
<span class="btn-loader" style="display:none"><i class="bi bi-arrow-clockwise spin"></i> <%= t('common.processing', 'Memproses...') %></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── MODAL GENIEACS SETTINGS ── -->
|
|
<div class="mo" id="genieacsModal">
|
|
<div class="mb" style="max-width:600px">
|
|
<div class="mh">
|
|
<span class="mt"><i class="bi bi-gear"></i> <%= t('admin.dash.acs_settings','Pengaturan GenieACS') %></span>
|
|
<button class="mc" onclick="closeModal('genieacsModal')">×</button>
|
|
</div>
|
|
<div class="mbody">
|
|
<form id="genieacsSettingsForm">
|
|
<div class="fg">
|
|
<label class="fl">Timeout (ms)</label>
|
|
<input type="number" class="fc" id="genieacs_timeout" name="genieacs_timeout"
|
|
value="<%= typeof settings !== 'undefined' && settings.genieacs_timeout ? settings.genieacs_timeout : 30000 %>"
|
|
min="5000" max="120000" step="1000">
|
|
<small class="text-muted" style="font-size:11px;display:block;margin-top:4px"><%= t('admin.dash.timeout_hint','Timeout untuk request ke GenieACS (5000-120000 ms)') %></small>
|
|
</div>
|
|
|
|
<div class="fg">
|
|
<label class="fl">RX Power Threshold (dBm)</label>
|
|
<input type="number" class="fc" id="genieacs_rxpower_threshold" name="genieacs_rxpower_threshold"
|
|
value="<%= typeof settings !== 'undefined' && settings.genieacs_rxpower_threshold ? settings.genieacs_rxpower_threshold : -27 %>"
|
|
min="-40" max="-15" step="0.5">
|
|
<small class="text-muted" style="font-size:11px;display:block;margin-top:4px"><%= t('admin.dash.threshold_hint','Threshold untuk warning RX Power (-40 sampai -15 dBm)') %></small>
|
|
</div>
|
|
|
|
<div class="fg">
|
|
<label class="fl"><%= t('admin.dash.interval_hours','Monitoring Interval (jam)') %></label>
|
|
<input type="number" class="fc" id="genieacs_monitoring_interval" name="genieacs_monitoring_interval"
|
|
value="<%= typeof settings !== 'undefined' && settings.genieacs_monitoring_interval ? settings.genieacs_monitoring_interval : 6 %>"
|
|
min="1" max="24" step="1">
|
|
<small class="text-muted" style="font-size:11px;display:block;margin-top:4px"><%= t('admin.dash.interval_label','Interval monitoring otomatis (1-24 jam)') %></small>
|
|
</div>
|
|
|
|
<div class="fg">
|
|
<label class="fl">Monitoring Enabled</label>
|
|
<div style="display:flex;align-items:center;gap:10px;margin-top:8px">
|
|
<input type="checkbox" id="genieacs_monitoring_enabled" name="genieacs_monitoring_enabled"
|
|
style="width:40px;height:20px;cursor:pointer"
|
|
<%= typeof settings !== 'undefined' && settings.genieacs_monitoring_enabled !== false ? 'checked' : '' %>>
|
|
<label for="genieacs_monitoring_enabled" style="cursor:pointer;font-size:13px;margin:0">
|
|
<%= t('admin.dash.enable_auto','Aktifkan monitoring otomatis RX Power & Offline Devices') %>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="genieacsMessage" style="display:none;margin-top:16px;padding:12px;border-radius:6px;font-size:13px"></div>
|
|
</form>
|
|
</div>
|
|
<div class="mf">
|
|
<button class="btn btn-g" onclick="testGenieACSConnection()">
|
|
<i class="bi bi-wifi"></i> <%= t('admin.dash.test_conn','Test Koneksi') %>
|
|
</button>
|
|
<button class="btn btn-p" onclick="saveGenieACSSettings()">
|
|
<i class="bi bi-save"></i> Simpan
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
</style>
|
|
|
|
<script>
|
|
/*
|
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
* DASHBOARD ONU MONITORING - AUTO-REFRESH DISABLED
|
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
*
|
|
* AUTO-REFRESH INTENTIONALLY DISABLED TO PREVENT CONTINUOUS POLLING
|
|
*
|
|
* Previously, this dashboard would refresh automatically:
|
|
* - Every 30 seconds (setInterval at the bottom)
|
|
* - Every 5 seconds during sync operations (scheduleFastRefreshIfNeeded)
|
|
*
|
|
* All auto-refresh mechanisms have been disabled. The dashboard will:
|
|
* - Load data once when the page is first opened
|
|
* - Only refresh when the user manually clicks the "Refresh" button
|
|
* - NOT refresh automatically during operations
|
|
*
|
|
* If you need to update the data, click the "Refresh" button in the UI.
|
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
*/
|
|
|
|
// ── STATE ──────────────────────────────────────────────────────
|
|
let devicesData = [];
|
|
let donutChart = null;
|
|
let fastRefreshTimer = null;
|
|
let autoRefreshDisabled = true; // Disable all auto-refresh mechanisms
|
|
|
|
function escHtml(s) {
|
|
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
}
|
|
const I18N = {
|
|
pageDashboard: "<%= t('admin.nav.dashboard', 'Dashboard') %>",
|
|
pageMonitoring: "<%= t('admin.dashboard.monitoring_onu', 'Monitoring ONU') %>",
|
|
online: "<%= t('admin.dashboard.online', 'Online') %>",
|
|
offline: "<%= t('admin.dashboard.offline', 'Offline') %>",
|
|
allStatus: "<%= t('common.all_status', 'Semua Status') %>",
|
|
refresh: "<%= t('common.refresh', 'Refresh') %>",
|
|
devicesSuffix: " <%= t('admin.dashboard.devices_suffix', 'perangkat') %>",
|
|
loading: "<%= t('common.loading_short', 'Memuat...') %>",
|
|
loadingData: "<%= t('common.loading', 'Memuat data...') %>",
|
|
loadingOnu: "<%= t('admin.dashboard.loading_onu', 'Memuat data ONU...') %>",
|
|
loadingDevices: "<%= t('admin.dashboard.loading_devices', 'Memuat perangkat...') %>",
|
|
updatePrefix: "<%= t('admin.dashboard.update_prefix', 'Update:') %>",
|
|
allOnuOnline: "<%= t('admin.dashboard.all_onu_online', 'Semua ONU online!') %>",
|
|
never: "<%= t('admin.dashboard.never', 'Tidak pernah') %>",
|
|
secondsAgo: "<%= t('admin.dashboard.seconds_ago', 'detik lalu') %>",
|
|
minutesAgo: "<%= t('admin.dashboard.minutes_ago', 'menit lalu') %>",
|
|
hoursAgo: "<%= t('admin.dashboard.hours_ago', 'jam lalu') %>",
|
|
daysAgo: "<%= t('admin.dashboard.days_ago', 'hari lalu') %>",
|
|
rebootConfirmPrefix: "<%= t('admin.dashboard.reboot_confirm_prefix', 'Yakin reboot ONU:') %>",
|
|
rebootSent: "<%= t('admin.dashboard.reboot_sent', 'Reboot terkirim') %>",
|
|
failed: "<%= t('common.failed', 'Gagal') %>",
|
|
success: "<%= t('common.success', 'Berhasil') %>",
|
|
errorLoadData: "<%= t('admin.dashboard.load_failed', 'Gagal memuat data') %>",
|
|
noDevicesFound: "<%= t('admin.dashboard.no_devices_found', 'Tidak ada perangkat ditemukan') %>",
|
|
onuDevice: "<%= t('admin.dashboard.onu_device_fallback', 'ONU Device') %>",
|
|
noIp: "<%= t('admin.dashboard.no_ip', 'No IP') %>",
|
|
connectedSuffix: "<%= t('admin.dashboard.connected_suffix', 'dev tersambung') %>",
|
|
titleDetail: "<%= t('common.details', 'Detail') %>",
|
|
titleEditSsid: "<%= t('admin.dashboard.edit_ssid', 'Ubah SSID') %>",
|
|
titleEditPassword: "<%= t('admin.dashboard.edit_password', 'Ubah Password') %>",
|
|
titleReboot: "<%= t('admin.dashboard.reboot', 'Reboot') %>",
|
|
ssidRequired: "<%= t('admin.dashboard.ssid_required', 'SSID tidak boleh kosong') %>",
|
|
ssidSent: "<%= t('admin.dashboard.ssid_sent', 'Perubahan SSID telah dikirim ke perangkat.') %>",
|
|
ssidFailed: "<%= t('admin.dashboard.ssid_failed', 'Gagal mengubah SSID.') %>",
|
|
passwordMin: "<%= t('admin.dashboard.password_min', 'Password minimal 8 karakter') %>",
|
|
passwordSent: "<%= t('admin.dashboard.password_sent', 'Perubahan Password telah dikirim ke perangkat.') %>",
|
|
passwordFailed: "<%= t('admin.dashboard.password_failed', 'Gagal mengubah password.') %>",
|
|
connectionError: "<%= t('admin.dashboard.connection_error', 'Terjadi kesalahan koneksi.') %>",
|
|
connectedDevicesTitlePrefix: "<%= t('admin.dashboard.connected_devices', 'Perangkat Terhubung') %>",
|
|
noConnectedDevices: "<%= t('admin.dashboard.no_connected_devices', 'Tidak ada perangkat terhubung') %>",
|
|
detailLoadFailed: "<%= t('admin.dashboard.detail_load_failed', 'Gagal memuat detail') %>",
|
|
offlineBadge: "<%= t('admin.dashboard.offline', 'Offline') %>",
|
|
lastInform: "Last Inform",
|
|
lastSync: "Last Sync",
|
|
syncNow: "Poll TR-069 Sekarang",
|
|
syncRunning: "Sinkronisasi berjalan"
|
|
};
|
|
|
|
// ── TABS ───────────────────────────────────────────────────────
|
|
function showTab(tab) {
|
|
const isDash = tab === 'dashboard';
|
|
document.getElementById('dashboard-section').style.display = isDash ? 'block' : 'none';
|
|
document.getElementById('monitoring-section').style.display = isDash ? 'none' : 'block';
|
|
document.getElementById('tab-dash').classList.toggle('active', isDash);
|
|
document.getElementById('tab-mon').classList.toggle('active', !isDash);
|
|
document.getElementById('page-title').textContent = isDash ? I18N.pageDashboard : I18N.pageMonitoring;
|
|
if (!isDash) loadDevices();
|
|
}
|
|
|
|
// ── MODAL HELPERS ──────────────────────────────────────────────
|
|
function openModal(id) { document.getElementById(id).classList.add('show'); }
|
|
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
|
|
document.querySelectorAll('.mo').forEach(m => m.addEventListener('click', e => { if (e.target === m) m.classList.remove('show'); }));
|
|
|
|
// ── LOAD STATS ─────────────────────────────────────────────────
|
|
async function loadStats() {
|
|
try {
|
|
const res = await fetch('/admin/api/stats');
|
|
const d = await res.json();
|
|
const total = d.total || 0;
|
|
const online = d.online || 0;
|
|
const offline = d.offline || 0;
|
|
const pct = total > 0 ? Math.round(online / total * 100) : 0;
|
|
|
|
// Dashboard stats
|
|
document.getElementById('stat-total').textContent = total;
|
|
document.getElementById('stat-online').textContent = online;
|
|
document.getElementById('stat-offline').textContent = offline;
|
|
document.getElementById('stat-warning').textContent = d.warning || 0;
|
|
|
|
// Monitoring mini stats
|
|
document.getElementById('m-total').textContent = total;
|
|
document.getElementById('m-online').textContent = online;
|
|
document.getElementById('m-offline').textContent= offline;
|
|
document.getElementById('m-pct').textContent = pct + '%';
|
|
|
|
// Donut
|
|
document.getElementById('donut-pct').textContent = pct + '%';
|
|
document.getElementById('leg-online').textContent = online;
|
|
document.getElementById('leg-offline').textContent = offline;
|
|
document.getElementById('last-update-time').textContent = I18N.updatePrefix + ' ' + new Date().toLocaleTimeString('id-ID', { timeZone: '<%= settings.timezone %>' });
|
|
|
|
updateDonut(online, offline);
|
|
} catch(e) { console.error('Stats error:', e); }
|
|
}
|
|
|
|
// ── DONUT CHART ────────────────────────────────────────────────
|
|
function updateDonut(online, offline) {
|
|
const ctx = document.getElementById('donutChart');
|
|
if (donutChart) { donutChart.data.datasets[0].data = [online, offline]; donutChart.update(); return; }
|
|
donutChart = new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
datasets: [{
|
|
data: [online, offline],
|
|
backgroundColor: ['#3fb950', '#f85149'],
|
|
borderWidth: 0,
|
|
hoverOffset: 4
|
|
}]
|
|
},
|
|
options: {
|
|
cutout: '72%',
|
|
plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => ctx.label + ': ' + ctx.raw } } },
|
|
animation: { animateRotate: true, duration: 600 }
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── LOAD DEVICES ───────────────────────────────────────────────
|
|
async function loadDevices() {
|
|
const tbody = document.getElementById('devices-tbody');
|
|
const loading = document.getElementById('loading-devices');
|
|
loading.style.display = 'block';
|
|
tbody.innerHTML = '';
|
|
|
|
try {
|
|
const search = document.getElementById('search-device')?.value || '';
|
|
const status = document.getElementById('filter-status')?.value || 'all';
|
|
const params = new URLSearchParams();
|
|
if (search) params.set('search', search);
|
|
if (status && status !== 'all') params.set('status', status);
|
|
let url = '/admin/api/devices';
|
|
const qs = params.toString();
|
|
if (qs) url += '?' + qs;
|
|
|
|
const res = await fetch(url);
|
|
const data = await res.json();
|
|
if (data && data.error) throw new Error(data.error);
|
|
devicesData = data.devices || [];
|
|
|
|
if (!search && status === 'all') {
|
|
const tot = data.total || devicesData.length;
|
|
let on = 0, off = 0;
|
|
devicesData.forEach(d => {
|
|
if (d.status === 'online') on++;
|
|
else off++;
|
|
});
|
|
const totalEl = document.getElementById('stat-total');
|
|
const onlineEl = document.getElementById('stat-online');
|
|
const offlineEl = document.getElementById('stat-offline');
|
|
if (totalEl) totalEl.textContent = tot;
|
|
if (onlineEl) onlineEl.textContent = on;
|
|
if (offlineEl) offlineEl.textContent = off;
|
|
|
|
const mTot = document.getElementById('m-total');
|
|
const mOn = document.getElementById('m-online');
|
|
const mOff = document.getElementById('m-offline');
|
|
const mPct = document.getElementById('m-pct');
|
|
if (mTot) mTot.textContent = tot;
|
|
if (mOn) mOn.textContent = on;
|
|
if (mOff) mOff.textContent = off;
|
|
if (mPct) mPct.textContent = (tot > 0 ? Math.round(on / tot * 100) : 0) + '%';
|
|
|
|
const legOn = document.getElementById('leg-online');
|
|
const legOff = document.getElementById('leg-offline');
|
|
const donutPct = document.getElementById('donut-pct');
|
|
if (legOn) legOn.textContent = on;
|
|
if (legOff) legOff.textContent = off;
|
|
if (donutPct) donutPct.textContent = (tot > 0 ? Math.round(on / tot * 100) : 0) + '%';
|
|
updateDonut(on, off);
|
|
}
|
|
|
|
document.getElementById('device-count').textContent = devicesData.length + I18N.devicesSuffix;
|
|
|
|
if (devicesData.length === 0) {
|
|
const monitorSyncEl = document.getElementById('monitor-sync-time');
|
|
if (monitorSyncEl) monitorSyncEl.textContent = '-';
|
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted" style="padding:32px">' + I18N.noDevicesFound + '</td></tr>';
|
|
} else {
|
|
const latestSyncRaw = devicesData
|
|
.map(d => d.lastSync || d.lastInform || '')
|
|
.filter(Boolean)
|
|
.sort()
|
|
.reverse()[0];
|
|
const monitorSyncEl = document.getElementById('monitor-sync-time');
|
|
if (monitorSyncEl) {
|
|
monitorSyncEl.textContent = latestSyncRaw
|
|
? new Date(latestSyncRaw).toLocaleString('id-ID', { day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit' })
|
|
: '-';
|
|
}
|
|
const lastUpdateEl = document.getElementById('last-update-time');
|
|
if (lastUpdateEl) {
|
|
const uiTime = new Date().toLocaleTimeString('id-ID', { timeZone: '<%= settings.timezone %>' });
|
|
lastUpdateEl.textContent = latestSyncRaw
|
|
? `TR-069: ${new Date(latestSyncRaw).toLocaleString('id-ID', { day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit' })} | UI: ${uiTime}`
|
|
: `${I18N.updatePrefix} ${uiTime}`;
|
|
}
|
|
tbody.innerHTML = devicesData.map((d, i) => {
|
|
const isOn = d.status === 'online';
|
|
const tagStr = (d.pppoeUsername && d.pppoeUsername !== 'N/A' && d.pppoeUsername !== '-') ? d.pppoeUsername : (d.tags.join(', ') || '-');
|
|
const lastInformStr = d.lastInform ? new Date(d.lastInform).toLocaleString('id-ID', {day:'2-digit',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '-';
|
|
const lastSyncStr = d.lastSync ? new Date(d.lastSync).toLocaleString('id-ID', {day:'2-digit',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'}) : (d.lastSyncLabel || '-');
|
|
const syncBadge = d.syncInProgress
|
|
? `<div style="font-size:11px;color:var(--warning);margin-top:4px"><i class="bi bi-arrow-repeat"></i> ${I18N.syncRunning} (${d.syncPendingCount || 0})</div>`
|
|
: `<div style="font-size:11px;color:var(--muted);margin-top:4px"><i class="bi bi-check2-circle"></i> ${d.syncStatusLabel || '-'}</div>`;
|
|
return `<tr>
|
|
<td style="text-align: center;">
|
|
<input type="checkbox" class="mon-device-checkbox" data-id="${d.id}" data-acs="${d.acs_server_id || 'legacy'}" onchange="updateMonBulkPanel()">
|
|
</td>
|
|
<td class="text-muted">${i + 1}</td>
|
|
<td>
|
|
<span class="badge bm">${tagStr}</span>
|
|
<div style="font-size:11px;color:var(--muted);margin-top:4px" title="${I18N.lastInform}"><i class="bi bi-clock"></i> ${lastInformStr}</div>
|
|
<div style="font-size:11px;color:var(--muted);margin-top:2px" title="${I18N.lastSync}"><i class="bi bi-arrow-repeat"></i> ${lastSyncStr}</div>
|
|
</td>
|
|
<td>
|
|
<div class="fw6" style="font-size:13px">${d.model !== '-' ? d.model : I18N.onuDevice}</div>
|
|
<div style="font-size:11px;color:var(--muted)">SN: <code style="color:var(--info);background:transparent;padding:0">${d.serialNumber}</code></div>
|
|
<div style="font-size:11px;color:var(--muted)">SW: ${d.softwareVersion}</div>
|
|
</td>
|
|
<td>
|
|
<div style="font-size:12px;margin-bottom:2px" title="IP Address"><i class="bi bi-globe" style="color:var(--primary)"></i> ${d.pppoeIP !== '-' ? d.pppoeIP : I18N.noIp}</div>
|
|
<div style="font-size:12px;margin-bottom:2px" title="PPPoE Username"><i class="bi bi-person-badge" style="color:var(--warning)"></i> ${d.pppoeUsername && d.pppoeUsername !== 'N/A' ? d.pppoeUsername : '<span class="text-muted">-</span>'}</div>
|
|
<div style="font-size:12px;margin-bottom:2px" title="SSID"><i class="bi bi-wifi" style="color:var(--success)"></i> ${d.ssid}</div>
|
|
<div style="font-size:11px;color:var(--muted)"><i class="bi bi-people"></i> ${d.userConnected} ${I18N.connectedSuffix}</div>
|
|
</td>
|
|
<td>
|
|
<div><span class="badge ${isOn ? 'ds-on' : 'ds-off'}"><span class="dot ${isOn ? 'dot-on' : 'dot-off'}"></span>${isOn ? I18N.online : I18N.offline}</span></div>
|
|
<div style="font-size:11px;color:var(--muted);margin-top:4px" title="RX Power"><i class="bi bi-activity"></i> ${d.rxPower}</div>
|
|
<div style="font-size:11px;color:var(--muted)" title="Uptime"><i class="bi bi-clock-history"></i> ${d.uptime}</div>
|
|
<div style="font-size:11px;color:var(--muted)" title="${I18N.lastSync}"><i class="bi bi-hourglass-split"></i> ${d.lastSyncAgo || '-'}</div>
|
|
${syncBadge}
|
|
</td>
|
|
<td style="white-space:nowrap">
|
|
<div class="act">
|
|
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
|
|
<div class="act-menu">
|
|
<button type="button" class="act-item btn-dev-detail" data-tag="${(d.tags && d.tags[0]) || d.id}"><i class="bi bi-eye"></i> ${I18N.titleDetail}</button>
|
|
<button type="button" class="act-item primary btn-dev-summon" data-id="${d.id}" data-acs="${d.acs_server_id || 'legacy'}"><i class="bi bi-arrow-repeat"></i> ${I18N.syncNow}</button>
|
|
<button type="button" class="act-item primary btn-dev-wifi" data-tag="${(d.tags && d.tags[0]) || d.id}" data-ssid="${escHtml(d.ssid)}"><i class="bi bi-wifi"></i> ${I18N.titleEditSsid}</button>
|
|
<button type="button" class="act-item btn-dev-pass" data-tag="${(d.tags && d.tags[0]) || d.id}"><i class="bi bi-key"></i> ${I18N.titleEditPassword}</button>
|
|
<div class="act-sep"></div>
|
|
<button type="button" class="act-item danger btn-dev-reboot" data-tag="${(d.tags && d.tags[0]) || d.id}"><i class="bi bi-power"></i> ${I18N.titleReboot}</button>
|
|
<button type="button" class="act-item danger btn-dev-delete" data-id="${d.id}" data-acs="${d.acs_server_id || 'legacy'}"><i class="bi bi-trash"></i> <%= t('admin.dash.del_device','Hapus Perangkat') %></button>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
// Bind event listeners for actions to prevent quote escaping HTML issues
|
|
tbody.querySelectorAll('.btn-dev-detail').forEach(btn => {
|
|
btn.addEventListener('click', function() { showDetail(this.getAttribute('data-tag')); });
|
|
});
|
|
tbody.querySelectorAll('.btn-dev-summon').forEach(btn => {
|
|
btn.addEventListener('click', function() { summonMonDevice(this.getAttribute('data-id'), this.getAttribute('data-acs')); });
|
|
});
|
|
tbody.querySelectorAll('.btn-dev-wifi').forEach(btn => {
|
|
btn.addEventListener('click', function() { editSsid(this.getAttribute('data-tag'), this.getAttribute('data-ssid')); });
|
|
});
|
|
tbody.querySelectorAll('.btn-dev-pass').forEach(btn => {
|
|
btn.addEventListener('click', function() { editPassword(this.getAttribute('data-tag')); });
|
|
});
|
|
tbody.querySelectorAll('.btn-dev-reboot').forEach(btn => {
|
|
btn.addEventListener('click', function() { rebootDevice(this.getAttribute('data-tag')); });
|
|
});
|
|
tbody.querySelectorAll('.btn-dev-delete').forEach(btn => {
|
|
btn.addEventListener('click', function() { deleteMonDevice(this.getAttribute('data-id'), this.getAttribute('data-acs')); });
|
|
});
|
|
}
|
|
|
|
// Update offline list on dashboard
|
|
renderOfflineList(devicesData.filter(d => d.status === 'offline').slice(0, 5));
|
|
// AUTO-REFRESH DISABLED: scheduleFastRefreshIfNeeded() is intentionally removed
|
|
// Dashboard will not auto-refresh during sync operations
|
|
|
|
} catch(e) {
|
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center" style="padding:32px;color:var(--danger)"><i class="bi bi-exclamation-circle"></i> ' + I18N.errorLoadData + '</td></tr>';
|
|
} finally {
|
|
loading.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// ── OFFLINE LIST (dashboard) ───────────────────────────────────
|
|
function renderOfflineList(offlineDevices) {
|
|
const el = document.getElementById('offline-list');
|
|
if (!el) return;
|
|
if (offlineDevices.length === 0) {
|
|
el.innerHTML = '<div class="text-center" style="padding:24px;font-size:13px;color:var(--success)"><i class="bi bi-check-circle-fill"></i> ' + I18N.allOnuOnline + '</div>';
|
|
return;
|
|
}
|
|
el.innerHTML = offlineDevices.map(d => {
|
|
const ago = d.lastInform ? timeSince(new Date(d.lastInform)) : I18N.never;
|
|
return `<div style="padding:10px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
|
<div>
|
|
<div style="font-size:13px;font-weight:600">${(d.pppoeUsername && d.pppoeUsername !== 'N/A' && d.pppoeUsername !== '-') ? d.pppoeUsername : (d.tags.join(', ') || '-')}</div>
|
|
<div style="font-size:11px;color:var(--muted)">${d.serialNumber}</div>
|
|
</div>
|
|
<div style="text-align:right">
|
|
<span class="badge bd">${I18N.offlineBadge}</span>
|
|
<div style="font-size:11px;color:var(--muted);margin-top:3px">${ago}</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function timeSince(date) {
|
|
const sec = Math.floor((Date.now() - date) / 1000);
|
|
if (sec < 60) return sec + ' ' + I18N.secondsAgo;
|
|
if (sec < 3600) return Math.floor(sec/60) + ' ' + I18N.minutesAgo;
|
|
if (sec < 86400) return Math.floor(sec/3600) + ' ' + I18N.hoursAgo;
|
|
return Math.floor(sec/86400) + ' ' + I18N.daysAgo;
|
|
}
|
|
|
|
// ── DEVICE ACTIONS ─────────────────────────────────────────────
|
|
async function showDetail(tag) {
|
|
openModal('deviceModal');
|
|
document.getElementById('device-detail').innerHTML = '<div class="text-muted text-center" style="padding:20px">' + I18N.loading + '</div>';
|
|
try {
|
|
const res = await fetch('/admin/api/device/' + encodeURIComponent(tag));
|
|
const d = await res.json();
|
|
const isOn = String(d.status || '').toLowerCase() === 'online';
|
|
const lastInformText = d.lastInform ? new Date(d.lastInform).toLocaleString('id-ID', { day:'2-digit', month:'short', year:'numeric', hour:'2-digit', minute:'2-digit' }) : '-';
|
|
const lastSyncText = d.lastSync ? new Date(d.lastSync).toLocaleString('id-ID', { day:'2-digit', month:'short', year:'numeric', hour:'2-digit', minute:'2-digit' }) : '-';
|
|
const syncStateText = d.syncInProgress ? `${I18N.syncRunning} (${d.syncPendingCount || 0})` : (d.syncStatusLabel || '-');
|
|
const connected = Array.isArray(d.connectedUsers) ? d.connectedUsers : [];
|
|
document.getElementById('device-detail').innerHTML = `
|
|
<div style="padding:14px 16px;border-radius:12px;margin-bottom:16px;font-weight:700;border:1px solid var(--border);background:${isOn ? 'rgba(16,185,129,.10)' : 'rgba(239,68,68,.10)'};color:${isOn ? 'var(--success)' : 'var(--danger)'}">
|
|
<i class="bi bi-circle-fill" style="font-size:10px"></i>
|
|
${isOn ? I18N.online : I18N.offline}
|
|
<span style="color:var(--muted);font-weight:600">— ${I18N.lastInform}: ${lastInformText} | ${I18N.lastSync}: ${lastSyncText} | Queue: ${syncStateText}</span>
|
|
</div>
|
|
|
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px">
|
|
<div style="background:var(--card-bg);border:1px solid var(--border);border-radius:12px;overflow:hidden">
|
|
<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;color:var(--muted)"><i class="bi bi-info-circle"></i> Device Info</div>
|
|
<div style="display:flex;justify-content:space-between;padding:8px 18px;border-bottom:1px solid rgba(51,65,85,.10)"><span style="color:var(--muted)">Tag</span><span style="font-weight:800">${d.phone || '-'}</span></div>
|
|
<div style="display:flex;justify-content:space-between;padding:8px 18px;border-bottom:1px solid rgba(51,65,85,.10)"><span style="color:var(--muted)">Model</span><span>${d.model || '-'}</span></div>
|
|
<div style="display:flex;justify-content:space-between;padding:8px 18px;border-bottom:1px solid rgba(51,65,85,.10)"><span style="color:var(--muted)">Serial</span><code style="color:var(--info)">${d.serialNumber || '-'}</code></div>
|
|
<div style="display:flex;justify-content:space-between;padding:8px 18px"><span style="color:var(--muted)">SW Version</span><span>${d.softwareVersion || '-'}</span></div>
|
|
</div>
|
|
|
|
<div style="background:var(--card-bg);border:1px solid var(--border);border-radius:12px;overflow:hidden">
|
|
<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;color:var(--muted)"><i class="bi bi-diagram-3"></i> Network Info</div>
|
|
<div style="display:flex;justify-content:space-between;padding:8px 18px;border-bottom:1px solid rgba(51,65,85,.10)"><span style="color:var(--muted)">PPPoE User</span><span>${d.pppoeUsername || '-'}</span></div>
|
|
<div style="display:flex;justify-content:space-between;padding:8px 18px;border-bottom:1px solid rgba(51,65,85,.10)"><span style="color:var(--muted)">IP</span><code>${d.pppoeIP || '-'}</code></div>
|
|
<div style="display:flex;justify-content:space-between;padding:8px 18px;border-bottom:1px solid rgba(51,65,85,.10)"><span style="color:var(--muted)">RX Power</span><span class="badge" style="background:var(--primary);color:white">${d.rxPower || '-'}</span></div>
|
|
<div style="display:flex;justify-content:space-between;padding:8px 18px;border-bottom:1px solid rgba(51,65,85,.10)"><span style="color:var(--muted)">SSID</span><span>${d.ssid || '-'}</span></div>
|
|
<div style="display:flex;justify-content:space-between;padding:8px 18px"><span style="color:var(--muted)">Uptime</span><span>${d.uptime || '-'}</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:12px;overflow:hidden">
|
|
<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;color:var(--muted);display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
|
|
<div><i class="bi bi-people"></i> <%= t('admin.dash.conn_live','Klien Terhubung (Live)') %></div>
|
|
<span class="badge bm">${connected.length}</span>
|
|
</div>
|
|
<div style="padding:16px 18px">
|
|
${connected.length > 0 ? connected.map((u) => {
|
|
const iface = String(u.iface || '');
|
|
const isWiFi = iface.toLowerCase().includes('wifi') || iface.toLowerCase().includes('wlan') || iface.toLowerCase().includes('802.11');
|
|
const isUon = String(u.status || '').toLowerCase() === 'online';
|
|
return `
|
|
<div style="background:var(--sub-bg);border:1px solid var(--border);border-radius:10px;padding:14px;margin-bottom:10px">
|
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
|
|
<div style="width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;background:rgba(59,130,246,.10);color:var(--primary)">
|
|
<i class="bi bi-${isWiFi ? 'wifi' : 'ethernet'}"></i>
|
|
</div>
|
|
<div style="flex:1">
|
|
<div style="font-weight:800">${u.hostname || '-'}</div>
|
|
<div style="font-size:11px;color:var(--muted)">${u.ip || '-'} | ${u.mac || '-'}</div>
|
|
</div>
|
|
<span class="badge ${isUon ? 'bs' : 'bm'}">${u.status || '-'}</span>
|
|
</div>
|
|
<div style="font-size:11px;color:var(--muted)">Interface: ${iface || '-'}</div>
|
|
</div>
|
|
`;
|
|
}).join('') : `<div style="text-align:center;color:var(--muted);padding:10px 0"><i class="bi bi-info-circle"></i> ${I18N.noConnectedDevices}</div>`}
|
|
</div>
|
|
</div>
|
|
`;
|
|
} catch(e) { document.getElementById('device-detail').innerHTML = '<p class="text-muted text-center" style="padding:20px">' + I18N.detailLoadFailed + '</p>'; }
|
|
}
|
|
|
|
function editSsid(tag, currentSsid) { document.getElementById('edit-tag').value = tag; document.getElementById('new-ssid').value = (currentSsid && currentSsid !== '-') ? currentSsid : ''; openModal('ssidModal'); }
|
|
function editPassword(tag) { document.getElementById('edit-pass-tag').value = tag; document.getElementById('new-password').value = ''; openModal('passwordModal'); }
|
|
|
|
async function rebootDevice(tag) {
|
|
if (!confirm(I18N.rebootConfirmPrefix + ' ' + tag + '?')) return;
|
|
try {
|
|
const res = await fetch('/admin/api/device/' + encodeURIComponent(tag) + '/reboot', { method: 'POST' });
|
|
const data = await res.json();
|
|
showToast(data.message || (data.ok ? I18N.rebootSent : I18N.failed), data.ok ? 's' : 'd');
|
|
} catch(e) { showToast('Error: ' + e.message, 'd'); }
|
|
}
|
|
|
|
document.getElementById('btn-save-ssid').addEventListener('click', async function() {
|
|
const btn = this;
|
|
const txt = btn.querySelector('.btn-text');
|
|
const ldr = btn.querySelector('.btn-loader');
|
|
const tag = document.getElementById('edit-tag').value;
|
|
const ssid = document.getElementById('new-ssid').value.trim();
|
|
|
|
if (!ssid) return showToast(I18N.ssidRequired, 'd');
|
|
|
|
btn.disabled = true;
|
|
txt.style.display = 'none';
|
|
ldr.style.display = 'inline-block';
|
|
|
|
try {
|
|
const res = await fetch('/admin/api/device/' + encodeURIComponent(tag) + '/ssid', {
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ ssid })
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
showToast(I18N.ssidSent, 's');
|
|
closeModal('ssidModal');
|
|
setTimeout(loadDevices, 2000);
|
|
} else {
|
|
showToast(data.message || I18N.ssidFailed, 'd');
|
|
}
|
|
} catch(e) {
|
|
showToast(I18N.connectionError, 'd');
|
|
} finally {
|
|
btn.disabled = false;
|
|
txt.style.display = 'inline-block';
|
|
ldr.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
document.getElementById('btn-save-password').addEventListener('click', async function() {
|
|
const btn = this;
|
|
const txt = btn.querySelector('.btn-text');
|
|
const ldr = btn.querySelector('.btn-loader');
|
|
const tag = document.getElementById('edit-pass-tag').value;
|
|
const pass = document.getElementById('new-password').value;
|
|
|
|
if (!pass || pass.length < 8) return showToast(I18N.passwordMin, 'd');
|
|
|
|
btn.disabled = true;
|
|
txt.style.display = 'none';
|
|
ldr.style.display = 'inline-block';
|
|
|
|
try {
|
|
const res = await fetch('/admin/api/device/' + encodeURIComponent(tag) + '/password', {
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ password: pass })
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
showToast(I18N.passwordSent, 's');
|
|
closeModal('passwordModal');
|
|
} else {
|
|
showToast(data.message || I18N.passwordFailed, 'd');
|
|
}
|
|
} catch(e) {
|
|
showToast(I18N.connectionError, 'd');
|
|
} finally {
|
|
btn.disabled = false;
|
|
txt.style.display = 'inline-block';
|
|
ldr.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// ── SEARCH DEBOUNCE ────────────────────────────────────────────
|
|
const searchEl = document.getElementById('search-device');
|
|
if (searchEl) {
|
|
let st;
|
|
searchEl.addEventListener('input', () => { clearTimeout(st); st = setTimeout(loadDevices, 400); });
|
|
}
|
|
|
|
// ── TOAST NOTIFICATION ─────────────────────────────────────────
|
|
function showToast(msg, type = 's') {
|
|
const old = document.querySelector('.toast-notif');
|
|
if (old) old.remove();
|
|
|
|
const t = document.createElement('div');
|
|
t.className = 'toast-notif';
|
|
t.style.cssText = `position:fixed;bottom:32px;right:32px;z-index:10000;background:var(--bg2);border-left:5px solid var(--${type==='s'?'success':'danger'});border-radius:8px;padding:16px 24px;font-size:14px;display:flex;align-items:center;gap:12px;box-shadow:0 12px 32px rgba(0,0,0,.5);animation:slideInRight .3s ease;color:var(--text);min-width:300px`;
|
|
|
|
const icon = type === 's' ? 'check-circle-fill' : 'exclamation-circle-fill';
|
|
const color = type === 's' ? 'var(--success)' : 'var(--danger)';
|
|
|
|
t.innerHTML = `
|
|
<i class="bi bi-${icon}" style="font-size:20px;color:${color}"></i>
|
|
<div style="flex:1">
|
|
<div style="font-weight:600;margin-bottom:2px">${type === 's' ? I18N.success : I18N.failed}</div>
|
|
<div style="color:var(--muted);font-size:13px">${msg}</div>
|
|
</div>
|
|
<button onclick="this.parentElement.remove()" style="background:none;border:none;color:var(--muted);cursor:pointer;padding:4px"><i class="bi bi-x-lg"></i></button>
|
|
`;
|
|
document.body.appendChild(t);
|
|
setTimeout(() => { if(t.parentElement) t.style.animation = 'slideOutRight .3s ease forwards'; setTimeout(() => t.remove(), 300); }, 5000);
|
|
}
|
|
|
|
// ── REFRESH ALL ────────────────────────────────────────────────
|
|
// DEPRECATED: This function is kept for backward compatibility but is disabled
|
|
// Use manualRefreshAll() for user-initiated refreshes only
|
|
function refreshAll() {
|
|
// INTENTIONALLY DISABLED - Auto-refresh has been turned off
|
|
// This function will only be called when user manually clicks "Refresh" button
|
|
loadStats();
|
|
loadDevices();
|
|
}
|
|
|
|
// This is the proper function to call for auto-refresh (which is now disabled)
|
|
function manualRefreshAll() {
|
|
loadStats();
|
|
loadDevices();
|
|
}
|
|
|
|
function scheduleFastRefreshIfNeeded() {
|
|
// Auto-refresh during sync disabled to prevent continuous polling
|
|
if (fastRefreshTimer) {
|
|
clearTimeout(fastRefreshTimer);
|
|
fastRefreshTimer = null;
|
|
}
|
|
// Commented out: Previously would auto-refresh every 5s when devices were syncing
|
|
// if (!Array.isArray(devicesData) || !devicesData.some(d => d.syncInProgress)) return;
|
|
// fastRefreshTimer = setTimeout(() => {
|
|
// refreshAll();
|
|
// }, 5000);
|
|
}
|
|
|
|
loadStats();
|
|
loadDevices();
|
|
// Auto-refresh disabled: Dashboard akan hanya refresh saat halaman pertama kali dibuka atau saat admin click tombol Refresh
|
|
// setInterval(refreshAll, 30000);
|
|
// NOTE: AUTO-REFRESH HAS BEEN COMPLETELY DISABLED
|
|
// Users must manually click the "Refresh" button to update device list
|
|
|
|
// ── CHECK IF SHOULD GO TO MONITORING TAB (from sidebar link) ──
|
|
if (window.location.hash === '#monitoring') showTab('monitoring');
|
|
window.addEventListener('hashchange', () => {
|
|
if (window.location.hash === '#monitoring') showTab('monitoring');
|
|
else if (window.location.hash === '#dashboard' || !window.location.hash) showTab('dashboard');
|
|
});
|
|
|
|
// ── GENIEACS SETTINGS FUNCTIONS ────────────────────────────────
|
|
function openGenieACSSettings() {
|
|
openModal('genieacsModal');
|
|
}
|
|
|
|
async function saveGenieACSSettings() {
|
|
const formData = {
|
|
genieacs_timeout: parseInt(document.getElementById('genieacs_timeout').value),
|
|
genieacs_rxpower_threshold: parseFloat(document.getElementById('genieacs_rxpower_threshold').value),
|
|
genieacs_monitoring_interval: parseInt(document.getElementById('genieacs_monitoring_interval').value),
|
|
genieacs_monitoring_enabled: document.getElementById('genieacs_monitoring_enabled').checked
|
|
};
|
|
|
|
// Validate
|
|
if (formData.genieacs_timeout < 5000 || formData.genieacs_timeout > 120000) {
|
|
showGenieACSMessage('Timeout harus antara 5000-120000 ms', 'danger');
|
|
return;
|
|
}
|
|
if (formData.genieacs_rxpower_threshold < -40 || formData.genieacs_rxpower_threshold > -15) {
|
|
showGenieACSMessage('RX Power threshold harus antara -40 sampai -15 dBm', 'danger');
|
|
return;
|
|
}
|
|
if (formData.genieacs_monitoring_interval < 1 || formData.genieacs_monitoring_interval > 24) {
|
|
showGenieACSMessage('Monitoring interval harus antara 1-24 jam', 'danger');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/admin/api/genieacs/settings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(formData)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
showGenieACSMessage(result.message, 'success');
|
|
setTimeout(() => {
|
|
closeModal('genieacsModal');
|
|
showToast('<%= t('admin.dash.genieacs_title','Pengaturan GenieACS') %> berhasil disimpan', 's');
|
|
}, 1500);
|
|
} else {
|
|
showGenieACSMessage(result.message, 'danger');
|
|
}
|
|
} catch (error) {
|
|
showGenieACSMessage('Error: ' + error.message, 'danger');
|
|
}
|
|
}
|
|
|
|
async function testGenieACSConnection() {
|
|
showGenieACSMessage('Testing koneksi ke GenieACS...', 'info');
|
|
|
|
try {
|
|
const response = await fetch('/admin/api/genieacs/test');
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
const msg = result.message + (result.deviceCount !== undefined ? ' (' + result.deviceCount + ' devices)' : '');
|
|
showGenieACSMessage(msg, 'success');
|
|
} else {
|
|
showGenieACSMessage('Koneksi gagal: ' + result.message, 'danger');
|
|
}
|
|
} catch (error) {
|
|
showGenieACSMessage('Error: ' + error.message, 'danger');
|
|
}
|
|
}
|
|
|
|
function showGenieACSMessage(msg, type) {
|
|
const messageDiv = document.getElementById('genieacsMessage');
|
|
messageDiv.style.display = 'block';
|
|
|
|
const colors = {
|
|
success: { bg: 'rgba(63, 185, 80, 0.15)', border: 'rgba(63, 185, 80, 0.3)', text: '#3fb950' },
|
|
danger: { bg: 'rgba(248, 81, 73, 0.15)', border: 'rgba(248, 81, 73, 0.3)', text: '#f85149' },
|
|
info: { bg: 'rgba(102, 126, 234, 0.15)', border: 'rgba(102, 126, 234, 0.3)', text: '#667eea' }
|
|
};
|
|
|
|
const color = colors[type] || colors.info;
|
|
messageDiv.style.background = color.bg;
|
|
messageDiv.style.border = '1px solid ' + color.border;
|
|
messageDiv.style.color = color.text;
|
|
|
|
const icons = { success: 'check-circle', danger: 'x-circle', info: 'info-circle' };
|
|
messageDiv.innerHTML = '<i class="bi bi-' + icons[type] + '"></i> ' + msg;
|
|
}
|
|
|
|
function toggleSelectAllMon(master) {
|
|
const checkboxes = document.querySelectorAll('.mon-device-checkbox');
|
|
checkboxes.forEach(cb => cb.checked = master.checked);
|
|
updateMonBulkPanel();
|
|
}
|
|
|
|
function updateMonBulkPanel() {
|
|
const checkboxes = document.querySelectorAll('.mon-device-checkbox:checked');
|
|
const panel = document.getElementById('monBulkActionsPanel');
|
|
const countSpan = document.getElementById('monSelectedCount');
|
|
|
|
if (checkboxes.length > 0) {
|
|
panel.style.display = 'flex';
|
|
countSpan.textContent = checkboxes.length;
|
|
} else {
|
|
panel.style.display = 'none';
|
|
document.getElementById('selectAllMonDevices').checked = false;
|
|
}
|
|
}
|
|
|
|
function getSelectedMonDevices() {
|
|
const checkboxes = document.querySelectorAll('.mon-device-checkbox:checked');
|
|
const devices = [];
|
|
checkboxes.forEach(cb => {
|
|
devices.push({
|
|
id: cb.dataset.id,
|
|
acsId: cb.dataset.acs
|
|
});
|
|
});
|
|
return devices;
|
|
}
|
|
|
|
async function bulkMonSummon() {
|
|
const devices = getSelectedMonDevices();
|
|
if (devices.length === 0) return;
|
|
if (!confirm(`Summon / Sinkronisasi ulang ${devices.length} perangkat?`)) return;
|
|
|
|
try {
|
|
const res = await fetch('/admin/acs/api/bulk/refresh', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ devices })
|
|
});
|
|
const d = await res.json();
|
|
showToast(d.message, d.success ? 's' : 'd');
|
|
if (d.success) setTimeout(refreshAll, 1500);
|
|
} catch (err) {
|
|
showToast('Gagal memproses bulk summon: ' + err.message, 'd');
|
|
}
|
|
}
|
|
|
|
async function bulkMonDelete() {
|
|
const devices = getSelectedMonDevices();
|
|
if (devices.length === 0) return;
|
|
if (!confirm(`HAPUS ${devices.length} perangkat dari database ACS?\n(Tindakan ini tidak dapat dibatalkan)`)) return;
|
|
|
|
try {
|
|
const res = await fetch('/admin/acs/api/bulk/delete', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ devices })
|
|
});
|
|
const d = await res.json();
|
|
showToast(d.message, d.success ? 's' : 'd');
|
|
if (d.success) setTimeout(refreshAll, 1500);
|
|
} catch (err) {
|
|
showToast('Gagal memproses bulk delete: ' + err.message, 'd');
|
|
}
|
|
}
|
|
|
|
async function summonMonDevice(deviceId, acsId) {
|
|
if (!confirm('Summon / Sinkronisasi ulang perangkat ' + deviceId + '?')) return;
|
|
try {
|
|
const res = await fetch('/admin/acs/api/refresh/' + encodeURIComponent(deviceId), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ acsId })
|
|
});
|
|
const d = await res.json();
|
|
showToast(d.message, d.success ? 's' : 'd');
|
|
if (d.success) setTimeout(refreshAll, 1500);
|
|
} catch (err) {
|
|
showToast('Gagal mengirim summon: ' + err.message, 'd');
|
|
}
|
|
}
|
|
|
|
async function deleteMonDevice(deviceId, acsId) {
|
|
if (!confirm('Hapus perangkat ' + deviceId + ' dari database ACS?')) return;
|
|
try {
|
|
const res = await fetch('/admin/acs/api/device/' + encodeURIComponent(deviceId), {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ acsId })
|
|
});
|
|
const d = await res.json();
|
|
showToast(d.message, d.success ? 's' : 'd');
|
|
if (d.success) setTimeout(refreshAll, 1500);
|
|
} catch (err) {
|
|
showToast('Gagal menghapus perangkat: ' + err.message, 'd');
|
|
}
|
|
}
|
|
|
|
// ── Grafik Pendapatan per Bulan ──
|
|
(function () {
|
|
var el = document.getElementById('revenueChart');
|
|
if (!el || typeof Chart === 'undefined') return;
|
|
var rows = <%- JSON.stringify(revenueMonthly || []).split('<').join('\\u003c') %>;
|
|
var months = ['Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agu','Sep','Okt','Nov','Des'];
|
|
var byM = {}; (rows || []).forEach(function (r) { byM[r.month] = Number(r.revenue || 0); });
|
|
var labels = [], vals = [];
|
|
for (var m = 1; m <= 12; m++) { labels.push(months[m - 1]); vals.push(byM[m] || 0); }
|
|
var light = document.documentElement.getAttribute('data-theme') === 'light' || document.documentElement.classList.contains('light-theme');
|
|
var tick = light ? '#475569' : '#7d8590';
|
|
var grid = light ? 'rgba(0,0,0,.07)' : 'rgba(255,255,255,.07)';
|
|
new Chart(el, {
|
|
type: 'bar',
|
|
data: { labels: labels, datasets: [{ label: 'Pendapatan', data: vals, backgroundColor: 'rgba(173,255,47,.5)', borderColor: '#ADFF2F', borderWidth: 1, borderRadius: 5, maxBarThickness: 34 }] },
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
plugins: { legend: { display: false }, tooltip: { callbacks: { label: function (c) { return 'Rp ' + Number(c.raw || 0).toLocaleString('id-ID'); } } } },
|
|
scales: { x: { ticks: { color: tick }, grid: { display: false } }, y: { beginAtZero: true, ticks: { color: tick, callback: function (v) { return 'Rp ' + (v >= 1000 ? (v / 1000) + 'k' : v); } }, grid: { color: grid } } }
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|