Compare commits
10 Commits
e68266bac9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 037d2f114b | |||
| 29fc12385e | |||
| 9b19c1e6b4 | |||
| 71cb3beeee | |||
| a41971a11e | |||
| 2f2037f4da | |||
| 0d61e623a7 | |||
| 4fd6bfa8da | |||
| f2e76bf07b | |||
| 9e95fe7403 |
@@ -4,8 +4,17 @@ return [
|
||||
'db' => [
|
||||
'path' => '/app/data/logging.db',
|
||||
],
|
||||
'clickhouse' => [
|
||||
'host' => 'clickhouse',
|
||||
'port' => 8123,
|
||||
'database' => 'jakach_logging',
|
||||
'username' => 'default',
|
||||
'password' => '',
|
||||
],
|
||||
'worker' => [
|
||||
'file_check_interval' => 500000,
|
||||
'buffer_flush_interval_ms' => 100,
|
||||
'buffer_max_rows' => 1000,
|
||||
],
|
||||
'sources' => [],
|
||||
'rules' => [
|
||||
|
||||
+19
-4
@@ -11,7 +11,7 @@ services:
|
||||
- ./composer.json:/app/composer.json
|
||||
- data:/app/data
|
||||
depends_on:
|
||||
- redis
|
||||
- clickhouse
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
@@ -41,13 +41,28 @@ services:
|
||||
- data:/app/data
|
||||
- log_collect:/collect
|
||||
depends_on:
|
||||
- redis
|
||||
- clickhouse
|
||||
command: ["php", "bin/consume", "--daemon"]
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:24.3-alpine
|
||||
ports:
|
||||
- "8123:8123"
|
||||
- "9000:9000"
|
||||
volumes:
|
||||
- clickhouse_data:/var/lib/clickhouse
|
||||
environment:
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
CLICKHOUSE_PASSWORD: ""
|
||||
CLICKHOUSE_USER: default
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
|
||||
|
||||
volumes:
|
||||
data:
|
||||
log_collect:
|
||||
clickhouse_data:
|
||||
@@ -4,6 +4,8 @@ RUN apk add --no-cache linux-headers curl-dev \
|
||||
&& docker-php-ext-install curl pcntl sockets || \
|
||||
docker-php-ext-install curl pcntl
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
FROM php:8.3-cli-alpine
|
||||
|
||||
RUN apk add --no-cache curl-dev git linux-headers
|
||||
RUN apk add --no-cache curl-dev git linux-headers curl
|
||||
|
||||
RUN docker-php-ext-install curl pcntl sockets
|
||||
|
||||
|
||||
+178
-83
@@ -154,6 +154,8 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
</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>
|
||||
@@ -164,7 +166,7 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0" id="alertsTable">
|
||||
<thead class="table-dark"><tr>
|
||||
<th style="width:80px;cursor:pointer" onclick="sortAlerts('id')">ID <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
|
||||
<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>
|
||||
@@ -263,34 +265,30 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
<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-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 Size</dt><dd class="col-sm-8"><span id="sysDbSize">—</span></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">Logged in as</dt><dd class="col-sm-8" id="settingsUser">—</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 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 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></p>
|
||||
<p class="mb-0"><strong>Rules</strong> — PHP regex patterns, e.g. <code>/error/i</code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mt-3">
|
||||
|
||||
<!-- 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">Auto-purge old data to keep the database small.</p>
|
||||
<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>
|
||||
@@ -305,52 +303,14 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
<small id="retentionResult" class="ms-2"></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mt-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:300px;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>
|
||||
<div class="col-md-6">
|
||||
|
||||
<!-- False Positives -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><i class="bi bi-shield-check me-1"></i>Security</div>
|
||||
<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="mb-3">
|
||||
<label class="form-label">Allowed User Tokens</label>
|
||||
<p class="small text-secondary">Only these Jakach user tokens can access this system. Leave empty to allow any authenticated Jakach user.</p>
|
||||
<textarea class="form-control font-monospace" id="allowedTokensInput" rows="4" placeholder="One user_token per line"></textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="saveTokensBtn"><i class="bi bi-floppy"></i> Save</button>
|
||||
<small id="tokenSaveStatus" class="ms-2"></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><i class="bi bi-telegram me-1"></i>Telegram Notifications</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-secondary">Send alerts to a Telegram chat via a bot.</p>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Bot Token</label>
|
||||
<input type="text" class="form-control" id="telegramBotToken" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11">
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Chat ID</label>
|
||||
<input type="text" class="form-control" id="telegramChatId" placeholder="-1001234567890">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="saveTelegramBtn"><i class="bi bi-floppy"></i> Save</button>
|
||||
<small id="telegramSaveStatus" class="ms-2"></small>
|
||||
<button class="btn btn-outline-secondary btn-sm ms-2" id="testTelegramBtn"><i class="bi bi-send"></i> Test</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><i class="bi bi-x-circle me-1"></i>False Positives</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-secondary">Lines matching these patterns will be ignored and not trigger any alert.</p>
|
||||
<div class="table-responsive mb-2">
|
||||
<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>
|
||||
@@ -358,22 +318,86 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
</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 (optional)" style="max-width:200px">
|
||||
<button class="btn btn-primary" id="addFpBtn"><i class="bi bi-plus-lg"></i> Add</button>
|
||||
<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>
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-info-circle me-1"></i>How to get your user token</div>
|
||||
<div class="card-body small">
|
||||
<ol class="mb-0 ps-3">
|
||||
<li>Log in at <a href="https://auth.jakach.ch" target="_blank">auth.jakach.ch</a></li>
|
||||
<li>Your <code>user_token</code> is shown in your profile</li>
|
||||
<li>Paste it above to grant access</li>
|
||||
</ol>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
@@ -1031,27 +1055,43 @@ async function loadSettings() {
|
||||
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');
|
||||
if (health.db_size) {
|
||||
document.getElementById('sysDbSize').textContent = health.db_size;
|
||||
}
|
||||
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 res = await api('/config/allowed_tokens');
|
||||
const tokens = res.tokens || [];
|
||||
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
|
||||
? (res.bot_token_masked || '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 {
|
||||
@@ -1089,6 +1129,18 @@ document.getElementById('saveTokensBtn').addEventListener('click', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -1296,8 +1348,41 @@ 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) {
|
||||
@@ -1345,7 +1430,7 @@ function renderAlerts(query) {
|
||||
}
|
||||
const label = document.getElementById('searchInput').value.trim() ? 'search results' : 'alerts';
|
||||
document.getElementById('alertsCount').textContent = sorted.length + ' ' + label;
|
||||
document.getElementById('bulkBar').classList.toggle('d-none', selectedAlertIds.size === 0);
|
||||
updateBulkBar();
|
||||
}
|
||||
|
||||
async function bulkAction(status) {
|
||||
@@ -1363,8 +1448,18 @@ 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 }) });
|
||||
if (format === 'json') {
|
||||
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);
|
||||
|
||||
+18
-37
@@ -164,7 +164,8 @@ class Router
|
||||
|
||||
private function health(): array
|
||||
{
|
||||
$dbOk = true;
|
||||
$sqliteOk = true;
|
||||
$clickhouseOk = true;
|
||||
$dbSize = 'unknown';
|
||||
try {
|
||||
$this->repo->getAlerts(1);
|
||||
@@ -176,12 +177,21 @@ class Router
|
||||
: round($bytes / 1024, 1) . ' KB');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$dbOk = false;
|
||||
$sqliteOk = false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->repo->clickhouse()->query('SELECT 1');
|
||||
} catch (\Throwable) {
|
||||
$clickhouseOk = false;
|
||||
}
|
||||
|
||||
$allOk = $sqliteOk && $clickhouseOk;
|
||||
|
||||
return [
|
||||
'status' => $dbOk ? 'ok' : 'degraded',
|
||||
'database' => $dbOk ? 'connected' : 'error',
|
||||
'status' => $allOk ? 'ok' : 'degraded',
|
||||
'sqlite' => $sqliteOk ? 'connected' : 'error',
|
||||
'clickhouse' => $clickhouseOk ? 'connected' : 'error',
|
||||
'db_size' => $dbSize,
|
||||
'time' => date('c'),
|
||||
];
|
||||
@@ -502,48 +512,19 @@ class Router
|
||||
return $this->repo->getLogContext($id, min($before, 50), min($after, 50));
|
||||
}
|
||||
|
||||
private function exportAlerts(): never
|
||||
private function exportAlerts(): array
|
||||
{
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$ids = $body['ids'] ?? [];
|
||||
$format = $body['format'] ?? 'json';
|
||||
$alerts = $this->repo->exportAlerts($ids);
|
||||
|
||||
if ($format === 'csv') {
|
||||
header('Content-Type: text/csv');
|
||||
header('Content-Disposition: attachment; filename="alerts.csv"');
|
||||
$out = fopen('php://output', 'w');
|
||||
fputcsv($out, ['ID', 'Rule', 'Severity', 'Status', 'Message', 'Source', 'Created']);
|
||||
foreach ($alerts as $a) {
|
||||
fputcsv($out, [$a->id, $a->ruleName, $a->severity->value, $a->status->value, $a->message, $a->sourceName ?? '', $a->createdAt->format('c')]);
|
||||
}
|
||||
fclose($out);
|
||||
} else {
|
||||
echo json_encode(['data' => array_map(fn($a) => $a->toArray(), $alerts)], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
exit;
|
||||
return array_map(fn($a) => $a->toArray(), $alerts);
|
||||
}
|
||||
|
||||
private function exportLogs(): never
|
||||
private function exportLogs(): array
|
||||
{
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$ids = $body['ids'] ?? [];
|
||||
$format = $body['format'] ?? 'json';
|
||||
$logs = $this->repo->exportLogs($ids);
|
||||
|
||||
if ($format === 'csv') {
|
||||
header('Content-Type: text/csv');
|
||||
header('Content-Disposition: attachment; filename="logs.csv"');
|
||||
$out = fopen('php://output', 'w');
|
||||
fputcsv($out, ['ID', 'Line', 'Source', 'Level', 'Created']);
|
||||
foreach ($logs as $l) {
|
||||
fputcsv($out, [$l['id'], $l['line'], $l['source_name'] ?? '', $l['level'] ?? '', $l['created_at']]);
|
||||
}
|
||||
fclose($out);
|
||||
} else {
|
||||
echo json_encode(['data' => $logs], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
exit;
|
||||
return $this->repo->exportLogs($ids);
|
||||
}
|
||||
|
||||
private function runRetention(): array
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace Jakach\Logging\Storage;
|
||||
|
||||
class ClickHouseBuffer
|
||||
{
|
||||
private ClickHouseClient $client;
|
||||
private int $maxRows;
|
||||
private int $flushIntervalMs;
|
||||
private array $logBuffer = [];
|
||||
private array $alertBuffer = [];
|
||||
private int $lastFlush;
|
||||
private int $idCounter;
|
||||
|
||||
private const LOG_COLUMNS = ['id', 'line', 'source_id', 'source_name', 'level', 'created_at'];
|
||||
private const ALERT_COLUMNS = ['id', 'rule_id', 'rule_name', 'severity', 'status', 'message', 'raw_line', 'source_id', 'source_name', 'created_at'];
|
||||
|
||||
public function __construct(
|
||||
ClickHouseClient $client,
|
||||
int $maxRows = 1000,
|
||||
int $flushIntervalMs = 100,
|
||||
) {
|
||||
$this->client = $client;
|
||||
$this->maxRows = $maxRows;
|
||||
$this->flushIntervalMs = $flushIntervalMs;
|
||||
$this->lastFlush = hrtime(true);
|
||||
$this->idCounter = $this->loadMaxId();
|
||||
}
|
||||
|
||||
private function loadMaxId(): int
|
||||
{
|
||||
try {
|
||||
$rows = $this->client->query("SELECT max(id) as max_id FROM (SELECT max(id) as id FROM log_entries UNION ALL SELECT max(id) as id FROM alerts)");
|
||||
$max = $rows[0]['max_id'] ?? null;
|
||||
return $max ? (int) $max : 0;
|
||||
} catch (\Throwable) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function nextId(): int
|
||||
{
|
||||
return ++$this->idCounter;
|
||||
}
|
||||
|
||||
public function pushLog(string $line, ?int $sourceId, ?string $sourceName, ?string $level): void
|
||||
{
|
||||
$this->logBuffer[] = [
|
||||
'id' => $this->nextId(),
|
||||
'line' => $line,
|
||||
'source_id' => $sourceId,
|
||||
'source_name' => $sourceName,
|
||||
'level' => $level,
|
||||
'created_at' => gmdate('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
if (count($this->logBuffer) >= $this->maxRows) {
|
||||
$this->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function pushAlert(int $ruleId, string $ruleName, string $severity, string $status, string $message, string $rawLine, ?int $sourceId, ?string $sourceName): array
|
||||
{
|
||||
$id = $this->nextId();
|
||||
$createdAt = gmdate('Y-m-d H:i:s');
|
||||
$this->alertBuffer[] = [
|
||||
'id' => $id,
|
||||
'rule_id' => $ruleId,
|
||||
'rule_name' => $ruleName,
|
||||
'severity' => $severity,
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'raw_line' => $rawLine,
|
||||
'source_id' => $sourceId,
|
||||
'source_name' => $sourceName,
|
||||
'created_at' => $createdAt,
|
||||
];
|
||||
|
||||
if (count($this->alertBuffer) >= $this->maxRows) {
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
return ['id' => $id, 'created_at' => $createdAt];
|
||||
}
|
||||
|
||||
public function tick(): void
|
||||
{
|
||||
$elapsed = (hrtime(true) - $this->lastFlush) / 1_000_000;
|
||||
if ($elapsed >= $this->flushIntervalMs && ($this->logBuffer || $this->alertBuffer)) {
|
||||
$this->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function flush(): void
|
||||
{
|
||||
$this->flushLogs();
|
||||
$this->flushAlerts();
|
||||
$this->lastFlush = hrtime(true);
|
||||
}
|
||||
|
||||
public function flushLogs(): void
|
||||
{
|
||||
if (empty($this->logBuffer)) {
|
||||
return;
|
||||
}
|
||||
$batch = $this->logBuffer;
|
||||
$this->logBuffer = [];
|
||||
try {
|
||||
$this->client->insert('log_entries', self::LOG_COLUMNS, $batch);
|
||||
} catch (\Throwable $e) {
|
||||
error_log("ClickHouse log insert error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function flushAlerts(): void
|
||||
{
|
||||
if (empty($this->alertBuffer)) {
|
||||
return;
|
||||
}
|
||||
$batch = $this->alertBuffer;
|
||||
$this->alertBuffer = [];
|
||||
try {
|
||||
$this->client->insert('alerts', self::ALERT_COLUMNS, $batch);
|
||||
} catch (\Throwable $e) {
|
||||
error_log("ClickHouse alert insert error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace Jakach\Logging\Storage;
|
||||
|
||||
class ClickHouseClient
|
||||
{
|
||||
private string $host;
|
||||
private int $port;
|
||||
private string $database;
|
||||
private string $username;
|
||||
private string $password;
|
||||
private int $timeout;
|
||||
|
||||
public function __construct(
|
||||
string $host = 'clickhouse',
|
||||
int $port = 8123,
|
||||
string $database = 'jakach_logging',
|
||||
string $username = 'default',
|
||||
string $password = '',
|
||||
int $timeout = 5,
|
||||
) {
|
||||
$this->host = $host;
|
||||
$this->port = $port;
|
||||
$this->database = $database;
|
||||
$this->username = $username;
|
||||
$this->password = $password;
|
||||
$this->timeout = $timeout;
|
||||
}
|
||||
|
||||
private function url(): string
|
||||
{
|
||||
return "http://{$this->host}:{$this->port}/";
|
||||
}
|
||||
|
||||
public function query(string $sql, array $params = []): array
|
||||
{
|
||||
if (!empty($params)) {
|
||||
$sql = $this->formatParams($sql, $params);
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $this->url() . '?' . http_build_query([
|
||||
'database' => $this->database,
|
||||
'default_format' => 'JSON',
|
||||
]),
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $sql,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => $this->timeout,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: text/plain'],
|
||||
]);
|
||||
|
||||
if ($this->username) {
|
||||
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||||
curl_setopt($ch, CURLOPT_USERPWD, $this->username . ':' . $this->password);
|
||||
}
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
throw new \RuntimeException("ClickHouse connection error: $error");
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
throw new \RuntimeException("ClickHouse error (HTTP $httpCode): $response");
|
||||
}
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $decoded['data'] ?? [];
|
||||
}
|
||||
|
||||
public function execute(string $sql): void
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $this->url() . '?' . http_build_query([
|
||||
'database' => $this->database,
|
||||
]),
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $sql,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => $this->timeout,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: text/plain'],
|
||||
]);
|
||||
|
||||
if ($this->username) {
|
||||
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||||
curl_setopt($ch, CURLOPT_USERPWD, $this->username . ':' . $this->password);
|
||||
}
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
throw new \RuntimeException("ClickHouse connection error: $error");
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
if (str_contains($response, 'already exists')) {
|
||||
return;
|
||||
}
|
||||
throw new \RuntimeException("ClickHouse error (HTTP $httpCode): $response");
|
||||
}
|
||||
}
|
||||
|
||||
public function insert(string $table, array $columns, array $rows): void
|
||||
{
|
||||
if (empty($rows)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$escapedRows = [];
|
||||
foreach ($rows as $row) {
|
||||
$vals = [];
|
||||
foreach ($columns as $col) {
|
||||
$v = $row[$col] ?? null;
|
||||
if ($v === null) {
|
||||
$vals[] = 'NULL';
|
||||
} elseif (is_int($v) || is_float($v)) {
|
||||
$vals[] = (string) $v;
|
||||
} else {
|
||||
$vals[] = "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], (string) $v) . "'";
|
||||
}
|
||||
}
|
||||
$escapedRows[] = '(' . implode(',', $vals) . ')';
|
||||
}
|
||||
|
||||
$sql = sprintf(
|
||||
'INSERT INTO %s (%s) VALUES %s',
|
||||
$table,
|
||||
implode(', ', $columns),
|
||||
implode(', ', $escapedRows)
|
||||
);
|
||||
|
||||
$this->execute($sql);
|
||||
}
|
||||
|
||||
private function formatParams(string $sql, array $params): string
|
||||
{
|
||||
$parts = explode('?', $sql);
|
||||
$result = $parts[0];
|
||||
for ($i = 0; $i < count($params); $i++) {
|
||||
$v = $params[$i];
|
||||
if ($v === null) {
|
||||
$result .= 'NULL';
|
||||
} elseif (is_int($v) || is_float($v)) {
|
||||
$result .= (string) $v;
|
||||
} else {
|
||||
$result .= "'" . str_replace(["'", "\\"], ["\\'", "\\\\"], (string) $v) . "'";
|
||||
}
|
||||
$result .= $parts[$i + 1] ?? '';
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function migrate(): void
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $this->url() . '?' . http_build_query(['database' => 'default']),
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => "CREATE DATABASE IF NOT EXISTS {$this->database}",
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => $this->timeout,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: text/plain'],
|
||||
]);
|
||||
if ($this->username) {
|
||||
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||||
curl_setopt($ch, CURLOPT_USERPWD, $this->username . ':' . $this->password);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($httpCode !== 200 && !str_contains($response, 'already exists')) {
|
||||
throw new \RuntimeException("ClickHouse error (HTTP $httpCode): $response");
|
||||
}
|
||||
|
||||
$this->execute("
|
||||
CREATE TABLE IF NOT EXISTS log_entries (
|
||||
id UInt64,
|
||||
line String,
|
||||
source_id Nullable(Int32),
|
||||
source_name Nullable(String),
|
||||
level Nullable(String),
|
||||
created_at DateTime('UTC')
|
||||
) ENGINE = MergeTree()
|
||||
PARTITION BY toDate(created_at)
|
||||
ORDER BY (created_at, id)
|
||||
TTL toDate(created_at) + INTERVAL 90 DAY DELETE
|
||||
SETTINGS index_granularity = 8192
|
||||
");
|
||||
|
||||
$this->execute("
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id UInt64,
|
||||
rule_id Int32,
|
||||
rule_name String,
|
||||
severity String,
|
||||
status String,
|
||||
message String,
|
||||
raw_line String,
|
||||
source_id Nullable(Int32),
|
||||
source_name Nullable(String),
|
||||
created_at DateTime('UTC')
|
||||
) ENGINE = MergeTree()
|
||||
PARTITION BY toDate(created_at)
|
||||
ORDER BY (created_at, id)
|
||||
TTL toDate(created_at) + INTERVAL 365 DAY DELETE
|
||||
SETTINGS index_granularity = 8192
|
||||
");
|
||||
|
||||
$this->query("SELECT count() FROM log_entries");
|
||||
}
|
||||
}
|
||||
+175
-130
@@ -2,31 +2,58 @@
|
||||
|
||||
namespace Jakach\Logging\Storage;
|
||||
|
||||
use Jakach\Logging\Model\{LogSource, Rule, Alert, AlertStatus, LogSourceType};
|
||||
use Jakach\Logging\Model\{LogSource, Rule, Alert, AlertSeverity, AlertStatus, LogSourceType};
|
||||
|
||||
class Repository
|
||||
{
|
||||
private \PDO $pdo;
|
||||
private ClickHouseClient $clickhouse;
|
||||
private ClickHouseBuffer $buffer;
|
||||
|
||||
public function __construct(
|
||||
private Database $db,
|
||||
) {}
|
||||
?ClickHouseClient $clickhouse = null,
|
||||
?ClickHouseBuffer $buffer = null,
|
||||
) {
|
||||
$this->pdo = $db->pdo();
|
||||
$this->clickhouse = $clickhouse ?? new ClickHouseClient();
|
||||
$this->buffer = $buffer ?? new ClickHouseBuffer($this->clickhouse);
|
||||
|
||||
// --- Log Sources ---
|
||||
$this->clickhouse->migrate();
|
||||
}
|
||||
|
||||
public function clickhouse(): ClickHouseClient
|
||||
{
|
||||
return $this->clickhouse;
|
||||
}
|
||||
|
||||
public function buffer(): ClickHouseBuffer
|
||||
{
|
||||
return $this->buffer;
|
||||
}
|
||||
|
||||
public function flush(): void
|
||||
{
|
||||
$this->buffer->flush();
|
||||
}
|
||||
|
||||
// --- Log Sources (SQLite) ---
|
||||
|
||||
public function getSources(): array
|
||||
{
|
||||
$rows = $this->db->pdo()->query("SELECT * FROM log_sources ORDER BY name")->fetchAll();
|
||||
$rows = $this->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();
|
||||
$rows = $this->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 = $this->pdo->prepare("SELECT * FROM log_sources WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ? LogSource::fromRow($row) : null;
|
||||
@@ -34,44 +61,44 @@ class Repository
|
||||
|
||||
public function createSource(string $name, LogSourceType $type, string $address, array $labels = []): LogSource
|
||||
{
|
||||
$stmt = $this->db->pdo()->prepare(
|
||||
$stmt = $this->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());
|
||||
return $this->getSource((int) $this->pdo->lastInsertId());
|
||||
}
|
||||
|
||||
public function deleteSource(int $id): void
|
||||
{
|
||||
$this->db->pdo()->prepare("DELETE FROM log_sources WHERE id = ?")->execute([$id]);
|
||||
$this->pdo->prepare("DELETE FROM log_sources WHERE id = ?")->execute([$id]);
|
||||
}
|
||||
|
||||
public function updateSource(int $id, string $name, LogSourceType $type, string $address, array $labels = [], bool $active = true): LogSource
|
||||
{
|
||||
$stmt = $this->db->pdo()->prepare(
|
||||
$stmt = $this->pdo->prepare(
|
||||
"UPDATE log_sources SET name = ?, type = ?, address = ?, labels = ?, active = ? WHERE id = ?"
|
||||
);
|
||||
$stmt->execute([$name, $type->value, $address, json_encode($labels), (int) $active, $id]);
|
||||
return $this->getSource($id);
|
||||
}
|
||||
|
||||
// --- Rules ---
|
||||
// --- Rules (SQLite) ---
|
||||
|
||||
public function getRules(): array
|
||||
{
|
||||
$rows = $this->db->pdo()->query("SELECT * FROM rules ORDER BY name")->fetchAll();
|
||||
$rows = $this->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();
|
||||
$rows = $this->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 = $this->pdo->prepare("SELECT * FROM rules WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ? Rule::fromRow($row) : null;
|
||||
@@ -79,46 +106,70 @@ class Repository
|
||||
|
||||
public function createRule(string $name, string $pattern, string $severity, ?int $rateLimitSeconds = null): Rule
|
||||
{
|
||||
$stmt = $this->db->pdo()->prepare(
|
||||
$stmt = $this->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());
|
||||
return $this->getRule((int) $this->pdo->lastInsertId());
|
||||
}
|
||||
|
||||
public function deleteRule(int $id): void
|
||||
{
|
||||
$this->db->pdo()->prepare("DELETE FROM rules WHERE id = ?")->execute([$id]);
|
||||
$this->pdo->prepare("DELETE FROM rules WHERE id = ?")->execute([$id]);
|
||||
}
|
||||
|
||||
public function updateRule(int $id, string $name, string $pattern, string $severity, ?int $rateLimitSeconds = null, bool $active = true): Rule
|
||||
{
|
||||
$stmt = $this->db->pdo()->prepare(
|
||||
$stmt = $this->pdo->prepare(
|
||||
"UPDATE rules SET name = ?, pattern = ?, severity = ?, rate_limit_seconds = ?, active = ? WHERE id = ?"
|
||||
);
|
||||
$stmt->execute([$name, $pattern, $severity, $rateLimitSeconds, (int) $active, $id]);
|
||||
return $this->getRule($id);
|
||||
}
|
||||
|
||||
// --- Alerts ---
|
||||
// --- Alerts (ClickHouse) ---
|
||||
|
||||
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', ?, ?, ?, ?)"
|
||||
$result = $this->buffer->pushAlert(
|
||||
ruleId: $ruleId,
|
||||
ruleName: $ruleName,
|
||||
severity: $severity,
|
||||
status: 'open',
|
||||
message: $message,
|
||||
rawLine: $rawLine,
|
||||
sourceId: $sourceId,
|
||||
sourceName: $sourceName,
|
||||
);
|
||||
|
||||
$this->buffer->flushAlerts();
|
||||
|
||||
$createdAt = new \DateTimeImmutable($result['created_at']);
|
||||
|
||||
return new Alert(
|
||||
id: $result['id'],
|
||||
ruleId: $ruleId,
|
||||
ruleName: $ruleName,
|
||||
severity: AlertSeverity::from($severity),
|
||||
status: AlertStatus::Open,
|
||||
message: $message,
|
||||
rawLine: $rawLine,
|
||||
sourceId: $sourceId,
|
||||
sourceName: $sourceName,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
$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;
|
||||
$rows = $this->clickhouse->query(
|
||||
"SELECT * FROM alerts WHERE id = ? LIMIT 1",
|
||||
[$id]
|
||||
);
|
||||
if (empty($rows)) {
|
||||
return null;
|
||||
}
|
||||
return $this->alertFromRow($rows[0]);
|
||||
}
|
||||
|
||||
public function getAlerts(int $limit = 100, int $offset = 0, ?string $status = null, ?string $severity = null, ?string $since = null, ?string $until = null): array
|
||||
@@ -127,19 +178,19 @@ class Repository
|
||||
$params = [];
|
||||
|
||||
if ($status) {
|
||||
$where[] = 'status = ?';
|
||||
$where[] = "status = ?";
|
||||
$params[] = $status;
|
||||
}
|
||||
if ($severity) {
|
||||
$where[] = 'severity = ?';
|
||||
$where[] = "severity = ?";
|
||||
$params[] = $severity;
|
||||
}
|
||||
if ($since) {
|
||||
$where[] = 'created_at >= ?';
|
||||
$where[] = "created_at >= ?";
|
||||
$params[] = str_replace('T', ' ', $since);
|
||||
}
|
||||
if ($until) {
|
||||
$where[] = 'created_at <= ?';
|
||||
$where[] = "created_at <= ?";
|
||||
$params[] = str_replace('T', ' ', $until);
|
||||
}
|
||||
|
||||
@@ -147,52 +198,44 @@ class Repository
|
||||
if ($where) {
|
||||
$sql .= ' WHERE ' . implode(' AND ', $where);
|
||||
}
|
||||
$sql .= " ORDER BY created_at DESC LIMIT ? OFFSET ?";
|
||||
$sql .= " ORDER BY created_at DESC, id 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);
|
||||
$rows = $this->clickhouse->query($sql, $params);
|
||||
return array_map(fn(array $r) => $this->alertFromRow($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]);
|
||||
$this->clickhouse->execute(
|
||||
"ALTER TABLE alerts UPDATE status = '{$status->value}' WHERE id = {$id}"
|
||||
);
|
||||
}
|
||||
|
||||
public function getAlertCounts(): array
|
||||
{
|
||||
return $this->db->pdo()->query(
|
||||
"SELECT status, severity, COUNT(*) as count FROM alerts GROUP BY status, severity"
|
||||
)->fetchAll();
|
||||
return $this->clickhouse->query(
|
||||
"SELECT status, severity, count() as count FROM alerts GROUP BY status, severity"
|
||||
);
|
||||
}
|
||||
|
||||
public function searchAlerts(string $query, int $limit = 100): array
|
||||
{
|
||||
$stmt = $this->db->pdo()->prepare(
|
||||
"SELECT a.* FROM alerts a
|
||||
JOIN alerts_fts fts ON a.id = fts.rowid
|
||||
WHERE alerts_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?"
|
||||
$like = '%' . str_replace(['%', '_'], ['\%', '\_'], $query) . '%';
|
||||
$rows = $this->clickhouse->query(
|
||||
"SELECT * FROM alerts WHERE message ILIKE ? OR raw_line ILIKE ? OR rule_name ILIKE ? ORDER BY created_at DESC LIMIT ?",
|
||||
[$like, $like, $like, $limit]
|
||||
);
|
||||
$stmt->execute([$query, $limit]);
|
||||
$rows = $stmt->fetchAll();
|
||||
return array_map(fn(array $r) => Alert::fromRow($r), $rows);
|
||||
return array_map(fn(array $r) => $this->alertFromRow($r), $rows);
|
||||
}
|
||||
|
||||
// --- Log Entries ---
|
||||
// --- Log Entries (ClickHouse) ---
|
||||
|
||||
public function storeLogEntry(string $line, ?int $sourceId = null, ?string $sourceName = null, ?string $level = null): void
|
||||
{
|
||||
$stmt = $this->db->pdo()->prepare(
|
||||
"INSERT INTO log_entries (line, source_id, source_name, level) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->execute([$line, $sourceId, $sourceName, $level]);
|
||||
$this->buffer->pushLog($line, $sourceId, $sourceName, $level);
|
||||
$this->buffer->flushLogs();
|
||||
}
|
||||
|
||||
public function searchLogEntries(string $query, int $limit = 200, int $offset = 0, ?string $since = null, ?string $until = null): array
|
||||
@@ -206,11 +249,11 @@ class Repository
|
||||
$params = [];
|
||||
|
||||
if ($since) {
|
||||
$where[] = 'e.created_at >= ?';
|
||||
$where[] = 'created_at >= ?';
|
||||
$params[] = str_replace('T', ' ', $since);
|
||||
}
|
||||
if ($until) {
|
||||
$where[] = 'e.created_at <= ?';
|
||||
$where[] = 'created_at <= ?';
|
||||
$params[] = str_replace('T', ' ', $until);
|
||||
}
|
||||
|
||||
@@ -219,29 +262,25 @@ class Repository
|
||||
if ($where) {
|
||||
$sql .= ' WHERE ' . implode(' AND ', $where);
|
||||
}
|
||||
$sql .= " ORDER BY e.created_at DESC LIMIT ? OFFSET ?";
|
||||
$sql .= " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
$stmt = $this->db->pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetchAll();
|
||||
return $this->clickhouse->query($sql, $params);
|
||||
}
|
||||
|
||||
$like = $this->toLikePattern($query);
|
||||
$where[] = 'e.line LIKE ?';
|
||||
$where[] = 'line ILIKE ?';
|
||||
$params[] = $like;
|
||||
$sql = "SELECT e.* FROM log_entries e";
|
||||
|
||||
$sql = "SELECT * FROM log_entries";
|
||||
if ($where) {
|
||||
$sql .= ' WHERE ' . implode(' AND ', $where);
|
||||
}
|
||||
$sql .= " ORDER BY e.created_at DESC LIMIT ? OFFSET ?";
|
||||
$sql .= " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
$stmt = $this->db->pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetchAll();
|
||||
return $this->clickhouse->query($sql, $params);
|
||||
}
|
||||
|
||||
private function toLikePattern(string $query): string
|
||||
@@ -265,11 +304,11 @@ class Repository
|
||||
return implode('%', $likeParts);
|
||||
}
|
||||
|
||||
// --- Config ---
|
||||
// --- Config (SQLite) ---
|
||||
|
||||
public function getAllowedUserTokens(): array
|
||||
{
|
||||
$stmt = $this->db->pdo()->prepare("SELECT value FROM config WHERE key = ?");
|
||||
$stmt = $this->pdo->prepare("SELECT value FROM config WHERE key = ?");
|
||||
$stmt->execute(['allowed_user_tokens']);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row || empty($row['value'])) {
|
||||
@@ -285,7 +324,7 @@ class Repository
|
||||
|
||||
public function getConfig(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$stmt = $this->db->pdo()->prepare("SELECT value FROM config WHERE key = ?");
|
||||
$stmt = $this->pdo->prepare("SELECT value FROM config WHERE key = ?");
|
||||
$stmt->execute([$key]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ? $row['value'] : $default;
|
||||
@@ -293,27 +332,27 @@ class Repository
|
||||
|
||||
public function setConfig(string $key, string $value): void
|
||||
{
|
||||
$stmt = $this->db->pdo()->prepare(
|
||||
$stmt = $this->pdo->prepare(
|
||||
"INSERT INTO config (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
||||
);
|
||||
$stmt->execute([$key, $value]);
|
||||
}
|
||||
|
||||
// --- Rate Limiting ---
|
||||
// --- Rate Limiting (SQLite) ---
|
||||
|
||||
public function checkRateLimit(int $ruleId, int $windowSeconds): bool
|
||||
{
|
||||
$now = time();
|
||||
$window = intdiv($now, $windowSeconds) * $windowSeconds;
|
||||
|
||||
$this->db->pdo()->prepare(
|
||||
$this->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(
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT count FROM rate_limiter WHERE rule_id = ? AND window_start = ?"
|
||||
);
|
||||
$stmt->execute([$ruleId, $window]);
|
||||
@@ -322,28 +361,28 @@ class Repository
|
||||
return $row['count'] <= 1;
|
||||
}
|
||||
|
||||
// --- False Positives ---
|
||||
// --- False Positives (SQLite) ---
|
||||
|
||||
public function getFalsePositives(): array
|
||||
{
|
||||
return $this->db->pdo()->query(
|
||||
return $this->pdo->query(
|
||||
"SELECT id, pattern, description, created_at FROM false_positives ORDER BY id"
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
public function createFalsePositive(string $pattern, string $description = ''): array
|
||||
{
|
||||
$stmt = $this->db->pdo()->prepare(
|
||||
$stmt = $this->pdo->prepare(
|
||||
"INSERT INTO false_positives (pattern, description) VALUES (?, ?)"
|
||||
);
|
||||
$stmt->execute([$pattern, $description]);
|
||||
$id = (int) $this->db->pdo()->lastInsertId();
|
||||
$id = (int) $this->pdo->lastInsertId();
|
||||
return $this->getFalsePositive($id);
|
||||
}
|
||||
|
||||
public function getFalsePositive(int $id): ?array
|
||||
{
|
||||
$stmt = $this->db->pdo()->prepare(
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT id, pattern, description, created_at FROM false_positives WHERE id = ?"
|
||||
);
|
||||
$stmt->execute([$id]);
|
||||
@@ -353,12 +392,12 @@ class Repository
|
||||
|
||||
public function deleteFalsePositive(int $id): void
|
||||
{
|
||||
$this->db->pdo()->prepare("DELETE FROM false_positives WHERE id = ?")->execute([$id]);
|
||||
$this->pdo->prepare("DELETE FROM false_positives WHERE id = ?")->execute([$id]);
|
||||
}
|
||||
|
||||
public function isFalsePositive(string $line): bool
|
||||
{
|
||||
$patterns = $this->db->pdo()->query(
|
||||
$patterns = $this->pdo->query(
|
||||
"SELECT pattern FROM false_positives"
|
||||
)->fetchAll(\PDO::FETCH_COLUMN);
|
||||
|
||||
@@ -370,11 +409,11 @@ class Repository
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Audit Log ---
|
||||
// --- Audit Log (SQLite) ---
|
||||
|
||||
public function logAudit(string $action, string $entityType, ?int $entityId = null, ?string $details = null, ?string $username = null): void
|
||||
{
|
||||
$stmt = $this->db->pdo()->prepare(
|
||||
$stmt = $this->pdo->prepare(
|
||||
"INSERT INTO audit_log (action, entity_type, entity_id, details, username) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->execute([$action, $entityType, $entityId, $details, $username]);
|
||||
@@ -382,53 +421,41 @@ class Repository
|
||||
|
||||
public function getAuditLog(int $limit = 50): array
|
||||
{
|
||||
$stmt = $this->db->pdo()->prepare(
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ?"
|
||||
);
|
||||
$stmt->execute([$limit]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
// --- Retention ---
|
||||
// --- Retention (ClickHouse TTL handles it; but keep purge for SQLite cleanup) ---
|
||||
|
||||
public function purgeOldData(int $logDays = 30, int $alertDays = 90): array
|
||||
{
|
||||
$stmt = $this->db->pdo()->prepare(
|
||||
"DELETE FROM log_entries WHERE created_at < datetime('now', ?)"
|
||||
);
|
||||
$stmt->execute(['-' . $logDays . ' days']);
|
||||
$deletedLogs = $stmt->rowCount();
|
||||
|
||||
$stmt = $this->db->pdo()->prepare(
|
||||
"DELETE FROM alerts WHERE status = 'resolved' AND created_at < datetime('now', ?)"
|
||||
);
|
||||
$stmt->execute(['-' . $alertDays . ' days']);
|
||||
$deletedAlerts = $stmt->rowCount();
|
||||
|
||||
$this->db->pdo()->exec("DELETE FROM rate_limiter WHERE window_start < " . (time() - 86400));
|
||||
|
||||
return ['log_entries_deleted' => $deletedLogs, 'alerts_deleted' => $deletedAlerts];
|
||||
$this->pdo->exec("DELETE FROM rate_limiter WHERE window_start < " . (time() - 86400));
|
||||
return ['log_entries_deleted' => -1, 'alerts_deleted' => -1, 'note' => 'ClickHouse TTL manages retention automatically'];
|
||||
}
|
||||
|
||||
// --- Log Context ---
|
||||
// --- Log Context (ClickHouse) ---
|
||||
|
||||
public function getLogContext(int $id, int $before = 5, int $after = 5): array
|
||||
{
|
||||
$beforeStmt = $this->db->pdo()->prepare(
|
||||
"SELECT * FROM log_entries WHERE id < ? ORDER BY id DESC LIMIT ?"
|
||||
$beforeRows = $this->clickhouse->query(
|
||||
"SELECT * FROM log_entries WHERE id < ? ORDER BY id DESC LIMIT ?",
|
||||
[$id, $before]
|
||||
);
|
||||
$beforeStmt->execute([$id, $before]);
|
||||
$beforeRows = array_reverse($beforeStmt->fetchAll());
|
||||
$beforeRows = array_reverse($beforeRows);
|
||||
|
||||
$current = $this->db->pdo()->prepare("SELECT * FROM log_entries WHERE id = ?");
|
||||
$current->execute([$id]);
|
||||
$currentRow = $current->fetch();
|
||||
|
||||
$afterStmt = $this->db->pdo()->prepare(
|
||||
"SELECT * FROM log_entries WHERE id > ? ORDER BY id ASC LIMIT ?"
|
||||
$currentRows = $this->clickhouse->query(
|
||||
"SELECT * FROM log_entries WHERE id = ? LIMIT 1",
|
||||
[$id]
|
||||
);
|
||||
$currentRow = $currentRows[0] ?? null;
|
||||
|
||||
$afterRows = $this->clickhouse->query(
|
||||
"SELECT * FROM log_entries WHERE id > ? ORDER BY id ASC LIMIT ?",
|
||||
[$id, $after]
|
||||
);
|
||||
$afterStmt->execute([$id, $after]);
|
||||
$afterRows = $afterStmt->fetchAll();
|
||||
|
||||
return [
|
||||
'before' => $beforeRows,
|
||||
@@ -437,34 +464,52 @@ class Repository
|
||||
];
|
||||
}
|
||||
|
||||
// --- Bulk Operations ---
|
||||
// --- Bulk Operations (ClickHouse) ---
|
||||
|
||||
public function bulkUpdateAlertStatus(array $ids, AlertStatus $status): int
|
||||
{
|
||||
if (empty($ids)) return 0;
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$stmt = $this->db->pdo()->prepare(
|
||||
"UPDATE alerts SET status = ? WHERE id IN ($placeholders)"
|
||||
$idList = implode(',', array_map('intval', $ids));
|
||||
$this->clickhouse->execute(
|
||||
"ALTER TABLE alerts UPDATE status = '{$status->value}' WHERE id IN ({$idList})"
|
||||
);
|
||||
$stmt->execute(array_merge([$status->value], $ids));
|
||||
return $stmt->rowCount();
|
||||
return count($ids);
|
||||
}
|
||||
|
||||
public function exportAlerts(array $ids): array
|
||||
{
|
||||
if (empty($ids)) return [];
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$stmt = $this->db->pdo()->prepare("SELECT * FROM alerts WHERE id IN ($placeholders) ORDER BY id");
|
||||
$stmt->execute($ids);
|
||||
return array_map(fn(array $r) => Alert::fromRow($r), $stmt->fetchAll());
|
||||
$idList = implode(',', array_map('intval', $ids));
|
||||
$rows = $this->clickhouse->query(
|
||||
"SELECT * FROM alerts WHERE id IN ({$idList}) ORDER BY id"
|
||||
);
|
||||
return array_map(fn(array $r) => $this->alertFromRow($r), $rows);
|
||||
}
|
||||
|
||||
public function exportLogs(array $ids): array
|
||||
{
|
||||
if (empty($ids)) return [];
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$stmt = $this->db->pdo()->prepare("SELECT * FROM log_entries WHERE id IN ($placeholders) ORDER BY id");
|
||||
$stmt->execute($ids);
|
||||
return $stmt->fetchAll();
|
||||
$idList = implode(',', array_map('intval', $ids));
|
||||
return $this->clickhouse->query(
|
||||
"SELECT * FROM log_entries WHERE id IN ({$idList}) ORDER BY id"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private function alertFromRow(array $row): Alert
|
||||
{
|
||||
return new Alert(
|
||||
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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ class Orchestrator
|
||||
pcntl_signal_dispatch();
|
||||
$this->fileWatcher->tick();
|
||||
$this->socketListener->tick();
|
||||
$this->repo->buffer()->tick();
|
||||
|
||||
if (time() - $this->lastCleanup > 3600) {
|
||||
$this->lastCleanup = time();
|
||||
@@ -103,6 +104,8 @@ class Orchestrator
|
||||
{
|
||||
$this->fileWatcher->stop();
|
||||
$this->socketListener->stop();
|
||||
fprintf(STDERR, "Flushing remaining buffers...\n");
|
||||
$this->repo->flush();
|
||||
fprintf(STDERR, "Worker stopped\n");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user