Files
pngx-sync/app/templates/logs.html
domverse 7b1a85bc84
All checks were successful
Deploy / deploy (push) Successful in 39s
feat: full UI overhaul — dark theme with custom design system
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>
2026-03-26 22:52:42 +01:00

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&hellip;" 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&hellip;</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 %}