5945 lines
225 KiB
HTML
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>
|