OPEN-ISP v0.5 (pickle-lime) — rilis open-source perdana

Aplikasi billing & manajemen ISP (fiber optic) — PWA pelanggan, portal teknisi & kolektor,
admin panel lengkap, multi-vendor OLT, RADIUS, WhatsApp/Telegram, pembayaran QRIS.

Kredit & lisensi (ISC):
- Base kode & repo: alijayanet/billing-rtrw oleh Alijaya Net — terima kasih banyak
  atas base kode dan repo yang bermanfaat.
- Add-on: GoWA, LibreSpeed/OpenSpeedTest, Uptime Kuma, notification-forwarder.
- Dikembangkan menjadi OPEN-ISP oleh oxpoe — https://github.com/oxpoe/open-isp
This commit is contained in:
oxpoe committed 2026-10-09 05:51:21 +07:00
commit eaf05f2811
280 files changed
+123975

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
<%
// ── Section switcher untuk grup menu (bukan tab bar, agar tidak "tab dalam tab") ──
// Halaman-halaman anggota sudah punya tab internal masing-masing; jadi navigasi
// antar-halaman grup pakai dropdown ringkas berlabel.
var _isCashierOnly = (typeof session !== 'undefined' && session && session.isCashier && !session.isAdmin);
var _groups = {
network: {
label: t('admin.nav.network', 'Jaringan'),
icon: 'bi bi-diagram-3',
items: [
{ key: 'mikrotik', href: '/admin/mikrotik', label: t('admin.nav.mikrotik', 'MikroTik'), roles: ['admin', 'cashier'] },
{ key: 'radius', href: '/admin/radius-settings', label: t('admin.nav.radius_server', 'RADIUS Server'), roles: ['admin'] },
{ key: 'acs', href: '/admin/acs', label: t('admin.nav.acs_pro', 'GenieACS Pro'), roles: ['admin'] },
{ key: 'onu', href: '/admin/onu-provision', label: t('admin.nav.onu_provision', 'ONU Provision'), roles: ['admin'] },
{ key: 'monitoring', href: '/admin/monitoring', label: t('admin.nav.monitoring', 'Monitoring Sistem'), roles: ['admin'] }
]
}
};
var _gkey = (typeof group !== 'undefined' && group && _groups[group]) ? group : 'network';
var _g = _groups[_gkey];
var _items = (_g.items || []).filter(function (it) {
if (_isCashierOnly && it.roles && it.roles.indexOf('cashier') === -1) return false;
return true;
});
%>
<div class="grp-nav">
<span class="grp-nav-title"><i class="<%= _g.icon %>"></i> <%= _g.label %></span>
<select class="grp-nav-select" onchange="if(this.value){window.location.href=this.value;}" aria-label="<%= _g.label %>">
<% _items.forEach(function (it) { %>
<option value="<%= it.href %>" <%= (typeof active !== 'undefined' && active === it.key) ? 'selected' : '' %>><%= it.label %></option>
<% }); %>
</select>
</div>
+243
View File
@@ -0,0 +1,243 @@
<!-- views/admin/partials/sidebar.ejs -->
<script>
(function() {
const savedTheme = localStorage.getItem('app-theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
if (savedTheme === 'light') {
document.documentElement.classList.add('light-theme');
} else {
document.documentElement.classList.remove('light-theme');
}
})();
function toggleAppTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
if (newTheme === 'light') {
document.documentElement.classList.add('light-theme');
if (document.body) document.body.classList.add('light-theme');
} else {
document.documentElement.classList.remove('light-theme');
if (document.body) document.body.classList.remove('light-theme');
}
localStorage.setItem('app-theme', newTheme);
updateThemeToggleIcons(newTheme);
}
function updateThemeToggleIcons(theme) {
const isLight = theme === 'light';
document.querySelectorAll('.theme-toggle-icon').forEach(icon => {
icon.className = isLight ? 'bi bi-sun-fill theme-toggle-icon' : 'bi bi-moon-stars-fill theme-toggle-icon';
icon.style.color = isLight ? '#f59e0b' : '#fbbf24';
});
document.querySelectorAll('.theme-toggle-btn').forEach(btn => {
btn.setAttribute('title', isLight ? 'Ganti ke Mode Gelap (Dark)' : 'Ganti ke Mode Terang (Light)');
});
}
document.addEventListener('DOMContentLoaded', () => {
const theme = localStorage.getItem('app-theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme);
if (theme === 'light') {
document.documentElement.classList.add('light-theme');
if (document.body) document.body.classList.add('light-theme');
}
updateThemeToggleIcons(theme);
});
</script>
<div class="sidebar-overlay" id="sidebarOverlay" onclick="toggleSidebar()"></div>
<aside class="sidebar" id="sidebar">
<div class="sb-brand">
<div class="sb-icon"><i class="bi bi-wifi"></i></div>
<div style="flex:1">
<div class="sb-name">
<%= company %>
</div>
<div class="sb-sub"><%= t('admin.panel', 'Admin Panel') %></div>
<div style="margin-top:8px;display:flex;gap:6px;align-items:center">
<a href="/lang/id" class="btn btn-g btn-sm" style="padding:4px 8px">ID</a>
<a href="/lang/en" class="btn btn-g btn-sm" style="padding:4px 8px">EN</a>
<button type="button" class="btn btn-g btn-sm theme-toggle-btn" onclick="toggleAppTheme()" title="Ganti Tema Dark / Light" style="padding:4px 8px;margin-left:auto">
<i class="bi bi-moon-stars-fill theme-toggle-icon" style="color:#fbbf24"></i> <%= t('admin.side.theme','Tema') %>
</button>
</div>
<% if (typeof multiRouterMode !== 'undefined' && multiRouterMode && allActiveRouters && allActiveRouters.length > 0) { %>
<div style="margin-top:10px;padding:8px;background:rgba(255,255,255,0.06);border-radius:10px;border:1px solid rgba(255,255,255,0.1)">
<div style="font-size:11px;color:#94a3b8;margin-bottom:4px;font-weight:600;display:flex;align-items:center;gap:4px">
<i class="bi bi-router"></i> <%= t('admin.side.router_filter','Router Filter') %>
</div>
<select class="form-select form-select-sm" style="font-size:12px;cursor:pointer" onchange="window.location.href='/admin/set-active-router?router_id=' + this.value">
<option value="all" <%= (!selectedRouterId) ? 'selected' : '' %>> <%= t('admin.side.all_routers','Semua Router') %><%= allActiveRouters.length %>)</option>
<% allActiveRouters.forEach(r => { %>
<option value="<%= r.id %>" <%= (selectedRouterId === r.id) ? 'selected' : '' %>>
<i class="bi bi-plug-fill"></i> <%= r.name %> (<%= r.host %>)
</option>
<% }) %>
</select>
</div>
<% } %>
</div>
<button class="sb-close btn-g btn-sm" onclick="toggleSidebar()"><i class="bi bi-x-lg"></i></button>
</div>
<nav class="sb-nav">
<% (sidebarSections || []).forEach((section) => { %>
<div class="sb-section"><%= section.labelKey ? t(section.labelKey, section.labelDefault) : section.labelDefault %></div>
<% section.items.forEach((item) => { %>
<% const _kids = item.children || []; %>
<% const _kidActive = _kids.some(k => (k.activePages || []).includes(activePage)); %>
<% const _selfActive = (item.activePages || []).includes(activePage) || _kidActive; %>
<% if (_kids.length) { %>
<a href="javascript:void(0)" class="sb-item sb-parent <%= _selfActive ? 'active' : '' %>" onclick="return sbToggleGroup(event, this)">
<i class="<%= item.icon %>"></i>
<span><%= item.labelKey ? t(item.labelKey, item.labelDefault) : item.labelDefault %></span>
<i class="bi bi-chevron-down sb-caret"></i>
</a>
<div class="sb-sub" style="display:<%= _selfActive ? 'block' : 'none' %>">
<% _kids.forEach((k) => { %>
<a href="<%= k.locked ? 'javascript:void(0)' : k.hrefResolved %>" class="sb-item sb-child <%= (k.activePages || []).includes(activePage) ? 'active' : '' %>"<% if (k.locked) { %> onclick='return showLockedMenuNotice(<%- JSON.stringify(k.lockedMessage).split('<').join('\\u003c') %>)'<% } %>>
<i class="<%= k.icon %>"></i>
<span><%= k.labelKey ? t(k.labelKey, k.labelDefault) : k.labelDefault %></span>
<% if (k.locked) { %><span style="margin-left:auto;padding:2px 6px;border-radius:999px;background:rgba(245,158,11,.18);color:#fde68a;font-size:10px"><%= t('admin.lock.lock','Kunci') %></span><% } %>
</a>
<% }) %>
</div>
<% } else { %>
<a
href="<%= item.hrefResolved %>"
class="sb-item <%= (item.activePages || []).includes(activePage) ? 'active' : '' %>"
<% if (item.locked) { %>
onclick='return showLockedMenuNotice(<%- JSON.stringify(item.lockedMessage).split('<').join('\\u003c') %>)'
<% } %>
>
<i class="<%= item.icon %>"></i>
<span><%= item.labelKey ? t(item.labelKey, item.labelDefault) : item.labelDefault %></span>
<% if (item.locked) { %>
<span style="margin-left:auto;padding:2px 8px;border-radius:999px;background:rgba(245,158,11,.18);color:#fde68a;font-size:11px"><%= t('admin.lock.lock','Kunci') %></span>
<% } %>
</a>
<% } %>
<% }) %>
<% }) %>
<div class="sb-section"><%= t('admin.side.section_control','KONTROL') %></div>
<a href="/admin/sidebar-settings" class="sb-item <%= activePage==='sidebar_settings' ? 'active' : '' %>">
<i class="bi bi-layout-sidebar-inset"></i><span><%= t('admin.side.settings','Pengaturan Sidebar') %></span>
</a>
<a href="/admin/logout" class="sb-item logout">
<i class="bi bi-box-arrow-left"></i><span><%= t('common.logout', 'Logout') %></span>
</a>
<div class="mt-4 px-3 py-2 text-center" style="border-top: 1px solid rgba(255,255,255,0.1);">
<a href="https://github.com/oxpoe/open-isp" target="_blank" rel="noopener" style="color: #cbd5e1; font-size: 11px; text-decoration: none; font-weight: 700;">
OPEN-ISP BILLING SYSTEM
</a>
<div style="color:#64748b;font-size:10px;margin-top:3px;line-height:1.5">
v<%= (typeof appVersion !== 'undefined' ? appVersion : '0.5') %> (pickle-lime) · ISC · oxpoe
</div>
</div>
</nav>
</aside>
<div id="lockedMenuModal" style="display:none;position:fixed;inset:0;z-index:1200;background:rgba(2,6,23,.72);backdrop-filter:blur(4px);padding:16px;align-items:center;justify-content:center">
<div style="width:min(100%,420px);background:linear-gradient(180deg,rgba(15,23,42,.98),rgba(30,41,59,.98));border:1px solid rgba(148,163,184,.18);border-radius:18px;box-shadow:0 24px 60px rgba(2,6,23,.45);overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 18px;border-bottom:1px solid rgba(148,163,184,.14)">
<div style="display:flex;align-items:center;gap:10px">
<div style="width:40px;height:40px;border-radius:12px;background:rgba(245,158,11,.16);color:#fde68a;display:grid;place-items:center;font-size:18px">
<i class="bi bi-lock-fill"></i>
</div>
<div>
<div style="font-weight:800;color:#f8fafc"><%= t('admin.lock.title','Menu Terkunci') %></div>
<div style="font-size:12px;color:#94a3b8"><%= t('admin.lock.reason','Aktivasi diperlukan untuk membuka fitur ini') %></div>
</div>
</div>
<button type="button" class="btn btn-g btn-sm" onclick="hideLockedMenuNotice()" style="padding:6px 10px">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div style="padding:18px">
<div id="lockedMenuModalMessage" style="color:#e2e8f0;line-height:1.6">
<%= t('admin.lock.desc','Menu ini sedang terkunci.') %>
</div>
<div style="margin-top:16px;padding:12px 14px;border-radius:14px;background:rgba(59,130,246,.12);border:1px solid rgba(59,130,246,.22);color:#bfdbfe;font-size:13px">
<%= t('admin.lock.code_label','Kode aktivasi menu ini') %> <strong style="color:#86efac">GRATIS</strong> <%= t('admin.lock.wiki_note','— tanpa perlu donasi untuk membukanya.') %>
</div>
<div style="margin-top:14px;display:flex;flex-direction:column;gap:8px">
<a href="https://github.com/oxpoe/open-isp/wiki" target="_blank" rel="noopener" class="btn btn-p" style="width:100%;display:flex;align-items:center;justify-content:center;gap:8px">
<i class="bi bi-github"></i> <%= t('admin.lock.get_code','Ambil Kode Aktivasi (Gratis)') %>
</a>
<a href="/admin/sidebar-settings" class="btn btn-g" style="width:100%;display:flex;align-items:center;justify-content:center;gap:8px" onclick="hideLockedMenuNotice()">
<i class="bi bi-layout-sidebar-inset"></i> <%= t('admin.lock.open_settings','Buka Pengaturan Sidebar') %>
</a>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;padding:0 18px 18px 18px">
<button type="button" class="btn btn-p" onclick="hideLockedMenuNotice()">
<i class="bi bi-check2-circle"></i> <%= t('admin.lock.understood','Mengerti') %>
</button>
</div>
</div>
</div>
<script>
function toggleSidebar() {
const sb = document.getElementById('sidebar');
const ov = document.getElementById('sidebarOverlay');
sb.classList.toggle('open');
ov.classList.toggle('show');
}
function showLockedMenuNotice(message) {
const modal = document.getElementById('lockedMenuModal');
const messageBox = document.getElementById('lockedMenuModalMessage');
if (messageBox) {
messageBox.textContent = message || 'Menu ini sedang terkunci.';
}
if (modal) {
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
return false;
}
function hideLockedMenuNotice() {
const modal = document.getElementById('lockedMenuModal');
if (modal) {
modal.style.display = 'none';
document.body.style.overflow = '';
}
}
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') hideLockedMenuNotice();
});
</script>
<script src="/js/theme.js"></script>
<!-- Bottom Navigation (Mobile Only) -->
<nav class="bottom-nav" id="bottomNav">
<% (sidebarBottomNavItems || []).slice(0, 4).forEach((item) => { %>
<a
href="<%= item.hrefResolved %>"
class="bn-item <%= (item.activePages || []).includes(activePage) ? 'active' : '' %>"
<% if (item.locked) { %>
onclick='return showLockedMenuNotice(<%- JSON.stringify(item.lockedMessage).split('<').join('\\u003c') %>)'
<% } %>
>
<i class="<%= item.icon %>"></i>
<span><%= item.labelKey ? t(item.labelKey, item.labelDefault) : item.labelDefault %></span>
</a>
<% }) %>
<button class="bn-item" onclick="toggleSidebar()">
<i class="bi bi-grid-3x3-gap"></i>
<span><%= t('common.menu', 'Menu') %></span>
</button>
</nav>
<script>
window.sbToggleGroup = function(e, el){ try{ e && e.preventDefault && e.preventDefault(); }catch(_){}
var sub = el.nextElementSibling;
if(sub){ var open = sub.style.display !== 'none'; sub.style.display = open ? 'none' : 'block'; el.classList.toggle('open', !open); }
return false;
};
</script>
+93
View File
@@ -0,0 +1,93 @@
<header class="bg-slate-900/85 backdrop-blur-xl border-b border-[color:var(--border)] px-3 sm:px-6 min-h-[64px] sm:min-h-[72px] sticky top-0 z-40 flex items-center justify-between gap-3 transition-all duration-300">
<div class="flex items-center gap-2 sm:gap-3 min-w-0">
<!-- Unified Sidebar Toggle -->
<button class="flex items-center justify-center w-9 h-9 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800/80 border border-transparent hover:border-slate-700/60 transition shadow-sm shrink-0" onclick="window.innerWidth < 1024 ? toggleSidebar() : toggleSidebarMin()" title="Toggle Sidebar">
<i class="bi bi-list text-xl lg:hidden"></i>
<i class="bi bi-layout-sidebar-inset text-lg hidden lg:block" id="desktop-toggle-icon"></i>
</button>
<!-- Breadcrumb / Title -->
<div class="flex items-center gap-2 sm:gap-2.5 ml-1 min-w-0">
<div class="w-8 h-8 sm:w-9 sm:h-9 rounded-xl bg-gradient-to-br from-primary-500 to-fun-purple flex items-center justify-center shadow-lg shadow-primary-500/20 shrink-0">
<i class="<%= locals.pageIcon || 'ti-layout-dashboard' %> text-white text-base sm:text-lg"></i>
</div>
<div class="min-w-0">
<div class="font-black text-white text-sm sm:text-base leading-tight truncate" id="page-title"><%- locals.pageTitle || 'Dashboard' %></div>
<div class="text-[10px] sm:text-[11px] text-slate-400 font-semibold leading-none mt-0.5 truncate"><%- locals.pageDesc || 'Control Panel' %></div>
</div>
</div>
</div>
<!-- Right Side Actions -->
<div class="flex items-center gap-2 sm:gap-3 shrink-0">
<% if (locals.showRouterSelector && locals.routers) { %>
<div class="uiverse-select-wrapper relative z-50 max-w-[150px] sm:max-w-[220px]" id="wrapper-topbar-router">
<input type="hidden" id="global-router-id" value="<%= locals.selectedRouterId || locals.activeRouterId || '' %>">
<div class="uiverse-select-trigger" style="padding: 6px 10px; font-size: 11.5px;" onclick="toggleUiverseDropdown('wrapper-topbar-router')">
<span class="flex items-center gap-1.5 truncate pointer-events-none">
<i class="bi bi-router text-primary-400 shrink-0 text-xs"></i>
<span id="selected-topbar-router-label" class="truncate font-bold text-slate-200">
<%
const curRouter = routers.find(r => String(locals.selectedRouterId || locals.activeRouterId || "") === String(r.id));
%>
<%= curRouter ? curRouter.name : t("admin.mikrotik.default_router", "-- Default / Semua Router --") %>
</span>
</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="uiverse-arrow pointer-events-none shrink-0" style="width:10px;height:10px;margin-left:4px;">
<path fill="currentColor" d="M233.4 406.6c12.5 12.5 32.8 12.5 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 338.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z"></path>
</svg>
</div>
<div class="uiverse-options topbar-dropdown-menu">
<div class="uiverse-option <%= (!locals.selectedRouterId && !locals.activeRouterId) ? 'active' : '' %>" onclick="selectTopbarRouter('', '<%= t("admin.mikrotik.default_router", "-- Default / Semua Router --") %>')">
<span class="flex items-center gap-2 pointer-events-none truncate">
<i class="bi bi-layers text-slate-400 shrink-0"></i>
<span class="truncate"><%= t("admin.mikrotik.default_router", "-- Default / Semua Router --") %></span>
</span>
<i class="bi bi-check-lg uiverse-opt-check pointer-events-none"></i>
</div>
<% routers.forEach(r => { %>
<div class="uiverse-option <%= (String(locals.selectedRouterId || locals.activeRouterId || "") === String(r.id)) ? 'active' : '' %>" onclick="selectTopbarRouter('<%= r.id %>', '<%= r.name %>')">
<span class="flex items-center gap-2 pointer-events-none truncate">
<i class="bi bi-router text-primary-400 shrink-0"></i>
<span class="truncate"><%= r.name %></span>
</span>
<i class="bi bi-check-lg uiverse-opt-check pointer-events-none"></i>
</div>
<% }) %>
</div>
</div>
<script>
function selectTopbarRouter(val, label) {
const inp = document.getElementById('global-router-id');
if (inp) inp.value = val;
const lbl = document.getElementById('selected-topbar-router-label');
if (lbl) lbl.textContent = label;
const wrap = document.getElementById('wrapper-topbar-router');
if (wrap) {
wrap.querySelectorAll('.uiverse-option').forEach(opt => opt.classList.remove('active'));
wrap.classList.remove('open');
}
if (typeof changeRouter === 'function') {
changeRouter();
} else {
window.location.href = '/admin/set-active-router?router_id=' + (val || 'all');
}
}
</script>
<% } else { %>
<input type="hidden" id="global-router-id" value="">
<% } %>
<% if (locals.showLiveUpdate) { %>
<div class="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-full bg-accent-500/10 border border-accent-500/30 text-accent-400 text-xs font-semibold shadow-sm">
<span class="w-2 h-2 rounded-full bg-accent-500 pulse-dot"></span>
<span><%= t('admin.dashboard.live_update', 'Live Update') %></span>
</div>
<% } %>
<span class="px-2.5 sm:px-3.5 py-1.5 rounded-full bg-primary-500/10 border border-primary-500/30 text-primary-400 text-xs font-bold flex items-center gap-1.5 shadow-sm">
<i class="bi bi-person text-sm"></i>
<span class="hidden sm:inline"><%= (typeof session !== 'undefined' && session && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %></span>
</span>
</div>
</header>