secubox-openwrt/package/secubox/secubox-app-webapp/files/www/secubox-dashboard/index.html
CyberMind-FR 35eb1f79b2 feat(webapp): Dashboard improvements
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 06:56:49 +01:00

5945 lines
225 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SecuBox Control Center - CyberMind.FR</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛡️</text></svg>">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
<style>
:root {
--bg-primary: #0a0f1a;
--bg-secondary: #0f172a;
--bg-card: rgba(15, 23, 42, 0.8);
--border: #1e293b;
--border-hover: #334155;
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--cyan: #06b6d4;
--cyan-glow: rgba(6, 182, 212, 0.3);
--emerald: #10b981;
--emerald-glow: rgba(16, 185, 129, 0.3);
--amber: #f59e0b;
--amber-glow: rgba(245, 158, 11, 0.3);
--rose: #f43f5e;
--rose-glow: rgba(244, 63, 94, 0.3);
--violet: #8b5cf6;
--violet-glow: rgba(139, 92, 246, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
.mono {
font-family: 'JetBrains Mono', monospace;
}
/* Background Effects */
.bg-effects {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
}
.bg-effects::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background:
radial-gradient(circle at 20% 20%, var(--cyan-glow) 0%, transparent 40%),
radial-gradient(circle at 80% 80%, var(--violet-glow) 0%, transparent 40%);
animation: bgMove 20s ease-in-out infinite;
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--border) 1px, transparent 1px),
linear-gradient(90deg, var(--border) 1px, transparent 1px);
background-size: 50px 50px;
opacity: 0.3;
}
@keyframes bgMove {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-5%, -5%); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Login Screen */
.login-screen {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
background: var(--bg-primary);
}
.login-screen.hidden {
display: none;
}
.login-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 1.5rem;
padding: 2.5rem;
width: 100%;
max-width: 420px;
backdrop-filter: blur(20px);
position: relative;
z-index: 1;
margin: 1rem;
}
.login-logo {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.login-logo-icon {
width: 56px;
height: 56px;
background: linear-gradient(135deg, var(--cyan), #0891b2);
border-radius: 1rem;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.login-logo-icon::after {
content: '';
position: absolute;
top: -4px;
right: -4px;
width: 12px;
height: 12px;
background: var(--emerald);
border-radius: 50%;
border: 3px solid var(--bg-primary);
animation: pulse 2s infinite;
}
.login-title {
font-size: 1.5rem;
font-weight: 700;
}
.login-subtitle {
font-size: 0.875rem;
color: var(--text-muted);
}
.login-method {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
padding: 0.25rem;
background: var(--bg-primary);
border-radius: 0.75rem;
}
.login-method-btn {
flex: 1;
padding: 0.625rem;
background: transparent;
border: none;
border-radius: 0.5rem;
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.login-method-btn.active {
background: var(--bg-secondary);
color: var(--cyan);
}
.login-method-btn:hover:not(.active) {
color: var(--text-primary);
}
.form-group {
margin-bottom: 1.25rem;
}
.form-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.form-label-hint {
font-size: 0.7rem;
color: var(--text-muted);
font-weight: 400;
}
.form-input {
width: 100%;
padding: 0.875rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.75rem;
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 0.875rem;
transition: all 0.2s;
}
.form-input:focus {
outline: none;
border-color: var(--cyan);
box-shadow: 0 0 0 3px var(--cyan-glow);
}
.form-input::placeholder {
color: var(--text-muted);
}
.form-input-group {
position: relative;
}
.form-input-group .form-input {
padding-right: 3rem;
}
.form-input-toggle {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.25rem;
}
.form-input-toggle:hover {
color: var(--text-primary);
}
.form-checkbox {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.form-checkbox input {
width: 18px;
height: 18px;
accent-color: var(--cyan);
}
.form-checkbox label {
font-size: 0.875rem;
color: var(--text-secondary);
cursor: pointer;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.875rem 1.5rem;
border-radius: 0.75rem;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
width: 100%;
background: linear-gradient(135deg, var(--cyan), #0891b2);
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px var(--cyan-glow);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: var(--bg-secondary);
border: 1px solid var(--border);
color: var(--text-secondary);
}
.btn-secondary:hover {
border-color: var(--border-hover);
color: var(--text-primary);
}
.btn-danger {
background: rgba(244, 63, 94, 0.2);
border: 1px solid rgba(244, 63, 94, 0.3);
color: var(--rose);
}
.btn-danger:hover {
background: rgba(244, 63, 94, 0.3);
}
.btn-icon {
padding: 0.625rem;
}
.error-message {
background: rgba(244, 63, 94, 0.1);
border: 1px solid rgba(244, 63, 94, 0.3);
color: var(--rose);
padding: 0.75rem 1rem;
border-radius: 0.75rem;
font-size: 0.875rem;
margin-bottom: 1rem;
display: none;
align-items: center;
gap: 0.5rem;
}
.error-message.show {
display: flex;
}
.success-message {
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
color: var(--emerald);
padding: 0.75rem 1rem;
border-radius: 0.75rem;
font-size: 0.875rem;
margin-bottom: 1rem;
display: none;
align-items: center;
gap: 0.5rem;
}
.success-message.show {
display: flex;
}
.divider {
display: flex;
align-items: center;
gap: 1rem;
margin: 1.5rem 0;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.divider span {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
}
/* Main App */
.app {
display: none;
min-height: 100vh;
}
.app.active {
display: block;
}
/* Sidebar */
.sidebar {
position: fixed;
top: 0;
left: 0;
width: 260px;
height: 100vh;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
z-index: 50;
transform: translateX(-100%);
transition: transform 0.3s ease;
display: flex;
flex-direction: column;
}
.sidebar.open {
transform: translateX(0);
}
@media (min-width: 1024px) {
.sidebar {
transform: translateX(0);
}
}
.sidebar-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border);
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.sidebar-logo-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, var(--cyan), #0891b2);
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.sidebar-logo-icon .status-dot {
position: absolute;
top: -2px;
right: -2px;
width: 10px;
height: 10px;
background: var(--emerald);
border-radius: 50%;
border: 2px solid var(--bg-secondary);
}
.sidebar-logo-icon .status-dot.offline {
background: var(--rose);
}
.sidebar-nav {
flex: 1;
padding: 1rem;
overflow-y: auto;
}
.nav-section {
margin-bottom: 1.5rem;
}
.nav-section-title {
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 0 1rem;
margin-bottom: 0.5rem;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
margin-bottom: 0.25rem;
font-size: 0.875rem;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
}
.nav-item.active {
background: rgba(6, 182, 212, 0.15);
color: var(--cyan);
border: 1px solid rgba(6, 182, 212, 0.3);
}
.nav-item-badge {
margin-left: auto;
padding: 0.125rem 0.5rem;
background: var(--rose);
border-radius: 1rem;
font-size: 0.65rem;
font-weight: 600;
}
.sidebar-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
}
.user-info {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--bg-primary);
border-radius: 0.75rem;
margin-bottom: 0.75rem;
}
.user-avatar {
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--violet), var(--cyan));
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.875rem;
}
.user-details {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-role {
font-size: 0.7rem;
color: var(--text-muted);
}
.server-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.2);
border-radius: 0.5rem;
margin-bottom: 0.75rem;
}
.server-info.offline {
background: rgba(244, 63, 94, 0.1);
border-color: rgba(244, 63, 94, 0.2);
}
.server-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--emerald);
animation: pulse 2s infinite;
}
.server-status.offline {
background: var(--rose);
animation: none;
}
.server-host {
flex: 1;
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
color: var(--emerald);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.server-info.offline .server-host {
color: var(--rose);
}
/* Overlay */
.overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 45;
}
.overlay.active {
display: block;
}
@media (min-width: 1024px) {
.overlay {
display: none !important;
}
}
/* Main Content */
.main {
margin-left: 0;
min-height: 100vh;
position: relative;
z-index: 1;
}
@media (min-width: 1024px) {
.main {
margin-left: 260px;
}
}
/* Header */
.header {
position: sticky;
top: 0;
background: rgba(10, 15, 26, 0.9);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
padding: 1rem 1.5rem;
z-index: 40;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.menu-btn {
display: flex;
padding: 0.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.5rem;
color: var(--text-secondary);
cursor: pointer;
}
@media (min-width: 1024px) {
.menu-btn {
display: none;
}
}
.header-title {
font-size: 1.25rem;
font-weight: 700;
}
.header-time {
font-size: 0.75rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-badge {
display: none;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(16, 185, 129, 0.15);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 0.75rem;
font-size: 0.8rem;
color: var(--emerald);
}
.status-badge.offline {
background: rgba(244, 63, 94, 0.15);
border-color: rgba(244, 63, 94, 0.3);
color: var(--rose);
}
@media (min-width: 640px) {
.status-badge {
display: flex;
}
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.75rem;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.icon-btn:hover {
border-color: var(--border-hover);
color: var(--text-primary);
}
.icon-btn .badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 18px;
height: 18px;
padding: 0 4px;
background: var(--rose);
border-radius: 9px;
font-size: 0.65rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.icon-btn.loading i {
animation: spin 1s linear infinite;
}
/* Dashboard Content */
.dashboard {
padding: 1.5rem;
}
@media (max-width: 1023px) {
.dashboard {
padding-bottom: 6rem;
}
}
/* Metrics Grid */
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
@media (min-width: 768px) {
.metrics-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.metric-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 1.25rem;
backdrop-filter: blur(10px);
position: relative;
overflow: hidden;
transition: all 0.3s;
}
.metric-card:hover {
transform: translateY(-2px);
border-color: var(--border-hover);
}
.metric-card.cyan { --accent: var(--cyan); --accent-glow: var(--cyan-glow); }
.metric-card.emerald { --accent: var(--emerald); --accent-glow: var(--emerald-glow); }
.metric-card.amber { --accent: var(--amber); --accent-glow: var(--amber-glow); }
.metric-card.rose { --accent: var(--rose); --accent-glow: var(--rose-glow); }
.metric-card.violet { --accent: var(--violet); --accent-glow: var(--violet-glow); }
.metric-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.metric-label {
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.metric-icon {
padding: 0.5rem;
background: var(--accent-glow);
border-radius: 0.5rem;
color: var(--accent);
}
.metric-value {
font-size: 1.75rem;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
}
.metric-sub {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
/* Cards */
.cards-grid {
display: grid;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
@media (min-width: 1024px) {
.cards-grid {
grid-template-columns: 2fr 1fr;
}
}
.cards-grid-2 {
display: grid;
gap: 1.5rem;
}
@media (min-width: 1024px) {
.cards-grid-2 {
grid-template-columns: 1fr 1fr;
}
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 1rem;
backdrop-filter: blur(10px);
overflow: hidden;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.card-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 600;
font-size: 0.9rem;
}
.card-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
background: var(--bg-primary);
border-radius: 1rem;
font-size: 0.7rem;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.card-badge .live-dot {
width: 6px;
height: 6px;
background: var(--emerald);
border-radius: 50%;
animation: pulse 2s infinite;
}
.card-body {
padding: 1.25rem;
}
/* System Gauges */
.gauges-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
@media (min-width: 640px) {
.gauges-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.gauge {
display: flex;
flex-direction: column;
align-items: center;
}
.gauge-circle {
position: relative;
width: 90px;
height: 90px;
}
.gauge-svg {
transform: rotate(-90deg);
}
.gauge-bg {
fill: none;
stroke: var(--border);
stroke-width: 8;
}
.gauge-fill {
fill: none;
stroke: var(--cyan);
stroke-width: 8;
stroke-linecap: round;
transition: stroke-dasharray 0.5s ease, stroke 0.3s;
filter: drop-shadow(0 0 6px var(--cyan-glow));
}
.gauge-fill.warning {
stroke: var(--amber);
filter: drop-shadow(0 0 6px var(--amber-glow));
}
.gauge-fill.danger {
stroke: var(--rose);
filter: drop-shadow(0 0 6px var(--rose-glow));
}
.gauge-value {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.gauge-number {
font-size: 1.25rem;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
}
.gauge-unit {
font-size: 0.65rem;
color: var(--text-muted);
}
.gauge-label {
margin-top: 0.5rem;
font-size: 0.7rem;
color: var(--text-secondary);
}
/* Bandwidth */
.bandwidth-display {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
}
.bandwidth-row {
display: flex;
align-items: center;
gap: 0.375rem;
}
.bandwidth-value {
font-size: 1rem;
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
transition: color 0.3s ease;
}
/* Value update animation */
.value-update {
animation: valueFlash 0.3s ease;
}
@keyframes valueFlash {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
.bandwidth-unit {
font-size: 0.6rem;
color: var(--text-muted);
}
/* Bouncers */
.bouncer-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
}
.bouncer-item:last-child {
margin-bottom: 0;
}
.bouncer-info {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.bouncer-icon {
padding: 0.5rem;
background: rgba(16, 185, 129, 0.15);
border-radius: 0.5rem;
color: var(--emerald);
flex-shrink: 0;
}
.bouncer-icon.offline {
background: rgba(100, 116, 139, 0.15);
color: var(--text-muted);
}
.bouncer-name {
font-size: 0.8rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bouncer-type {
font-size: 0.7rem;
color: var(--text-muted);
}
.bouncer-status {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--emerald);
box-shadow: 0 0 8px var(--emerald-glow);
animation: pulse 2s infinite;
flex-shrink: 0;
}
.bouncer-status.offline {
background: var(--text-muted);
box-shadow: none;
animation: none;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
margin-top: 1rem;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 1.25rem;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
}
.stat-label {
font-size: 0.65rem;
color: var(--text-muted);
text-transform: uppercase;
}
/* Logs */
.logs-container {
max-height: 360px;
overflow-y: auto;
}
.logs-container::-webkit-scrollbar {
width: 6px;
}
.logs-container::-webkit-scrollbar-track {
background: var(--bg-primary);
border-radius: 3px;
}
.logs-container::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
.log-entry {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 0.625rem;
margin-bottom: 0.5rem;
transition: background 0.2s;
}
.log-entry:hover {
background: rgba(255, 255, 255, 0.03);
}
.log-entry:last-child {
margin-bottom: 0;
}
.log-entry.ban { background: rgba(244, 63, 94, 0.08); }
.log-entry.alert { background: rgba(245, 158, 11, 0.08); }
.log-entry.info { background: rgba(6, 182, 212, 0.08); }
.log-entry.success { background: rgba(16, 185, 129, 0.08); }
.log-icon {
flex-shrink: 0;
margin-top: 2px;
}
.log-icon.ban { color: var(--rose); }
.log-icon.alert { color: var(--amber); }
.log-icon.info { color: var(--cyan); }
.log-icon.success { color: var(--emerald); }
.log-content {
flex: 1;
min-width: 0;
}
.log-message {
font-size: 0.8rem;
color: var(--text-primary);
word-break: break-word;
line-height: 1.4;
}
.log-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
font-size: 0.7rem;
color: var(--text-muted);
}
/* Quick Actions */
.actions-grid {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
}
.action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.625rem;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
font-size: 0.8rem;
font-weight: 500;
}
.action-btn:hover {
border-color: var(--border-hover);
color: var(--text-primary);
transform: translateY(-2px);
}
.action-btn.primary {
background: linear-gradient(135deg, var(--cyan), #0891b2);
border: none;
color: white;
}
.action-btn.primary:hover {
box-shadow: 0 4px 15px var(--cyan-glow);
}
.action-btn.danger {
background: rgba(244, 63, 94, 0.15);
border-color: rgba(244, 63, 94, 0.3);
color: var(--rose);
}
.action-btn.danger:hover {
background: rgba(244, 63, 94, 0.25);
}
/* Quick Actions Grid */
.quick-actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
}
.quick-action-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.quick-action-title {
font-size: 0.7rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.quick-action-buttons {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.quick-action-buttons .action-btn {
justify-content: flex-start;
width: 100%;
}
/* Interfaces Table */
.interfaces-table {
width: 100%;
border-collapse: collapse;
}
.interfaces-table th {
text-align: left;
padding: 0.625rem;
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
border-bottom: 1px solid var(--border);
font-weight: 500;
}
.interfaces-table td {
padding: 0.625rem;
font-size: 0.8rem;
border-bottom: 1px solid var(--border);
}
.interfaces-table tr:last-child td {
border-bottom: none;
}
.interface-name {
font-family: 'JetBrains Mono', monospace;
color: var(--cyan);
}
.interface-status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border-radius: 1rem;
font-size: 0.7rem;
font-weight: 500;
}
.interface-status.up {
background: rgba(16, 185, 129, 0.15);
color: var(--emerald);
}
.interface-status.down {
background: rgba(244, 63, 94, 0.15);
color: var(--rose);
}
/* Decisions Table */
.decisions-table {
width: 100%;
border-collapse: collapse;
}
.decisions-table th {
text-align: left;
padding: 0.625rem;
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
border-bottom: 1px solid var(--border);
}
.decisions-table td {
padding: 0.625rem;
font-size: 0.8rem;
border-bottom: 1px solid var(--border);
}
.decisions-table tr:last-child td {
border-bottom: none;
}
.decisions-table .ip {
font-family: 'JetBrains Mono', monospace;
color: var(--cyan);
}
.decisions-table .action {
display: inline-flex;
padding: 0.2rem 0.5rem;
background: rgba(244, 63, 94, 0.15);
color: var(--rose);
border-radius: 0.25rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
}
/* Footer */
.footer {
text-align: center;
padding: 2rem;
border-top: 1px solid var(--border);
margin-top: 1.5rem;
}
.footer-text {
font-size: 0.8rem;
color: var(--text-muted);
}
.footer-text .cyan { color: var(--cyan); }
.footer-text .emerald { color: var(--emerald); }
.footer-text .violet { color: var(--violet); }
.footer-copy {
font-size: 0.7rem;
color: var(--text-muted);
margin-top: 0.5rem;
opacity: 0.7;
}
/* Mobile Nav */
.mobile-nav {
display: flex;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
z-index: 40;
padding: 0.5rem;
}
@media (min-width: 1024px) {
.mobile-nav {
display: none;
}
}
.mobile-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.5rem;
color: var(--text-muted);
cursor: pointer;
border-radius: 0.5rem;
transition: all 0.2s;
}
.mobile-nav-item:hover,
.mobile-nav-item.active {
color: var(--cyan);
}
.mobile-nav-item span {
font-size: 0.6rem;
}
/* Modal */
.modal {
display: none;
position: fixed;
inset: 0;
z-index: 200;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
padding: 1rem;
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 1.5rem;
width: 100%;
max-width: 450px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.modal-title {
font-size: 1.125rem;
font-weight: 600;
}
.modal-close {
display: flex;
padding: 0.375rem;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 0.375rem;
}
.modal-close:hover {
color: var(--text-primary);
background: var(--bg-primary);
}
/* Toast */
.toast-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toast {
padding: 0.875rem 1.25rem;
border-radius: 0.75rem;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.75rem;
animation: slideIn 0.3s ease;
max-width: 350px;
}
.toast.success {
background: rgba(16, 185, 129, 0.15);
border: 1px solid rgba(16, 185, 129, 0.3);
color: var(--emerald);
}
.toast.error {
background: rgba(244, 63, 94, 0.15);
border: 1px solid rgba(244, 63, 94, 0.3);
color: var(--rose);
}
.toast.info {
background: rgba(6, 182, 212, 0.15);
border: 1px solid rgba(6, 182, 212, 0.3);
color: var(--cyan);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Tab Pages */
.tab-page {
display: none;
animation: fadeIn 0.3s ease;
}
.tab-page.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Info Grid */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border-radius: 0.5rem;
border: 1px solid var(--border);
}
.info-label {
color: var(--text-secondary);
font-size: 0.8rem;
}
.info-value {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
color: var(--text-primary);
}
/* Service List */
.service-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.service-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.75rem;
transition: all 0.2s;
}
.service-item:hover {
border-color: var(--border-hover);
}
.service-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.service-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
border-radius: 0.5rem;
color: var(--cyan);
}
.service-icon.running {
color: var(--emerald);
}
.service-icon.stopped {
color: var(--rose);
}
.service-name {
font-weight: 500;
font-size: 0.9rem;
}
.service-status {
font-size: 0.75rem;
color: var(--text-muted);
}
.service-actions {
display: flex;
gap: 0.5rem;
}
.service-btn {
padding: 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 0.5rem;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.service-btn:hover {
border-color: var(--cyan);
color: var(--cyan);
}
.service-btn.danger:hover {
border-color: var(--rose);
color: var(--rose);
}
.service-btn.success:hover {
border-color: var(--emerald);
color: var(--emerald);
}
.service-btn.warning:hover {
border-color: var(--amber);
color: var(--amber);
}
.service-item.important {
border-left: 3px solid var(--cyan);
}
.service-badge {
font-size: 0.6rem;
padding: 0.125rem 0.375rem;
background: var(--cyan-glow);
color: var(--cyan);
border-radius: 0.25rem;
margin-left: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-running {
color: var(--emerald);
}
.status-stopped {
color: var(--text-muted);
}
.status-disabled {
color: var(--amber);
font-size: 0.7rem;
}
.loading-services {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
color: var(--text-muted);
}
/* Process Table */
.process-table {
font-size: 0.75rem;
}
.process-table th,
.process-table td {
padding: 0.5rem 0.75rem;
}
.process-cmd {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
}
.status-sleeping {
color: var(--text-muted);
}
/* DHCP Leases Table */
.leases-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.leases-table th,
.leases-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.leases-table th {
color: var(--text-muted);
font-weight: 500;
font-size: 0.7rem;
text-transform: uppercase;
}
.leases-table td {
font-family: 'JetBrains Mono', monospace;
}
/* Traffic Graph */
.traffic-graph {
height: 150px;
background: var(--bg-secondary);
border-radius: 0.75rem;
padding: 1rem;
position: relative;
overflow: hidden;
}
.traffic-canvas {
width: 100%;
height: 100%;
}
.traffic-legend {
display: flex;
gap: 1.5rem;
margin-top: 0.75rem;
justify-content: center;
}
.traffic-legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.traffic-legend-color {
width: 12px;
height: 3px;
border-radius: 2px;
}
/* Firewall Zones */
.zone-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.zone-card {
padding: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.75rem;
border-left: 3px solid var(--cyan);
}
.zone-card.lan {
border-left-color: var(--emerald);
}
.zone-card.wan {
border-left-color: var(--rose);
}
.zone-card.guest {
border-left-color: var(--amber);
}
.zone-name {
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.5rem;
text-transform: uppercase;
}
.zone-policy {
font-size: 0.75rem;
color: var(--text-muted);
}
.zone-interfaces {
margin-top: 0.5rem;
font-size: 0.8rem;
font-family: 'JetBrains Mono', monospace;
color: var(--text-secondary);
}
/* WiFi Status */
.wifi-card {
padding: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.75rem;
margin-bottom: 0.75rem;
}
.wifi-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.wifi-ssid {
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.wifi-band {
padding: 0.25rem 0.5rem;
background: var(--cyan);
color: white;
border-radius: 0.25rem;
font-size: 0.65rem;
font-weight: 600;
}
.wifi-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
font-size: 0.75rem;
}
.wifi-stat {
text-align: center;
padding: 0.5rem;
background: var(--bg-primary);
border-radius: 0.375rem;
}
.wifi-stat-value {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
color: var(--cyan);
}
.wifi-stat-label {
color: var(--text-muted);
font-size: 0.65rem;
}
/* Logs Filter */
.logs-header {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.logs-filter {
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.5rem;
color: var(--text-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.logs-filter:hover,
.logs-filter.active {
border-color: var(--cyan);
color: var(--cyan);
}
.logs-search {
flex: 1;
min-width: 200px;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.5rem;
color: var(--text-primary);
font-size: 0.8rem;
}
.logs-search:focus {
outline: none;
border-color: var(--cyan);
}
/* Full Logs Container */
.full-logs-container {
max-height: 500px;
overflow-y: auto;
}
/* Loading Skeleton */
.skeleton {
background: linear-gradient(90deg, var(--bg-secondary) 25%, var(--border) 50%, var(--bg-secondary) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 0.375rem;
}
.skeleton-text {
height: 1em;
width: 60%;
display: inline-block;
}
.skeleton-value {
height: 2em;
width: 3em;
display: inline-block;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Value transition animations */
.value-transition {
transition: all 0.3s ease-out;
}
.value-increase {
color: var(--emerald) !important;
}
.value-decrease {
color: var(--rose) !important;
}
/* Pulse effect for live data */
.pulse-on-update {
animation: pulseUpdate 0.5s ease-out;
}
@keyframes pulseUpdate {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
/* Loading overlay */
.loading-overlay {
position: absolute;
inset: 0;
background: rgba(13, 17, 23, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.loading-overlay.active {
opacity: 1;
pointer-events: auto;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--cyan);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Connection status indicator */
.connection-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
background: var(--bg-secondary);
border-radius: 1rem;
font-size: 0.7rem;
transition: all 0.3s;
}
.connection-indicator.connected {
background: rgba(16, 185, 129, 0.15);
color: var(--emerald);
}
.connection-indicator.disconnected {
background: rgba(244, 63, 94, 0.15);
color: var(--rose);
}
.connection-indicator.reconnecting {
background: rgba(245, 158, 11, 0.15);
color: var(--amber);
}
/* Smooth number counter */
.counter {
display: inline-block;
transition: transform 0.2s;
}
/* Progress bar for operations */
.progress-bar {
height: 3px;
background: var(--bg-secondary);
border-radius: 2px;
overflow: hidden;
position: relative;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--cyan), var(--emerald));
border-radius: 2px;
transition: width 0.3s ease-out;
}
.progress-bar.indeterminate .progress-bar-fill {
width: 30%;
animation: indeterminate 1.5s infinite;
}
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
/* Real-time badge */
.realtime-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
background: rgba(6, 182, 212, 0.15);
border: 1px solid rgba(6, 182, 212, 0.3);
border-radius: 0.375rem;
font-size: 0.65rem;
color: var(--cyan);
}
.realtime-badge .dot {
width: 6px;
height: 6px;
background: var(--cyan);
border-radius: 50%;
animation: pulse 2s infinite;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-muted);
}
.empty-state-icon {
margin-bottom: 1rem;
opacity: 0.5;
}
/* Responsive card loading */
.card.loading {
position: relative;
}
.card.loading::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
animation: loadingBar 1s infinite;
}
@keyframes loadingBar {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
</style>
</head>
<body>
<div class="bg-effects">
<div class="bg-grid"></div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<!-- Login Screen -->
<div class="login-screen" id="loginScreen">
<div class="login-card">
<div class="login-logo">
<div class="login-logo-icon">
<i data-lucide="shield" style="width: 28px; height: 28px; color: white;"></i>
</div>
<div>
<h1 class="login-title">SecuBox</h1>
<p class="login-subtitle">Control Center</p>
</div>
</div>
<div class="error-message" id="loginError">
<i data-lucide="alert-circle" style="width: 18px; height: 18px;"></i>
<span id="loginErrorText">Erreur de connexion</span>
</div>
<form id="loginForm">
<div class="form-group">
<label class="form-label">
<i data-lucide="server" style="width: 14px; height: 14px;"></i>
Serveur SecuBox
</label>
<input type="text" class="form-input mono" id="serverUrl" placeholder="http://192.168.1.1">
</div>
<div class="form-group">
<label class="form-label">
<i data-lucide="user" style="width: 14px; height: 14px;"></i>
Utilisateur
<span class="form-label-hint">(root par défaut)</span>
</label>
<input type="text" class="form-input mono" id="username" value="root" placeholder="root">
</div>
<div class="form-group">
<label class="form-label">
<i data-lucide="lock" style="width: 14px; height: 14px;"></i>
Mot de passe
</label>
<div class="form-input-group">
<input type="password" class="form-input mono" id="password" placeholder="Mot de passe OpenWrt">
<button type="button" class="form-input-toggle" onclick="togglePassword()">
<i data-lucide="eye" style="width: 18px; height: 18px;" id="passwordToggleIcon"></i>
</button>
</div>
</div>
<div class="form-checkbox">
<input type="checkbox" id="rememberMe" checked>
<label for="rememberMe">Se souvenir de moi</label>
</div>
<button type="submit" class="btn btn-primary" id="loginBtn">
<i data-lucide="log-in" style="width: 18px; height: 18px;"></i>
<span>Connexion</span>
</button>
</form>
<div class="divider">
<span>Authentification LuCI/rpcd</span>
</div>
<p style="text-align: center; font-size: 0.75rem; color: var(--text-muted);">
Utilisez vos identifiants OpenWrt/LuCI
</p>
<p style="text-align: center; margin-top: 1.5rem; font-size: 0.7rem; color: var(--text-muted);">
CyberMind.FR - Gandalf © 2025
</p>
</div>
</div>
<!-- Main App -->
<div class="app" id="app">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="sidebar-logo">
<div class="sidebar-logo-icon">
<i data-lucide="shield" style="width: 22px; height: 22px; color: white;"></i>
<span class="status-dot" id="sidebarStatusDot"></span>
</div>
<div>
<div style="font-weight: 700; font-size: 1rem;">SecuBox</div>
<div style="font-size: 0.7rem; color: var(--text-muted);" id="deviceHostname">Control Center</div>
</div>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-section-title">Monitoring</div>
<div class="nav-item active" data-tab="overview">
<i data-lucide="layout-dashboard" style="width: 18px; height: 18px;"></i>
<span>Vue d'ensemble</span>
</div>
<div class="nav-item" data-tab="system">
<i data-lucide="cpu" style="width: 18px; height: 18px;"></i>
<span>Système</span>
</div>
<div class="nav-item" data-tab="network">
<i data-lucide="wifi" style="width: 18px; height: 18px;"></i>
<span>Réseau</span>
</div>
</div>
<div class="nav-section">
<div class="nav-section-title">Sécurité</div>
<div class="nav-item" data-tab="crowdsec">
<i data-lucide="shield" style="width: 18px; height: 18px;"></i>
<span>CrowdSec</span>
<span class="nav-item-badge" id="navAlertsBadge">0</span>
</div>
<div class="nav-item" data-tab="firewall">
<i data-lucide="flame" style="width: 18px; height: 18px;"></i>
<span>Pare-feu</span>
</div>
<div class="nav-item" data-tab="logs">
<i data-lucide="scroll-text" style="width: 18px; height: 18px;"></i>
<span>Journaux</span>
</div>
</div>
<div class="nav-section">
<div class="nav-section-title">Administration</div>
<div class="nav-item" data-tab="services">
<i data-lucide="box" style="width: 18px; height: 18px;"></i>
<span>Services</span>
</div>
<div class="nav-item" data-tab="settings">
<i data-lucide="settings" style="width: 18px; height: 18px;"></i>
<span>Paramètres</span>
</div>
</div>
</nav>
<div class="sidebar-footer">
<div class="server-info" id="serverInfo">
<span class="server-status" id="serverStatus"></span>
<span class="server-host" id="serverHost">secubox.maegia.tv</span>
</div>
<div class="user-info">
<div class="user-avatar" id="userAvatar">R</div>
<div class="user-details">
<div class="user-name" id="userName">root</div>
<div class="user-role">Administrateur</div>
</div>
</div>
<button class="btn btn-secondary" style="width: 100%; font-size: 0.75rem;" onclick="logout()">
<i data-lucide="log-out" style="width: 14px; height: 14px;"></i>
Déconnexion
</button>
</div>
</aside>
<!-- Overlay -->
<div class="overlay" id="overlay" onclick="closeSidebar()"></div>
<!-- Main Content -->
<main class="main">
<!-- Header -->
<header class="header">
<div class="header-content">
<div class="header-left">
<button class="menu-btn" onclick="toggleSidebar()">
<i data-lucide="menu" style="width: 20px; height: 20px;"></i>
</button>
<div>
<h1 class="header-title" id="pageTitle">Tableau de Bord</h1>
<div class="header-time">
<i data-lucide="clock" style="width: 12px; height: 12px;"></i>
<span id="lastUpdate">--:--:--</span>
</div>
</div>
</div>
<div class="header-right">
<div class="status-badge" id="statusBadge">
<span class="server-status"></span>
<span id="statusText">Connecté</span>
</div>
<button class="icon-btn" id="alertsBtn" onclick="showAlertsModal()">
<i data-lucide="bell" style="width: 18px; height: 18px;"></i>
<span class="badge" id="alertsBadge">0</span>
</button>
<button class="icon-btn" onclick="refreshData()" id="refreshBtn">
<i data-lucide="refresh-cw" style="width: 18px; height: 18px;"></i>
</button>
</div>
</div>
</header>
<!-- Dashboard Content -->
<div class="dashboard" id="dashboardContent">
<!-- Overview Tab -->
<div class="tab-page active" id="tab-overview">
<!-- Metrics Grid -->
<div class="metrics-grid">
<div class="metric-card cyan">
<div class="metric-header">
<span class="metric-label">Décisions CrowdSec</span>
<div class="metric-icon">
<i data-lucide="shield" style="width: 18px; height: 18px;"></i>
</div>
</div>
<div class="metric-value" id="activeDecisions">--</div>
<div class="metric-sub">actives</div>
</div>
<div class="metric-card amber">
<div class="metric-header">
<span class="metric-label">Alertes</span>
<div class="metric-icon">
<i data-lucide="alert-triangle" style="width: 18px; height: 18px;"></i>
</div>
</div>
<div class="metric-value" id="alertsCount">--</div>
<div class="metric-sub">dernières 24h</div>
</div>
<div class="metric-card rose">
<div class="metric-header">
<span class="metric-label">Connexions</span>
<div class="metric-icon">
<i data-lucide="activity" style="width: 18px; height: 18px;"></i>
</div>
</div>
<div class="metric-value" id="connections">--</div>
<div class="metric-sub">actives</div>
</div>
<div class="metric-card emerald">
<div class="metric-header">
<span class="metric-label">Uptime</span>
<div class="metric-icon">
<i data-lucide="clock" style="width: 18px; height: 18px;"></i>
</div>
</div>
<div class="metric-value" id="uptime">--</div>
<div class="metric-sub" id="uptimeSub">--</div>
</div>
</div>
<!-- Main Cards Grid -->
<div class="cards-grid">
<!-- System Monitoring -->
<div class="card">
<div class="card-header">
<div class="card-title">
<i data-lucide="cpu" style="width: 18px; height: 18px; color: var(--cyan);"></i>
Ressources Système
</div>
<div class="card-badge" id="loadBadge">
Load: --
</div>
</div>
<div class="card-body">
<div class="gauges-grid">
<div class="gauge">
<div class="gauge-circle">
<svg class="gauge-svg" width="90" height="90" viewBox="0 0 100 100">
<circle class="gauge-bg" cx="50" cy="50" r="40"/>
<circle class="gauge-fill" id="cpuGauge" cx="50" cy="50" r="40" stroke-dasharray="0 251"/>
</svg>
<div class="gauge-value">
<span class="gauge-number" id="cpuValue">--</span>
<span class="gauge-unit">%</span>
</div>
</div>
<span class="gauge-label">CPU</span>
</div>
<div class="gauge">
<div class="gauge-circle">
<svg class="gauge-svg" width="90" height="90" viewBox="0 0 100 100">
<circle class="gauge-bg" cx="50" cy="50" r="40"/>
<circle class="gauge-fill" id="ramGauge" cx="50" cy="50" r="40" stroke-dasharray="0 251"/>
</svg>
<div class="gauge-value">
<span class="gauge-number" id="ramValue">--</span>
<span class="gauge-unit">%</span>
</div>
</div>
<span class="gauge-label">RAM</span>
</div>
<div class="gauge">
<div class="gauge-circle">
<svg class="gauge-svg" width="90" height="90" viewBox="0 0 100 100">
<circle class="gauge-bg" cx="50" cy="50" r="40"/>
<circle class="gauge-fill" id="diskGauge" cx="50" cy="50" r="40" stroke-dasharray="0 251"/>
</svg>
<div class="gauge-value">
<span class="gauge-number" id="diskValue">--</span>
<span class="gauge-unit">%</span>
</div>
</div>
<span class="gauge-label">Disque</span>
</div>
<div class="gauge">
<div class="bandwidth-display">
<div class="bandwidth-row">
<i data-lucide="arrow-down" style="width: 12px; height: 12px; color: var(--emerald);"></i>
<span class="bandwidth-value" style="color: var(--emerald);" id="rxRate">--</span>
<span class="bandwidth-unit">KB/s</span>
</div>
<div class="bandwidth-row">
<i data-lucide="arrow-up" style="width: 12px; height: 12px; color: var(--cyan);"></i>
<span class="bandwidth-value" style="color: var(--cyan);" id="txRate">--</span>
<span class="bandwidth-unit">KB/s</span>
</div>
</div>
<span class="gauge-label">Réseau</span>
</div>
</div>
</div>
</div>
<!-- CrowdSec Status -->
<div class="card">
<div class="card-header">
<div class="card-title">
<i data-lucide="shield" style="width: 18px; height: 18px; color: var(--emerald);"></i>
CrowdSec
</div>
<div class="card-badge" id="crowdsecStatus">
<span class="live-dot"></span>
Actif
</div>
</div>
<div class="card-body">
<div id="bouncersList">
<!-- Bouncers dynamically loaded -->
</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value" id="parsersCount">--</div>
<div class="stat-label">Parsers</div>
</div>
<div class="stat-item">
<div class="stat-value" id="scenariosCount">--</div>
<div class="stat-label">Scénarios</div>
</div>
</div>
</div>
</div>
</div>
<!-- Second Row -->
<div class="cards-grid-2">
<!-- System Logs -->
<div class="card">
<div class="card-header">
<div class="card-title">
<i data-lucide="scroll-text" style="width: 18px; height: 18px; color: var(--amber);"></i>
Journaux Système
</div>
<div class="card-badge">
<span class="live-dot"></span>
Live
</div>
</div>
<div class="card-body">
<div class="logs-container" id="logsContainer">
<!-- Logs dynamically loaded -->
</div>
</div>
</div>
<!-- Network Interfaces -->
<div class="card">
<div class="card-header">
<div class="card-title">
<i data-lucide="network" style="width: 18px; height: 18px; color: var(--violet);"></i>
Interfaces Réseau
</div>
</div>
<div class="card-body" style="overflow-x: auto;">
<table class="interfaces-table" id="interfacesTable">
<thead>
<tr>
<th>Interface</th>
<th>IP</th>
<th>État</th>
</tr>
</thead>
<tbody id="interfacesBody">
<!-- Interfaces dynamically loaded -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Third Row - Admin Sessions -->
<div class="cards-grid-2" style="margin-top: 1.5rem;">
<!-- Admin Sessions -->
<div class="card">
<div class="card-header">
<div class="card-title">
<i data-lucide="users" style="width: 18px; height: 18px; color: var(--rose);"></i>
Sessions Admin LuCI
</div>
<div class="card-badge" id="adminSessionsCount">0 sessions</div>
</div>
<div class="card-body" style="overflow-x: auto; max-height: 200px; overflow-y: auto;">
<table class="interfaces-table">
<thead>
<tr>
<th>Utilisateur</th>
<th>IP Source</th>
<th>Expiration</th>
<th>Statut</th>
</tr>
</thead>
<tbody id="adminSessionsBody">
<!-- Sessions dynamically loaded -->
</tbody>
</table>
</div>
</div>
<!-- Firewall Stats Overview -->
<div class="card">
<div class="card-header">
<div class="card-title">
<i data-lucide="shield-ban" style="width: 18px; height: 18px; color: var(--amber);"></i>
Statistiques Blocage
</div>
</div>
<div class="card-body">
<div class="stats-grid" style="grid-template-columns: repeat(2, 1fr);">
<div class="stat-item">
<div class="stat-value" id="totalBansToday">--</div>
<div class="stat-label">Bans Aujourd'hui</div>
</div>
<div class="stat-item">
<div class="stat-value" id="totalBlockedAttempts">--</div>
<div class="stat-label">Tentatives Bloquées</div>
</div>
<div class="stat-item">
<div class="stat-value" id="topScenario">--</div>
<div class="stat-label">Top Scénario</div>
</div>
<div class="stat-item">
<div class="stat-value" id="uniqueIPs">--</div>
<div class="stat-label">IPs Uniques</div>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card" style="margin-top: 1.5rem;">
<div class="card-header">
<div class="card-title">
<i data-lucide="zap" style="width: 18px; height: 18px; color: var(--violet);"></i>
Actions Rapides
</div>
</div>
<div class="card-body">
<div class="quick-actions-grid">
<div class="quick-action-group">
<div class="quick-action-title">Sécurité</div>
<div class="quick-action-buttons">
<button class="action-btn primary" onclick="reloadCrowdSec()" title="Recharger CrowdSec">
<i data-lucide="shield" style="width: 16px; height: 16px;"></i>
Reload CrowdSec
</button>
<button class="action-btn" onclick="showBlockModal()" title="Bloquer une IP manuellement">
<i data-lucide="ban" style="width: 16px; height: 16px;"></i>
Bloquer IP
</button>
<button class="action-btn" onclick="showDecisionsModal()" title="Voir les décisions actives">
<i data-lucide="list" style="width: 16px; height: 16px;"></i>
Décisions
</button>
</div>
</div>
<div class="quick-action-group">
<div class="quick-action-title">Réseau</div>
<div class="quick-action-buttons">
<button class="action-btn" onclick="restartNetwork()" title="Redémarrer le réseau">
<i data-lucide="wifi" style="width: 16px; height: 16px;"></i>
Restart Réseau
</button>
<button class="action-btn" onclick="restartFirewall()" title="Redémarrer le pare-feu">
<i data-lucide="flame" style="width: 16px; height: 16px;"></i>
Restart Firewall
</button>
<button class="action-btn" onclick="flushDNS()" title="Vider le cache DNS">
<i data-lucide="database" style="width: 16px; height: 16px;"></i>
Flush DNS
</button>
</div>
</div>
<div class="quick-action-group">
<div class="quick-action-title">Système</div>
<div class="quick-action-buttons">
<a href="/cgi-bin/luci/" target="_blank" class="action-btn" title="Ouvrir LuCI">
<i data-lucide="external-link" style="width: 16px; height: 16px;"></i>
LuCI Admin
</a>
<button class="action-btn" onclick="syncTime()" title="Synchroniser l'heure NTP">
<i data-lucide="clock" style="width: 16px; height: 16px;"></i>
Sync NTP
</button>
<button class="action-btn danger" onclick="confirmReboot()" title="Redémarrer le système">
<i data-lucide="power" style="width: 16px; height: 16px;"></i>
Redémarrer
</button>
</div>
</div>
</div>
</div>
</div>
</div><!-- End tab-overview -->
<!-- System Tab -->
<div class="tab-page" id="tab-system">
<div class="card" style="margin-bottom: 1.5rem;">
<div class="card-header">
<div class="card-title">
<i data-lucide="info" style="width: 18px; height: 18px; color: var(--cyan);"></i>
Informations Système
</div>
</div>
<div class="card-body">
<div class="info-grid" id="systemInfoGrid">
<div class="info-item"><span class="info-label">Hostname</span><span class="info-value" id="sysHostname">--</span></div>
<div class="info-item"><span class="info-label">Modèle</span><span class="info-value" id="sysModel">--</span></div>
<div class="info-item"><span class="info-label">Architecture</span><span class="info-value" id="sysArch">--</span></div>
<div class="info-item"><span class="info-label">Kernel</span><span class="info-value" id="sysKernel">--</span></div>
<div class="info-item"><span class="info-label">OpenWrt</span><span class="info-value" id="sysRelease">--</span></div>
<div class="info-item"><span class="info-label">Uptime</span><span class="info-value" id="sysUptime">--</span></div>
<div class="info-item"><span class="info-label">Date locale</span><span class="info-value" id="sysLocaltime">--</span></div>
<div class="info-item"><span class="info-label">Fuseau horaire</span><span class="info-value" id="sysTimezone">--</span></div>
</div>
</div>
</div>
<div class="cards-grid">
<div class="card">
<div class="card-header">
<div class="card-title">
<i data-lucide="hard-drive" style="width: 18px; height: 18px; color: var(--amber);"></i>
Stockage
</div>
</div>
<div class="card-body">
<div id="storageInfo">
<div class="info-item"><span class="info-label">Racine (/)</span><span class="info-value" id="storageRoot">--</span></div>
<div class="info-item"><span class="info-label">/tmp</span><span class="info-value" id="storageTmp">--</span></div>
<div class="info-item"><span class="info-label">/overlay</span><span class="info-value" id="storageOverlay">--</span></div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">
<i data-lucide="memory-stick" style="width: 18px; height: 18px; color: var(--violet);"></i>
Mémoire
</div>
</div>
<div class="card-body">
<div id="memoryInfo">
<div class="info-item"><span class="info-label">Total</span><span class="info-value" id="memTotal">--</span></div>
<div class="info-item"><span class="info-label">Utilisée</span><span class="info-value" id="memUsed">--</span></div>
<div class="info-item"><span class="info-label">Libre</span><span class="info-value" id="memFree">--</span></div>
<div class="info-item"><span class="info-label">Buffers</span><span class="info-value" id="memBuffers">--</span></div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">
<i data-lucide="cpu" style="width: 18px; height: 18px; color: var(--rose);"></i>
Processus
</div>
<div class="card-badge" id="processCount">0 processus</div>
</div>
<div class="card-body" style="overflow-x: auto; max-height: 400px; overflow-y: auto;">
<table class="interfaces-table process-table">
<thead>
<tr>
<th>PID</th>
<th>Utilisateur</th>
<th>CPU%</th>
<th>MEM%</th>
<th>Commande</th>
</tr>
</thead>
<tbody id="processListBody">
</tbody>
</table>
</div>
</div>
</div>
<!-- Network Tab -->
<div class="tab-page" id="tab-network">
<div class="cards-grid" style="margin-bottom: 1.5rem;">
<div class="card">
<div class="card-header">
<div class="card-title">
<i data-lucide="wifi" style="width: 18px; height: 18px; color: var(--cyan);"></i>
Réseaux WiFi
</div>
</div>
<div class="card-body" id="wifiNetworks">
<div class="empty-state">Chargement...</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">
<i data-lucide="activity" style="width: 18px; height: 18px; color: var(--emerald);"></i>
Trafic Réseau
</div>
</div>
<div class="card-body">
<div class="traffic-graph">
<canvas id="trafficCanvas" class="traffic-canvas"></canvas>
</div>
<div class="traffic-legend">
<div class="traffic-legend-item">
<span class="traffic-legend-color" style="background: var(--emerald);"></span>
<span>Download</span>
</div>
<div class="traffic-legend-item">
<span class="traffic-legend-color" style="background: var(--cyan);"></span>
<span>Upload</span>
</div>
</div>
</div>
</div>
</div>
<div class="card" style="margin-bottom: 1.5rem;">
<div class="card-header">
<div class="card-title">
<i data-lucide="network" style="width: 18px; height: 18px; color: var(--violet);"></i>
Interfaces Réseau
</div>
</div>
<div class="card-body" style="overflow-x: auto;">
<table class="interfaces-table" id="fullInterfacesTable">
<thead>
<tr>
<th>Interface</th>
<th>Adresse IP</th>
<th>Passerelle</th>
<th>DNS</th>
<th>État</th>
</tr>
</thead>
<tbody id="fullInterfacesBody">
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">
<i data-lucide="users" style="width: 18px; height: 18px; color: var(--amber);"></i>
Baux DHCP
</div>
<div class="card-badge" id="dhcpCount">0 clients</div>
</div>
<div class="card-body" style="overflow-x: auto;">
<table class="leases-table">
<thead>
<tr>
<th>Hostname</th>
<th>Adresse IP</th>
<th>Adresse MAC</th>
<th>Expire</th>
</tr>
</thead>
<tbody id="dhcpLeasesBody">
</tbody>
</table>
</div>
</div>
</div>
<!-- CrowdSec Tab -->
<div class="tab-page" id="tab-crowdsec">
<div class="metrics-grid" style="margin-bottom: 1.5rem;">
<div class="metric-card cyan">
<div class="metric-header">
<span class="metric-label">Décisions</span>
<div class="metric-icon"><i data-lucide="shield" style="width: 18px; height: 18px;"></i></div>
</div>
<div class="metric-value" id="csDecisions">--</div>
<div class="metric-sub">actives</div>
</div>
<div class="metric-card amber">
<div class="metric-header">
<span class="metric-label">Alertes 24h</span>
<div class="metric-icon"><i data-lucide="alert-triangle" style="width: 18px; height: 18px;"></i></div>
</div>
<div class="metric-value" id="csAlerts">--</div>
<div class="metric-sub">dernières 24h</div>
</div>
<div class="metric-card emerald">
<div class="metric-header">
<span class="metric-label">Bouncers</span>
<div class="metric-icon"><i data-lucide="shield-check" style="width: 18px; height: 18px;"></i></div>
</div>
<div class="metric-value" id="csBouncers">--</div>
<div class="metric-sub">connectés</div>
</div>
<div class="metric-card violet">
<div class="metric-header">
<span class="metric-label">Machines</span>
<div class="metric-icon"><i data-lucide="server" style="width: 18px; height: 18px;"></i></div>
</div>
<div class="metric-value" id="csMachines">--</div>
<div class="metric-sub">enregistrées</div>
</div>
</div>
<div class="card" style="margin-bottom: 1.5rem;">
<div class="card-header">
<div class="card-title">
<i data-lucide="list" style="width: 18px; height: 18px; color: var(--cyan);"></i>
Décisions Actives
</div>
<button class="btn btn-secondary" style="font-size: 0.75rem; padding: 0.5rem 0.75rem;" onclick="refreshCrowdSecDecisions()">
<i data-lucide="refresh-cw" style="width: 14px; height: 14px;"></i>
Actualiser
</button>
</div>
<div class="card-body" style="overflow-x: auto;">
<table class="decisions-table">
<thead>
<tr>
<th>IP / Range</th>
<th>Raison</th>
<th>Type</th>
<th>Durée</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="csDecisionsBody">
</tbody>
</table>
</div>
</div>
<div class="card" style="margin-bottom: 1.5rem;">
<div class="card-header">
<div class="card-title">
<i data-lucide="alert-triangle" style="width: 18px; height: 18px; color: var(--amber);"></i>
Alertes Récentes (24h)
</div>
<div class="card-badge" id="csAlertsBadge">0 alertes</div>
</div>
<div class="card-body" style="overflow-x: auto; max-height: 350px; overflow-y: auto;">
<table class="decisions-table alerts-table">
<thead>
<tr>
<th>Source</th>
<th>Scénario</th>
<th>Événements</th>
<th>Date</th>
</tr>
</thead>
<tbody id="csAlertsBody">
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">
<i data-lucide="zap" style="width: 18px; height: 18px; color: var(--violet);"></i>
Actions CrowdSec
</div>
</div>
<div class="card-body">
<div class="actions-grid">
<button class="action-btn primary" onclick="reloadCrowdSec()">
<i data-lucide="refresh-cw" style="width: 16px; height: 16px;"></i>
Recharger CrowdSec
</button>
<button class="action-btn" onclick="showBlockModal()">
<i data-lucide="ban" style="width: 16px; height: 16px;"></i>
Bloquer IP
</button>
<button class="action-btn" onclick="showUnbanModal()">
<i data-lucide="unlock" style="width: 16px; height: 16px;"></i>
Débloquer IP
</button>
<button class="action-btn" onclick="updateCrowdSecHub()">
<i data-lucide="download" style="width: 16px; height: 16px;"></i>
Mettre à jour Hub
</button>
</div>
</div>
</div>
</div>
<!-- Firewall Tab -->
<div class="tab-page" id="tab-firewall">
<div class="card" style="margin-bottom: 1.5rem;">
<div class="card-header">
<div class="card-title">
<i data-lucide="layers" style="width: 18px; height: 18px; color: var(--cyan);"></i>
Zones Firewall
</div>
</div>
<div class="card-body">
<div class="zone-grid" id="firewallZones">
</div>
</div>
</div>
<div class="card" style="margin-bottom: 1.5rem;">
<div class="card-header">
<div class="card-title">
<i data-lucide="arrow-right-left" style="width: 18px; height: 18px; color: var(--emerald);"></i>
Règles de Forwarding
</div>
</div>
<div class="card-body" style="overflow-x: auto;">
<table class="interfaces-table">
<thead>
<tr>
<th>Source</th>
<th>Destination</th>
<th>Action</th>
</tr>
</thead>
<tbody id="forwardingRulesBody">
</tbody>
</table>
</div>
</div>
<div class="card" style="margin-bottom: 1.5rem;">
<div class="card-header">
<div class="card-title">
<i data-lucide="arrow-up-right" style="width: 18px; height: 18px; color: var(--amber);"></i>
Redirections de Ports (DNAT)
</div>
<div class="card-badge" id="portForwardsCount">0 règles</div>
</div>
<div class="card-body" style="overflow-x: auto;">
<table class="interfaces-table">
<thead>
<tr>
<th>Nom</th>
<th>Protocole</th>
<th>Port Externe</th>
<th>Destination</th>
<th>État</th>
</tr>
</thead>
<tbody id="portForwardsBody">
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">
<i data-lucide="zap" style="width: 18px; height: 18px; color: var(--violet);"></i>
Actions Firewall
</div>
</div>
<div class="card-body">
<div class="actions-grid">
<button class="action-btn primary" onclick="restartFirewall()">
<i data-lucide="refresh-cw" style="width: 16px; height: 16px;"></i>
Redémarrer Firewall
</button>
<button class="action-btn" onclick="reloadFirewall()">
<i data-lucide="rotate-cw" style="width: 16px; height: 16px;"></i>
Recharger Règles
</button>
</div>
</div>
</div>
</div>
<!-- Logs Tab -->
<div class="tab-page" id="tab-logs">
<div class="card">
<div class="card-header">
<div class="card-title">
<i data-lucide="scroll-text" style="width: 18px; height: 18px; color: var(--amber);"></i>
Journaux Système
</div>
<div class="card-badge">
<span class="live-dot"></span>
Live
</div>
</div>
<div class="card-body">
<div class="logs-header">
<button class="logs-filter active" data-filter="all">Tous</button>
<button class="logs-filter" data-filter="error">Erreurs</button>
<button class="logs-filter" data-filter="warn">Warnings</button>
<button class="logs-filter" data-filter="crowdsec">CrowdSec</button>
<input type="text" class="logs-search" id="logsSearch" placeholder="Rechercher dans les logs...">
</div>
<div class="full-logs-container" id="fullLogsContainer">
</div>
</div>
</div>
</div>
<!-- Services Tab -->
<div class="tab-page" id="tab-services">
<div class="card">
<div class="card-header">
<div class="card-title">
<i data-lucide="box" style="width: 18px; height: 18px; color: var(--cyan);"></i>
Services Système
</div>
<button class="btn btn-secondary" style="font-size: 0.75rem; padding: 0.5rem 0.75rem;" onclick="refreshServices()">
<i data-lucide="refresh-cw" style="width: 14px; height: 14px;"></i>
Actualiser
</button>
</div>
<div class="card-body">
<div class="service-list" id="servicesList">
</div>
</div>
</div>
</div>
<!-- Settings Tab -->
<div class="tab-page" id="tab-settings">
<div class="card" style="margin-bottom: 1.5rem;">
<div class="card-header">
<div class="card-title">
<i data-lucide="settings" style="width: 18px; height: 18px; color: var(--cyan);"></i>
Paramètres Dashboard
</div>
</div>
<div class="card-body">
<div class="form-group">
<label class="form-label">Intervalle de rafraîchissement</label>
<select class="form-input" id="settingsRefreshInterval" onchange="updateRefreshInterval()">
<option value="3000">3 secondes</option>
<option value="5000" selected>5 secondes</option>
<option value="10000">10 secondes</option>
<option value="30000">30 secondes</option>
<option value="60000">1 minute</option>
</select>
</div>
</div>
</div>
<div class="card" style="margin-bottom: 1.5rem;">
<div class="card-header">
<div class="card-title">
<i data-lucide="link" style="width: 18px; height: 18px; color: var(--violet);"></i>
Liens Utiles
</div>
</div>
<div class="card-body">
<div class="actions-grid">
<a href="/cgi-bin/luci/" target="_blank" class="action-btn">
<i data-lucide="external-link" style="width: 16px; height: 16px;"></i>
LuCI Admin
</a>
<a href="/cgi-bin/luci/admin/status/overview" target="_blank" class="action-btn">
<i data-lucide="activity" style="width: 16px; height: 16px;"></i>
Status LuCI
</a>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">
<i data-lucide="power" style="width: 18px; height: 18px; color: var(--rose);"></i>
Actions Système
</div>
</div>
<div class="card-body">
<div class="actions-grid">
<button class="action-btn danger" onclick="confirmReboot()">
<i data-lucide="power" style="width: 16px; height: 16px;"></i>
Redémarrer
</button>
<button class="action-btn" onclick="logout()">
<i data-lucide="log-out" style="width: 16px; height: 16px;"></i>
Déconnexion
</button>
</div>
</div>
</div>
</div>
<!-- Footer -->
<footer class="footer">
<p class="footer-text">
SecuBox Control Center v2.4 • Propulsé par
<span class="cyan">CrowdSec</span> +
<span class="emerald">OpenWrt</span> +
<span class="violet">LuCI</span>
</p>
<p class="footer-copy">© 2025 CyberMind.FR - Gandalf</p>
</footer>
</div>
</main>
<!-- Mobile Nav -->
<nav class="mobile-nav">
<div class="mobile-nav-item active" data-tab="overview">
<i data-lucide="layout-dashboard" style="width: 18px; height: 18px;"></i>
<span>Accueil</span>
</div>
<div class="mobile-nav-item" data-tab="crowdsec">
<i data-lucide="shield" style="width: 18px; height: 18px;"></i>
<span>CrowdSec</span>
</div>
<div class="mobile-nav-item" data-tab="network">
<i data-lucide="wifi" style="width: 18px; height: 18px;"></i>
<span>Réseau</span>
</div>
<div class="mobile-nav-item" data-tab="logs">
<i data-lucide="scroll-text" style="width: 18px; height: 18px;"></i>
<span>Logs</span>
</div>
<div class="mobile-nav-item" data-tab="settings">
<i data-lucide="settings" style="width: 18px; height: 18px;"></i>
<span>Config</span>
</div>
</nav>
</div>
<!-- Block IP Modal -->
<div class="modal" id="blockModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Bloquer une IP</h3>
<button class="modal-close" onclick="closeModal('blockModal')">
<i data-lucide="x" style="width: 18px; height: 18px;"></i>
</button>
</div>
<form id="blockForm" onsubmit="blockIP(event)">
<div class="form-group">
<label class="form-label">Adresse IP</label>
<input type="text" class="form-input mono" id="blockIp" placeholder="192.168.1.100" required>
</div>
<div class="form-group">
<label class="form-label">Durée</label>
<select class="form-input" id="blockDuration">
<option value="1h">1 heure</option>
<option value="4h">4 heures</option>
<option value="24h" selected>24 heures</option>
<option value="168h">7 jours</option>
<option value="720h">30 jours</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Raison</label>
<input type="text" class="form-input" id="blockReason" value="Blocage manuel via dashboard" placeholder="Raison du blocage">
</div>
<button type="submit" class="btn btn-primary">
<i data-lucide="ban" style="width: 18px; height: 18px;"></i>
Bloquer l'IP
</button>
</form>
</div>
</div>
<!-- Decisions Modal -->
<div class="modal" id="decisionsModal">
<div class="modal-content" style="max-width: 700px;">
<div class="modal-header">
<h3 class="modal-title">Décisions CrowdSec</h3>
<button class="modal-close" onclick="closeModal('decisionsModal')">
<i data-lucide="x" style="width: 18px; height: 18px;"></i>
</button>
</div>
<div style="overflow-x: auto;">
<table class="decisions-table">
<thead>
<tr>
<th>IP</th>
<th>Raison</th>
<th>Type</th>
<th>Durée</th>
</tr>
</thead>
<tbody id="decisionsBody">
<!-- Decisions dynamically loaded -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Unban Modal -->
<div class="modal" id="unbanModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Débloquer une IP</h3>
<button class="modal-close" onclick="closeModal('unbanModal')">
<i data-lucide="x" style="width: 18px; height: 18px;"></i>
</button>
</div>
<form id="unbanForm" onsubmit="unbanIP(event)">
<div class="form-group">
<label class="form-label">Adresse IP à débloquer</label>
<input type="text" class="form-input mono" id="unbanIp" placeholder="192.168.1.100" required>
</div>
<button type="submit" class="btn btn-primary" style="background: linear-gradient(135deg, var(--emerald), #059669);">
<i data-lucide="unlock" style="width: 18px; height: 18px;"></i>
Débloquer l'IP
</button>
</form>
</div>
</div>
<script>
// =====================================================
// SecuBox Dashboard - OpenWrt/LuCI/rpcd Authentication
// =====================================================
const CONFIG = {
// Auto-detect server URL from current page location
serverUrl: window.location.origin,
username: '',
sessionToken: null,
ubusSession: '00000000000000000000000000000000',
refreshInterval: 3000, // 3 seconds for reactive updates
sessionTimeout: 3600000, // 1 hour
maxRetries: 3,
retryDelay: 1000
};
// State management for reactive updates
const STATE = {
cpu: { previous: null, current: 0 },
memory: { previous: null, current: 0 },
network: { previousRx: 0, previousTx: 0, previousTime: 0, rxRate: 0, txRate: 0 },
connections: 0,
disk: 0,
uptime: 0,
lastUpdate: null,
retryCount: 0,
isOnline: true
};
// Data cache for optimization
const CACHE = {
boardInfo: null,
services: null,
servicesTimestamp: 0,
cacheDuration: 30000 // 30 seconds cache
};
let refreshTimer = null;
let sessionTimer = null;
let tabRefreshTimer = null;
let isConnected = false;
let previousNetStats = null;
// =====================================================
// UBUS/RPC API
// =====================================================
const UBUS = {
rpcId: 1,
// Call ubus via HTTP POST
async call(object, method, params = {}) {
const body = {
jsonrpc: '2.0',
id: this.rpcId++,
method: 'call',
params: [CONFIG.ubusSession, object, method, params]
};
try {
const response = await fetch(`${CONFIG.serverUrl}/ubus`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body),
credentials: 'include'
});
const data = await response.json();
if (data.error) {
throw new Error(data.error.message || 'UBUS Error');
}
// UBUS returns [code, result] - code 0 means success
if (data.result && Array.isArray(data.result)) {
const code = data.result[0];
if (code === 0) {
return data.result[1] || {};
} else if (code === 6) {
// Session expired
throw new Error('SESSION_EXPIRED');
} else {
// Other error codes: 1=invalid cmd, 2=invalid arg, 4=not found, 5=no data, 7=permission denied
console.warn(`UBUS error code ${code} for ${object}.${method}`);
return null;
}
}
return data.result?.[1] || null;
} catch (error) {
console.error(`UBUS Error [${object}.${method}]:`, error);
throw error;
}
},
// Login via ubus session
async login(username, password) {
const body = {
jsonrpc: '2.0',
id: this.rpcId++,
method: 'call',
params: [
'00000000000000000000000000000000',
'session',
'login',
{ username, password }
]
};
const response = await fetch(`${CONFIG.serverUrl}/ubus`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body),
credentials: 'include'
});
const data = await response.json();
if (data.result && data.result[0] === 0 && data.result[1]?.ubus_rpc_session) {
return data.result[1].ubus_rpc_session;
}
throw new Error('Authentification échouée');
},
// System calls
async getSystemBoard() {
return await this.call('system', 'board');
},
async getSystemInfo() {
return await this.call('system', 'info');
},
// Network calls
async getNetworkDevices() {
return await this.call('network.device', 'status');
},
async getNetworkInterfaces() {
return await this.call('network.interface', 'dump');
},
// Service management
async getServices() {
return await this.call('service', 'list');
},
async restartService(name) {
return await this.call('service', 'signal', { name, signal: 'reload' });
},
// File operations
async execCommand(command, params = []) {
return await this.call('file', 'exec', {
command: command,
params: params
});
},
// Firewall
async getFirewallStatus() {
return await this.call('luci', 'getInitList', { name: 'firewall' });
},
// System actions
async reboot() {
return await this.call('system', 'reboot');
}
};
// =====================================================
// LuCI RPC (Fallback)
// =====================================================
const LUCI_RPC = {
token: null,
async auth(username, password) {
const response = await fetch(`${CONFIG.serverUrl}/cgi-bin/luci/rpc/auth`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: 1,
method: 'login',
params: [username, password]
}),
credentials: 'include'
});
const data = await response.json();
if (data.result) {
this.token = data.result;
return data.result;
}
throw new Error('Authentification LuCI échouée');
},
async call(module, method, params = []) {
const response = await fetch(`${CONFIG.serverUrl}/cgi-bin/luci/rpc/${module}?auth=${this.token}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: 1,
method: method,
params: params
}),
credentials: 'include'
});
return await response.json();
}
};
// =====================================================
// CrowdSec Integration via RPC backend
// =====================================================
const CROWDSEC = {
async getDecisions() {
try {
const result = await UBUS.call('luci.crowdsec-dashboard', 'decisions');
return result?.alerts || [];
} catch (e) {
console.warn('CrowdSec decisions error:', e);
return [];
}
},
async getBouncers() {
try {
const result = await UBUS.call('luci.crowdsec-dashboard', 'bouncers');
return result?.bouncers || [];
} catch (e) {
console.warn('CrowdSec bouncers error:', e);
return [];
}
},
async getAlerts() {
try {
const result = await UBUS.call('luci.crowdsec-dashboard', 'alerts', { limit: 50 });
return result?.alerts || [];
} catch (e) {
console.warn('CrowdSec alerts error:', e);
return [];
}
},
async getMetrics() {
try {
const result = await UBUS.call('luci.crowdsec-dashboard', 'metrics');
return result || null;
} catch (e) {
return null;
}
},
async getNftablesStats() {
try {
const result = await UBUS.call('luci.crowdsec-dashboard', 'nftables_stats');
return result || {};
} catch (e) {
console.warn('CrowdSec nftables_stats error:', e);
return {};
}
},
async getStatus() {
try {
const result = await UBUS.call('luci.crowdsec-dashboard', 'status');
return result || {};
} catch (e) {
return {};
}
},
async addDecision(ip, duration, reason) {
try {
const result = await UBUS.call('luci.crowdsec-dashboard', 'ban', {
ip: ip,
duration: duration,
reason: reason
});
return result;
} catch (e) {
throw new Error('Erreur lors du blocage: ' + e.message);
}
},
async removeDecision(ip) {
try {
const result = await UBUS.call('luci.crowdsec-dashboard', 'unban', { ip: ip });
return result;
} catch (e) {
throw new Error('Erreur lors du déblocage: ' + e.message);
}
},
async reload() {
try {
await UBUS.call('luci.crowdsec-dashboard', 'service_control', { action: 'reload' });
return true;
} catch (e) {
throw new Error('Erreur reload CrowdSec: ' + e.message);
}
}
};
// =====================================================
// Authentication
// =====================================================
async function login(serverUrl, username, password) {
CONFIG.serverUrl = serverUrl.replace(/\/$/, '');
CONFIG.username = username;
// Try UBUS first
try {
CONFIG.ubusSession = await UBUS.login(username, password);
console.log('UBUS login success');
} catch (e) {
console.warn('UBUS login failed, trying LuCI RPC...');
// Try LuCI RPC as fallback
try {
await LUCI_RPC.auth(username, password);
console.log('LuCI RPC login success');
} catch (e2) {
throw new Error('Échec de l\'authentification');
}
}
// Save session
if (document.getElementById('rememberMe').checked) {
localStorage.setItem('secubox_server', CONFIG.serverUrl);
localStorage.setItem('secubox_username', CONFIG.username);
localStorage.setItem('secubox_session', CONFIG.ubusSession);
localStorage.setItem('secubox_timestamp', Date.now().toString());
}
// Start session timer
sessionTimer = setTimeout(() => {
showToast('Session expirée, reconnexion nécessaire', 'error');
logout();
}, CONFIG.sessionTimeout);
return true;
}
function logout() {
if (refreshTimer) clearInterval(refreshTimer);
if (sessionTimer) clearTimeout(sessionTimer);
localStorage.removeItem('secubox_server');
localStorage.removeItem('secubox_username');
localStorage.removeItem('secubox_session');
localStorage.removeItem('secubox_timestamp');
CONFIG.ubusSession = '00000000000000000000000000000000';
isConnected = false;
document.getElementById('app').classList.remove('active');
document.getElementById('loginScreen').classList.remove('hidden');
document.getElementById('password').value = '';
}
function autoLogin() {
const server = localStorage.getItem('secubox_server');
const username = localStorage.getItem('secubox_username');
const session = localStorage.getItem('secubox_session');
const timestamp = localStorage.getItem('secubox_timestamp');
if (server && username && session && timestamp) {
// Check if session is still valid (1 hour)
if (Date.now() - parseInt(timestamp) < CONFIG.sessionTimeout) {
CONFIG.serverUrl = server;
CONFIG.username = username;
CONFIG.ubusSession = session;
document.getElementById('serverUrl').value = server;
document.getElementById('username').value = username;
// Try to verify session
UBUS.getSystemBoard().then(() => {
showApp();
refreshData();
}).catch(() => {
// Session invalid, show login
logout();
});
}
}
}
function showApp() {
document.getElementById('loginScreen').classList.add('hidden');
document.getElementById('app').classList.add('active');
// Update UI
const hostname = new URL(CONFIG.serverUrl).hostname;
document.getElementById('serverHost').textContent = hostname;
document.getElementById('userName').textContent = CONFIG.username;
document.getElementById('userAvatar').textContent = CONFIG.username.charAt(0).toUpperCase();
isConnected = true;
setConnectionStatus(true);
// Start refresh
refreshTimer = setInterval(refreshData, CONFIG.refreshInterval);
lucide.createIcons();
}
// =====================================================
// Data Loading
// =====================================================
async function refreshData() {
const refreshBtn = document.getElementById('refreshBtn');
refreshBtn.classList.add('loading');
try {
// Batch all fast queries together
const [boardInfo, systemInfo, interfaces, cpuUsage, netRates] = await Promise.all([
// Cache board info (doesn't change often)
CACHE.boardInfo || UBUS.getSystemBoard().then(r => { CACHE.boardInfo = r; return r; }).catch(() => null),
UBUS.getSystemInfo().catch(() => null),
UBUS.getNetworkInterfaces().catch(() => ({ interface: [] })),
getRealCpuUsage(),
getNetworkRates()
]);
// Update board info (only on first load or cache miss)
if (boardInfo) {
const hostname = boardInfo.hostname || 'SecuBox';
const hostnameEl = document.getElementById('deviceHostname');
if (hostnameEl && hostnameEl.textContent !== hostname) {
hostnameEl.textContent = hostname;
document.title = `${hostname} - Control Center`;
}
}
// Update system metrics with animations
if (systemInfo) {
updateSystemMetricsAnimated(systemInfo, cpuUsage);
}
// Update network rates with animation
updateNetworkRatesAnimated(netRates);
// Update network interfaces (overview tab)
updateNetworkInterfaces(interfaces.interface || []);
// Run less critical updates in background (non-blocking)
Promise.all([
updateDiskUsage().catch(() => {}),
updateConnectionsCount().catch(() => {})
]);
// Get CrowdSec data (in parallel) - only on overview tab
if (currentTab === 'overview' || currentTab === 'crowdsec') {
const [decisions, bouncers, alerts, nftStats] = await Promise.all([
CROWDSEC.getDecisions(),
CROWDSEC.getBouncers(),
CROWDSEC.getAlerts(),
CROWDSEC.getNftablesStats()
]);
updateCrowdSecMetrics(decisions, bouncers, alerts, nftStats);
// Update firewall blocking statistics
if (currentTab === 'overview') {
updateBlockingStats(decisions, alerts, nftStats);
}
}
// Load admin sessions on overview tab
if (currentTab === 'overview') {
loadAdminSessions().catch(e => console.warn('Admin sessions error:', e));
}
// Get logs only when on overview or logs tab
if (currentTab === 'overview') {
await loadSystemLogs();
}
// Update traffic graph data
updateTrafficGraph(netRates.rx, netRates.tx);
setConnectionStatus(true);
updateConnectionUI();
updateLastUpdate();
STATE.lastUpdate = Date.now();
} catch (error) {
console.error('Refresh error:', error);
if (error.message === 'SESSION_EXPIRED') {
showToast('Session expirée', 'error');
logout();
} else {
setConnectionStatus(false);
updateConnectionUI();
// Only show error toast once per disconnect
if (STATE.isOnline) {
showToast('Connexion perdue, tentative de reconnexion...', 'error');
}
}
} finally {
refreshBtn.classList.remove('loading');
}
}
// Optimized metrics update with animations
function updateSystemMetricsAnimated(info, realCpuUsage) {
// Memory with animation
if (info.memory) {
const memTotal = info.memory.total;
const memFree = info.memory.free + (info.memory.buffered || 0) + (info.memory.cached || 0);
const memUsed = memTotal - memFree;
const memPercent = Math.round((memUsed / memTotal) * 100);
animateValue('ramValue', memPercent);
updateGauge('ramGauge', memPercent);
STATE.memory.current = memPercent;
}
// Real CPU usage (from /proc/stat) with animation
const cpuPercent = realCpuUsage !== null ? realCpuUsage :
(info.load ? Math.min(Math.round((info.load[0] / 65536) * 100), 100) : 0);
animateValue('cpuValue', cpuPercent);
updateGauge('cpuGauge', cpuPercent);
// Load badge
if (info.load) {
const load1 = (info.load[0] / 65536).toFixed(2);
document.getElementById('loadBadge').textContent = `Load: ${load1}`;
}
// Uptime
if (info.uptime) {
const uptime = formatUptime(info.uptime);
document.getElementById('uptime').textContent = uptime.short;
document.getElementById('uptimeSub').textContent = uptime.full;
STATE.uptime = info.uptime;
}
}
// Animated network rates update
function updateNetworkRatesAnimated(rates) {
const rxEl = document.getElementById('rxRate');
const txEl = document.getElementById('txRate');
if (rxEl) {
const rxValue = rates.rx.toFixed(1);
if (rxEl.textContent !== rxValue) {
rxEl.textContent = rxValue;
rxEl.classList.add('pulse-on-update');
setTimeout(() => rxEl.classList.remove('pulse-on-update'), 500);
}
}
if (txEl) {
const txValue = rates.tx.toFixed(1);
if (txEl.textContent !== txValue) {
txEl.textContent = txValue;
txEl.classList.add('pulse-on-update');
setTimeout(() => txEl.classList.remove('pulse-on-update'), 500);
}
}
}
function updateSystemMetrics(info) {
// Memory
if (info.memory) {
const memTotal = info.memory.total;
const memFree = info.memory.free + (info.memory.buffered || 0) + (info.memory.cached || 0);
const memUsed = memTotal - memFree;
const memPercent = Math.round((memUsed / memTotal) * 100);
document.getElementById('ramValue').textContent = memPercent;
updateGauge('ramGauge', memPercent);
}
// Load average (as CPU approximation)
if (info.load) {
const load1 = info.load[0] / 65536;
const cpuPercent = Math.min(Math.round(load1 * 100), 100);
document.getElementById('cpuValue').textContent = cpuPercent;
updateGauge('cpuGauge', cpuPercent);
document.getElementById('loadBadge').textContent = `Load: ${load1.toFixed(2)}`;
}
// Uptime
if (info.uptime) {
const uptime = formatUptime(info.uptime);
document.getElementById('uptime').textContent = uptime.short;
document.getElementById('uptimeSub').textContent = uptime.full;
}
}
// Get real disk usage via df command
async function updateDiskUsage() {
try {
// Use luci.system-hub RPC for disk info
const result = await UBUS.call('luci.system-hub', 'get_system_status').catch(() => null);
if (result && result.disk) {
const percent = result.disk.usage || 0;
document.getElementById('diskValue').textContent = percent;
updateGauge('diskGauge', percent);
return;
}
// Fallback: try file.exec with df
const dfResult = await UBUS.execCommand('/bin/df', ['-P', '/']).catch(() => null);
if (dfResult && dfResult.stdout) {
const lines = dfResult.stdout.trim().split('\n');
if (lines.length >= 2) {
const parts = lines[1].split(/\s+/);
if (parts.length >= 5) {
const used = parseInt(parts[2]) || 0;
const available = parseInt(parts[3]) || 0;
const total = used + available;
const percent = total > 0 ? Math.round((used / total) * 100) : 0;
document.getElementById('diskValue').textContent = percent;
updateGauge('diskGauge', percent);
return;
}
}
}
} catch (e) {
console.warn('Disk usage error:', e);
}
// Fallback to showing '--'
document.getElementById('diskValue').textContent = '--';
}
// Get active connections count
async function updateConnectionsCount() {
try {
// Try conntrack first
let result = await UBUS.execCommand('/usr/sbin/conntrack', ['-C']).catch(() => null);
if (result && result.stdout) {
const count = parseInt(result.stdout.trim()) || 0;
document.getElementById('connections').textContent = count;
return;
}
// Fallback: count lines in /proc/net/nf_conntrack
result = await UBUS.execCommand('/bin/cat', ['/proc/net/nf_conntrack']).catch(() => null);
if (result && result.stdout) {
const lines = result.stdout.trim().split('\n').filter(l => l);
document.getElementById('connections').textContent = lines.length;
return;
}
// Fallback: use netstat
result = await UBUS.execCommand('/bin/netstat', ['-tn']).catch(() => null);
if (result && result.stdout) {
const lines = result.stdout.trim().split('\n').filter(l => l.includes('ESTABLISHED'));
document.getElementById('connections').textContent = lines.length;
return;
}
} catch (e) {
console.warn('Connections count error:', e);
}
document.getElementById('connections').textContent = '--';
}
function updateNetworkInterfaces(interfaces) {
const tbody = document.getElementById('interfacesBody');
if (!interfaces || interfaces.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="3" class="empty-state">Aucune interface</td>
</tr>
`;
return;
}
tbody.innerHTML = interfaces.map(iface => {
const ip = iface['ipv4-address']?.[0]?.address ||
iface['ipv6-address']?.[0]?.address ||
'--';
const isUp = iface.up;
return `
<tr>
<td class="interface-name">${escapeHtml(iface.interface)}</td>
<td class="mono" style="font-size: 0.75rem;">${ip}</td>
<td>
<span class="interface-status ${isUp ? 'up' : 'down'}">
${isUp ? 'UP' : 'DOWN'}
</span>
</td>
</tr>
`;
}).join('');
}
function updateNetworkStats(devices) {
// Calculate network rates
let totalRx = 0, totalTx = 0;
Object.values(devices).forEach(dev => {
if (dev.statistics) {
totalRx += dev.statistics.rx_bytes || 0;
totalTx += dev.statistics.tx_bytes || 0;
}
});
if (previousNetStats) {
const timeDiff = (Date.now() - previousNetStats.time) / 1000;
const rxRate = ((totalRx - previousNetStats.rx) / timeDiff / 1024).toFixed(1);
const txRate = ((totalTx - previousNetStats.tx) / timeDiff / 1024).toFixed(1);
document.getElementById('rxRate').textContent = Math.max(0, rxRate);
document.getElementById('txRate').textContent = Math.max(0, txRate);
}
previousNetStats = { rx: totalRx, tx: totalTx, time: Date.now() };
}
function updateCrowdSecMetrics(decisions, bouncers, alerts, nftStats) {
const crowdsecStatus = document.getElementById('crowdsecStatus');
// Check if CrowdSec is available
const isAvailable = (decisions !== null && decisions !== undefined) ||
(bouncers !== null && bouncers !== undefined) ||
(alerts !== null && alerts !== undefined);
if (!isAvailable) {
// CrowdSec not installed or not responding
crowdsecStatus.innerHTML = '<span class="live-dot" style="background: var(--text-muted); animation: none;"></span> Non installé';
document.getElementById('activeDecisions').textContent = 'N/A';
document.getElementById('alertsCount').textContent = 'N/A';
document.getElementById('alertsBadge').textContent = '0';
document.getElementById('navAlertsBadge').textContent = '0';
document.getElementById('parsersCount').textContent = '--';
document.getElementById('scenariosCount').textContent = '--';
renderBouncers([]);
return;
}
// CrowdSec is active
crowdsecStatus.innerHTML = '<span class="live-dot"></span> Actif';
// Decisions count - use nftables stats if available for more accurate count
let decisionCount = Array.isArray(decisions) ? decisions.length : 0;
if (nftStats && nftStats.available) {
const ipv4Total = nftStats.ipv4_total_count || 0;
const ipv6Total = nftStats.ipv6_total_count || 0;
const nftTotal = ipv4Total + ipv6Total;
// Use nftables count if it's higher (more accurate for blocked IPs)
if (nftTotal > decisionCount) {
decisionCount = nftTotal;
}
}
document.getElementById('activeDecisions').textContent = decisionCount;
// Alerts count
const alertCount = Array.isArray(alerts) ? alerts.length : 0;
document.getElementById('alertsCount').textContent = alertCount;
document.getElementById('alertsBadge').textContent = alertCount > 99 ? '99+' : alertCount;
document.getElementById('navAlertsBadge').textContent = alertCount;
// Bouncers
renderBouncers(bouncers || []);
// Get actual parser and scenario counts from hub
updateCrowdSecCounts();
}
async function updateCrowdSecCounts() {
try {
// Get hub status for parsers and scenarios count via RPC
const hub = await UBUS.call('luci.crowdsec-dashboard', 'hub').catch(() => null);
if (hub) {
const parsers = hub.parsers ? hub.parsers.filter(p => p.status === 'installed').length : 0;
const scenarios = hub.scenarios ? hub.scenarios.filter(s => s.status === 'installed').length : 0;
document.getElementById('parsersCount').textContent = parsers || '--';
document.getElementById('scenariosCount').textContent = scenarios || '--';
}
} catch (e) {
// Keep default values
}
}
function renderBouncers(bouncers) {
const container = document.getElementById('bouncersList');
if (!bouncers || bouncers.length === 0) {
container.innerHTML = `
<div class="bouncer-item">
<div class="bouncer-info">
<div class="bouncer-icon offline">
<i data-lucide="shield" style="width: 16px; height: 16px;"></i>
</div>
<div>
<div class="bouncer-name">Aucun bouncer</div>
<div class="bouncer-type">Configurez CrowdSec</div>
</div>
</div>
</div>
`;
lucide.createIcons();
return;
}
container.innerHTML = bouncers.slice(0, 4).map(bouncer => {
const isValid = bouncer.is_valid !== false;
const name = bouncer.name || 'bouncer';
const type = bouncer.type || 'unknown';
return `
<div class="bouncer-item">
<div class="bouncer-info">
<div class="bouncer-icon ${isValid ? '' : 'offline'}">
<i data-lucide="shield" style="width: 16px; height: 16px;"></i>
</div>
<div>
<div class="bouncer-name">${escapeHtml(name)}</div>
<div class="bouncer-type">${escapeHtml(type)}</div>
</div>
</div>
<span class="bouncer-status ${isValid ? '' : 'offline'}"></span>
</div>
`;
}).join('');
lucide.createIcons();
}
// =====================================================
// Admin Sessions & Blocking Statistics
// =====================================================
async function loadAdminSessions() {
const tbody = document.getElementById('adminSessionsBody');
const countBadge = document.getElementById('adminSessionsCount');
try {
let sessions = [];
// Get current session info via session.get
try {
const sessionData = await UBUS.call('session', 'get', {});
if (sessionData) {
// Extract username from session data
let username = 'root';
if (sessionData.data && sessionData.data.username) {
username = sessionData.data.username;
} else if (sessionData.values && sessionData.values.username) {
username = sessionData.values.username;
}
const currentSession = {
id: STATE.sessionId ? STATE.sessionId.substring(0, 8) : 'current',
username: username,
ip: window.location.hostname,
mtime: Math.floor(Date.now() / 1000),
expires: sessionData.expires || 300,
current: true
};
sessions.push(currentSession);
}
} catch (e) {
console.warn('session.get error:', e);
}
// If no session from API, add current session from STATE (user IS logged in)
if (sessions.length === 0 && STATE.sessionId) {
sessions.push({
id: STATE.sessionId.substring(0, 8),
username: 'root',
ip: window.location.hostname,
mtime: Math.floor(Date.now() / 1000),
expires: 300,
current: true
});
}
// Update badge
countBadge.textContent = `${sessions.length} session${sessions.length > 1 ? 's' : ''}`;
if (sessions.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="4" style="text-align: center; color: var(--text-muted);">
Aucune session active
</td>
</tr>
`;
return;
}
const now = Math.floor(Date.now() / 1000);
tbody.innerHTML = sessions.map(session => {
const age = now - session.mtime;
const isActive = age < 3600; // Active if less than 1 hour old
const expireText = session.expires ?
`${Math.floor(session.expires / 60)} min` :
(age < 60 ? 'maintenant' : `${Math.floor(age / 60)} min`);
return `
<tr>
<td>
<div style="display: flex; align-items: center; gap: 6px;">
<i data-lucide="user" style="width: 14px; height: 14px; color: var(--cyan);"></i>
${escapeHtml(session.username)}
${session.current ? '<span style="font-size: 0.65rem; background: var(--cyan); color: var(--bg); padding: 1px 4px; border-radius: 3px;">vous</span>' : ''}
</div>
</td>
<td>
<code style="font-size: 0.7rem; background: var(--surface); padding: 2px 6px; border-radius: 3px;">
${escapeHtml(session.ip)}
</code>
</td>
<td style="font-size: 0.75rem;">${expireText}</td>
<td>
<span class="status-badge ${isActive ? 'online' : 'offline'}" style="font-size: 0.65rem;">
${isActive ? 'Actif' : 'Expiré'}
</span>
</td>
</tr>
`;
}).join('');
lucide.createIcons();
} catch (e) {
console.warn('Error loading admin sessions:', e);
tbody.innerHTML = `
<tr>
<td colspan="4" style="text-align: center; color: var(--text-muted);">
Impossible de charger les sessions
</td>
</tr>
`;
}
}
function updateBlockingStats(decisions, alerts, nftStats) {
const totalBansEl = document.getElementById('totalBansToday');
const blockedEl = document.getElementById('totalBlockedAttempts');
const topScenarioEl = document.getElementById('topScenario');
const uniqueIPsEl = document.getElementById('uniqueIPs');
// Calculate stats from decisions and alerts
const decisionsList = Array.isArray(decisions) ? decisions : [];
const alertsList = Array.isArray(alerts) ? alerts : [];
// Today's date
const today = new Date();
today.setHours(0, 0, 0, 0);
// Count today's bans
const todayBans = decisionsList.filter(d => {
if (d.created_at) {
const created = new Date(d.created_at);
return created >= today;
}
return true; // Count all if no date
}).length;
// Total blocked attempts (sum of events from alerts)
const totalBlocked = alertsList.reduce((sum, alert) => {
return sum + (alert.events_count || 1);
}, 0);
// Find top scenario
const scenarioCounts = {};
alertsList.forEach(alert => {
const scenario = alert.scenario || 'unknown';
scenarioCounts[scenario] = (scenarioCounts[scenario] || 0) + 1;
});
let topScenario = '--';
let maxCount = 0;
Object.entries(scenarioCounts).forEach(([scenario, count]) => {
if (count > maxCount) {
maxCount = count;
// Extract short name from scenario (e.g., "crowdsecurity/ssh-bf" -> "ssh-bf")
const parts = scenario.split('/');
topScenario = parts[parts.length - 1] || scenario;
}
});
// Count unique IPs - use nftables stats if available for more accurate count
let uniqueIPsCount = 0;
if (nftStats && nftStats.available) {
const ipv4Total = nftStats.ipv4_total_count || 0;
const ipv6Total = nftStats.ipv6_total_count || 0;
uniqueIPsCount = ipv4Total + ipv6Total;
}
// Fallback to counting from decisions/alerts if nftStats not available
if (uniqueIPsCount === 0) {
const uniqueIPs = new Set();
decisionsList.forEach(d => {
if (d.value) uniqueIPs.add(d.value);
});
alertsList.forEach(a => {
if (a.source && a.source.ip) uniqueIPs.add(a.source.ip);
});
uniqueIPsCount = uniqueIPs.size;
}
// Update UI
totalBansEl.textContent = todayBans;
blockedEl.textContent = totalBlocked || '--';
topScenarioEl.textContent = topScenario.length > 10 ? topScenario.substring(0, 10) + '...' : topScenario;
topScenarioEl.title = topScenario; // Full name on hover
uniqueIPsEl.textContent = uniqueIPsCount;
}
async function loadSystemLogs() {
const container = document.getElementById('logsContainer');
try {
// Try luci.system-hub RPC first
const result = await UBUS.call('luci.system-hub', 'get_logs', { lines: 20 }).catch(() => null);
let lines = [];
if (result && result.logs && Array.isArray(result.logs)) {
lines = result.logs;
} else {
// Fallback: use shell to get last 20 lines (logread -l N not available on BusyBox)
const logResult = await UBUS.execCommand('/bin/sh', ['-c', 'logread | tail -n 20']).catch(() => null);
if (logResult && logResult.stdout) {
lines = logResult.stdout.trim().split('\n').filter(l => l);
}
}
if (lines.length > 0) {
container.innerHTML = lines.slice(-10).reverse().map(line => {
// Handle both string and object formats
const logLine = typeof line === 'string' ? line : (line.message || line.line || '');
const type = getLogType(logLine);
const icons = { ban: 'x-circle', alert: 'alert-triangle', info: 'info', success: 'check-circle' };
// Parse log line
const parts = logLine.match(/^(\w+\s+\d+\s+[\d:]+)\s+\S+\s+(.+)$/);
const time = parts?.[1] || (line.time || '');
const message = parts?.[2] || logLine;
return `
<div class="log-entry ${type}">
<i data-lucide="${icons[type]}" class="log-icon ${type}" style="width: 16px; height: 16px;"></i>
<div class="log-content">
<div class="log-message">${escapeHtml(message.substring(0, 100))}</div>
<div class="log-meta">
<span>system</span>
<span>•</span>
<span>${time}</span>
</div>
</div>
</div>
`;
}).join('');
lucide.createIcons();
return;
}
throw new Error('No logs available');
} catch (e) {
container.innerHTML = `
<div class="empty-state">
<i data-lucide="file-x" class="empty-state-icon" style="width: 32px; height: 32px;"></i>
<p>Impossible de charger les logs</p>
</div>
`;
lucide.createIcons();
}
}
function getLogType(line) {
const lower = line.toLowerCase();
if (lower.includes('error') || lower.includes('fail') || lower.includes('denied')) return 'ban';
if (lower.includes('warn') || lower.includes('alert')) return 'alert';
if (lower.includes('success') || lower.includes('started') || lower.includes('loaded')) return 'success';
return 'info';
}
// =====================================================
// Actions
// =====================================================
async function reloadCrowdSec() {
try {
showToast('Rechargement de CrowdSec...', 'info');
await CROWDSEC.reload();
showToast('CrowdSec rechargé avec succès', 'success');
await refreshData();
} catch (e) {
showToast(e.message, 'error');
}
}
async function blockIP(event) {
event.preventDefault();
const ip = document.getElementById('blockIp').value;
const duration = document.getElementById('blockDuration').value;
const reason = document.getElementById('blockReason').value;
try {
showToast(`Blocage de ${ip}...`, 'info');
await CROWDSEC.addDecision(ip, duration, reason);
showToast(`IP ${ip} bloquée avec succès`, 'success');
closeModal('blockModal');
await refreshData();
} catch (e) {
showToast(e.message, 'error');
}
}
async function restartNetwork() {
if (!confirm('Redémarrer le réseau ? La connexion sera interrompue.')) return;
try {
showToast('Redémarrage du réseau...', 'info');
await UBUS.execCommand('/etc/init.d/network', ['restart']);
showToast('Réseau redémarré', 'success');
} catch (e) {
showToast(e.message, 'error');
}
}
async function restartFirewall() {
try {
showToast('Redémarrage du pare-feu...', 'info');
await UBUS.execCommand('/etc/init.d/firewall', ['restart']);
showToast('Pare-feu redémarré', 'success');
} catch (e) {
showToast(e.message, 'error');
}
}
async function flushDNS() {
try {
showToast('Vidage du cache DNS...', 'info');
// Restart dnsmasq to flush DNS cache
await UBUS.execCommand('/etc/init.d/dnsmasq', ['restart']);
showToast('Cache DNS vidé', 'success');
} catch (e) {
showToast(`Erreur: ${e.message}`, 'error');
}
}
async function syncTime() {
try {
showToast('Synchronisation NTP...', 'info');
// Try ntpd -n -q first (query and quit)
const result = await UBUS.execCommand('/usr/sbin/ntpd', ['-n', '-q', '-p', 'pool.ntp.org']).catch(() => null);
if (result) {
showToast('Heure synchronisée', 'success');
} else {
// Fallback: restart sysntpd
await UBUS.execCommand('/etc/init.d/sysntpd', ['restart']);
showToast('Service NTP redémarré', 'success');
}
} catch (e) {
showToast(`Erreur: ${e.message}`, 'error');
}
}
async function confirmReboot() {
if (!confirm('Êtes-vous sûr de vouloir redémarrer SecuBox ?')) return;
if (!confirm('Cette action va redémarrer le système. Confirmer ?')) return;
try {
showToast('Redémarrage en cours...', 'info');
await UBUS.reboot();
} catch (e) {
showToast(e.message, 'error');
}
}
async function showDecisionsModal() {
document.getElementById('decisionsModal').classList.add('active');
try {
const decisions = await CROWDSEC.getDecisions();
const tbody = document.getElementById('decisionsBody');
if (!decisions || decisions.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="4" class="empty-state">Aucune décision active</td>
</tr>
`;
return;
}
tbody.innerHTML = decisions.slice(0, 20).map(d => `
<tr>
<td class="ip">${escapeHtml(d.value || d.ip || '--')}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis;">
${escapeHtml((d.scenario || d.reason || '--').substring(0, 50))}
</td>
<td><span class="action">${d.type || 'ban'}</span></td>
<td class="mono" style="font-size: 0.7rem; color: var(--text-muted);">
${d.duration || '--'}
</td>
</tr>
`).join('');
} catch (e) {
showToast('Erreur chargement décisions', 'error');
}
}
// =====================================================
// UI Helpers
// =====================================================
function updateGauge(gaugeId, value) {
const gauge = document.getElementById(gaugeId);
const dashArray = Math.min((value / 100) * 251, 251).toFixed(0);
gauge.setAttribute('stroke-dasharray', `${dashArray} 251`);
gauge.classList.remove('warning', 'danger');
if (value > 80) gauge.classList.add('danger');
else if (value > 60) gauge.classList.add('warning');
}
function setConnectionStatus(connected) {
const statusBadge = document.getElementById('statusBadge');
const statusText = document.getElementById('statusText');
const serverInfo = document.getElementById('serverInfo');
const serverStatus = document.getElementById('serverStatus');
const sidebarDot = document.getElementById('sidebarStatusDot');
if (connected) {
statusBadge.classList.remove('offline');
statusText.textContent = 'Connecté';
serverInfo.classList.remove('offline');
serverStatus.classList.remove('offline');
sidebarDot.classList.remove('offline');
} else {
statusBadge.classList.add('offline');
statusText.textContent = 'Déconnecté';
serverInfo.classList.add('offline');
serverStatus.classList.add('offline');
sidebarDot.classList.add('offline');
}
}
function updateLastUpdate() {
const element = document.getElementById('lastUpdate');
element.textContent = new Date().toLocaleTimeString('fr-FR');
// Add visual flash to indicate data update
element.classList.add('value-update');
setTimeout(() => element.classList.remove('value-update'), 300);
}
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return {
short: `${days}j`,
full: `${days}j ${hours}h ${minutes}m`
};
} else if (hours > 0) {
return {
short: `${hours}h`,
full: `${hours}h ${minutes}m`
};
} else {
return {
short: `${minutes}m`,
full: `${minutes} minutes`
};
}
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// =====================================================
// Reactive Update Helpers
// =====================================================
// Animate value change with color indication
function animateValue(elementId, newValue, suffix = '') {
const el = document.getElementById(elementId);
if (!el) return;
const oldValue = parseFloat(el.textContent) || 0;
const value = parseFloat(newValue) || 0;
// Add direction class
if (value > oldValue) {
el.classList.add('value-increase');
setTimeout(() => el.classList.remove('value-increase'), 500);
} else if (value < oldValue) {
el.classList.add('value-decrease');
setTimeout(() => el.classList.remove('value-decrease'), 500);
}
// Animate number
animateNumber(el, oldValue, value, 300, suffix);
}
// Smooth number animation
function animateNumber(element, start, end, duration, suffix = '') {
const startTime = performance.now();
const diff = end - start;
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Easing function (ease-out)
const easeOut = 1 - Math.pow(1 - progress, 3);
const current = start + diff * easeOut;
element.textContent = Math.round(current) + suffix;
if (progress < 1) {
requestAnimationFrame(update);
}
}
requestAnimationFrame(update);
}
// Real CPU usage calculation from /proc/stat
async function getRealCpuUsage() {
try {
const result = await UBUS.execCommand('/bin/cat', ['/proc/stat']);
if (!result || !result.stdout) return null;
const lines = result.stdout.split('\n');
const cpuLine = lines.find(l => l.startsWith('cpu '));
if (!cpuLine) return null;
const parts = cpuLine.split(/\s+/).slice(1).map(Number);
const idle = parts[3] + (parts[4] || 0); // idle + iowait
const total = parts.reduce((a, b) => a + b, 0);
if (STATE.cpu.previous) {
const idleDiff = idle - STATE.cpu.previous.idle;
const totalDiff = total - STATE.cpu.previous.total;
if (totalDiff > 0) {
STATE.cpu.current = Math.round((1 - idleDiff / totalDiff) * 100);
}
}
STATE.cpu.previous = { idle, total };
return STATE.cpu.current;
} catch (e) {
return null;
}
}
// Real network rate calculation
async function getNetworkRates() {
try {
const result = await UBUS.execCommand('/bin/cat', ['/proc/net/dev']);
if (!result || !result.stdout) return { rx: 0, tx: 0 };
let totalRx = 0, totalTx = 0;
const lines = result.stdout.split('\n');
for (const line of lines) {
// Skip loopback and header lines
if (line.includes('lo:') || !line.includes(':')) continue;
const match = line.match(/:\s*(\d+)\s+\d+\s+\d+\s+\d+\s+\d+\s+\d+\s+\d+\s+\d+\s+(\d+)/);
if (match) {
totalRx += parseInt(match[1]) || 0;
totalTx += parseInt(match[2]) || 0;
}
}
const now = Date.now();
let rxRate = 0, txRate = 0;
if (STATE.network.previousTime > 0) {
const timeDiff = (now - STATE.network.previousTime) / 1000;
if (timeDiff > 0) {
rxRate = Math.max(0, (totalRx - STATE.network.previousRx) / timeDiff / 1024); // KB/s
txRate = Math.max(0, (totalTx - STATE.network.previousTx) / timeDiff / 1024); // KB/s
}
}
STATE.network.previousRx = totalRx;
STATE.network.previousTx = totalTx;
STATE.network.previousTime = now;
STATE.network.rxRate = rxRate;
STATE.network.txRate = txRate;
return { rx: rxRate, tx: txRate };
} catch (e) {
return { rx: 0, tx: 0 };
}
}
// Connection retry with exponential backoff
async function fetchWithRetry(fetchFn, maxRetries = CONFIG.maxRetries) {
let lastError;
for (let i = 0; i < maxRetries; i++) {
try {
const result = await fetchFn();
STATE.retryCount = 0;
STATE.isOnline = true;
return result;
} catch (e) {
lastError = e;
STATE.retryCount = i + 1;
if (i < maxRetries - 1) {
const delay = CONFIG.retryDelay * Math.pow(2, i);
await new Promise(r => setTimeout(r, delay));
}
}
}
STATE.isOnline = false;
throw lastError;
}
// Update connection status UI
function updateConnectionUI() {
const statusEl = document.getElementById('connectionStatus');
if (!statusEl) return;
if (STATE.isOnline) {
statusEl.className = 'connection-indicator connected';
statusEl.innerHTML = '<span class="live-dot"></span> Connecté';
} else if (STATE.retryCount > 0) {
statusEl.className = 'connection-indicator reconnecting';
statusEl.innerHTML = `<span class="spinner" style="width:12px;height:12px;border-width:2px;"></span> Reconnexion...`;
} else {
statusEl.className = 'connection-indicator disconnected';
statusEl.innerHTML = '<i data-lucide="wifi-off" style="width:12px;height:12px;"></i> Déconnecté';
lucide.createIcons();
}
}
function togglePassword() {
const input = document.getElementById('password');
const icon = document.getElementById('passwordToggleIcon');
if (input.type === 'password') {
input.type = 'text';
icon.setAttribute('data-lucide', 'eye-off');
} else {
input.type = 'password';
icon.setAttribute('data-lucide', 'eye');
}
lucide.createIcons();
}
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
document.getElementById('overlay').classList.toggle('active');
}
function closeSidebar() {
document.getElementById('sidebar').classList.remove('open');
document.getElementById('overlay').classList.remove('active');
}
function showBlockModal() {
document.getElementById('blockModal').classList.add('active');
}
function closeModal(id) {
document.getElementById(id).classList.remove('active');
}
function showAlertsModal() {
// Could show alerts modal
showDecisionsModal();
}
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icons = {
success: 'check-circle',
error: 'x-circle',
info: 'info'
};
toast.innerHTML = `
<i data-lucide="${icons[type]}" style="width: 18px; height: 18px;"></i>
<span>${escapeHtml(message)}</span>
`;
container.appendChild(toast);
lucide.createIcons();
setTimeout(() => {
toast.remove();
}, 4000);
}
// =====================================================
// Tab Navigation
// =====================================================
let currentTab = 'overview';
const tabTitles = {
overview: 'Tableau de Bord',
system: 'Informations Système',
network: 'Réseau',
crowdsec: 'CrowdSec',
firewall: 'Pare-feu',
logs: 'Journaux Système',
services: 'Services',
settings: 'Paramètres'
};
function switchTab(tabName) {
// Hide all tabs
document.querySelectorAll('.tab-page').forEach(tab => tab.classList.remove('active'));
// Show selected tab
const tabEl = document.getElementById(`tab-${tabName}`);
if (tabEl) {
tabEl.classList.add('active');
currentTab = tabName;
document.getElementById('pageTitle').textContent = tabTitles[tabName] || 'Dashboard';
// Load tab-specific data
loadTabData(tabName);
}
}
async function loadTabData(tabName) {
switch(tabName) {
case 'system':
await loadSystemInfo();
break;
case 'network':
await loadNetworkInfo();
break;
case 'crowdsec':
await loadCrowdSecInfo();
break;
case 'firewall':
await loadFirewallInfo();
break;
case 'logs':
await loadFullLogs();
break;
case 'services':
await loadServices();
break;
}
lucide.createIcons();
}
// =====================================================
// System Tab Functions
// =====================================================
async function loadSystemInfo() {
try {
const [boardInfo, systemInfo] = await Promise.all([
UBUS.getSystemBoard().catch(() => null),
UBUS.getSystemInfo().catch(() => null)
]);
if (boardInfo) {
document.getElementById('sysHostname').textContent = boardInfo.hostname || '--';
document.getElementById('sysModel').textContent = boardInfo.model || '--';
document.getElementById('sysArch').textContent = boardInfo.system || '--';
document.getElementById('sysKernel').textContent = boardInfo.kernel || '--';
document.getElementById('sysRelease').textContent = boardInfo.release?.description || '--';
}
if (systemInfo) {
document.getElementById('sysUptime').textContent = formatUptime(systemInfo.uptime).full;
document.getElementById('sysLocaltime').textContent = new Date(systemInfo.localtime * 1000).toLocaleString('fr-FR');
// Memory info
if (systemInfo.memory) {
const mem = systemInfo.memory;
document.getElementById('memTotal').textContent = formatBytes(mem.total);
document.getElementById('memUsed').textContent = formatBytes(mem.total - mem.free - (mem.buffered || 0) - (mem.cached || 0));
document.getElementById('memFree').textContent = formatBytes(mem.free);
document.getElementById('memBuffers').textContent = formatBytes((mem.buffered || 0) + (mem.cached || 0));
}
}
// Load storage info and processes in parallel
await Promise.all([
loadStorageInfo(),
loadProcessList()
]);
// Get timezone
const tzResult = await UBUS.execCommand('/bin/cat', ['/etc/TZ']).catch(() => null);
if (tzResult && tzResult.stdout) {
document.getElementById('sysTimezone').textContent = tzResult.stdout.trim() || 'UTC';
}
} catch (e) {
console.error('System info error:', e);
}
}
async function loadProcessList() {
const tbody = document.getElementById('processListBody');
const countBadge = document.getElementById('processCount');
try {
// Use ps with custom format for BusyBox compatibility
const result = await UBUS.execCommand('/bin/ps', ['w']);
if (!result || !result.stdout) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">Impossible de charger les processus</td></tr>';
return;
}
const lines = result.stdout.trim().split('\n');
const processes = [];
// Parse ps output (skip header)
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// BusyBox ps format: PID USER VSZ STAT COMMAND
const match = line.match(/^\s*(\d+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(.+)$/);
if (match) {
const pid = match[1];
const user = match[2];
const vsz = parseInt(match[3]) || 0; // Virtual memory in KB
const stat = match[4];
const cmd = match[5];
// Skip kernel threads and very low-level processes
if (cmd.startsWith('[') && cmd.endsWith(']')) continue;
processes.push({
pid,
user,
vsz,
stat,
cmd: cmd.substring(0, 50), // Truncate long commands
fullCmd: cmd
});
}
}
countBadge.textContent = `${processes.length} processus`;
if (processes.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">Aucun processus</td></tr>';
return;
}
// Sort by memory usage (VSZ) descending
processes.sort((a, b) => b.vsz - a.vsz);
// Get total memory for percentage calculation
let totalMem = 0;
try {
const memResult = await UBUS.getSystemInfo().catch(() => null);
if (memResult && memResult.memory) {
totalMem = memResult.memory.total / 1024; // Convert to KB
}
} catch (e) {}
tbody.innerHTML = processes.slice(0, 50).map(proc => {
const memPercent = totalMem > 0 ? ((proc.vsz / totalMem) * 100).toFixed(1) : '--';
const statClass = proc.stat.includes('R') ? 'status-running' :
proc.stat.includes('S') ? 'status-sleeping' : '';
return `
<tr title="${escapeHtml(proc.fullCmd)}">
<td class="mono">${proc.pid}</td>
<td>${escapeHtml(proc.user)}</td>
<td class="mono ${statClass}">${proc.stat}</td>
<td class="mono">${memPercent}%</td>
<td class="process-cmd" title="${escapeHtml(proc.fullCmd)}">${escapeHtml(proc.cmd)}</td>
</tr>
`;
}).join('');
} catch (e) {
console.error('Process list error:', e);
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">Erreur chargement processus</td></tr>';
}
}
async function loadStorageInfo() {
try {
// Try luci.system-hub RPC first
const rpcResult = await UBUS.call('luci.system-hub', 'get_storage_info').catch(() => null);
if (rpcResult && rpcResult.filesystems) {
rpcResult.filesystems.forEach(fs => {
if (fs.mount === '/') {
document.getElementById('storageRoot').textContent = `${fs.used_human || fs.used} / ${fs.size_human || fs.size} (${fs.percent || '--'}%)`;
} else if (fs.mount === '/tmp') {
document.getElementById('storageTmp').textContent = `${fs.used_human || fs.used} / ${fs.size_human || fs.size} (${fs.percent || '--'}%)`;
} else if (fs.mount === '/overlay') {
document.getElementById('storageOverlay').textContent = `${fs.used_human || fs.used} / ${fs.size_human || fs.size} (${fs.percent || '--'}%)`;
}
});
return;
}
// Fallback: use shell command
const result = await UBUS.execCommand('/bin/sh', ['-c', 'df -h']).catch(() => null);
if (result && result.stdout) {
const lines = result.stdout.trim().split('\n');
lines.forEach(line => {
const parts = line.split(/\s+/);
if (parts.length >= 6) {
if (parts[5] === '/') {
document.getElementById('storageRoot').textContent = `${parts[2]} / ${parts[1]} (${parts[4]})`;
} else if (parts[5] === '/tmp') {
document.getElementById('storageTmp').textContent = `${parts[2]} / ${parts[1]} (${parts[4]})`;
} else if (parts[5] === '/overlay') {
document.getElementById('storageOverlay').textContent = `${parts[2]} / ${parts[1]} (${parts[4]})`;
}
}
});
}
} catch (e) {
console.error('Storage info error:', e);
}
}
function formatBytes(bytes) {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
// =====================================================
// Network Tab Functions
// =====================================================
let trafficHistory = { rx: [], tx: [], labels: [] };
const maxTrafficPoints = 30;
async function loadNetworkInfo() {
await Promise.all([
loadWifiNetworks(),
loadFullInterfaces(),
loadDHCPLeases()
]);
initTrafficGraph();
}
async function loadWifiNetworks() {
const container = document.getElementById('wifiNetworks');
try {
const result = await UBUS.call('network.wireless', 'status').catch(() => null);
if (!result || Object.keys(result).length === 0) {
container.innerHTML = '<div class="empty-state">Aucun réseau WiFi configuré</div>';
return;
}
let html = '';
for (const [radio, data] of Object.entries(result)) {
if (data.interfaces) {
data.interfaces.forEach(iface => {
const ssid = iface.config?.ssid || 'N/A';
const channel = data.config?.channel || '?';
const mode = iface.config?.mode || 'ap';
const encryption = iface.config?.encryption || 'none';
const disabled = data.disabled || iface.config?.disabled;
html += `
<div class="wifi-card">
<div class="wifi-header">
<div class="wifi-ssid">
<i data-lucide="wifi" style="width: 16px; height: 16px; color: ${disabled ? 'var(--text-muted)' : 'var(--emerald)'};"></i>
${escapeHtml(ssid)}
<span class="wifi-band">${radio.includes('5g') ? '5GHz' : '2.4GHz'}</span>
</div>
<span class="interface-status ${disabled ? 'down' : 'up'}">${disabled ? 'OFF' : 'ON'}</span>
</div>
<div class="wifi-stats">
<div class="wifi-stat">
<div class="wifi-stat-value">${channel}</div>
<div class="wifi-stat-label">Canal</div>
</div>
<div class="wifi-stat">
<div class="wifi-stat-value">${mode.toUpperCase()}</div>
<div class="wifi-stat-label">Mode</div>
</div>
<div class="wifi-stat">
<div class="wifi-stat-value">${encryption}</div>
<div class="wifi-stat-label">Chiffrement</div>
</div>
</div>
</div>
`;
});
}
}
container.innerHTML = html || '<div class="empty-state">Aucun réseau WiFi configuré</div>';
} catch (e) {
container.innerHTML = '<div class="empty-state">Erreur chargement WiFi</div>';
}
}
async function loadFullInterfaces() {
try {
const interfaces = await UBUS.getNetworkInterfaces().catch(() => ({ interface: [] }));
const tbody = document.getElementById('fullInterfacesBody');
if (!interfaces.interface || interfaces.interface.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">Aucune interface</td></tr>';
return;
}
tbody.innerHTML = interfaces.interface.map(iface => {
const ip = iface['ipv4-address']?.[0]?.address || iface['ipv6-address']?.[0]?.address || '--';
const gateway = iface.route?.[0]?.nexthop || '--';
const dns = iface['dns-server']?.join(', ') || '--';
const isUp = iface.up;
return `
<tr>
<td class="interface-name">${escapeHtml(iface.interface)}</td>
<td class="mono" style="font-size: 0.75rem;">${ip}</td>
<td class="mono" style="font-size: 0.75rem;">${gateway}</td>
<td class="mono" style="font-size: 0.7rem;">${dns}</td>
<td><span class="interface-status ${isUp ? 'up' : 'down'}">${isUp ? 'UP' : 'DOWN'}</span></td>
</tr>
`;
}).join('');
} catch (e) {
console.error('Full interfaces error:', e);
}
}
async function loadDHCPLeases() {
try {
const result = await UBUS.call('luci-rpc', 'getDHCPLeases').catch(() => null);
const tbody = document.getElementById('dhcpLeasesBody');
const countBadge = document.getElementById('dhcpCount');
let leases = [];
if (result && result.dhcp_leases) {
leases = result.dhcp_leases;
} else {
// Fallback: read lease file
const fileResult = await UBUS.execCommand('/bin/cat', ['/tmp/dhcp.leases']).catch(() => null);
if (fileResult && fileResult.stdout) {
const lines = fileResult.stdout.trim().split('\n').filter(l => l);
leases = lines.map(line => {
const parts = line.split(' ');
return {
expires: parseInt(parts[0]) || 0,
macaddr: parts[1] || '',
ipaddr: parts[2] || '',
hostname: parts[3] || '*'
};
});
}
}
countBadge.textContent = `${leases.length} client${leases.length !== 1 ? 's' : ''}`;
if (leases.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">Aucun bail DHCP</td></tr>';
return;
}
tbody.innerHTML = leases.map(lease => {
const expires = lease.expires ? new Date(lease.expires * 1000).toLocaleString('fr-FR') : '--';
return `
<tr>
<td>${escapeHtml(lease.hostname || '*')}</td>
<td class="mono">${lease.ipaddr || '--'}</td>
<td class="mono" style="font-size: 0.7rem;">${lease.macaddr || '--'}</td>
<td style="font-size: 0.75rem;">${expires}</td>
</tr>
`;
}).join('');
} catch (e) {
console.error('DHCP leases error:', e);
}
}
function initTrafficGraph() {
const canvas = document.getElementById('trafficCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
drawTrafficGraph(ctx, canvas);
}
function drawTrafficGraph(ctx, canvas) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw grid
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.lineWidth = 1;
for (let i = 0; i < 5; i++) {
const y = (canvas.height / 5) * i;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
}
if (trafficHistory.rx.length < 2) return;
const maxVal = Math.max(...trafficHistory.rx, ...trafficHistory.tx, 1);
const xStep = canvas.width / (maxTrafficPoints - 1);
// Draw RX line (download - green)
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 2;
ctx.beginPath();
trafficHistory.rx.forEach((val, i) => {
const x = i * xStep;
const y = canvas.height - (val / maxVal) * canvas.height * 0.9;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
// Draw TX line (upload - cyan)
ctx.strokeStyle = '#06b6d4';
ctx.beginPath();
trafficHistory.tx.forEach((val, i) => {
const x = i * xStep;
const y = canvas.height - (val / maxVal) * canvas.height * 0.9;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
}
function updateTrafficGraph(rxRate, txRate) {
trafficHistory.rx.push(parseFloat(rxRate) || 0);
trafficHistory.tx.push(parseFloat(txRate) || 0);
if (trafficHistory.rx.length > maxTrafficPoints) {
trafficHistory.rx.shift();
trafficHistory.tx.shift();
}
const canvas = document.getElementById('trafficCanvas');
if (canvas && currentTab === 'network') {
const ctx = canvas.getContext('2d');
drawTrafficGraph(ctx, canvas);
}
}
// =====================================================
// CrowdSec Tab Functions
// =====================================================
async function loadCrowdSecInfo() {
try {
const [decisions, bouncers, alerts, machines, nftStats] = await Promise.all([
CROWDSEC.getDecisions(),
CROWDSEC.getBouncers(),
CROWDSEC.getAlerts(),
getCrowdSecMachines(),
CROWDSEC.getNftablesStats()
]);
// Use nftables stats for more accurate blocked IPs count
let decisionsCount = Array.isArray(decisions) ? decisions.length : 0;
if (nftStats && nftStats.available) {
const nftTotal = (nftStats.ipv4_total_count || 0) + (nftStats.ipv6_total_count || 0);
if (nftTotal > decisionsCount) {
decisionsCount = nftTotal;
}
}
document.getElementById('csDecisions').textContent = decisionsCount || '--';
document.getElementById('csAlerts').textContent = Array.isArray(alerts) ? alerts.length : '--';
document.getElementById('csBouncers').textContent = Array.isArray(bouncers) ? bouncers.length : '--';
document.getElementById('csMachines').textContent = Array.isArray(machines) ? machines.length : '--';
// Render decisions table with unban button
renderCrowdSecDecisions(decisions);
// Render alerts table
renderCrowdSecAlerts(alerts);
} catch (e) {
console.error('CrowdSec info error:', e);
}
}
function renderCrowdSecAlerts(alerts) {
const tbody = document.getElementById('csAlertsBody');
const countBadge = document.getElementById('csAlertsBadge');
if (!alerts || alerts.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">Aucune alerte dans les dernières 24h</td></tr>';
countBadge.textContent = '0 alertes';
return;
}
countBadge.textContent = `${alerts.length} alerte${alerts.length !== 1 ? 's' : ''}`;
tbody.innerHTML = alerts.slice(0, 50).map(alert => {
const source = alert.source?.ip || alert.source?.value || '--';
const scenario = (alert.scenario || '--').replace('crowdsecurity/', '');
const eventsCount = alert.events_count || alert.events?.length || 0;
const createdAt = alert.created_at ? new Date(alert.created_at).toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
}) : '--';
return `
<tr>
<td class="ip mono">${escapeHtml(source)}</td>
<td style="max-width: 180px; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(alert.scenario || '')}">${escapeHtml(scenario)}</td>
<td class="mono">${eventsCount}</td>
<td style="font-size: 0.7rem; color: var(--text-muted);">${createdAt}</td>
</tr>
`;
}).join('');
}
async function getCrowdSecMachines() {
try {
const result = await UBUS.call('luci.crowdsec-dashboard', 'machines');
return result?.machines || [];
} catch (e) {}
return [];
}
function renderCrowdSecDecisions(decisions) {
const tbody = document.getElementById('csDecisionsBody');
if (!decisions || decisions.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">Aucune décision active</td></tr>';
return;
}
tbody.innerHTML = decisions.slice(0, 50).map(d => {
const ip = d.value || d.ip || '--';
const reason = (d.scenario || d.reason || '--').substring(0, 40);
return `
<tr>
<td class="ip mono">${escapeHtml(ip)}</td>
<td style="max-width: 150px; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(d.scenario || d.reason || '')}">${escapeHtml(reason)}</td>
<td><span class="action">${d.type || 'ban'}</span></td>
<td class="mono" style="font-size: 0.7rem;">${d.duration || '--'}</td>
<td>
<button class="service-btn success" onclick="unbanIPDirect('${escapeHtml(ip)}')" title="Débloquer">
<i data-lucide="unlock" style="width: 14px; height: 14px;"></i>
</button>
</td>
</tr>
`;
}).join('');
}
async function refreshCrowdSecDecisions() {
showToast('Actualisation...', 'info');
await loadCrowdSecInfo();
lucide.createIcons();
showToast('Décisions actualisées', 'success');
}
function showUnbanModal() {
document.getElementById('unbanModal').classList.add('active');
}
async function unbanIP(event) {
event.preventDefault();
const ip = document.getElementById('unbanIp').value;
await unbanIPDirect(ip);
closeModal('unbanModal');
document.getElementById('unbanIp').value = '';
}
async function unbanIPDirect(ip) {
try {
showToast(`Déblocage de ${ip}...`, 'info');
await CROWDSEC.removeDecision(ip);
showToast(`IP ${ip} débloquée`, 'success');
if (currentTab === 'crowdsec') {
await loadCrowdSecInfo();
lucide.createIcons();
}
await refreshData();
} catch (e) {
showToast(`Erreur: ${e.message}`, 'error');
}
}
async function updateCrowdSecHub() {
try {
showToast('Mise à jour du hub CrowdSec...', 'info');
await UBUS.call('luci.crowdsec-dashboard', 'update_hub');
showToast('Hub CrowdSec mis à jour', 'success');
} catch (e) {
showToast(`Erreur: ${e.message}`, 'error');
}
}
// =====================================================
// Firewall Tab Functions
// =====================================================
async function loadFirewallInfo() {
try {
// Get firewall zones via uci
const zonesResult = await UBUS.execCommand('/bin/uci', ['show', 'firewall']).catch(() => null);
const zonesContainer = document.getElementById('firewallZones');
const forwardingBody = document.getElementById('forwardingRulesBody');
const portForwardsBody = document.getElementById('portForwardsBody');
const portForwardsCount = document.getElementById('portForwardsCount');
// Parse zones and redirects from UCI output
const zones = [];
const forwardings = [];
const redirects = [];
let currentZone = null;
let currentRedirect = null;
if (zonesResult && zonesResult.stdout) {
const lines = zonesResult.stdout.split('\n');
lines.forEach(line => {
// Parse zones
if (line.includes('.name=') && line.includes('@zone')) {
const match = line.match(/firewall\.(@zone\[\d+\]|zone\d+)\.name='?(\w+)'?/);
if (match) {
currentZone = { name: match[2], input: 'ACCEPT', output: 'ACCEPT', forward: 'REJECT', network: [] };
zones.push(currentZone);
}
}
if (currentZone && line.includes('@zone') && line.includes('.input=')) {
const match = line.match(/\.input='?(\w+)'?/);
if (match) currentZone.input = match[1];
}
if (currentZone && line.includes('@zone') && line.includes('.output=')) {
const match = line.match(/\.output='?(\w+)'?/);
if (match) currentZone.output = match[1];
}
if (currentZone && line.includes('@zone') && line.includes('.forward=')) {
const match = line.match(/\.forward='?(\w+)'?/);
if (match) currentZone.forward = match[1];
}
if (currentZone && line.includes('@zone') && line.includes('.network=')) {
const match = line.match(/\.network='([^']+)'/);
if (match) currentZone.network = match[1].split(' ');
}
// Forwarding rules
if (line.includes('@forwarding') && line.includes('.src=')) {
const srcMatch = line.match(/\.src='?(\w+)'?/);
if (srcMatch) {
forwardings.push({ src: srcMatch[1], dest: '' });
}
}
if (forwardings.length > 0 && line.includes('@forwarding') && line.includes('.dest=')) {
const destMatch = line.match(/\.dest='?(\w+)'?/);
if (destMatch && forwardings[forwardings.length - 1].dest === '') {
forwardings[forwardings.length - 1].dest = destMatch[1];
}
}
// Port redirects (DNAT)
if (line.includes('@redirect') && line.includes('.name=')) {
const match = line.match(/\.name='([^']+)'/);
if (match) {
currentRedirect = { name: match[1], proto: 'tcp', src_dport: '', dest_ip: '', dest_port: '', enabled: true };
redirects.push(currentRedirect);
}
}
if (currentRedirect && line.includes('@redirect') && line.includes('.proto=')) {
const match = line.match(/\.proto='?(\w+)'?/);
if (match) currentRedirect.proto = match[1];
}
if (currentRedirect && line.includes('@redirect') && line.includes('.src_dport=')) {
const match = line.match(/\.src_dport='?(\d+)'?/);
if (match) currentRedirect.src_dport = match[1];
}
if (currentRedirect && line.includes('@redirect') && line.includes('.dest_ip=')) {
const match = line.match(/\.dest_ip='([^']+)'/);
if (match) currentRedirect.dest_ip = match[1];
}
if (currentRedirect && line.includes('@redirect') && line.includes('.dest_port=')) {
const match = line.match(/\.dest_port='?(\d+)'?/);
if (match) currentRedirect.dest_port = match[1];
}
if (currentRedirect && line.includes('@redirect') && line.includes('.enabled=')) {
const match = line.match(/\.enabled='?(\d+)'?/);
if (match) currentRedirect.enabled = match[1] !== '0';
}
});
}
// Default zones if none found
if (zones.length === 0) {
zones.push(
{ name: 'lan', input: 'ACCEPT', output: 'ACCEPT', forward: 'ACCEPT', network: ['lan'] },
{ name: 'wan', input: 'REJECT', output: 'ACCEPT', forward: 'REJECT', network: ['wan', 'wan6'] }
);
}
zonesContainer.innerHTML = zones.map(zone => {
const zoneClass = zone.name.toLowerCase();
return `
<div class="zone-card ${zoneClass}">
<div class="zone-name">${escapeHtml(zone.name)}</div>
<div class="zone-policy">
Input: ${zone.input} | Output: ${zone.output} | Forward: ${zone.forward}
</div>
<div class="zone-interfaces">${zone.network?.join(', ') || '--'}</div>
</div>
`;
}).join('');
// Forwarding rules
if (forwardings.length === 0) {
forwardings.push({ src: 'lan', dest: 'wan' });
}
forwardingBody.innerHTML = forwardings.filter(f => f.dest).map(fwd => `
<tr>
<td><span class="interface-status up">${escapeHtml(fwd.src)}</span></td>
<td><span class="interface-status up">${escapeHtml(fwd.dest)}</span></td>
<td>ACCEPT</td>
</tr>
`).join('');
// Port forwards
portForwardsCount.textContent = `${redirects.length} règle${redirects.length !== 1 ? 's' : ''}`;
if (redirects.length === 0) {
portForwardsBody.innerHTML = '<tr><td colspan="5" class="empty-state">Aucune redirection configurée</td></tr>';
} else {
portForwardsBody.innerHTML = redirects.map(redir => {
const destPort = redir.dest_port || redir.src_dport;
return `
<tr>
<td>${escapeHtml(redir.name)}</td>
<td class="mono" style="text-transform: uppercase;">${redir.proto}</td>
<td class="mono">${redir.src_dport}</td>
<td class="mono">${redir.dest_ip}:${destPort}</td>
<td><span class="interface-status ${redir.enabled ? 'up' : 'down'}">${redir.enabled ? 'ON' : 'OFF'}</span></td>
</tr>
`;
}).join('');
}
} catch (e) {
console.error('Firewall info error:', e);
}
}
async function reloadFirewall() {
try {
showToast('Rechargement des règles...', 'info');
await UBUS.execCommand('/etc/init.d/firewall', ['reload']);
showToast('Règles rechargées', 'success');
await loadFirewallInfo();
lucide.createIcons();
} catch (e) {
showToast(`Erreur: ${e.message}`, 'error');
}
}
// =====================================================
// Logs Tab Functions
// =====================================================
let allLogs = [];
let currentLogFilter = 'all';
async function loadFullLogs() {
const container = document.getElementById('fullLogsContainer');
try {
// Use shell to get last 100 lines (logread -l N not available on BusyBox)
const result = await UBUS.execCommand('/bin/sh', ['-c', 'logread | tail -n 100']);
if (result && result.stdout) {
allLogs = result.stdout.trim().split('\n').filter(l => l).reverse();
renderFilteredLogs();
} else {
container.innerHTML = '<div class="empty-state">Aucun log disponible</div>';
}
} catch (e) {
container.innerHTML = '<div class="empty-state">Erreur chargement logs</div>';
}
}
function renderFilteredLogs() {
const container = document.getElementById('fullLogsContainer');
const searchTerm = document.getElementById('logsSearch')?.value?.toLowerCase() || '';
let filteredLogs = allLogs;
// Apply filter
if (currentLogFilter !== 'all') {
filteredLogs = filteredLogs.filter(log => {
const lower = log.toLowerCase();
switch(currentLogFilter) {
case 'error': return lower.includes('error') || lower.includes('fail');
case 'warn': return lower.includes('warn');
case 'crowdsec': return lower.includes('crowdsec');
default: return true;
}
});
}
// Apply search
if (searchTerm) {
filteredLogs = filteredLogs.filter(log => log.toLowerCase().includes(searchTerm));
}
if (filteredLogs.length === 0) {
container.innerHTML = '<div class="empty-state">Aucun log correspondant</div>';
return;
}
container.innerHTML = filteredLogs.slice(0, 100).map(line => {
const type = getLogType(line);
const icons = { ban: 'x-circle', alert: 'alert-triangle', info: 'info', success: 'check-circle' };
const parts = line.match(/^(\w+\s+\d+\s+[\d:]+)\s+\S+\s+(.+)$/);
const time = parts?.[1] || '';
const message = parts?.[2] || line;
return `
<div class="log-entry ${type}">
<i data-lucide="${icons[type]}" class="log-icon ${type}" style="width: 16px; height: 16px;"></i>
<div class="log-content">
<div class="log-message">${escapeHtml(message)}</div>
<div class="log-meta">
<span>system</span>
<span>•</span>
<span>${time}</span>
</div>
</div>
</div>
`;
}).join('');
lucide.createIcons();
}
// =====================================================
// Services Tab Functions
// =====================================================
const importantServices = [
'crowdsec', 'firewall', 'network', 'dnsmasq', 'uhttpd', 'rpcd',
'dropbear', 'odhcpd', 'ntpd', 'sysntpd', 'cron', 'log', 'urngd'
];
// Service to process name mapping for pgrep detection
const serviceProcessMap = {
'crowdsec': 'crowdsec',
'firewall': 'fw4',
'network': 'netifd',
'dnsmasq': 'dnsmasq',
'uhttpd': 'uhttpd',
'rpcd': 'rpcd',
'dropbear': 'dropbear',
'odhcpd': 'odhcpd',
'ntpd': 'ntpd',
'sysntpd': 'ntpd',
'cron': 'crond',
'log': 'logd',
'urngd': 'urngd',
'umdns': 'umdns',
'wpad': 'wpad',
'mitmproxy': 'mitmdump'
};
async function getServiceStatus(serviceName) {
// Use pgrep for reliable status detection (without -x flag per CLAUDE.md)
const processName = serviceProcessMap[serviceName] || serviceName;
try {
// Try pgrep first (most reliable)
const pgrepResult = await UBUS.execCommand('/usr/bin/pgrep', [processName]).catch(() => null);
if (pgrepResult && pgrepResult.stdout && pgrepResult.stdout.trim()) {
return true;
}
// Fallback: check if enabled and try init.d status
const enabledResult = await UBUS.execCommand('/etc/init.d/' + serviceName, ['enabled']).catch(() => ({ code: 1 }));
if (enabledResult && enabledResult.code === 0) {
// Service is enabled, check if running via procd
const serviceList = await UBUS.getServices().catch(() => ({}));
if (serviceList && serviceList[serviceName]) {
return true;
}
}
return false;
} catch (e) {
return false;
}
}
async function loadServices() {
const container = document.getElementById('servicesList');
container.innerHTML = '<div class="loading-services"><span class="spinner"></span> Chargement des services...</div>';
try {
// Get init scripts
const result = await UBUS.execCommand('/bin/ls', ['/etc/init.d/']);
if (!result || !result.stdout) {
container.innerHTML = '<div class="empty-state">Aucun service trouvé</div>';
return;
}
const scripts = result.stdout.trim().split('\n').filter(s => s && !s.startsWith('.'));
// Filter to important services first, then others
const sortedServices = [
...scripts.filter(s => importantServices.includes(s)),
...scripts.filter(s => !importantServices.includes(s)).sort()
];
// Get procd service list for batch status check
const procdServices = await UBUS.getServices().catch(() => ({}));
// Get status for each service using improved detection
const servicesHtml = await Promise.all(sortedServices.slice(0, 40).map(async (svc) => {
let running = false;
let enabled = false;
// Check procd first (fastest)
if (procdServices && procdServices[svc]) {
running = true;
} else {
// Fallback to pgrep
running = await getServiceStatus(svc);
}
// Check if enabled
const enabledResult = await UBUS.execCommand('/etc/init.d/' + svc, ['enabled']).catch(() => ({ code: 1 }));
enabled = enabledResult && enabledResult.code === 0;
const isImportant = importantServices.includes(svc);
return `
<div class="service-item ${isImportant ? 'important' : ''}">
<div class="service-info">
<div class="service-icon ${running ? 'running' : 'stopped'}">
<i data-lucide="${running ? 'play-circle' : 'circle'}" style="width: 18px; height: 18px;"></i>
</div>
<div>
<div class="service-name">
${escapeHtml(svc)}
${isImportant ? '<span class="service-badge">système</span>' : ''}
</div>
<div class="service-status">
${running ? '<span class="status-running">En cours</span>' : '<span class="status-stopped">Arrêté</span>'}
${enabled ? '' : ' <span class="status-disabled">(désactivé)</span>'}
</div>
</div>
</div>
<div class="service-actions">
${running ? `
<button class="service-btn" onclick="serviceAction('${svc}', 'restart')" title="Redémarrer">
<i data-lucide="refresh-cw" style="width: 14px; height: 14px;"></i>
</button>
<button class="service-btn danger" onclick="serviceAction('${svc}', 'stop')" title="Arrêter">
<i data-lucide="square" style="width: 14px; height: 14px;"></i>
</button>
` : `
<button class="service-btn success" onclick="serviceAction('${svc}', 'start')" title="Démarrer">
<i data-lucide="play" style="width: 14px; height: 14px;"></i>
</button>
`}
<button class="service-btn ${enabled ? 'warning' : 'success'}" onclick="serviceAction('${svc}', '${enabled ? 'disable' : 'enable'}')" title="${enabled ? 'Désactiver' : 'Activer'}">
<i data-lucide="${enabled ? 'toggle-right' : 'toggle-left'}" style="width: 14px; height: 14px;"></i>
</button>
</div>
</div>
`;
}));
container.innerHTML = servicesHtml.join('');
lucide.createIcons();
} catch (e) {
container.innerHTML = '<div class="empty-state">Erreur chargement services</div>';
}
}
async function refreshServices() {
showToast('Actualisation...', 'info');
await loadServices();
showToast('Services actualisés', 'success');
}
async function serviceAction(service, action) {
const actionMessages = {
'start': { doing: 'Démarrage', done: 'démarré' },
'stop': { doing: 'Arrêt', done: 'arrêté' },
'restart': { doing: 'Redémarrage', done: 'redémarré' },
'enable': { doing: 'Activation', done: 'activé' },
'disable': { doing: 'Désactivation', done: 'désactivé' }
};
const msg = actionMessages[action] || { doing: action, done: action };
try {
showToast(`${msg.doing} de ${service}...`, 'info');
await UBUS.execCommand('/etc/init.d/' + service, [action]);
showToast(`${service} ${msg.done}`, 'success');
await loadServices();
} catch (e) {
showToast(`Erreur: ${e.message}`, 'error');
}
}
// =====================================================
// Settings Functions
// =====================================================
function updateRefreshInterval() {
const select = document.getElementById('settingsRefreshInterval');
const newInterval = parseInt(select.value);
CONFIG.refreshInterval = newInterval;
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = setInterval(refreshData, CONFIG.refreshInterval);
}
showToast(`Intervalle mis à jour: ${newInterval / 1000}s`, 'success');
}
// =====================================================
// Event Listeners
// =====================================================
document.addEventListener('DOMContentLoaded', () => {
lucide.createIcons();
// Initialize server URL with current location
const serverUrlInput = document.getElementById('serverUrl');
if (serverUrlInput && !serverUrlInput.value) {
serverUrlInput.value = window.location.origin;
}
// Login form
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('loginBtn');
const error = document.getElementById('loginError');
btn.disabled = true;
btn.innerHTML = `
<i data-lucide="loader-2" style="width: 18px; height: 18px; animation: spin 1s linear infinite;"></i>
<span>Connexion...</span>
`;
lucide.createIcons();
error.classList.remove('show');
try {
await login(
document.getElementById('serverUrl').value,
document.getElementById('username').value,
document.getElementById('password').value
);
showApp();
refreshData();
} catch (err) {
error.classList.add('show');
document.getElementById('loginErrorText').textContent = err.message;
} finally {
btn.disabled = false;
btn.innerHTML = `
<i data-lucide="log-in" style="width: 18px; height: 18px;"></i>
<span>Connexion</span>
`;
lucide.createIcons();
}
});
// Navigation
document.querySelectorAll('[data-tab]').forEach(item => {
item.addEventListener('click', () => {
document.querySelectorAll('[data-tab]').forEach(i => i.classList.remove('active'));
document.querySelectorAll(`[data-tab="${item.dataset.tab}"]`).forEach(i => i.classList.add('active'));
switchTab(item.dataset.tab);
closeSidebar();
});
});
// Logs filter buttons
document.querySelectorAll('.logs-filter').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.logs-filter').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentLogFilter = btn.dataset.filter;
renderFilteredLogs();
});
});
// Logs search
const logsSearch = document.getElementById('logsSearch');
if (logsSearch) {
logsSearch.addEventListener('input', renderFilteredLogs);
}
// Close modals on escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal.active').forEach(m => m.classList.remove('active'));
}
});
// Auto-login
autoLogin();
});
</script>
</body>
</html>