Compare commits
8 Commits
f2e76bf07b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 037d2f114b | |||
| 29fc12385e | |||
| 9b19c1e6b4 | |||
| 71cb3beeee | |||
| a41971a11e | |||
| 2f2037f4da | |||
| 0d61e623a7 | |||
| 4fd6bfa8da |
+7
-9
@@ -11,8 +11,7 @@ services:
|
|||||||
- ./composer.json:/app/composer.json
|
- ./composer.json:/app/composer.json
|
||||||
- data:/app/data
|
- data:/app/data
|
||||||
depends_on:
|
depends_on:
|
||||||
clickhouse:
|
- clickhouse
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
@@ -42,8 +41,7 @@ services:
|
|||||||
- data:/app/data
|
- data:/app/data
|
||||||
- log_collect:/collect
|
- log_collect:/collect
|
||||||
depends_on:
|
depends_on:
|
||||||
clickhouse:
|
- clickhouse
|
||||||
condition: service_healthy
|
|
||||||
command: ["php", "bin/consume", "--daemon"]
|
command: ["php", "bin/consume", "--daemon"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
@@ -54,15 +52,15 @@ services:
|
|||||||
- "9000:9000"
|
- "9000:9000"
|
||||||
volumes:
|
volumes:
|
||||||
- clickhouse_data:/var/lib/clickhouse
|
- clickhouse_data:/var/lib/clickhouse
|
||||||
|
environment:
|
||||||
|
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||||
|
CLICKHOUSE_PASSWORD: ""
|
||||||
|
CLICKHOUSE_USER: default
|
||||||
ulimits:
|
ulimits:
|
||||||
nofile:
|
nofile:
|
||||||
soft: 262144
|
soft: 262144
|
||||||
hard: 262144
|
hard: 262144
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
|
|||||||
@@ -2,16 +2,4 @@
|
|||||||
mkdir -p /app/data
|
mkdir -p /app/data
|
||||||
chmod -R 777 /app/data
|
chmod -R 777 /app/data
|
||||||
rm -f /app/data/*.lock
|
rm -f /app/data/*.lock
|
||||||
|
|
||||||
# Wait for ClickHouse to be ready
|
|
||||||
echo "Waiting for ClickHouse..."
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
if wget --spider -q http://clickhouse:8123/ping 2>/dev/null; then
|
|
||||||
echo "ClickHouse is ready"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo " attempt $i..."
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
exec docker-php-entrypoint "$@"
|
exec docker-php-entrypoint "$@"
|
||||||
+178
-83
@@ -154,6 +154,8 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2 align-items-center mb-2" id="bulkBar" style="display:none">
|
<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>
|
<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-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-secondary btn-sm" onclick="bulkAction('resolved')"><i class="bi bi-check-all"></i> Resolve</button>
|
||||||
<button class="btn btn-outline-info btn-sm" onclick="exportAlerts('json')"><i class="bi bi-download"></i> Export JSON</button>
|
<button class="btn btn-outline-info btn-sm" onclick="exportAlerts('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">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover table-sm mb-0" id="alertsTable">
|
<table class="table table-hover table-sm mb-0" id="alertsTable">
|
||||||
<thead class="table-dark"><tr>
|
<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: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 style="width:100px;cursor:pointer" onclick="sortAlerts('status')">Status <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
|
||||||
<th>Message</th>
|
<th>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="page-section" id="page-settings">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<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>
|
<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>
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
|
<!-- Left column -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
|
|
||||||
|
<!-- Database status -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header"><i class="bi bi-info-circle me-1"></i>System Info</div>
|
<div class="card-header"><i class="bi bi-database me-1"></i>Database</div>
|
||||||
<div class="card-body">
|
<div class="card-body py-2">
|
||||||
<dl class="row mb-0">
|
<div class="row g-2">
|
||||||
<dt class="col-sm-4">Health</dt><dd class="col-sm-8"><span id="sysHealth" class="badge bg-secondary">checking...</span></dd>
|
<div class="col-6"><small class="text-secondary d-block">SQLite</small><span id="sysSqlite" class="badge bg-secondary">checking...</span></div>
|
||||||
<dt class="col-sm-4">DB Size</dt><dd class="col-sm-8"><span id="sysDbSize">—</span></dd>
|
<div class="col-6"><small class="text-secondary d-block">ClickHouse</small><span id="sysClickhouse" class="badge bg-secondary">checking...</span></div>
|
||||||
<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>
|
<div class="col-6"><small class="text-secondary d-block">Health</small><span id="sysHealth" class="badge bg-secondary">checking...</span></div>
|
||||||
<dt class="col-sm-4">Logged in as</dt><dd class="col-sm-8" id="settingsUser">—</dd>
|
<div class="col-6"><small class="text-secondary d-block">DB Size</small><span id="sysDbSize">—</span></div>
|
||||||
<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>
|
||||||
<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>
|
|
||||||
<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-header"><i class="bi bi-clock-history me-1"></i>Data Retention</div>
|
||||||
<div class="card-body">
|
<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="row g-2 mb-2">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<label class="form-label">Keep logs (days)</label>
|
<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>
|
<small id="retentionResult" class="ms-2"></small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card mt-3">
|
|
||||||
<div class="card-header"><i class="bi bi-journal-text me-1"></i>Audit Log</div>
|
<!-- False Positives -->
|
||||||
<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">
|
|
||||||
<div class="card mb-3">
|
<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="card-body">
|
||||||
<div class="mb-3">
|
<div class="table-responsive mb-2" style="max-height:240px;overflow-y:auto">
|
||||||
<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">
|
|
||||||
<table class="table table-sm mb-0">
|
<table class="table table-sm mb-0">
|
||||||
<thead><tr><th>Pattern</th><th>Description</th><th style="width:50px"></th></tr></thead>
|
<thead><tr><th>Pattern</th><th>Description</th><th style="width:50px"></th></tr></thead>
|
||||||
<tbody id="falsePositivesBody"></tbody>
|
<tbody id="falsePositivesBody"></tbody>
|
||||||
@@ -358,22 +318,86 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
|||||||
</div>
|
</div>
|
||||||
<div class="input-group input-group-sm">
|
<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 font-monospace" id="fpPattern" placeholder="/healthcheck/i">
|
||||||
<input type="text" class="form-control" id="fpDescription" placeholder="Description (optional)" style="max-width:200px">
|
<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> Add</button>
|
<button class="btn btn-primary" id="addFpBtn"><i class="bi bi-plus-lg"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
<!-- Audit Log -->
|
||||||
<div class="card-body small">
|
<div class="card mb-3">
|
||||||
<ol class="mb-0 ps-3">
|
<div class="card-header"><i class="bi bi-journal-text me-1"></i>Audit Log</div>
|
||||||
<li>Log in at <a href="https://auth.jakach.ch" target="_blank">auth.jakach.ch</a></li>
|
<div class="card-body p-0">
|
||||||
<li>Your <code>user_token</code> is shown in your profile</li>
|
<div class="table-responsive" style="max-height:250px;overflow-y:auto">
|
||||||
<li>Paste it above to grant access</li>
|
<table class="table table-sm mb-0">
|
||||||
</ol>
|
<tbody id="auditLogBody"><tr><td class="text-secondary small text-center">Loading...</td></tr></tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
|
||||||
|
<!-- Security -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><i class="bi bi-shield-check me-1"></i>Security</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Allowed User Tokens <i class="bi bi-question-circle text-secondary" title="Only these Jakach user tokens can access this system. Leave empty to allow any authenticated Jakach user."></i></label>
|
||||||
|
<textarea class="form-control font-monospace" id="allowedTokensInput" rows="3" placeholder="One user_token per line"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<button class="btn btn-primary btn-sm" id="saveTokensBtn"><i class="bi bi-floppy"></i> Save</button>
|
||||||
|
<small id="tokenSaveStatus"></small>
|
||||||
|
<span class="ms-auto text-secondary small">Your token: <code id="settingsUserToken" class="user-select-all">—</code></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Telegram -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-telegram me-1"></i>Telegram Notifications</span>
|
||||||
|
<span id="telegramStatusBadge"></span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Bot Token</label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="password" class="form-control" id="telegramBotToken" placeholder="Enter bot token">
|
||||||
|
<button class="btn btn-outline-secondary" id="telegramTokenToggle" onclick="toggleTelegramToken()"><i class="bi bi-eye"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Chat ID</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="telegramChatId" placeholder="-1001234567890">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<button class="btn btn-primary btn-sm" id="saveTelegramBtn"><i class="bi bi-floppy"></i> Save</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" id="testTelegramBtn"><i class="bi bi-send"></i> Test</button>
|
||||||
|
<small id="telegramSaveStatus"></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><i class="bi bi-info-circle me-1"></i>System</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0 small">
|
||||||
|
<dt class="col-sm-4">Logged in as</dt><dd class="col-sm-8" id="settingsUser">—</dd>
|
||||||
|
<dt class="col-sm-4">Auth Server</dt><dd class="col-sm-8"><a href="https://auth.jakach.ch" target="_blank">auth.jakach.ch</a></dd>
|
||||||
|
<dt class="col-sm-4">File sources</dt><dd class="col-sm-8">path on worker container</dd>
|
||||||
|
<dt class="col-sm-4">TCP/UDP</dt><dd class="col-sm-8"><code>tcp://0.0.0.0:9514</code></dd>
|
||||||
|
<dt class="col-sm-4">Rules</dt><dd class="col-sm-8">PHP regex, e.g. <code>/error/i</code></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1031,27 +1055,43 @@ async function loadSettings() {
|
|||||||
const health = await api('/health');
|
const health = await api('/health');
|
||||||
document.getElementById('sysHealth').textContent = health.status === 'ok' ? 'Healthy' : 'Degraded';
|
document.getElementById('sysHealth').textContent = health.status === 'ok' ? 'Healthy' : 'Degraded';
|
||||||
document.getElementById('sysHealth').className = 'badge bg-' + (health.status === 'ok' ? 'success' : 'warning');
|
document.getElementById('sysHealth').className = 'badge bg-' + (health.status === 'ok' ? 'success' : 'warning');
|
||||||
if (health.db_size) {
|
document.getElementById('sysSqlite').textContent = health.sqlite === 'connected' ? 'Connected' : 'Error';
|
||||||
document.getElementById('sysDbSize').textContent = health.db_size;
|
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 {
|
} catch {
|
||||||
document.getElementById('sysHealth').textContent = 'Unreachable';
|
document.getElementById('sysHealth').textContent = 'Unreachable';
|
||||||
document.getElementById('sysHealth').className = 'badge bg-danger';
|
document.getElementById('sysHealth').className = 'badge bg-danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api('/config/allowed_tokens');
|
const [tokensRes, meRes] = await Promise.all([
|
||||||
const tokens = res.tokens || [];
|
api('/config/allowed_tokens'),
|
||||||
|
api('/auth/me'),
|
||||||
|
]);
|
||||||
|
const tokens = tokensRes.tokens || [];
|
||||||
document.getElementById('allowedTokensInput').value = tokens.join('\n');
|
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); }
|
} catch (e) { console.error('load tokens error', e); }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api('/config/telegram');
|
const res = await api('/config/telegram');
|
||||||
document.getElementById('telegramBotToken').value = '';
|
document.getElementById('telegramBotToken').value = '';
|
||||||
document.getElementById('telegramBotToken').placeholder = res.bot_token_configured
|
document.getElementById('telegramBotToken').placeholder = res.bot_token_configured
|
||||||
? (res.bot_token_masked || 'Token configured')
|
? 'Token configured (enter new to change)'
|
||||||
: 'Enter bot token';
|
: 'Enter bot token';
|
||||||
document.getElementById('telegramChatId').value = res.chat_id || '';
|
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); }
|
} catch (e) { console.error('load telegram error', e); }
|
||||||
|
|
||||||
try {
|
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 () => {
|
document.getElementById('saveTelegramBtn').addEventListener('click', async () => {
|
||||||
const botToken = document.getElementById('telegramBotToken').value.trim();
|
const botToken = document.getElementById('telegramBotToken').value.trim();
|
||||||
const chatId = document.getElementById('telegramChatId').value.trim();
|
const chatId = document.getElementById('telegramChatId').value.trim();
|
||||||
@@ -1296,8 +1348,41 @@ let selectedAlertIds = new Set();
|
|||||||
function toggleAlertSelection(id) {
|
function toggleAlertSelection(id) {
|
||||||
if (selectedAlertIds.has(id)) selectedAlertIds.delete(id);
|
if (selectedAlertIds.has(id)) selectedAlertIds.delete(id);
|
||||||
else selectedAlertIds.add(id);
|
else selectedAlertIds.add(id);
|
||||||
|
updateBulkBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBulkBar() {
|
||||||
document.getElementById('bulkBar').classList.toggle('d-none', selectedAlertIds.size === 0);
|
document.getElementById('bulkBar').classList.toggle('d-none', selectedAlertIds.size === 0);
|
||||||
document.getElementById('selectedCount').textContent = selectedAlertIds.size;
|
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) {
|
function renderAlerts(query) {
|
||||||
@@ -1345,7 +1430,7 @@ function renderAlerts(query) {
|
|||||||
}
|
}
|
||||||
const label = document.getElementById('searchInput').value.trim() ? 'search results' : 'alerts';
|
const label = document.getElementById('searchInput').value.trim() ? 'search results' : 'alerts';
|
||||||
document.getElementById('alertsCount').textContent = sorted.length + ' ' + label;
|
document.getElementById('alertsCount').textContent = sorted.length + ' ' + label;
|
||||||
document.getElementById('bulkBar').classList.toggle('d-none', selectedAlertIds.size === 0);
|
updateBulkBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bulkAction(status) {
|
async function bulkAction(status) {
|
||||||
@@ -1363,8 +1448,18 @@ async function exportAlerts(format) {
|
|||||||
const ids = Array.from(selectedAlertIds);
|
const ids = Array.from(selectedAlertIds);
|
||||||
if (!ids.length) { toast('Select alerts first', 'warning'); return; }
|
if (!ids.length) { toast('Select alerts first', 'warning'); return; }
|
||||||
try {
|
try {
|
||||||
const res = await api('/alerts/export', { method: 'POST', body: JSON.stringify({ ids, format }) });
|
const res = await api('/alerts/export', { method: 'POST', body: JSON.stringify({ ids, format: 'json' }) });
|
||||||
if (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 blob = new Blob([JSON.stringify(res, null, 2)], { type: 'application/json' });
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = URL.createObjectURL(blob);
|
a.href = URL.createObjectURL(blob);
|
||||||
|
|||||||
+4
-33
@@ -512,48 +512,19 @@ class Router
|
|||||||
return $this->repo->getLogContext($id, min($before, 50), min($after, 50));
|
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);
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
$ids = $body['ids'] ?? [];
|
$ids = $body['ids'] ?? [];
|
||||||
$format = $body['format'] ?? 'json';
|
|
||||||
$alerts = $this->repo->exportAlerts($ids);
|
$alerts = $this->repo->exportAlerts($ids);
|
||||||
|
return array_map(fn($a) => $a->toArray(), $alerts);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function exportLogs(): never
|
private function exportLogs(): array
|
||||||
{
|
{
|
||||||
$body = json_decode(file_get_contents('php://input'), true);
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
$ids = $body['ids'] ?? [];
|
$ids = $body['ids'] ?? [];
|
||||||
$format = $body['format'] ?? 'json';
|
return $this->repo->exportLogs($ids);
|
||||||
$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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function runRetention(): array
|
private function runRetention(): array
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class ClickHouseClient
|
|||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_URL => $this->url() . '?' . http_build_query([
|
CURLOPT_URL => $this->url() . '?' . http_build_query([
|
||||||
'database' => $this->database,
|
'database' => $this->database,
|
||||||
'default_format' => 'JSONCompact',
|
'default_format' => 'JSON',
|
||||||
]),
|
]),
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
CURLOPT_POSTFIELDS => $sql,
|
CURLOPT_POSTFIELDS => $sql,
|
||||||
@@ -167,7 +167,26 @@ class ClickHouseClient
|
|||||||
|
|
||||||
public function migrate(): void
|
public function migrate(): void
|
||||||
{
|
{
|
||||||
$this->execute("CREATE DATABASE IF NOT EXISTS {$this->database}");
|
$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("
|
$this->execute("
|
||||||
CREATE TABLE IF NOT EXISTS log_entries (
|
CREATE TABLE IF NOT EXISTS log_entries (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace Jakach\Logging\Storage;
|
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
|
class Repository
|
||||||
{
|
{
|
||||||
@@ -142,6 +142,8 @@ class Repository
|
|||||||
sourceName: $sourceName,
|
sourceName: $sourceName,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->buffer->flushAlerts();
|
||||||
|
|
||||||
$createdAt = new \DateTimeImmutable($result['created_at']);
|
$createdAt = new \DateTimeImmutable($result['created_at']);
|
||||||
|
|
||||||
return new Alert(
|
return new Alert(
|
||||||
@@ -233,6 +235,7 @@ class Repository
|
|||||||
public function storeLogEntry(string $line, ?int $sourceId = null, ?string $sourceName = null, ?string $level = null): void
|
public function storeLogEntry(string $line, ?int $sourceId = null, ?string $sourceName = null, ?string $level = null): void
|
||||||
{
|
{
|
||||||
$this->buffer->pushLog($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
|
public function searchLogEntries(string $query, int $limit = 200, int $offset = 0, ?string $since = null, ?string $until = null): array
|
||||||
|
|||||||
Reference in New Issue
Block a user