initial commit

This commit is contained in:
2026-05-06 11:28:41 +02:00
commit 39ee31debb
23 changed files with 1645 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
vendor
.git
data
*.md
+5
View File
@@ -0,0 +1,5 @@
/vendor/
/data/
.env
*.log
.DS_Store
Executable
+15
View File
@@ -0,0 +1,15 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use Jakach\Logging\Storage\Database;
use Jakach\Logging\Storage\Repository;
use Jakach\Logging\RuleEngine\Engine;
use Jakach\Logging\Worker\Orchestrator;
$db = new Database();
$repo = new Repository($db);
$engine = new Engine($repo);
$orch = new Orchestrator($repo, $engine);
$orch->run();
Executable
+24
View File
@@ -0,0 +1,24 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use Jakach\Logging\Storage\Database;
use Jakach\Logging\Storage\Repository;
use Jakach\Logging\Model\LogSourceType;
$db = new Database();
$repo = new Repository($db);
$config = require __DIR__ . '/../config/default.php';
echo "Seeding default rules...\n";
foreach ($config['rules'] as $rule) {
$r = $repo->createRule(
name: $rule['name'],
pattern: $rule['pattern'],
severity: $rule['severity'],
rateLimitSeconds: $rule['rate_limit_seconds'] ?? null,
);
echo sprintf(" + Rule #%d: %s (%s)\n", $r->id, $r->name, $r->severity->value);
}
echo "Done.\n";
+16
View File
@@ -0,0 +1,16 @@
{
"name": "jakach/logging",
"description": "Log analysis and alerting system",
"type": "project",
"autoload": {
"psr-4": {
"Jakach\\Logging\\": "src/"
}
},
"require": {
"php": ">=8.2"
},
"scripts": {
"serve": "php -S 0.0.0.0:8080 -t public"
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
return [
'db' => [
'path' => '/app/data/logging.db',
],
'worker' => [
'file_check_interval' => 500000,
],
'sources' => [],
'rules' => [
[
'name' => 'PHP Error',
'pattern' => '/PHP (Fatal|Parse|Catchable|Notice|Warning)/i',
'severity' => 'warning',
'rate_limit_seconds' => 60,
],
[
'name' => 'PHP Exception',
'pattern' => '/Uncaught (Exception|Error)/',
'severity' => 'critical',
'rate_limit_seconds' => 30,
],
[
'name' => 'HTTP 5xx',
'pattern' => '/" (50[0-9]) /',
'severity' => 'critical',
],
[
'name' => 'HTTP 4xx',
'pattern' => '/" (4[0-9]{2}) /',
'severity' => 'warning',
'rate_limit_seconds' => 60,
],
[
'name' => 'Failed Login',
'pattern' => '/Failed (login|password|authentication)/i',
'severity' => 'critical',
],
[
'name' => 'Out of Memory',
'pattern' => '/out of memory/i',
'severity' => 'critical',
'rate_limit_seconds' => 60,
],
[
'name' => 'Connection Refused',
'pattern' => '/Connection (refused|reset|timed? out)/i',
'severity' => 'warning',
],
[
'name' => 'Disk Space',
'pattern' => '/disk (full|space|usage|low)/i',
'severity' => 'warning',
'rate_limit_seconds' => 300,
],
],
];
+38
View File
@@ -0,0 +1,38 @@
services:
api:
build:
context: .
dockerfile: docker/Dockerfile.api
volumes:
- .:/app
- data:/app/data
depends_on:
- redis
nginx:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- .:/app
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- api
worker:
build:
context: .
dockerfile: docker/Dockerfile.php
volumes:
- .:/app
- /var/log:/host/logs:ro
- data:/app/data
depends_on:
- redis
command: ["php", "bin/consume", "--daemon"]
redis:
image: redis:7-alpine
volumes:
data:
+7
View File
@@ -0,0 +1,7 @@
FROM php:8.3-fpm-alpine
RUN docker-php-ext-install pcntl sockets
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
+9
View File
@@ -0,0 +1,9 @@
FROM php:8.3-cli-alpine
RUN apk add --no-cache linux-headers git
RUN docker-php-ext-install pcntl sockets
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
+15
View File
@@ -0,0 +1,15 @@
server {
listen 80;
root /app/public;
index index.html;
location / {
try_files $uri /index.html /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass api:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
+632
View File
@@ -0,0 +1,632 @@
<!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; }
@media (max-width: 768px) { .sidebar { display: none; } .main { margin-left: 0; } }
</style>
</head>
<body>
<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>
<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>
</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="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 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="critical">Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</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>
<button class="btn btn-outline-secondary btn-sm" id="refreshAlertsBtn"><i class="bi bi-arrow-clockwise"></i></button>
</div>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-dark"><tr>
<th style="width:60px">ID</th>
<th style="width:90px">Severity</th>
<th style="width:100px">Status</th>
<th>Message</th>
<th>Source</th>
<th style="width:170px">Created</th>
<th style="width:60px"></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</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>
<nav><ul class="pagination pagination-sm mb-0" id="alertsPagination"></ul></nav>
</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" data-bs-toggle="modal" data-bs-target="#sourceModal"><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:60px"></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" data-bs-toggle="modal" data-bs-target="#ruleModal"><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>
</div>
<div class="row g-3">
<div class="col-md-6">
<div class="card">
<div class="card-header"><i class="bi bi-info-circle me-1"></i>System Info</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Health</dt><dd class="col-sm-8"><span id="sysHealth" class="badge bg-secondary">checking...</span></dd>
<dt class="col-sm-4">DB Path</dt><dd class="col-sm-8"><code>/app/data/logging.db</code></dd>
<dt class="col-sm-4">Worker</dt><dd class="col-sm-8"><code>php bin/consume</code></dd>
</dl>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header"><i class="bi bi-book me-1"></i>Quick Reference</div>
<div class="card-body">
<p class="mb-1"><strong>File sources</strong> — path to a log file on the worker container</p>
<p class="mb-1"><strong>TCP/UDP sources</strong><code>tcp://0.0.0.0:9514</code> or <code>udp://0.0.0.0:9514</code></p>
<p class="mb-0"><strong>Rules</strong> — use PHP regex patterns, e.g. <code>/error/i</code></p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Alert Detail Modal -->
<div class="modal fade" id="detailModal" tabindex="-1">
<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">
<button class="btn btn-success btn-sm" id="ackBtn"><i class="bi bi-check-circle"></i> Acknowledge</button>
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div></div></div>
<!-- Source Modal -->
<div class="modal fade" id="sourceModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form id="sourceForm">
<div class="modal-header"><h5 class="modal-title">Add Log Source</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">Name</label>
<input type="text" class="form-control" name="name" required placeholder="e.g. nginx-access">
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<select class="form-select" name="type" 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" 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" placeholder='{"env":"prod"}'>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">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">
<div class="modal-dialog">
<div class="modal-content">
<form id="ruleForm">
<div class="modal-header"><h5 class="modal-title">Add Alert Rule</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">Name</label>
<input type="text" class="form-control" name="name" required placeholder="e.g. PHP Error">
</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" required placeholder="/error/i">
</div>
<div class="mb-3">
<label class="form-label">Severity</label>
<select class="form-select" name="severity" required>
<option value="critical">Critical</option>
<option value="warning" selected>Warning</option>
<option value="info">Info</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" placeholder="60">
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Add Rule</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</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: [], alertPage: 0, alertPageSize: 50 };
let autoRefreshInterval = null;
let currentAlertId = null;
// --- 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 'sources': loadSources(); break;
case 'rules': loadRules(); break;
case 'settings': loadSettings(); break;
}
}
// --- API Helpers ---
async function api(path, opts = {}) {
const res = await fetch(API + path, {
headers: { 'Accept': 'application/json', ...(opts.body ? { 'Content-Type': 'application/json' } : {}) },
...opts,
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
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 = { critical: 'danger', warning: 'warning', info: 'info' };
return `<span class="badge badge-severity bg-${map[s] || 'secondary'}">${s}</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.reduce((s, c) => s + parseInt(c.count), 0);
const critical = counts.filter(c => c.severity === 'critical').reduce((s, c) => s + parseInt(c.count), 0);
const warning = counts.filter(c => c.severity === 'warning').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</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>${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('');
}
// Mini bar chart using CSS
const chartEl = document.getElementById('chartContainer');
const severityCounts = { critical: 0, warning: 0, info: 0 };
counts.forEach(c => { if (severityCounts[c.severity] !== undefined) severityCounts[c.severity] += parseInt(c.count); });
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 = (cnt / maxVal) * 100;
return `<div class="d-flex flex-column align-items-center flex-fill"><div class="bg-${color}" style="width:100%;height:${pct}%;min-height:4px;border-radius:4px 4px 0 0"></div><small class="mt-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 params = new URLSearchParams({ limit: state.alertPageSize, offset: state.alertPage * state.alertPageSize });
if (severity) params.set('severity', severity);
if (status) params.set('status', status);
try {
const res = await api('/alerts?' + params.toString());
const alerts = res.data || [];
state.alerts = alerts;
const tbody = document.getElementById('alertsBody');
if (!alerts.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 = alerts.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>${a.status === 'open' ? `<button class="btn btn-outline-success btn-sm py-0" onclick="event.stopPropagation();ackAlert(${a.id})"><i class="bi bi-check"></i></button>` : ''}</td>
</tr>`).join('');
}
document.getElementById('alertsCount').textContent = alerts.length + ' alerts';
} catch (e) { console.error('alerts error', e); }
}
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)} (ID ${a.rule_id})</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('ackBtn').style.display = a.status === 'open' ? '' : 'none';
new bootstrap.Modal(document.getElementById('detailModal')).show();
}
document.getElementById('ackBtn').addEventListener('click', async () => {
if (currentAlertId) await ackAlert(currentAlertId);
bootstrap.Modal.getInstance(document.getElementById('detailModal')).hide();
});
async function ackAlert(id) {
try {
await api(`/alerts/${id}/ack`, { method: 'POST' });
toast('Alert #' + id + ' acknowledged');
loadPage(document.querySelector('.sidebar .nav-link.active')?.dataset.page || 'dashboard');
} catch (e) { toast('Failed to acknowledge', 'danger'); }
}
document.getElementById('filterSeverity').addEventListener('change', () => { state.alertPage = 0; loadAlerts(); });
document.getElementById('filterStatus').addEventListener('change', () => { state.alertPage = 0; 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-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'); }
}
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 = {}; }
try {
await api('/sources', { method: 'POST', body: JSON.stringify(data) });
toast('Source added');
bootstrap.Modal.getInstance(document.getElementById('sourceModal')).hide();
e.target.reset();
loadSources();
} catch (err) { toast('Failed to add 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-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'); }
}
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;
try {
await api('/rules', { method: 'POST', body: JSON.stringify(data) });
toast('Rule added');
bootstrap.Modal.getInstance(document.getElementById('ruleModal')).hide();
e.target.reset();
loadRules();
} catch (err) { toast('Failed to add rule', 'danger'); }
});
async function loadSettings() {
try {
await api('/health');
document.getElementById('sysHealth').textContent = 'Healthy';
document.getElementById('sysHealth').className = 'badge bg-success';
} catch {
document.getElementById('sysHealth').textContent = 'Unreachable';
document.getElementById('sysHealth').className = 'badge bg-danger';
}
}
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-', ''));
});
// --- Bootstrap ---
startAutoRefresh();
loadDashboard();
</script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use Jakach\Logging\Api\Router;
$router = new Router();
$router->handle();
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace Jakach\Logging\Api;
use Jakach\Logging\Model\{LogSourceType, AlertStatus};
use Jakach\Logging\Storage\{Database, Repository};
class Router
{
private Repository $repo;
public function __construct()
{
$db = new Database();
$this->repo = new Repository($db);
}
public function handle(): void
{
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$path = rtrim($path, '/');
header('Content-Type: application/json');
try {
$result = match (true) {
$path === '/sources' && $method === 'GET' => $this->repo->getSources(),
$path === '/sources' && $method === 'POST' => $this->createSource(),
$path === '/rules' && $method === 'GET' => $this->repo->getRules(),
$path === '/rules' && $method === 'POST' => $this->createRule(),
$path === '/alerts' && $method === 'GET' => $this->getAlerts(),
preg_match('#^/alerts/(\d+)/ack$#', $path, $m) && $method === 'POST' => $this->ackAlert((int) $m[1]),
preg_match('#^/alerts/counts$#', $path) && $method === 'GET' => $this->repo->getAlertCounts(),
$path === '/health' && $method === 'GET' => ['status' => 'ok'],
default => throw new \RuntimeException('Not found', 404),
};
if ($result instanceof \UnitEnum || is_scalar($result)) {
$result = ['data' => $result];
} elseif (is_array($result) && !empty($result) && $result[array_key_first($result)] instanceof \UnitEnum) {
$result = ['data' => $result];
} elseif (is_array($result)) {
$needsWrap = false;
foreach ($result as $key => $val) {
if (is_object($val) && method_exists($val, 'toArray')) {
$result[$key] = $val->toArray();
} else {
$needsWrap = true;
}
}
if ($needsWrap || empty($result)) {
$result = ['data' => $result];
}
} elseif (is_object($result) && method_exists($result, 'toArray')) {
$result = ['data' => $result->toArray()];
}
http_response_code(200);
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
} catch (\RuntimeException $e) {
http_response_code($e->getCode() ?: 500);
echo json_encode(['error' => $e->getMessage()]);
}
}
private function createSource(): array
{
$body = json_decode(file_get_contents('php://input'), true);
$type = LogSourceType::from($body['type'] ?? '');
return $this->repo->createSource(
name: $body['name'],
type: $type,
address: $body['address'],
labels: $body['labels'] ?? [],
);
}
private function createRule(): array
{
$body = json_decode(file_get_contents('php://input'), true);
return $this->repo->createRule(
name: $body['name'],
pattern: $body['pattern'],
severity: $body['severity'] ?? 'warning',
rateLimitSeconds: $body['rate_limit_seconds'] ?? null,
);
}
private function getAlerts(): array
{
$limit = (int) ($_GET['limit'] ?? 100);
$offset = (int) ($_GET['offset'] ?? 0);
$status = $_GET['status'] ?? null;
$severity = $_GET['severity'] ?? null;
return $this->repo->getAlerts($limit, $offset, $status, $severity);
}
private function ackAlert(int $id): array
{
$this->repo->updateAlertStatus($id, AlertStatus::Acknowledged);
return ['status' => 'acknowledged', 'id' => $id];
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace Jakach\Logging\Model;
class Alert
{
public function __construct(
public readonly int $id,
public readonly int $ruleId,
public readonly string $ruleName,
public readonly AlertSeverity $severity,
public readonly AlertStatus $status,
public readonly string $message,
public readonly string $rawLine,
public readonly ?int $sourceId = null,
public readonly ?string $sourceName = null,
public readonly \DateTimeImmutable $createdAt = new \DateTimeImmutable(),
) {}
public static function fromRow(array $row): self
{
return new self(
id: (int) $row['id'],
ruleId: (int) $row['rule_id'],
ruleName: $row['rule_name'],
severity: AlertSeverity::from($row['severity']),
status: AlertStatus::from($row['status']),
message: $row['message'],
rawLine: $row['raw_line'],
sourceId: isset($row['source_id']) ? (int) $row['source_id'] : null,
sourceName: $row['source_name'] ?? null,
createdAt: new \DateTimeImmutable($row['created_at']),
);
}
public function toArray(): array
{
return [
'id' => $this->id,
'rule_id' => $this->ruleId,
'rule_name' => $this->ruleName,
'severity' => $this->severity->value,
'status' => $this->status->value,
'message' => $this->message,
'raw_line' => $this->rawLine,
'source_id' => $this->sourceId,
'source_name' => $this->sourceName,
'created_at' => $this->createdAt->format('c'),
];
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Jakach\Logging\Model;
enum LogSourceType: string
{
case File = 'file';
case Tcp = 'tcp';
case Udp = 'udp';
case Http = 'http';
}
enum AlertSeverity: string
{
case Info = 'info';
case Warning = 'warning';
case Critical = 'critical';
}
enum AlertStatus: string
{
case Open = 'open';
case Acknowledged = 'acknowledged';
case Resolved = 'resolved';
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Jakach\Logging\Model;
class LogSource
{
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly LogSourceType $type,
public readonly string $address,
public readonly array $labels = [],
public readonly bool $active = true,
public readonly \DateTimeImmutable $createdAt = new \DateTimeImmutable(),
) {}
public static function fromRow(array $row): self
{
return new self(
id: (int) $row['id'],
name: $row['name'],
type: LogSourceType::from($row['type']),
address: $row['address'],
labels: json_decode($row['labels'] ?? '[]', true),
active: (bool) $row['active'],
createdAt: new \DateTimeImmutable($row['created_at']),
);
}
public function toArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'type' => $this->type->value,
'address' => $this->address,
'labels' => $this->labels,
'active' => $this->active,
'created_at' => $this->createdAt->format('c'),
];
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Jakach\Logging\Model;
class Rule
{
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly string $pattern,
public readonly AlertSeverity $severity,
public readonly ?int $rateLimitSeconds = null,
public readonly bool $active = true,
public readonly \DateTimeImmutable $createdAt = new \DateTimeImmutable(),
) {}
public static function fromRow(array $row): self
{
return new self(
id: (int) $row['id'],
name: $row['name'],
pattern: $row['pattern'],
severity: AlertSeverity::from($row['severity']),
rateLimitSeconds: isset($row['rate_limit_seconds']) ? (int) $row['rate_limit_seconds'] : null,
active: (bool) $row['active'],
createdAt: new \DateTimeImmutable($row['created_at']),
);
}
public function toArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'pattern' => $this->pattern,
'severity' => $this->severity->value,
'rate_limit_seconds' => $this->rateLimitSeconds,
'active' => $this->active,
'created_at' => $this->createdAt->format('c'),
];
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace Jakach\Logging\RuleEngine;
use Jakach\Logging\Model\{Rule, Alert, LogSource};
use Jakach\Logging\Storage\Repository;
class Engine
{
private array $rateCache = [];
private array $compiledPatterns = [];
public function __construct(
private Repository $repo,
) {}
public function evaluate(string $line, ?LogSource $source = null): ?Alert
{
$rules = $this->repo->getActiveRules();
foreach ($rules as $rule) {
if ($this->matches($line, $rule)) {
if ($rule->rateLimitSeconds !== null && !$this->repo->checkRateLimit($rule->id, $rule->rateLimitSeconds)) {
continue;
}
$message = sprintf(
'[%s] Rule "%s" matched: %s',
strtoupper($rule->severity->value),
$rule->name,
substr($line, 0, 200)
);
$alert = $this->repo->createAlert(
ruleId: $rule->id,
ruleName: $rule->name,
severity: $rule->severity->value,
message: $message,
rawLine: $line,
sourceId: $source?->id,
sourceName: $source?->name,
);
return $alert;
}
}
return null;
}
private function matches(string $line, Rule $rule): bool
{
if (!isset($this->compiledPatterns[$rule->id])) {
$delimiter = $rule->pattern[0] ?? '/';
$this->compiledPatterns[$rule->id] = $rule->pattern;
}
return (bool) preg_match($this->compiledPatterns[$rule->id], $line);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace Jakach\Logging\Storage;
class Database
{
private \PDO $pdo;
public function __construct(string $path = '/app/data/logging.db')
{
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$this->pdo = new \PDO("sqlite:$path");
$this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC);
$this->pdo->exec('PRAGMA journal_mode=WAL');
$this->migrate();
}
public function pdo(): \PDO
{
return $this->pdo;
}
private function migrate(): void
{
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS log_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
address TEXT NOT NULL,
labels TEXT DEFAULT '[]',
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
)
");
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
pattern TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'warning',
rate_limit_seconds INTEGER,
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
)
");
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_id INTEGER NOT NULL,
rule_name TEXT NOT NULL,
severity TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
message TEXT NOT NULL,
raw_line TEXT NOT NULL,
source_id INTEGER,
source_name TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (rule_id) REFERENCES rules(id)
)
");
$this->pdo->exec("
CREATE INDEX IF NOT EXISTS idx_alerts_status ON alerts(status)
");
$this->pdo->exec("
CREATE INDEX IF NOT EXISTS idx_alerts_created ON alerts(created_at)
");
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS rate_limiter (
rule_id INTEGER NOT NULL,
window_start INTEGER NOT NULL,
count INTEGER DEFAULT 0,
PRIMARY KEY (rule_id, window_start)
)
");
}
}
+169
View File
@@ -0,0 +1,169 @@
<?php
namespace Jakach\Logging\Storage;
use Jakach\Logging\Model\{LogSource, Rule, Alert, AlertStatus, LogSourceType};
class Repository
{
public function __construct(
private Database $db,
) {}
// --- Log Sources ---
public function getSources(): array
{
$rows = $this->db->pdo()->query("SELECT * FROM log_sources ORDER BY name")->fetchAll();
return array_map(fn(array $r) => LogSource::fromRow($r), $rows);
}
public function getActiveSources(): array
{
$rows = $this->db->pdo()->query("SELECT * FROM log_sources WHERE active = 1 ORDER BY name")->fetchAll();
return array_map(fn(array $r) => LogSource::fromRow($r), $rows);
}
public function getSource(int $id): ?LogSource
{
$stmt = $this->db->pdo()->prepare("SELECT * FROM log_sources WHERE id = ?");
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ? LogSource::fromRow($row) : null;
}
public function createSource(string $name, LogSourceType $type, string $address, array $labels = []): LogSource
{
$stmt = $this->db->pdo()->prepare(
"INSERT INTO log_sources (name, type, address, labels) VALUES (?, ?, ?, ?)"
);
$stmt->execute([$name, $type->value, $address, json_encode($labels)]);
return $this->getSource((int) $this->db->pdo()->lastInsertId());
}
public function deleteSource(int $id): void
{
$this->db->pdo()->prepare("DELETE FROM log_sources WHERE id = ?")->execute([$id]);
}
// --- Rules ---
public function getRules(): array
{
$rows = $this->db->pdo()->query("SELECT * FROM rules ORDER BY name")->fetchAll();
return array_map(fn(array $r) => Rule::fromRow($r), $rows);
}
public function getActiveRules(): array
{
$rows = $this->db->pdo()->query("SELECT * FROM rules WHERE active = 1 ORDER BY name")->fetchAll();
return array_map(fn(array $r) => Rule::fromRow($r), $rows);
}
public function getRule(int $id): ?Rule
{
$stmt = $this->db->pdo()->prepare("SELECT * FROM rules WHERE id = ?");
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ? Rule::fromRow($row) : null;
}
public function createRule(string $name, string $pattern, string $severity, ?int $rateLimitSeconds = null): Rule
{
$stmt = $this->db->pdo()->prepare(
"INSERT INTO rules (name, pattern, severity, rate_limit_seconds) VALUES (?, ?, ?, ?)"
);
$stmt->execute([$name, $pattern, $severity, $rateLimitSeconds]);
return $this->getRule((int) $this->db->pdo()->lastInsertId());
}
public function deleteRule(int $id): void
{
$this->db->pdo()->prepare("DELETE FROM rules WHERE id = ?")->execute([$id]);
}
// --- Alerts ---
public function createAlert(int $ruleId, string $ruleName, string $severity, string $message, string $rawLine, ?int $sourceId = null, ?string $sourceName = null): Alert
{
$stmt = $this->db->pdo()->prepare(
"INSERT INTO alerts (rule_id, rule_name, severity, status, message, raw_line, source_id, source_name)
VALUES (?, ?, ?, 'open', ?, ?, ?, ?)"
);
$stmt->execute([$ruleId, $ruleName, $severity, $message, $rawLine, $sourceId, $sourceName]);
$id = (int) $this->db->pdo()->lastInsertId();
return $this->getAlert($id);
}
public function getAlert(int $id): ?Alert
{
$stmt = $this->db->pdo()->prepare("SELECT * FROM alerts WHERE id = ?");
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ? Alert::fromRow($row) : null;
}
public function getAlerts(int $limit = 100, int $offset = 0, ?string $status = null, ?string $severity = null): array
{
$where = [];
$params = [];
if ($status) {
$where[] = 'status = ?';
$params[] = $status;
}
if ($severity) {
$where[] = 'severity = ?';
$params[] = $severity;
}
$sql = "SELECT * FROM alerts";
if ($where) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sql .= " ORDER BY created_at DESC LIMIT ? OFFSET ?";
$params[] = $limit;
$params[] = $offset;
$stmt = $this->db->pdo()->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
return array_map(fn(array $r) => Alert::fromRow($r), $rows);
}
public function updateAlertStatus(int $id, AlertStatus $status): void
{
$stmt = $this->db->pdo()->prepare("UPDATE alerts SET status = ? WHERE id = ?");
$stmt->execute([$status->value, $id]);
}
public function getAlertCounts(): array
{
return $this->db->pdo()->query(
"SELECT status, severity, COUNT(*) as count FROM alerts GROUP BY status, severity"
)->fetchAll();
}
// --- Rate Limiting ---
public function checkRateLimit(int $ruleId, int $windowSeconds): bool
{
$now = time();
$window = intdiv($now, $windowSeconds) * $windowSeconds;
$this->db->pdo()->prepare(
"INSERT INTO rate_limiter (rule_id, window_start, count)
VALUES (?, ?, 1)
ON CONFLICT(rule_id, window_start) DO UPDATE SET count = count + 1"
)->execute([$ruleId, $window]);
$stmt = $this->db->pdo()->prepare(
"SELECT count FROM rate_limiter WHERE rule_id = ? AND window_start = ?"
);
$stmt->execute([$ruleId, $window]);
$row = $stmt->fetch();
return $row['count'] <= 1;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace Jakach\Logging\Worker;
use Jakach\Logging\Model\{LogSource, LogSourceType, Alert};
class FileWatcher
{
private array $handles = [];
private array $inodes = [];
private int $checkInterval;
public function __construct(
private \Closure $onLine,
int $checkInterval = 500000,
) {
$this->checkInterval = $checkInterval;
}
public function watch(LogSource $source): void
{
if ($source->type !== LogSourceType::File) {
return;
}
$path = $source->address;
if (!file_exists($path)) {
fprintf(STDERR, "File not found: %s\n", $path);
return;
}
$handle = fopen($path, 'r');
if (!$handle) {
fprintf(STDERR, "Cannot open file: %s\n", $path);
return;
}
fseek($handle, 0, SEEK_END);
$stat = fstat($handle);
$this->handles[$source->id] = $handle;
$this->inodes[$source->id] = $stat['ino'] ?? 0;
fprintf(STDERR, "Watching file: %s (source: %s)\n", $path, $source->name);
}
public function tick(): void
{
foreach ($this->handles as $id => $handle) {
if (feof($handle)) {
clearstatcache();
$source = null;
if (file_exists(stream_get_meta_data($handle)['uri'])) {
usleep(10000);
continue;
}
}
while ($line = fgets($handle)) {
$line = rtrim($line, "\r\n");
if ($line !== '') {
($this->onLine)($line, $id);
}
}
}
usleep($this->checkInterval);
}
public function stop(): void
{
foreach ($this->handles as $handle) {
fclose($handle);
}
$this->handles = [];
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace Jakach\Logging\Worker;
use Jakach\Logging\Model\{LogSource, Alert};
use Jakach\Logging\RuleEngine\Engine;
use Jakach\Logging\Storage\Repository;
class Orchestrator
{
private FileWatcher $fileWatcher;
private SocketListener $socketListener;
private Repository $repo;
private Engine $engine;
private array $sourceMap = [];
private bool $running = true;
public function __construct(Repository $repo, Engine $engine)
{
$this->repo = $repo;
$this->engine = $engine;
$this->socketListener = new SocketListener(function (string $line, int $sourceId) {
$this->handleLine($line, $sourceId);
});
$this->fileWatcher = new FileWatcher(function (string $line, int $sourceId) {
$this->handleLine($line, $sourceId);
});
}
public function run(): void
{
$this->loadSources();
pcntl_signal(SIGTERM, function () { $this->running = false; });
pcntl_signal(SIGINT, function () { $this->running = false; });
fprintf(STDERR, "Worker started, watching %d sources\n", count($this->sourceMap));
while ($this->running) {
pcntl_signal_dispatch();
$this->fileWatcher->tick();
$this->socketListener->tick();
}
$this->stop();
}
private function loadSources(): void
{
$sources = $this->repo->getActiveSources();
foreach ($sources as $source) {
$this->sourceMap[$source->id] = $source;
$this->fileWatcher->watch($source);
$this->socketListener->listen($source);
}
}
private function handleLine(string $line, int $sourceId): void
{
$source = $this->sourceMap[$sourceId] ?? null;
$alert = $this->engine->evaluate($line, $source);
if ($alert !== null) {
$msg = sprintf(
"[%s] ALERT #%d [%s] %s",
date('c'),
$alert->id,
strtoupper($alert->severity->value),
$alert->message
);
fprintf(STDERR, "%s\n", $msg);
echo $msg . "\n";
}
}
private function stop(): void
{
$this->fileWatcher->stop();
$this->socketListener->stop();
fprintf(STDERR, "Worker stopped\n");
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace Jakach\Logging\Worker;
use Jakach\Logging\Model\{LogSource, LogSourceType};
class SocketListener
{
private array $servers = [];
public function __construct(
private \Closure $onLine,
) {}
public function listen(LogSource $source): void
{
if (!in_array($source->type, [LogSourceType::Tcp, LogSourceType::Udp])) {
return;
}
$parts = parse_url($source->address);
$host = $parts['host'] ?? '0.0.0.0';
$port = $parts['port'] ?? 9514;
$protocol = $source->type === LogSourceType::Udp ? SOL_UDP : SOL_TCP;
$sockType = $source->type === LogSourceType::Udp ? SOCK_DGRAM : SOCK_STREAM;
$sock = socket_create(AF_INET, $sockType, $protocol);
if (!$sock) {
fprintf(STDERR, "Cannot create socket for %s: %s\n", $source->name, socket_strerror(socket_last_error()));
return;
}
socket_set_option($sock, SOL_SOCKET, SO_REUSEADDR, 1);
if (!socket_bind($sock, $host, $port)) {
fprintf(STDERR, "Cannot bind %s on %s:%d: %s\n", $source->name, $host, $port, socket_strerror(socket_last_error()));
return;
}
if ($source->type === LogSourceType::Tcp) {
socket_listen($sock, 5);
socket_set_nonblock($sock);
}
$this->servers[$source->id] = $sock;
fprintf(STDERR, "Listening on %s://%s:%d (source: %s)\n", $source->type->value, $host, $port, $source->name);
}
public function tick(): void
{
foreach ($this->servers as $id => $sock) {
$from = '';
$port = 0;
if (@socket_recvfrom($sock, $data, 65535, 0, $from, $port)) {
$line = rtrim($data, "\r\n");
if ($line !== '') {
($this->onLine)($line, $id);
}
}
}
}
public function stop(): void
{
foreach ($this->servers as $sock) {
socket_close($sock);
}
$this->servers = [];
}
}