1548 lines
71 KiB
HTML
1548 lines
71 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en" data-bs-theme="dark">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Jakach Logging</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
|
<style>
|
|
body { font-size: .875rem; }
|
|
.sidebar { position: fixed; top: 0; left: 0; bottom: 0; width: 220px; padding-top: 56px; z-index: 100; border-right: 1px solid var(--bs-border-color); }
|
|
.sidebar .nav-link { color: var(--bs-secondary-color); padding: .5rem 1rem; border-radius: 0; }
|
|
.sidebar .nav-link:hover, .sidebar .nav-link.active { color: var(--bs-body-color); background: var(--bs-tertiary-bg); }
|
|
.sidebar .nav-link i { margin-right: .5rem; }
|
|
.main { margin-left: 220px; padding-top: 56px; }
|
|
.navbar-brand i { margin-right: .5rem; }
|
|
.stat-card { border-left: 3px solid var(--bs-border-color); }
|
|
.stat-card.critical { border-left-color: var(--bs-danger); }
|
|
.stat-card.warning { border-left-color: var(--bs-warning); }
|
|
.stat-card.info { border-left-color: var(--bs-info); }
|
|
.card-header .btn-sm { font-size: .75rem; }
|
|
.page-section { display: none; }
|
|
.page-section.active { display: block; }
|
|
.badge-severity { text-transform: uppercase; font-size: .65rem; letter-spacing: .5px; }
|
|
.alert-row { cursor: pointer; }
|
|
.alert-row:hover { background: var(--bs-tertiary-bg); }
|
|
.log-line { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-size: .8rem; max-width: 500px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.empty-state { text-align: center; padding: 4rem 1rem; color: var(--bs-secondary-color); }
|
|
.empty-state i { font-size: 3rem; margin-bottom: 1rem; }
|
|
.toast-container { z-index: 1060; }
|
|
#detailModal .modal-body { max-height: 70vh; overflow-y: auto; }
|
|
pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius: .375rem; font-size: .8rem; white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow-y: auto; }
|
|
.login-container { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
|
.login-card { max-width: 420px; width: 100%; }
|
|
.login-container .navbar-brand { font-size: 1.5rem; }
|
|
.login-footer { position: fixed; bottom: 1rem; left: 0; right: 0; text-align: center; font-size: .8rem; color: var(--bs-secondary-color); }
|
|
@media (max-width: 768px) { .sidebar { display: none; } .main { margin-left: 0; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="appLogin" class="login-container">
|
|
<div class="login-card">
|
|
<div class="text-center mb-4">
|
|
<h3 class="navbar-brand"><i class="bi bi-terminal-plus"></i> Jakach Logging</h3>
|
|
<p class="text-secondary">Log analysis & alerting system</p>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-body p-4 text-center">
|
|
<i class="bi bi-shield-lock" style="font-size:3rem;display:block;margin-bottom:1rem"></i>
|
|
<h5>Sign in required</h5>
|
|
<div id="authErrorBox" class="alert alert-danger d-none" role="alert"></div>
|
|
<p class="text-secondary mb-4">Authenticate with your Jakach account to access the system.</p>
|
|
<a id="loginBtn" class="btn btn-primary btn-lg w-100" href="#">
|
|
<i class="bi bi-box-arrow-in-right me-2"></i>Log in using Jakach Login
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div class="login-footer">Powered by <a href="https://auth.jakach.ch" class="text-secondary">Jakach Auth</a></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="appMain" style="display:none">
|
|
<nav class="navbar navbar-expand navbar-dark bg-dark fixed-top">
|
|
<div class="container-fluid">
|
|
<a class="navbar-brand" href="#"><i class="bi bi-terminal-plus"></i>Jakach Logging</a>
|
|
<div class="d-flex align-items-center gap-2 ms-auto">
|
|
<span class="badge bg-danger d-none" id="criticalBadge">0</span>
|
|
<span class="badge bg-warning text-dark d-none" id="warningBadge">0</span>
|
|
<span class="text-secondary small me-2 d-none d-md-inline" id="userDisplay"></span>
|
|
<button class="btn btn-outline-secondary btn-sm" id="refreshBtn" title="Refresh"><i class="bi bi-arrow-clockwise"></i></button>
|
|
<div class="form-check form-switch ms-2">
|
|
<input class="form-check-input" type="checkbox" id="autoRefresh" checked>
|
|
<label class="form-check-label" for="autoRefresh" style="font-size:.8rem">Auto</label>
|
|
</div>
|
|
<button class="btn btn-outline-danger btn-sm" id="logoutBtn" title="Logout"><i class="bi bi-box-arrow-right"></i></button>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="sidebar">
|
|
<ul class="nav flex-column">
|
|
<li class="nav-item"><a class="nav-link active" href="#" data-page="dashboard"><i class="bi bi-speedometer2"></i>Dashboard</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="#" data-page="alerts"><i class="bi bi-bell"></i>Alerts</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="#" data-page="logs"><i class="bi bi-search"></i>Logs</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="#" data-page="sources"><i class="bi bi-database"></i>Sources</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="#" data-page="rules"><i class="bi bi-sliders"></i>Rules</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="#" data-page="settings"><i class="bi bi-gear"></i>Settings</a></li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="main">
|
|
<div class="container-fluid p-3">
|
|
|
|
<!-- DASHBOARD -->
|
|
<div class="page-section active" id="page-dashboard">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h5 class="mb-0"><i class="bi bi-speedometer2 me-2"></i>Dashboard</h5>
|
|
<small class="text-secondary" id="lastUpdated"></small>
|
|
</div>
|
|
<div class="row g-3 mb-3" id="statCards"></div>
|
|
<div class="row g-3">
|
|
<div class="col-lg-8">
|
|
<div class="card h-100">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span><i class="bi bi-bell me-1"></i>Recent Critical Alerts</span>
|
|
<a href="#" data-page="alerts" class="btn btn-outline-secondary btn-sm">View all</a>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive"><table class="table table-sm table-borderless mb-0"><tbody id="dashboardAlerts"><tr><td colspan="4" class="empty-state"><i class="bi bi-check-circle"></i><p class="mb-0">No alerts yet</p></td></tr></tbody></table></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-4">
|
|
<div class="card h-100">
|
|
<div class="card-header"><i class="bi bi-pie-chart me-1"></i>Alert Distribution</div>
|
|
<div class="card-body" id="chartContainer"><div class="empty-state"><i class="bi bi-bar-chart"></i><p class="mb-0">No data</p></div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ALERTS -->
|
|
<div class="page-section" id="page-alerts">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h5 class="mb-0"><i class="bi bi-bell me-2"></i>Alerts</h5>
|
|
<div class="d-flex gap-2">
|
|
<select class="form-select form-select-sm" id="filterSeverity" style="width:auto">
|
|
<option value="">All Severities</option>
|
|
<option value="emergency">Emergency</option>
|
|
<option value="critical_high">Critical High</option>
|
|
<option value="critical">Critical</option>
|
|
<option value="critical_low">Critical Low</option>
|
|
<option value="error">Error</option>
|
|
<option value="warning_high">Warning High</option>
|
|
<option value="warning">Warning</option>
|
|
<option value="warning_low">Warning Low</option>
|
|
<option value="notice">Notice</option>
|
|
<option value="info">Info</option>
|
|
<option value="debug">Debug</option>
|
|
</select>
|
|
<select class="form-select form-select-sm" id="filterStatus" style="width:auto">
|
|
<option value="">All Statuses</option>
|
|
<option value="open">Open</option>
|
|
<option value="acknowledged">Acknowledged</option>
|
|
<option value="resolved">Resolved</option>
|
|
</select>
|
|
<div class="input-group input-group-sm" style="width:220px">
|
|
<input type="text" class="form-control" id="searchInput" placeholder="Search logs..." maxlength="100">
|
|
<button class="btn btn-outline-secondary" id="searchBtn"><i class="bi bi-search"></i></button>
|
|
</div>
|
|
<button class="btn btn-outline-secondary btn-sm" id="refreshAlertsBtn"><i class="bi bi-arrow-clockwise"></i></button>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex gap-2 align-items-center mb-2" id="bulkBar" style="display:none">
|
|
<small class="text-secondary"><span id="selectedCount">0</span> selected</small>
|
|
<button class="btn btn-outline-primary btn-sm py-0" onclick="selectAllVisible()"><i class="bi bi-check-all"></i> Select all</button>
|
|
<button class="btn btn-outline-secondary btn-sm py-0" onclick="deselectAll()"><i class="bi bi-x"></i> Clear</button>
|
|
<button class="btn btn-outline-success btn-sm" onclick="bulkAction('acknowledged')"><i class="bi bi-check"></i> Acknowledge</button>
|
|
<button class="btn btn-outline-secondary btn-sm" onclick="bulkAction('resolved')"><i class="bi bi-check-all"></i> Resolve</button>
|
|
<button class="btn btn-outline-info btn-sm" onclick="exportAlerts('json')"><i class="bi bi-download"></i> Export JSON</button>
|
|
<button class="btn btn-outline-info btn-sm" onclick="exportAlerts('csv')"><i class="bi bi-filetype-csv"></i> Export CSV</button>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover table-sm mb-0" id="alertsTable">
|
|
<thead class="table-dark"><tr>
|
|
<th style="width:80px"><input type="checkbox" class="form-check-input" id="selectAllCheckbox" onchange="toggleSelectAll()" title="Select all visible"> <span style="cursor:pointer" onclick="sortAlerts('id')">ID <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></span></th>
|
|
<th style="width:90px;cursor:pointer" onclick="sortAlerts('severity')">Severity <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
|
|
<th style="width:100px;cursor:pointer" onclick="sortAlerts('status')">Status <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
|
|
<th>Message</th>
|
|
<th style="cursor:pointer" onclick="sortAlerts('source_name')">Source <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
|
|
<th style="width:170px;cursor:pointer" onclick="sortAlerts('created_at')">Created <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
|
|
<th style="width:100px"></th>
|
|
</tr></thead>
|
|
<tbody id="alertsBody"><tr><td colspan="7" class="empty-state"><i class="bi bi-inbox"></i><p class="mb-0">No alerts match those filters</p></td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div class="card-footer d-flex justify-content-between align-items-center py-2">
|
|
<small class="text-secondary" id="alertsCount">0 alerts</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- LOGS -->
|
|
<div class="page-section" id="page-logs">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h5 class="mb-0"><i class="bi bi-search me-2"></i>Log Search</h5>
|
|
<small class="text-secondary" id="logsCount"></small>
|
|
<button class="btn btn-outline-info btn-sm" onclick="exportLogs()"><i class="bi bi-download"></i> Export</button>
|
|
</div>
|
|
<div class="card mb-3">
|
|
<div class="card-body py-3">
|
|
<div class="input-group">
|
|
<input type="text" class="form-control" id="logSearchInput" placeholder="Search all log lines..." maxlength="200">
|
|
<button class="btn btn-primary" id="logSearchBtn"><i class="bi bi-search me-1"></i>Search</button>
|
|
</div>
|
|
<div class="d-flex gap-2 mt-2 align-items-center">
|
|
<small class="text-secondary">Use <code>*</code> as wildcard. <code>bla</code> matches "blabla". <code>*</code> shows all logs. Separate terms match all (AND).</small>
|
|
<input type="datetime-local" class="form-control form-control-sm" id="logSince" style="width:auto" title="From">
|
|
<input type="datetime-local" class="form-control form-control-sm" id="logUntil" style="width:auto" title="To">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-hover mb-0">
|
|
<thead class="table-dark"><tr>
|
|
<th style="width:60px">ID</th>
|
|
<th style="width:170px">Time</th>
|
|
<th>Line</th>
|
|
<th>Source</th>
|
|
</tr></thead>
|
|
<tbody id="logsBody"><tr><td colspan="4" class="empty-state"><i class="bi bi-search"></i><p class="mb-0">Enter a search query above</p></td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SOURCES -->
|
|
<div class="page-section" id="page-sources">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h5 class="mb-0"><i class="bi bi-database me-2"></i>Log Sources</h5>
|
|
<button class="btn btn-primary btn-sm" onclick="resetSourceForm();setTimeout(()=>bootstrap.Modal.getOrCreateInstance(document.getElementById('sourceModal')).show(),50)"><i class="bi bi-plus-lg"></i> Add Source</button>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-sm mb-0">
|
|
<thead class="table-dark"><tr>
|
|
<th>Name</th><th>Type</th><th>Address</th><th>Labels</th><th>Status</th><th style="width:100px"></th>
|
|
</tr></thead>
|
|
<tbody id="sourcesBody"><tr><td colspan="6" class="empty-state"><i class="bi bi-database"></i><p class="mb-0">No sources configured</p></td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- RULES -->
|
|
<div class="page-section" id="page-rules">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h5 class="mb-0"><i class="bi bi-sliders me-2"></i>Alert Rules</h5>
|
|
<button class="btn btn-primary btn-sm" onclick="resetRuleForm();setTimeout(()=>bootstrap.Modal.getOrCreateInstance(document.getElementById('ruleModal')).show(),50)"><i class="bi bi-plus-lg"></i> Add Rule</button>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-sm mb-0">
|
|
<thead class="table-dark"><tr>
|
|
<th>Name</th><th>Pattern</th><th>Severity</th><th>Rate Limit</th><th>Status</th><th style="width:60px"></th>
|
|
</tr></thead>
|
|
<tbody id="rulesBody"><tr><td colspan="6" class="empty-state"><i class="bi bi-sliders"></i><p class="mb-0">No rules configured</p></td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SETTINGS -->
|
|
<div class="page-section" id="page-settings">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h5 class="mb-0"><i class="bi bi-gear me-2"></i>Settings</h5>
|
|
<button class="btn btn-outline-secondary btn-sm" onclick="loadSettings()"><i class="bi bi-arrow-clockwise"></i></button>
|
|
</div>
|
|
<div class="row g-3">
|
|
<!-- Left column -->
|
|
<div class="col-md-6">
|
|
|
|
<!-- Database status -->
|
|
<div class="card mb-3">
|
|
<div class="card-header"><i class="bi bi-database me-1"></i>Database</div>
|
|
<div class="card-body py-2">
|
|
<div class="row g-2">
|
|
<div class="col-6"><small class="text-secondary d-block">SQLite</small><span id="sysSqlite" class="badge bg-secondary">checking...</span></div>
|
|
<div class="col-6"><small class="text-secondary d-block">ClickHouse</small><span id="sysClickhouse" class="badge bg-secondary">checking...</span></div>
|
|
<div class="col-6"><small class="text-secondary d-block">Health</small><span id="sysHealth" class="badge bg-secondary">checking...</span></div>
|
|
<div class="col-6"><small class="text-secondary d-block">DB Size</small><span id="sysDbSize">—</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Data Retention -->
|
|
<div class="card mb-3">
|
|
<div class="card-header"><i class="bi bi-clock-history me-1"></i>Data Retention</div>
|
|
<div class="card-body">
|
|
<p class="small text-secondary mb-2">ClickHouse TTL auto-deletes old data. Manual purge below removes rate-limiter state.</p>
|
|
<div class="row g-2 mb-2">
|
|
<div class="col">
|
|
<label class="form-label">Keep logs (days)</label>
|
|
<input type="number" class="form-control form-control-sm" id="retentionLogDays" value="30">
|
|
</div>
|
|
<div class="col">
|
|
<label class="form-label">Keep resolved alerts (days)</label>
|
|
<input type="number" class="form-control form-control-sm" id="retentionAlertDays" value="90">
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-outline-danger btn-sm" id="runRetentionBtn"><i class="bi bi-trash3"></i> Purge Now</button>
|
|
<small id="retentionResult" class="ms-2"></small>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- False Positives -->
|
|
<div class="card mb-3">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span><i class="bi bi-x-circle me-1"></i>False Positives</span>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="table-responsive mb-2" style="max-height:240px;overflow-y:auto">
|
|
<table class="table table-sm mb-0">
|
|
<thead><tr><th>Pattern</th><th>Description</th><th style="width:50px"></th></tr></thead>
|
|
<tbody id="falsePositivesBody"></tbody>
|
|
</table>
|
|
</div>
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" class="form-control font-monospace" id="fpPattern" placeholder="/healthcheck/i">
|
|
<input type="text" class="form-control" id="fpDescription" placeholder="Description" style="max-width:160px">
|
|
<button class="btn btn-primary" id="addFpBtn"><i class="bi bi-plus-lg"></i></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Audit Log -->
|
|
<div class="card mb-3">
|
|
<div class="card-header"><i class="bi bi-journal-text me-1"></i>Audit Log</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive" style="max-height:250px;overflow-y:auto">
|
|
<table class="table table-sm mb-0">
|
|
<tbody id="auditLogBody"><tr><td class="text-secondary small text-center">Loading...</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Right column -->
|
|
<div class="col-md-6">
|
|
|
|
<!-- Security -->
|
|
<div class="card mb-3">
|
|
<div class="card-header"><i class="bi bi-shield-check me-1"></i>Security</div>
|
|
<div class="card-body">
|
|
<div class="mb-2">
|
|
<label class="form-label">Allowed User Tokens <i class="bi bi-question-circle text-secondary" title="Only these Jakach user tokens can access this system. Leave empty to allow any authenticated Jakach user."></i></label>
|
|
<textarea class="form-control font-monospace" id="allowedTokensInput" rows="3" placeholder="One user_token per line"></textarea>
|
|
</div>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<button class="btn btn-primary btn-sm" id="saveTokensBtn"><i class="bi bi-floppy"></i> Save</button>
|
|
<small id="tokenSaveStatus"></small>
|
|
<span class="ms-auto text-secondary small">Your token: <code id="settingsUserToken" class="user-select-all">—</code></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Telegram -->
|
|
<div class="card mb-3">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span><i class="bi bi-telegram me-1"></i>Telegram Notifications</span>
|
|
<span id="telegramStatusBadge"></span>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="mb-2">
|
|
<label class="form-label">Bot Token</label>
|
|
<div class="input-group input-group-sm">
|
|
<input type="password" class="form-control" id="telegramBotToken" placeholder="Enter bot token">
|
|
<button class="btn btn-outline-secondary" id="telegramTokenToggle" onclick="toggleTelegramToken()"><i class="bi bi-eye"></i></button>
|
|
</div>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label">Chat ID</label>
|
|
<input type="text" class="form-control form-control-sm" id="telegramChatId" placeholder="-1001234567890">
|
|
</div>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<button class="btn btn-primary btn-sm" id="saveTelegramBtn"><i class="bi bi-floppy"></i> Save</button>
|
|
<button class="btn btn-outline-secondary btn-sm" id="testTelegramBtn"><i class="bi bi-send"></i> Test</button>
|
|
<small id="telegramSaveStatus"></small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- System -->
|
|
<div class="card mb-3">
|
|
<div class="card-header"><i class="bi bi-info-circle me-1"></i>System</div>
|
|
<div class="card-body">
|
|
<dl class="row mb-0 small">
|
|
<dt class="col-sm-4">Logged in as</dt><dd class="col-sm-8" id="settingsUser">—</dd>
|
|
<dt class="col-sm-4">Auth Server</dt><dd class="col-sm-8"><a href="https://auth.jakach.ch" target="_blank">auth.jakach.ch</a></dd>
|
|
<dt class="col-sm-4">File sources</dt><dd class="col-sm-8">path on worker container</dd>
|
|
<dt class="col-sm-4">TCP/UDP</dt><dd class="col-sm-8"><code>tcp://0.0.0.0:9514</code></dd>
|
|
<dt class="col-sm-4">Rules</dt><dd class="col-sm-8">PHP regex, e.g. <code>/error/i</code></dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Alert Detail Modal -->
|
|
<div class="modal fade" id="detailModal" tabindex="-1" style="z-index:9999">
|
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
<div class="modal-content">
|
|
<div class="modal-header"><h5 class="modal-title">Alert Detail</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
|
<div class="modal-body" id="detailBody"></div>
|
|
<div class="modal-footer d-flex justify-content-between">
|
|
<div>
|
|
<select class="form-select form-select-sm" id="statusSelect">
|
|
<option value="open">Open</option>
|
|
<option value="acknowledged">Acknowledge</option>
|
|
<option value="resolved">Resolved</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<button class="btn btn-primary btn-sm" id="updateStatusBtn"><i class="bi bi-check2"></i> Update</button>
|
|
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div></div></div>
|
|
|
|
<!-- Source Modal -->
|
|
<div class="modal fade" id="sourceModal" tabindex="-1" style="z-index:10000">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<form id="sourceForm">
|
|
<div class="modal-header"><h5 class="modal-title" id="sourceModalLabel">Add Source</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
|
<div class="modal-body">
|
|
<input type="hidden" name="id" id="sourceFormId" value="">
|
|
<div class="mb-3">
|
|
<label class="form-label">Name</label>
|
|
<input type="text" class="form-control" name="name" id="sourceFormName" required placeholder="e.g. nginx-access">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Type</label>
|
|
<select class="form-select" name="type" id="sourceFormType" required>
|
|
<option value="file">File</option>
|
|
<option value="tcp">TCP</option>
|
|
<option value="udp">UDP</option>
|
|
<option value="http">HTTP</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Address</label>
|
|
<input type="text" class="form-control" name="address" id="sourceFormAddress" required placeholder="/var/log/nginx/access.log or tcp://0.0.0.0:9514">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Labels <small class="text-secondary">(JSON)</small></label>
|
|
<input type="text" class="form-control" name="labels" id="sourceFormLabels" placeholder='{"env":"prod"}'>
|
|
</div>
|
|
<div class="form-check mb-3">
|
|
<input class="form-check-input" type="checkbox" name="active" id="sourceFormActive" checked>
|
|
<label class="form-check-label" for="sourceFormActive">Active</label>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="submit" class="btn btn-primary" id="sourceFormSubmit">Add Source</button>
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
</div>
|
|
</form>
|
|
</div></div></div>
|
|
|
|
<!-- Rule Modal -->
|
|
<div class="modal fade" id="ruleModal" tabindex="-1" style="z-index:10001">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<form id="ruleForm">
|
|
<div class="modal-header"><h5 class="modal-title" id="ruleModalTitle">Add Alert Rule</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
|
<div class="modal-body">
|
|
<input type="hidden" name="id" id="ruleFormId" value="">
|
|
<div class="mb-3">
|
|
<label class="form-label">Name</label>
|
|
<input type="text" class="form-control" name="name" id="ruleFormName" required placeholder="e.g. SSH Login">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Pattern <small class="text-secondary">(PHP regex)</small></label>
|
|
<input type="text" class="form-control" name="pattern" id="ruleFormPattern" required placeholder="/sshd.*Accepted/i">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Severity</label>
|
|
<select class="form-select" name="severity" id="ruleFormSeverity" required>
|
|
<option value="emergency">Emergency</option>
|
|
<option value="critical_high">Critical High</option>
|
|
<option value="critical">Critical</option>
|
|
<option value="critical_low">Critical Low</option>
|
|
<option value="error">Error</option>
|
|
<option value="warning_high">Warning High</option>
|
|
<option value="warning" selected>Warning</option>
|
|
<option value="warning_low">Warning Low</option>
|
|
<option value="notice">Notice</option>
|
|
<option value="info">Info</option>
|
|
<option value="debug">Debug</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Rate Limit <small class="text-secondary">(seconds, empty = no limit)</small></label>
|
|
<input type="number" class="form-control" name="rate_limit_seconds" id="ruleFormRateLimit" placeholder="60">
|
|
</div>
|
|
<div class="form-check mb-3">
|
|
<input class="form-check-input" type="checkbox" name="active" id="ruleFormActive" checked>
|
|
<label class="form-check-label" for="ruleFormActive">Active</label>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="submit" class="btn btn-primary" id="ruleFormSubmit">Add Rule</button>
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
</div>
|
|
</form>
|
|
</div></div></div>
|
|
|
|
<!-- Rule Test Modal -->
|
|
<div class="modal fade" id="ruleTestModal" tabindex="-1" style="z-index:10001">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header"><h5 class="modal-title">Test Rule Matching</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Log Line</label>
|
|
<textarea class="form-control font-monospace" id="ruleTestInput" rows="3" placeholder="Paste a log line to test..."></textarea>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm" id="ruleTestBtn"><i class="bi bi-play"></i> Test</button>
|
|
</div>
|
|
<div class="modal-body" id="ruleTestResults" style="display:none"></div>
|
|
</div></div></div>
|
|
|
|
<!-- Log Context Modal -->
|
|
<div class="modal fade" id="contextModal" tabindex="-1" style="z-index:9999">
|
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
<div class="modal-content">
|
|
<div class="modal-header"><h5 class="modal-title">Log Context</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
|
<div class="modal-body" id="contextBody"><p class="text-secondary">Loading...</p></div>
|
|
</div></div></div>
|
|
|
|
<!-- Toast -->
|
|
<div class="toast-container position-fixed bottom-0 end-0 p-3"></div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script>
|
|
const API = window.location.origin;
|
|
|
|
let state = { alerts: [], sources: [], rules: [], counts: [], user: null, sortField: 'created_at', sortDir: 'desc' };
|
|
let autoRefreshInterval = null;
|
|
let currentAlertId = null;
|
|
|
|
// --- Auth ---
|
|
async function checkAuth() {
|
|
try {
|
|
const res = await api('/auth/me');
|
|
if (res.user) {
|
|
state.user = res.user;
|
|
document.getElementById('userDisplay').textContent = res.user.username;
|
|
document.getElementById('settingsUser').textContent = res.user.username + ' (' + res.user.user_token.substring(0, 12) + '...)';
|
|
document.getElementById('appLogin').style.display = 'none';
|
|
document.getElementById('appMain').style.display = '';
|
|
fetchCsrf();
|
|
initApp();
|
|
return true;
|
|
}
|
|
if (res.login_url) {
|
|
showLogin(res.error, res.login_url);
|
|
} else if (res.error) {
|
|
showLogin(res.error);
|
|
} else {
|
|
showLogin();
|
|
}
|
|
return false;
|
|
} catch (e) {
|
|
if (e.login_url) {
|
|
showLogin('Session expired. Please log in again.', e.login_url);
|
|
} else {
|
|
showLogin('Cannot connect to server: ' + e.message);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function showLogin(errorMsg, loginUrl) {
|
|
document.getElementById('appLogin').style.display = '';
|
|
document.getElementById('appMain').style.display = 'none';
|
|
const cbUrl = window.location.origin + '/oauth.php?redirect=' + encodeURIComponent(window.location.href);
|
|
const href = loginUrl || ('https://auth.jakach.ch/?send_to=' + encodeURIComponent(cbUrl));
|
|
document.getElementById('loginBtn').href = href;
|
|
const errorBox = document.getElementById('authErrorBox');
|
|
if (errorMsg) {
|
|
errorBox.textContent = errorMsg;
|
|
errorBox.classList.remove('d-none');
|
|
document.getElementById('loginBtn').innerHTML = '<i class="bi bi-box-arrow-in-right me-2"></i>Try again';
|
|
} else {
|
|
errorBox.classList.add('d-none');
|
|
document.getElementById('loginBtn').innerHTML = '<i class="bi bi-box-arrow-in-right me-2"></i>Log in using Jakach Login';
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
try {
|
|
await api('/auth/logout', { method: 'POST' });
|
|
} catch (e) {}
|
|
state.user = null;
|
|
showLogin();
|
|
}
|
|
|
|
document.getElementById('logoutBtn').addEventListener('click', logout);
|
|
|
|
// --- Navigation ---
|
|
document.querySelectorAll('[data-page]').forEach(el => {
|
|
el.addEventListener('click', e => {
|
|
e.preventDefault();
|
|
showPage(el.dataset.page);
|
|
});
|
|
});
|
|
|
|
function showPage(name) {
|
|
document.querySelectorAll('.page-section').forEach(p => p.classList.remove('active'));
|
|
document.getElementById('page-' + name).classList.add('active');
|
|
document.querySelectorAll('.sidebar .nav-link').forEach(l => l.classList.remove('active'));
|
|
document.querySelector(`.sidebar .nav-link[data-page="${name}"]`)?.classList.add('active');
|
|
loadPage(name);
|
|
}
|
|
|
|
function loadPage(name) {
|
|
switch (name) {
|
|
case 'dashboard': loadDashboard(); break;
|
|
case 'alerts': loadAlerts(); break;
|
|
case 'logs': break;
|
|
case 'sources': loadSources(); break;
|
|
case 'rules': loadRules(); break;
|
|
case 'settings': loadSettings(); break;
|
|
}
|
|
}
|
|
|
|
// --- API Helpers ---
|
|
let csrfToken = '';
|
|
|
|
async function fetchCsrf() {
|
|
try {
|
|
const res = await api('/auth/csrf', { method: 'GET', noCsrf: true });
|
|
csrfToken = res.csrf_token || '';
|
|
} catch {}
|
|
}
|
|
|
|
async function api(path, opts = {}) {
|
|
const headers = { 'Accept': 'application/json' };
|
|
if (opts.body) {
|
|
headers['Content-Type'] = 'application/json';
|
|
}
|
|
if (opts.method && opts.method !== 'GET' && !opts.noCsrf && !csrfToken) {
|
|
await fetchCsrf();
|
|
}
|
|
if (opts.method && opts.method !== 'GET' && !opts.noCsrf && csrfToken) {
|
|
headers['X-CSRF-TOKEN'] = csrfToken;
|
|
}
|
|
const res = await fetch(API + path, { headers, ...opts });
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
const err = new Error(data.error || 'Request failed');
|
|
err.login_url = data.login_url;
|
|
err.status = res.status;
|
|
throw err;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function toast(msg, type = 'success') {
|
|
const container = document.querySelector('.toast-container');
|
|
const el = document.createElement('div');
|
|
el.className = `toast align-items-center text-bg-${type} border-0`;
|
|
el.role = 'alert';
|
|
el.innerHTML = `<div class="d-flex"><div class="toast-body">${msg}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
|
container.appendChild(el);
|
|
const t = new bootstrap.Toast(el);
|
|
t.show();
|
|
el.addEventListener('hidden.bs.toast', () => el.remove());
|
|
}
|
|
|
|
function severityBadge(s) {
|
|
const map = {
|
|
debug: 'secondary', info: 'info', notice: 'info',
|
|
warning_low: 'warning', warning: 'warning', warning_high: 'warning',
|
|
error: 'danger',
|
|
critical_low: 'danger', critical: 'danger', critical_high: 'danger',
|
|
emergency: 'dark',
|
|
};
|
|
const label = s.replace(/_/g, ' ');
|
|
return `<span class="badge badge-severity bg-${map[s] || 'secondary'}">${label}</span>`;
|
|
}
|
|
|
|
function statusBadge(s) {
|
|
const map = { open: 'danger', acknowledged: 'warning', resolved: 'success' };
|
|
return `<span class="badge bg-${map[s] || 'secondary'}">${s}</span>`;
|
|
}
|
|
|
|
function timeAgo(dateStr) {
|
|
const sec = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
|
if (sec < 5) return 'just now';
|
|
if (sec < 60) return sec + 's ago';
|
|
const min = Math.floor(sec / 60);
|
|
if (min < 60) return min + 'm ago';
|
|
const hrs = Math.floor(min / 60);
|
|
if (hrs < 24) return hrs + 'h ago';
|
|
return new Date(dateStr).toLocaleString();
|
|
}
|
|
|
|
// --- DASHBOARD ---
|
|
async function loadDashboard() {
|
|
try {
|
|
const [countsRes, alertsRes] = await Promise.all([
|
|
api('/alerts/counts'),
|
|
api('/alerts?limit=10&severity=critical&status=open'),
|
|
]);
|
|
const counts = Array.isArray(countsRes) ? countsRes : (countsRes.data || []);
|
|
const alerts = alertsRes.data || [];
|
|
|
|
const total = counts.filter(c => c.status !== 'resolved').reduce((s, c) => s + parseInt(c.count), 0);
|
|
const critical = counts.filter(c => c.severity === 'critical' && c.status !== 'resolved').reduce((s, c) => s + parseInt(c.count), 0);
|
|
const warning = counts.filter(c => c.severity === 'warning' && c.status !== 'resolved').reduce((s, c) => s + parseInt(c.count), 0);
|
|
const open = counts.filter(c => c.status === 'open').reduce((s, c) => s + parseInt(c.count), 0);
|
|
|
|
document.getElementById('statCards').innerHTML = `
|
|
<div class="col-md-3"><div class="card stat-card critical"><div class="card-body"><h6 class="card-subtitle text-secondary mb-1">Critical</h6><h3 class="mb-0 text-danger">${critical}</h3></div></div></div>
|
|
<div class="col-md-3"><div class="card stat-card warning"><div class="card-body"><h6 class="card-subtitle text-secondary mb-1">Warnings</h6><h3 class="mb-0 text-warning">${warning}</h3></div></div></div>
|
|
<div class="col-md-3"><div class="card stat-card"><div class="card-body"><h6 class="card-subtitle text-secondary mb-1">Open</h6><h3 class="mb-0">${open}</h3></div></div></div>
|
|
<div class="col-md-3"><div class="card stat-card"><div class="card-body"><h6 class="card-subtitle text-secondary mb-1">Total Active</h6><h3 class="mb-0">${total}</h3></div></div></div>
|
|
`;
|
|
|
|
document.getElementById('criticalBadge').textContent = critical;
|
|
document.getElementById('criticalBadge').classList.toggle('d-none', critical === 0);
|
|
document.getElementById('warningBadge').textContent = warning;
|
|
document.getElementById('warningBadge').classList.toggle('d-none', warning === 0);
|
|
|
|
const tbody = document.getElementById('dashboardAlerts');
|
|
if (!alerts.length) {
|
|
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><i class="bi bi-check-circle"></i><p class="mb-0">No critical alerts</p></td></tr>';
|
|
} else {
|
|
tbody.innerHTML = alerts.map(a => `<tr class="alert-row" onclick="showAlert(${a.id})">
|
|
<td>${severityBadge(a.severity)}</td>
|
|
<td>${esc(a.rule_name)}</td>
|
|
<td class="log-line">${esc(a.message)}</td>
|
|
<td class="text-secondary" style="white-space:nowrap">${timeAgo(a.created_at)}</td>
|
|
</tr>`).join('');
|
|
}
|
|
|
|
const chartEl = document.getElementById('chartContainer');
|
|
const severityGroups = { critical: ['critical_high','critical','critical_low','emergency'], warning: ['warning_high','warning','warning_low','error'], info: ['notice','info','debug'] };
|
|
const severityCounts = { critical: 0, warning: 0, info: 0 };
|
|
counts.forEach(c => {
|
|
if (c.status === 'resolved') return;
|
|
for (const [group, levels] of Object.entries(severityGroups)) {
|
|
if (levels.includes(c.severity)) {
|
|
severityCounts[group] += parseInt(c.count);
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
const maxVal = Math.max(...Object.values(severityCounts), 1);
|
|
chartEl.innerHTML = `<div class="d-flex align-items-end gap-2" style="height:120px">${Object.entries(severityCounts).map(([sev, cnt]) => {
|
|
const color = { critical: 'danger', warning: 'warning', info: 'info' }[sev] || 'secondary';
|
|
const pct = maxVal > 0 ? (cnt / maxVal) * 100 : 0;
|
|
return `<div class="d-flex flex-column align-items-center justify-content-end flex-fill" style="height:100%"><div class="bg-${color}" style="width:100%;height:${pct}%;min-height:4px;border-radius:4px 4px 0 0"></div><small class="mt-1" style="line-height:1">${sev}<br><strong>${cnt}</strong></small></div>`;
|
|
}).join('')}</div>`;
|
|
|
|
document.getElementById('lastUpdated').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
|
} catch (e) { console.error('dashboard error', e); }
|
|
}
|
|
|
|
// --- ALERTS ---
|
|
async function loadAlerts() {
|
|
const severity = document.getElementById('filterSeverity').value;
|
|
const status = document.getElementById('filterStatus').value;
|
|
const query = document.getElementById('searchInput').value.trim();
|
|
|
|
let url;
|
|
if (query) {
|
|
url = '/alerts/search?q=' + encodeURIComponent(query) + '&limit=100';
|
|
} else {
|
|
const params = new URLSearchParams({ limit: 100, offset: 0 });
|
|
if (severity) params.set('severity', severity);
|
|
if (status) params.set('status', status);
|
|
url = '/alerts?' + params.toString();
|
|
}
|
|
|
|
try {
|
|
const res = await api(url);
|
|
const alerts = res.data || [];
|
|
state.alerts = alerts;
|
|
renderAlerts(query);
|
|
} catch (e) { console.error('alerts error', e); }
|
|
}
|
|
|
|
function renderAlerts(query) {
|
|
let sorted = [...state.alerts];
|
|
const field = state.sortField;
|
|
const dir = state.sortDir;
|
|
sorted.sort((a, b) => {
|
|
let va = a[field], vb = b[field];
|
|
if (field === 'severity') {
|
|
const order = ['debug','info','notice','warning_low','warning','warning_high','error','critical_low','critical','critical_high','emergency'];
|
|
va = order.indexOf(va);
|
|
vb = order.indexOf(vb);
|
|
} else if (field === 'created_at') {
|
|
va = new Date(va).getTime();
|
|
vb = new Date(vb).getTime();
|
|
} else {
|
|
va = (va || '').toString().toLowerCase();
|
|
vb = (vb || '').toString().toLowerCase();
|
|
if (va < vb) return dir === 'asc' ? -1 : 1;
|
|
if (va > vb) return dir === 'asc' ? 1 : -1;
|
|
return 0;
|
|
}
|
|
return dir === 'asc' ? va - vb : vb - va;
|
|
});
|
|
|
|
const tbody = document.getElementById('alertsBody');
|
|
if (!sorted.length) {
|
|
tbody.innerHTML = '<tr><td colspan="7" class="empty-state"><i class="bi bi-inbox"></i><p class="mb-0">No alerts match those filters</p></td></tr>';
|
|
} else {
|
|
tbody.innerHTML = sorted.map(a => `<tr class="alert-row" onclick="showAlert(${a.id})">
|
|
<td class="text-secondary">#${a.id}</td>
|
|
<td>${severityBadge(a.severity)}</td>
|
|
<td>${statusBadge(a.status)}</td>
|
|
<td class="log-line">${esc(a.message)}</td>
|
|
<td>${esc(a.source_name || '—')}</td>
|
|
<td class="text-secondary" style="white-space:nowrap">${new Date(a.created_at).toLocaleString()}</td>
|
|
<td style="white-space:nowrap">
|
|
${a.status === 'open' ? `<button class="btn btn-outline-success btn-sm py-0 me-1" onclick="event.stopPropagation();quickAction(${a.id},'acknowledged')" title="Acknowledge"><i class="bi bi-check"></i></button>` : ''}
|
|
${a.status !== 'resolved' ? `<button class="btn btn-outline-secondary btn-sm py-0" onclick="event.stopPropagation();quickAction(${a.id},'resolved')" title="Resolve"><i class="bi bi-check-all"></i></button>` : ''}
|
|
</td>
|
|
</tr>`).join('');
|
|
}
|
|
const label = document.getElementById('searchInput').value.trim() ? 'search results' : 'alerts';
|
|
document.getElementById('alertsCount').textContent = sorted.length + ' ' + label;
|
|
}
|
|
|
|
function sortAlerts(field) {
|
|
if (state.sortField === field) {
|
|
state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
state.sortField = field;
|
|
state.sortDir = 'asc';
|
|
}
|
|
renderAlerts();
|
|
}
|
|
|
|
document.getElementById('searchBtn').addEventListener('click', loadAlerts);
|
|
document.getElementById('searchInput').addEventListener('keydown', e => { if (e.key === 'Enter') loadAlerts(); });
|
|
|
|
function showAlert(id) {
|
|
const a = state.alerts.find(x => x.id === id);
|
|
if (!a) return;
|
|
currentAlertId = id;
|
|
document.getElementById('detailBody').innerHTML = `
|
|
<dl class="row mb-0">
|
|
<dt class="col-sm-3">ID</dt><dd class="col-sm-9">#${a.id}</dd>
|
|
<dt class="col-sm-3">Rule</dt><dd class="col-sm-9">${esc(a.rule_name)}</dd>
|
|
<dt class="col-sm-3">Severity</dt><dd class="col-sm-9">${severityBadge(a.severity)}</dd>
|
|
<dt class="col-sm-3">Status</dt><dd class="col-sm-9">${statusBadge(a.status)}</dd>
|
|
<dt class="col-sm-3">Source</dt><dd class="col-sm-9">${esc(a.source_name || '—')}</dd>
|
|
<dt class="col-sm-3">Created</dt><dd class="col-sm-9">${new Date(a.created_at).toLocaleString()}</dd>
|
|
<dt class="col-sm-3">Message</dt><dd class="col-sm-9">${esc(a.message)}</dd>
|
|
<dt class="col-sm-3">Raw Line</dt><dd class="col-sm-9"><pre class="raw-line">${esc(a.raw_line)}</pre></dd>
|
|
</dl>`;
|
|
document.getElementById('statusSelect').value = a.status;
|
|
setTimeout(() => bootstrap.Modal.getOrCreateInstance(document.getElementById('detailModal')).show(), 50);
|
|
}
|
|
|
|
document.getElementById('updateStatusBtn').addEventListener('click', async () => {
|
|
const newStatus = document.getElementById('statusSelect').value;
|
|
if (currentAlertId) await updateAlertStatus(currentAlertId, newStatus);
|
|
bootstrap.Modal.getInstance(document.getElementById('detailModal')).hide();
|
|
});
|
|
|
|
async function updateAlertStatus(id, status) {
|
|
try {
|
|
await api(`/alerts/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) });
|
|
toast('Alert #' + id + ' ' + status);
|
|
loadPage(document.querySelector('.sidebar .nav-link.active')?.dataset.page || 'dashboard');
|
|
} catch (e) { toast('Failed to update status', 'danger'); }
|
|
}
|
|
|
|
async function quickAction(id, status) {
|
|
try {
|
|
await api(`/alerts/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) });
|
|
toast('Alert #' + id + ' ' + status);
|
|
loadPage(document.querySelector('.sidebar .nav-link.active')?.dataset.page || 'dashboard');
|
|
} catch (e) { toast('Failed', 'danger'); }
|
|
}
|
|
|
|
document.getElementById('filterSeverity').addEventListener('change', loadAlerts);
|
|
document.getElementById('filterStatus').addEventListener('change', loadAlerts);
|
|
document.getElementById('refreshAlertsBtn').addEventListener('click', loadAlerts);
|
|
|
|
// --- SOURCES ---
|
|
async function loadSources() {
|
|
try {
|
|
const res = await api('/sources');
|
|
const sources = res.data || [];
|
|
state.sources = sources;
|
|
const tbody = document.getElementById('sourcesBody');
|
|
if (!sources.length) {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><i class="bi bi-database"></i><p class="mb-0">No sources configured</p></td></tr>';
|
|
} else {
|
|
tbody.innerHTML = sources.map(s => `<tr>
|
|
<td><strong>${esc(s.name)}</strong></td>
|
|
<td><span class="badge bg-secondary">${s.type}</span></td>
|
|
<td><code>${esc(s.address)}</code></td>
|
|
<td><small>${s.labels && Object.keys(s.labels).length ? esc(JSON.stringify(s.labels)) : '—'}</small></td>
|
|
<td>${s.active ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-secondary">Inactive</span>'}</td>
|
|
<td><button class="btn btn-outline-primary btn-sm py-0 me-1" onclick="editSource(${s.id})"><i class="bi bi-pencil"></i></button><button class="btn btn-outline-danger btn-sm py-0" onclick="deleteSource(${s.id})"><i class="bi bi-trash"></i></button></td>
|
|
</tr>`).join('');
|
|
}
|
|
} catch (e) { console.error('sources error', e); }
|
|
}
|
|
|
|
async function deleteSource(id) {
|
|
if (!confirm('Delete source?')) return;
|
|
try {
|
|
await api('/sources/' + id, { method: 'DELETE' });
|
|
toast('Source deleted');
|
|
loadSources();
|
|
} catch (e) { toast('Delete failed', 'danger'); }
|
|
}
|
|
|
|
function editSource(id) {
|
|
const s = state.sources.find(x => x.id === id);
|
|
if (!s) return;
|
|
document.getElementById('sourceModalLabel').textContent = 'Edit Source';
|
|
document.getElementById('sourceFormSubmit').textContent = 'Update Source';
|
|
document.getElementById('sourceFormId').value = s.id;
|
|
document.getElementById('sourceFormName').value = s.name;
|
|
document.getElementById('sourceFormType').value = s.type;
|
|
document.getElementById('sourceFormAddress').value = s.address;
|
|
document.getElementById('sourceFormLabels').value = s.labels && Object.keys(s.labels).length ? JSON.stringify(s.labels) : '';
|
|
document.getElementById('sourceFormActive').checked = s.active;
|
|
setTimeout(() => bootstrap.Modal.getOrCreateInstance(document.getElementById('sourceModal')).show(), 50);
|
|
}
|
|
|
|
function resetSourceForm() {
|
|
document.getElementById('sourceModalLabel').textContent = 'Add Source';
|
|
document.getElementById('sourceFormSubmit').textContent = 'Add Source';
|
|
document.getElementById('sourceFormId').value = '';
|
|
document.getElementById('sourceForm').reset();
|
|
document.getElementById('sourceFormActive').checked = true;
|
|
}
|
|
|
|
document.getElementById('sourceModal').addEventListener('hidden.bs.modal', resetSourceForm);
|
|
|
|
document.getElementById('sourceForm').addEventListener('submit', async e => {
|
|
e.preventDefault();
|
|
const data = Object.fromEntries(new FormData(e.target));
|
|
if (data.labels) { try { data.labels = JSON.parse(data.labels); } catch { data.labels = {}; } }
|
|
else { data.labels = {}; }
|
|
data.active = !!data.active;
|
|
const id = data.id;
|
|
delete data.id;
|
|
try {
|
|
if (id) {
|
|
await api('/sources/' + id, { method: 'PUT', body: JSON.stringify(data) });
|
|
toast('Source updated');
|
|
} else {
|
|
await api('/sources', { method: 'POST', body: JSON.stringify(data) });
|
|
toast('Source added');
|
|
}
|
|
bootstrap.Modal.getInstance(document.getElementById('sourceModal')).hide();
|
|
loadSources();
|
|
} catch (err) { toast('Failed to save source', 'danger'); }
|
|
});
|
|
|
|
// --- RULES ---
|
|
async function loadRules() {
|
|
try {
|
|
const res = await api('/rules');
|
|
const rules = res.data || [];
|
|
state.rules = rules;
|
|
const tbody = document.getElementById('rulesBody');
|
|
if (!rules.length) {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><i class="bi bi-sliders"></i><p class="mb-0">No rules configured</p></td></tr>';
|
|
} else {
|
|
tbody.innerHTML = rules.map(r => `<tr>
|
|
<td><strong>${esc(r.name)}</strong></td>
|
|
<td><code>${esc(r.pattern)}</code></td>
|
|
<td>${severityBadge(r.severity)}</td>
|
|
<td>${r.rate_limit_seconds ? r.rate_limit_seconds + 's' : '—'}</td>
|
|
<td>${r.active ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-secondary">Inactive</span>'}</td>
|
|
<td><button class="btn btn-outline-primary btn-sm py-0 me-1" onclick="editRule(${r.id})"><i class="bi bi-pencil"></i></button><button class="btn btn-outline-danger btn-sm py-0" onclick="deleteRule(${r.id})"><i class="bi bi-trash"></i></button></td>
|
|
</tr>`).join('');
|
|
}
|
|
} catch (e) { console.error('rules error', e); }
|
|
}
|
|
|
|
async function deleteRule(id) {
|
|
if (!confirm('Delete rule?')) return;
|
|
try {
|
|
await api('/rules/' + id, { method: 'DELETE' });
|
|
toast('Rule deleted');
|
|
loadRules();
|
|
} catch (e) { toast('Delete failed', 'danger'); }
|
|
}
|
|
|
|
function editRule(id) {
|
|
const r = state.rules.find(x => x.id === id);
|
|
if (!r) return;
|
|
document.getElementById('ruleModalTitle').textContent = 'Edit Rule';
|
|
document.getElementById('ruleFormSubmit').textContent = 'Update Rule';
|
|
document.getElementById('ruleFormId').value = r.id;
|
|
document.getElementById('ruleFormName').value = r.name;
|
|
document.getElementById('ruleFormPattern').value = r.pattern;
|
|
document.getElementById('ruleFormSeverity').value = r.severity;
|
|
document.getElementById('ruleFormRateLimit').value = r.rate_limit_seconds || '';
|
|
document.getElementById('ruleFormActive').checked = r.active;
|
|
setTimeout(() => bootstrap.Modal.getOrCreateInstance(document.getElementById('ruleModal')).show(), 50);
|
|
}
|
|
|
|
function resetRuleForm() {
|
|
document.getElementById('ruleModalTitle').textContent = 'Add Alert Rule';
|
|
document.getElementById('ruleFormSubmit').textContent = 'Add Rule';
|
|
document.getElementById('ruleFormId').value = '';
|
|
document.getElementById('ruleForm').reset();
|
|
document.getElementById('ruleFormActive').checked = true;
|
|
}
|
|
|
|
document.getElementById('ruleModal').addEventListener('hidden.bs.modal', resetRuleForm);
|
|
|
|
document.getElementById('ruleForm').addEventListener('submit', async e => {
|
|
e.preventDefault();
|
|
const data = Object.fromEntries(new FormData(e.target));
|
|
if (data.rate_limit_seconds) data.rate_limit_seconds = parseInt(data.rate_limit_seconds);
|
|
else data.rate_limit_seconds = null;
|
|
data.active = !!data.active;
|
|
|
|
const id = data.id;
|
|
delete data.id;
|
|
|
|
try {
|
|
if (id) {
|
|
await api('/rules/' + id, { method: 'PUT', body: JSON.stringify(data) });
|
|
toast('Rule updated');
|
|
} else {
|
|
await api('/rules', { method: 'POST', body: JSON.stringify(data) });
|
|
toast('Rule added');
|
|
}
|
|
bootstrap.Modal.getInstance(document.getElementById('ruleModal')).hide();
|
|
loadRules();
|
|
} catch (err) { toast('Failed to save rule', 'danger'); }
|
|
});
|
|
|
|
// --- SETTINGS ---
|
|
async function loadSettings() {
|
|
try {
|
|
const health = await api('/health');
|
|
document.getElementById('sysHealth').textContent = health.status === 'ok' ? 'Healthy' : 'Degraded';
|
|
document.getElementById('sysHealth').className = 'badge bg-' + (health.status === 'ok' ? 'success' : 'warning');
|
|
document.getElementById('sysSqlite').textContent = health.sqlite === 'connected' ? 'Connected' : 'Error';
|
|
document.getElementById('sysSqlite').className = 'badge bg-' + (health.sqlite === 'connected' ? 'success' : 'danger');
|
|
document.getElementById('sysClickhouse').textContent = health.clickhouse === 'connected' ? 'Connected' : 'Error';
|
|
document.getElementById('sysClickhouse').className = 'badge bg-' + (health.clickhouse === 'connected' ? 'success' : 'danger');
|
|
document.getElementById('sysDbSize').textContent = health.db_size || '—';
|
|
} catch {
|
|
document.getElementById('sysHealth').textContent = 'Unreachable';
|
|
document.getElementById('sysHealth').className = 'badge bg-danger';
|
|
}
|
|
|
|
try {
|
|
const [tokensRes, meRes] = await Promise.all([
|
|
api('/config/allowed_tokens'),
|
|
api('/auth/me'),
|
|
]);
|
|
const tokens = tokensRes.tokens || [];
|
|
document.getElementById('allowedTokensInput').value = tokens.join('\n');
|
|
const userToken = meRes.user?.user_token || '';
|
|
document.getElementById('settingsUserToken').textContent = userToken || '—';
|
|
document.getElementById('settingsUser').textContent = meRes.user?.username || '—';
|
|
} catch (e) { console.error('load tokens error', e); }
|
|
|
|
try {
|
|
const res = await api('/config/telegram');
|
|
document.getElementById('telegramBotToken').value = '';
|
|
document.getElementById('telegramBotToken').placeholder = res.bot_token_configured
|
|
? 'Token configured (enter new to change)'
|
|
: 'Enter bot token';
|
|
document.getElementById('telegramChatId').value = res.chat_id || '';
|
|
const badge = document.getElementById('telegramStatusBadge');
|
|
if (res.bot_token_configured && res.chat_id) {
|
|
badge.innerHTML = '<span class="badge bg-success">Configured</span>';
|
|
} else if (res.bot_token_configured) {
|
|
badge.innerHTML = '<span class="badge bg-warning text-dark">Missing Chat ID</span>';
|
|
} else {
|
|
badge.innerHTML = '<span class="badge bg-secondary">Not configured</span>';
|
|
}
|
|
} catch (e) { console.error('load telegram error', e); }
|
|
|
|
try {
|
|
await loadFalsePositives();
|
|
} catch (e) { console.error('load false positives error', e); }
|
|
|
|
try {
|
|
const ret = await api('/system/retention');
|
|
if (ret.log_days) document.getElementById('retentionLogDays').value = ret.log_days;
|
|
if (ret.alert_days) document.getElementById('retentionAlertDays').value = ret.alert_days;
|
|
} catch (e) { /* not critical */ }
|
|
|
|
loadAuditLog();
|
|
}
|
|
|
|
document.getElementById('saveTokensBtn').addEventListener('click', async () => {
|
|
const raw = document.getElementById('allowedTokensInput').value;
|
|
const tokens = raw.split('\n').map(t => t.trim()).filter(t => t.length > 0);
|
|
const statusEl = document.getElementById('tokenSaveStatus');
|
|
statusEl.textContent = 'Saving...';
|
|
statusEl.className = 'ms-2 text-secondary';
|
|
try {
|
|
await api('/config/allowed_tokens', {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ tokens }),
|
|
});
|
|
statusEl.textContent = 'Saved';
|
|
statusEl.className = 'ms-2 text-success';
|
|
toast('Allowed tokens updated');
|
|
setTimeout(() => { statusEl.textContent = ''; }, 3000);
|
|
} catch (e) {
|
|
statusEl.textContent = 'Failed';
|
|
statusEl.className = 'ms-2 text-danger';
|
|
toast('Failed to save tokens', 'danger');
|
|
}
|
|
});
|
|
|
|
function toggleTelegramToken() {
|
|
const input = document.getElementById('telegramBotToken');
|
|
const btn = document.getElementById('telegramTokenToggle');
|
|
if (input.type === 'password') {
|
|
input.type = 'text';
|
|
btn.innerHTML = '<i class="bi bi-eye-slash"></i>';
|
|
} else {
|
|
input.type = 'password';
|
|
btn.innerHTML = '<i class="bi bi-eye"></i>';
|
|
}
|
|
}
|
|
|
|
document.getElementById('saveTelegramBtn').addEventListener('click', async () => {
|
|
const botToken = document.getElementById('telegramBotToken').value.trim();
|
|
const chatId = document.getElementById('telegramChatId').value.trim();
|
|
const payload = { chat_id: chatId };
|
|
if (botToken) payload.bot_token = botToken;
|
|
const statusEl = document.getElementById('telegramSaveStatus');
|
|
statusEl.textContent = 'Saving...';
|
|
statusEl.className = 'ms-2 text-secondary';
|
|
try {
|
|
await api('/config/telegram', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
statusEl.textContent = 'Saved';
|
|
statusEl.className = 'ms-2 text-success';
|
|
toast('Telegram config saved');
|
|
setTimeout(() => { statusEl.textContent = ''; }, 3000);
|
|
} catch (e) {
|
|
statusEl.textContent = 'Failed';
|
|
statusEl.className = 'ms-2 text-danger';
|
|
toast('Failed to save Telegram config', 'danger');
|
|
}
|
|
});
|
|
|
|
document.getElementById('testTelegramBtn').addEventListener('click', async () => {
|
|
const btn = document.getElementById('testTelegramBtn');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Sending...';
|
|
try {
|
|
await api('/ingest', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ line: 'Jakach Logging: test notification from settings — PHP Warning: test alert', source: 'test' }),
|
|
});
|
|
toast('Test alert sent. Check Telegram.');
|
|
} catch (e) { toast('Failed to send test', 'danger'); }
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="bi bi-send"></i> Test';
|
|
});
|
|
|
|
// --- FALSE POSITIVES ---
|
|
let fpItems = [];
|
|
|
|
async function loadFalsePositives() {
|
|
try {
|
|
const res = await api('/false-positives');
|
|
fpItems = res.data || res || [];
|
|
renderFalsePositives();
|
|
} catch (e) { console.error('fp load error', e); }
|
|
}
|
|
|
|
function renderFalsePositives() {
|
|
const tbody = document.getElementById('falsePositivesBody');
|
|
if (!fpItems.length) {
|
|
tbody.innerHTML = '<tr><td colspan="3" class="text-secondary small text-center">No false positives configured</td></tr>';
|
|
} else {
|
|
tbody.innerHTML = fpItems.map(fp => `<tr>
|
|
<td><code>${esc(fp.pattern)}</code></td>
|
|
<td class="text-secondary">${esc(fp.description || '')}</td>
|
|
<td><button class="btn btn-outline-danger btn-sm py-0" onclick="deleteFp(${fp.id})"><i class="bi bi-trash"></i></button></td>
|
|
</tr>`).join('');
|
|
}
|
|
}
|
|
|
|
async function deleteFp(id) {
|
|
if (!confirm('Delete this false positive pattern?')) return;
|
|
try {
|
|
await api('/false-positives/' + id, { method: 'DELETE' });
|
|
toast('False positive deleted');
|
|
loadFalsePositives();
|
|
} catch (e) { toast('Delete failed', 'danger'); }
|
|
}
|
|
|
|
document.getElementById('addFpBtn').addEventListener('click', async () => {
|
|
const pattern = document.getElementById('fpPattern').value.trim();
|
|
const description = document.getElementById('fpDescription').value.trim();
|
|
if (!pattern) { toast('Enter a pattern', 'danger'); return; }
|
|
try {
|
|
await api('/false-positives', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ pattern, description }),
|
|
});
|
|
document.getElementById('fpPattern').value = '';
|
|
document.getElementById('fpDescription').value = '';
|
|
toast('False positive added');
|
|
loadFalsePositives();
|
|
} catch (e) { toast('Failed to add', 'danger'); }
|
|
});
|
|
|
|
// --- RULE TEST ---
|
|
document.getElementById('ruleTestBtn').addEventListener('click', async () => {
|
|
const line = document.getElementById('ruleTestInput').value.trim();
|
|
if (!line) { toast('Enter a log line', 'danger'); return; }
|
|
try {
|
|
const res = await api('/rules/test', { method: 'POST', body: JSON.stringify({ line }) });
|
|
const matches = res.data || [];
|
|
const results = document.getElementById('ruleTestResults');
|
|
if (!matches.length) {
|
|
results.innerHTML = '<div class="alert alert-success mb-0">No rules matched this line.</div>';
|
|
} else {
|
|
results.innerHTML = matches.map(m => `<div class="alert ${m.is_false_positive ? 'alert-warning' : 'alert-info'} mb-1 py-2 small">${m.is_false_positive ? '<i class="bi bi-x-circle me-1"></i>Blocked by false positive — ' : ''}<strong>${esc(m.rule_name)}</strong> → ${severityBadge(m.severity)}</div>`).join('');
|
|
}
|
|
results.style.display = 'block';
|
|
} catch (e) { toast('Test failed', 'danger'); }
|
|
});
|
|
|
|
document.querySelector('[data-page="rules"]').addEventListener('click', () => {
|
|
setTimeout(() => {
|
|
if (!document.getElementById('ruleTestModal')) return;
|
|
const toolbar = document.querySelector('#page-rules .d-flex');
|
|
if (toolbar && !toolbar.querySelector('#openRuleTestBtn')) {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'btn btn-outline-secondary btn-sm ms-2';
|
|
btn.id = 'openRuleTestBtn';
|
|
btn.innerHTML = '<i class="bi bi-play-circle"></i> Test Rule';
|
|
btn.onclick = () => bootstrap.Modal.getOrCreateInstance(document.getElementById('ruleTestModal')).show();
|
|
toolbar.querySelector('.btn-primary').after(btn);
|
|
}
|
|
}, 100);
|
|
});
|
|
|
|
// --- LOGS ---
|
|
async function loadLogs(query) {
|
|
if (!query) {
|
|
query = document.getElementById('logSearchInput').value.trim();
|
|
if (!query) {
|
|
document.getElementById('logsBody').innerHTML = '<tr><td colspan="4" class="empty-state"><i class="bi bi-search"></i><p class="mb-0">Enter a search query above</p></td></tr>';
|
|
document.getElementById('logsCount').textContent = '';
|
|
return;
|
|
}
|
|
}
|
|
|
|
let url = '/logs/search?q=' + encodeURIComponent(query) + '&limit=200';
|
|
const since = document.getElementById('logSince').value;
|
|
const until = document.getElementById('logUntil').value;
|
|
if (since) url += '&since=' + encodeURIComponent(since);
|
|
if (until) url += '&until=' + encodeURIComponent(until);
|
|
|
|
try {
|
|
const res = await api(url);
|
|
const entries = res.data || [];
|
|
const tbody = document.getElementById('logsBody');
|
|
if (!entries.length) {
|
|
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><i class="bi bi-inbox"></i><p class="mb-0">No results for "' + esc(query) + '"</p></td></tr>';
|
|
} else {
|
|
tbody.innerHTML = entries.map(e => `<tr style="cursor:pointer" onclick="showLogContext(${e.id})">
|
|
<td class="text-secondary" style="font-size:.75rem">#${e.id}</td>
|
|
<td class="text-secondary" style="white-space:nowrap;font-size:.8rem">${new Date(e.created_at).toLocaleString()}</td>
|
|
<td style="font-family:monospace;font-size:.8rem;max-width:600px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(e.line)}</td>
|
|
<td>${e.source_name ? '<span class="badge bg-secondary">' + esc(e.source_name) + '</span>' : '—'}</td>
|
|
</tr>`).join('');
|
|
}
|
|
document.getElementById('logsCount').textContent = entries.length + ' results';
|
|
} catch (e) { console.error('logs error', e); }
|
|
}
|
|
|
|
async function showLogContext(id) {
|
|
try {
|
|
const res = await api('/logs/context/' + id + '?before=5&after=5');
|
|
const ctx = document.getElementById('contextBody');
|
|
let html = '';
|
|
if (res.before) res.before.forEach(l => {
|
|
html += `<div class="small mb-1" style="font-family:monospace;opacity:.6">#${l.id} <span class="text-secondary">${new Date(l.created_at).toLocaleString()}</span> ${esc(l.line)}</div>`;
|
|
});
|
|
if (res.current) {
|
|
html += `<div class="small mb-1 p-1" style="font-family:monospace;background:var(--bs-primary-bg-subtle);border-left:3px solid var(--bs-primary)">#${res.current.id} <span class="text-secondary">${new Date(res.current.created_at).toLocaleString()}</span> ${esc(res.current.line)}</div>`;
|
|
}
|
|
if (res.after) res.after.forEach(l => {
|
|
html += `<div class="small mb-1" style="font-family:monospace;opacity:.6">#${l.id} <span class="text-secondary">${new Date(l.created_at).toLocaleString()}</span> ${esc(l.line)}</div>`;
|
|
});
|
|
ctx.innerHTML = html || '<p class="text-secondary">No context found</p>';
|
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('contextModal')).show();
|
|
} catch (e) { toast('Failed to load context', 'danger'); }
|
|
}
|
|
|
|
document.getElementById('logSearchBtn').addEventListener('click', () => loadLogs());
|
|
document.getElementById('logSearchInput').addEventListener('keydown', e => { if (e.key === 'Enter') loadLogs(); });
|
|
|
|
async function exportLogs() {
|
|
const query = document.getElementById('logSearchInput').value.trim();
|
|
if (!query) { toast('Search something first', 'warning'); return; }
|
|
const since = document.getElementById('logSince').value;
|
|
const until = document.getElementById('logUntil').value;
|
|
let url = '/logs/search?q=' + encodeURIComponent(query) + '&limit=10000';
|
|
if (since) url += '&since=' + encodeURIComponent(since);
|
|
if (until) url += '&until=' + encodeURIComponent(until);
|
|
try {
|
|
const res = await api(url);
|
|
const entries = res.data || [];
|
|
if (!entries.length) { toast('No results to export', 'warning'); return; }
|
|
const csv = ['ID,Line,Source,Level,Created', ...entries.map(e =>
|
|
[e.id, '"' + (e.line || '').replace(/"/g, '""') + '"',
|
|
e.source_name || '', e.level || '', e.created_at].join(','))].join('\n');
|
|
const blob = new Blob([csv], { type: 'text/csv' });
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = 'logs.csv';
|
|
a.click();
|
|
toast('Exported ' + entries.length + ' rows');
|
|
} catch (e) { toast('Export failed', 'danger'); }
|
|
}
|
|
|
|
// --- BULK ALERT OPERATIONS ---
|
|
let selectedAlertIds = new Set();
|
|
|
|
function toggleAlertSelection(id) {
|
|
if (selectedAlertIds.has(id)) selectedAlertIds.delete(id);
|
|
else selectedAlertIds.add(id);
|
|
updateBulkBar();
|
|
}
|
|
|
|
function updateBulkBar() {
|
|
document.getElementById('bulkBar').classList.toggle('d-none', selectedAlertIds.size === 0);
|
|
document.getElementById('selectedCount').textContent = selectedAlertIds.size;
|
|
const cb = document.getElementById('selectAllCheckbox');
|
|
if (cb) cb.checked = selectedAlertIds.size > 0 && selectedAlertIds.size === state.alerts.length;
|
|
}
|
|
|
|
function toggleSelectAll() {
|
|
const cb = document.getElementById('selectAllCheckbox');
|
|
if (cb.checked) {
|
|
state.alerts.forEach(a => selectedAlertIds.add(a.id));
|
|
} else {
|
|
selectedAlertIds.clear();
|
|
}
|
|
updateBulkBar();
|
|
renderAlerts();
|
|
}
|
|
|
|
function selectAllVisible() {
|
|
state.alerts.forEach(a => selectedAlertIds.add(a.id));
|
|
const cb = document.getElementById('selectAllCheckbox');
|
|
if (cb) cb.checked = true;
|
|
updateBulkBar();
|
|
renderAlerts();
|
|
}
|
|
|
|
function deselectAll() {
|
|
selectedAlertIds.clear();
|
|
const cb = document.getElementById('selectAllCheckbox');
|
|
if (cb) cb.checked = false;
|
|
updateBulkBar();
|
|
renderAlerts();
|
|
}
|
|
|
|
function renderAlerts(query) {
|
|
let sorted = [...state.alerts];
|
|
const field = state.sortField;
|
|
const dir = state.sortDir;
|
|
sorted.sort((a, b) => {
|
|
let va = a[field], vb = b[field];
|
|
if (field === 'severity') {
|
|
const order = ['debug','info','notice','warning_low','warning','warning_high','error','critical_low','critical','critical_high','emergency'];
|
|
va = order.indexOf(va);
|
|
vb = order.indexOf(vb);
|
|
} else if (field === 'created_at') {
|
|
va = new Date(va).getTime();
|
|
vb = new Date(vb).getTime();
|
|
} else {
|
|
va = (va || '').toString().toLowerCase();
|
|
vb = (vb || '').toString().toLowerCase();
|
|
if (va < vb) return dir === 'asc' ? -1 : 1;
|
|
if (va > vb) return dir === 'asc' ? 1 : -1;
|
|
return 0;
|
|
}
|
|
return dir === 'asc' ? va - vb : vb - va;
|
|
});
|
|
|
|
const tbody = document.getElementById('alertsBody');
|
|
if (!sorted.length) {
|
|
tbody.innerHTML = '<tr><td colspan="7" class="empty-state"><i class="bi bi-inbox"></i><p class="mb-0">No alerts match those filters</p></td></tr>';
|
|
} else {
|
|
tbody.innerHTML = sorted.map(a => `<tr class="alert-row" onclick="showAlert(${a.id})">
|
|
<td class="text-secondary" onclick="event.stopPropagation()">
|
|
<input type="checkbox" class="form-check-input" ${selectedAlertIds.has(a.id) ? 'checked' : ''} onchange="toggleAlertSelection(${a.id})">
|
|
<span class="ms-1">#${a.id}</span>
|
|
</td>
|
|
<td>${severityBadge(a.severity)}</td>
|
|
<td>${statusBadge(a.status)}</td>
|
|
<td class="log-line">${esc(a.message)}</td>
|
|
<td>${esc(a.source_name || '—')}</td>
|
|
<td class="text-secondary" style="white-space:nowrap">${new Date(a.created_at).toLocaleString()}</td>
|
|
<td style="white-space:nowrap">
|
|
${a.status === 'open' ? `<button class="btn btn-outline-success btn-sm py-0 me-1" onclick="event.stopPropagation();quickAction(${a.id},'acknowledged')" title="Acknowledge"><i class="bi bi-check"></i></button>` : ''}
|
|
${a.status !== 'resolved' ? `<button class="btn btn-outline-secondary btn-sm py-0" onclick="event.stopPropagation();quickAction(${a.id},'resolved')" title="Resolve"><i class="bi bi-check-all"></i></button>` : ''}
|
|
</td>
|
|
</tr>`).join('');
|
|
}
|
|
const label = document.getElementById('searchInput').value.trim() ? 'search results' : 'alerts';
|
|
document.getElementById('alertsCount').textContent = sorted.length + ' ' + label;
|
|
updateBulkBar();
|
|
}
|
|
|
|
async function bulkAction(status) {
|
|
const ids = Array.from(selectedAlertIds);
|
|
if (!ids.length) return;
|
|
try {
|
|
await api('/alerts/bulk', { method: 'POST', body: JSON.stringify({ ids, status }) });
|
|
toast(ids.length + ' alerts ' + status);
|
|
selectedAlertIds.clear();
|
|
loadAlerts();
|
|
} catch (e) { toast('Bulk action failed', 'danger'); }
|
|
}
|
|
|
|
async function exportAlerts(format) {
|
|
const ids = Array.from(selectedAlertIds);
|
|
if (!ids.length) { toast('Select alerts first', 'warning'); return; }
|
|
try {
|
|
const res = await api('/alerts/export', { method: 'POST', body: JSON.stringify({ ids, format: 'json' }) });
|
|
const alerts = res.data || [];
|
|
if (format === 'csv') {
|
|
const csv = ['ID,Rule,Severity,Status,Message,Source,Created', ...alerts.map(a =>
|
|
[a.id, '"' + (a.rule_name || '').replace(/"/g, '""') + '"', a.severity, a.status,
|
|
'"' + (a.message || '').replace(/"/g, '""') + '"', a.source_name || '', a.created_at].join(','))].join('\n');
|
|
const blob = new Blob([csv], { type: 'text/csv' });
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = 'alerts.csv';
|
|
a.click();
|
|
} else {
|
|
const blob = new Blob([JSON.stringify(res, null, 2)], { type: 'application/json' });
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = 'alerts.json';
|
|
a.click();
|
|
}
|
|
toast('Export done');
|
|
} catch (e) { toast('Export failed', 'danger'); }
|
|
}
|
|
|
|
// --- RETENTION ---
|
|
document.getElementById('runRetentionBtn').addEventListener('click', async () => {
|
|
const logDays = document.getElementById('retentionLogDays').value;
|
|
const alertDays = document.getElementById('retentionAlertDays').value;
|
|
const el = document.getElementById('retentionResult');
|
|
el.textContent = 'Purging...';
|
|
try {
|
|
const res = await api('/system/retention', { method: 'POST', body: JSON.stringify({ log_days: parseInt(logDays), alert_days: parseInt(alertDays) }) });
|
|
el.textContent = res.log_entries_deleted + ' logs, ' + res.alerts_deleted + ' alerts purged';
|
|
el.className = 'ms-2 text-success';
|
|
setTimeout(() => { el.textContent = ''; }, 5000);
|
|
} catch (e) { el.textContent = 'Failed'; el.className = 'ms-2 text-danger'; }
|
|
});
|
|
|
|
// --- AUDIT LOG ---
|
|
async function loadAuditLog() {
|
|
try {
|
|
const res = await api('/system/audit-log');
|
|
const entries = res.data || [];
|
|
const tbody = document.getElementById('auditLogBody');
|
|
if (!entries.length) {
|
|
tbody.innerHTML = '<tr><td class="text-secondary small text-center">No audit entries</td></tr>';
|
|
} else {
|
|
tbody.innerHTML = entries.map(e => `<tr><td class="small"><span class="text-secondary">${new Date(e.created_at).toLocaleString()}</span> <strong>${esc(e.action)}</strong> ${esc(e.entity_type)}${e.entity_id ? ' #' + e.entity_id : ''}${e.username ? ' by ' + esc(e.username) : ''}${e.details ? '<br><small class="text-secondary">' + esc(e.details) + '</small>' : ''}</td></tr>`).join('');
|
|
}
|
|
} catch (e) { console.error('audit log error', e); }
|
|
}
|
|
|
|
// --- Helpers ---
|
|
function esc(s) {
|
|
const d = document.createElement('div');
|
|
d.textContent = s || '';
|
|
return d.innerHTML;
|
|
}
|
|
|
|
// --- Auto-refresh ---
|
|
document.getElementById('autoRefresh').addEventListener('change', () => {
|
|
if (document.getElementById('autoRefresh').checked) startAutoRefresh();
|
|
else stopAutoRefresh();
|
|
});
|
|
|
|
function startAutoRefresh() {
|
|
stopAutoRefresh();
|
|
autoRefreshInterval = setInterval(() => {
|
|
const active = document.querySelector('.page-section.active');
|
|
if (active) loadPage(active.id.replace('page-', ''));
|
|
}, 5000);
|
|
}
|
|
|
|
function stopAutoRefresh() {
|
|
if (autoRefreshInterval) clearInterval(autoRefreshInterval);
|
|
autoRefreshInterval = null;
|
|
}
|
|
|
|
document.getElementById('refreshBtn').addEventListener('click', () => {
|
|
const active = document.querySelector('.page-section.active');
|
|
if (active) loadPage(active.id.replace('page-', ''));
|
|
});
|
|
|
|
// --- Init ---
|
|
function initApp() {
|
|
startAutoRefresh();
|
|
loadDashboard();
|
|
document.querySelectorAll('[data-page]').forEach(el => {
|
|
el.addEventListener('click', e => {
|
|
e.preventDefault();
|
|
showPage(el.dataset.page);
|
|
});
|
|
});
|
|
}
|
|
|
|
checkAuth();
|
|
</script>
|
|
</body>
|
|
</html>
|