All checks were successful
Deploy / deploy (push) Successful in 39s
Replace Pico CSS with a custom dark design system: - Deep navy background (#090b12) with violet-indigo accent (#7b6cf5) - Outfit font for UI, JetBrains Mono for code/URLs - Custom nav with SVG icons and active-state highlighting - Pill badges with colored status dots - Uppercase letter-spaced table headers - Stat cards on replica detail page - h3 section headings with extending rule line - Refined dialog/modal styling with blur backdrop - Terminal-style SSE log stream - Progress bar with accent gradient animation - Page entrance fade-in animation - All Pico CSS variable references replaced with custom tokens Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
126 lines
4.5 KiB
HTML
126 lines
4.5 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Logs — pngx-controller{% endblock %}
|
|
{% block content %}
|
|
|
|
<div class="page-hd">
|
|
<h2>Logs</h2>
|
|
<div class="page-actions">
|
|
<button class="secondary outline"
|
|
hx-delete="/api/logs?older_than_days=90"
|
|
hx-confirm="Delete logs older than 90 days?"
|
|
hx-on::after-request="window.location.reload()">
|
|
Clear old logs
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display:flex; gap:.85rem; flex-wrap:wrap; align-items:flex-end; margin-bottom:1.25rem;">
|
|
<label style="flex:1; min-width:140px; margin:0;">
|
|
Replica
|
|
<select id="filter-replica" onchange="applyFilters()">
|
|
<option value="">All replicas</option>
|
|
{% for r in replicas %}
|
|
<option value="{{ r.id }}">{{ r.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</label>
|
|
<label style="flex:1; min-width:120px; margin:0;">
|
|
Level
|
|
<select id="filter-level" onchange="applyFilters()">
|
|
<option value="">All levels</option>
|
|
<option value="info">Info</option>
|
|
<option value="warning">Warning</option>
|
|
<option value="error">Error</option>
|
|
</select>
|
|
</label>
|
|
<label style="flex:2; min-width:180px; margin:0;">
|
|
Search
|
|
<input type="text" id="filter-q" placeholder="Full-text search…" oninput="debounceFilter()">
|
|
</label>
|
|
<button class="secondary outline" onclick="applyFilters()" style="align-self:flex-end; margin-bottom:0;">
|
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><circle cx="5" cy="5" r="3.5" stroke="currentColor" stroke-width="1.4"/><path d="M8.5 8.5L11 11" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
|
Filter
|
|
</button>
|
|
</div>
|
|
|
|
<h3>Live stream</h3>
|
|
|
|
<details open id="live-section">
|
|
<summary>Live log stream</summary>
|
|
<div id="sse-log"
|
|
hx-ext="sse"
|
|
sse-connect="/api/logs/stream"
|
|
class="sse-terminal"
|
|
style="margin-top:.75rem;">
|
|
<div sse-swap="message" hx-swap="afterbegin">
|
|
<span class="muted">Connecting to log stream…</span>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<h3>Log history</h3>
|
|
|
|
<div id="log-table-area">
|
|
{% include "partials/log_table.html" %}
|
|
</div>
|
|
|
|
<script>
|
|
let debounceTimer;
|
|
function debounceFilter() {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(applyFilters, 400);
|
|
}
|
|
|
|
function applyFilters() {
|
|
const replica = document.getElementById('filter-replica').value;
|
|
const level = document.getElementById('filter-level').value;
|
|
const q = document.getElementById('filter-q').value;
|
|
const params = new URLSearchParams();
|
|
if (replica) params.set('replica_id', replica);
|
|
if (level) params.set('level', level);
|
|
if (q) params.set('q', q);
|
|
|
|
htmx.ajax('GET', '/api/logs?' + params.toString(), {
|
|
target: '#log-table-area',
|
|
swap: 'innerHTML',
|
|
handler: function(elt, info) {
|
|
const data = JSON.parse(info.xhr.responseText);
|
|
const rows = data.map(l => `
|
|
<tr class="log-entry-${l.level || 'info'}">
|
|
<td><small style="font-family:var(--mono);">${l.created_at || ''}</small></td>
|
|
<td><span class="badge badge-${l.level}">${l.level || ''}</span></td>
|
|
<td style="font-size:.82rem;">${l.replica_id || ''}</td>
|
|
<td style="font-family:var(--mono); font-size:.8rem;">${l.doc_id || ''}</td>
|
|
<td style="font-size:.83rem;">${l.message || ''}</td>
|
|
</tr>`).join('');
|
|
elt.innerHTML = `<div class="overflow-auto"><table><thead><tr><th>Time</th><th>Level</th><th>Replica</th><th>Doc</th><th>Message</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
}
|
|
});
|
|
}
|
|
|
|
document.body.addEventListener('htmx:sseMessage', function(e) {
|
|
try {
|
|
const data = JSON.parse(e.detail.data);
|
|
const el = document.createElement('div');
|
|
const color = data.level === 'error' ? 'var(--err)' : data.level === 'warning' ? 'var(--warn)' : 'var(--ok)';
|
|
el.style.color = color;
|
|
el.style.marginBottom = '.1rem';
|
|
const ts = data.ts || '';
|
|
const lvl = (data.level || 'info').toUpperCase();
|
|
const rep = data.replica_id ? ' [r:' + data.replica_id + ']' : '';
|
|
const doc = data.doc_id ? ' [d:' + data.doc_id + ']' : '';
|
|
el.textContent = '[' + ts + '] ' + lvl + rep + doc + ' ' + (data.message || '');
|
|
const container = document.querySelector('#sse-log div[sse-swap]');
|
|
if (container) {
|
|
const first = container.querySelector('span.muted');
|
|
if (first) first.remove();
|
|
container.insertBefore(el, container.firstChild);
|
|
while (container.children.length > 200) container.removeChild(container.lastChild);
|
|
}
|
|
} catch(err) {}
|
|
return false;
|
|
});
|
|
</script>
|
|
|
|
{% endblock %}
|