Files
Personal-Finance-Management/app/templates/logs/index.html
T

540 lines
25 KiB
HTML

{% extends 'base.html' %}
{% block title %}System Logs{% endblock %}
{% block page_title %}System Logs{% endblock %}
{% block extra_css %}
<style>
/* ── Layout ─────────────────────────────────────────────────────────────────── */
.log-header {
display: flex; align-items: center; flex-wrap: wrap; gap: 10px;
margin-bottom: 16px;
}
.log-meta {
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
}
.log-meta-chip {
display: inline-flex; align-items: center; gap: 4px;
font-size: 11px; color: var(--muted); font-family: 'DM Mono', monospace;
background: #f1f5f9; border-radius: 4px; padding: 2px 8px;
}
.log-meta-chip i { font-size: 10px; }
/* ── Level pills / filter bar ────────────────────────────────────────────────── */
.level-pills {
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px;
}
.lvl-pill {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600;
cursor: pointer; border: 1.5px solid transparent; transition: all .15s;
user-select: none;
}
.lvl-pill .cnt { font-size: 11px; opacity: .8; }
.lvl-pill:not(.active) { opacity: .6; }
.lvl-pill:hover { opacity: 1; }
.lvl-pill.active { border-color: currentColor; opacity: 1; }
.lp-ALL { background:#f1f5f9; color:#475569; }
.lp-ERROR { background:#fee2e2; color:#991b1b; }
.lp-CRITICAL { background:#fce7f3; color:#9d174d; }
.lp-WARNING { background:#fef9c3; color:#854d0e; }
.lp-INFO { background:#dbeafe; color:#1e40af; }
.lp-DEBUG { background:#f0fdf4; color:#166534; }
/* ── Toolbar ────────────────────────────────────────────────────────────────── */
.log-toolbar {
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 10px; padding: 10px 14px; margin-bottom: 14px;
}
.toolbar-sep { flex: 1; min-width: 8px; }
/* ── Auto-refresh indicator ──────────────────────────────────────────────────── */
.ar-wrap {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--muted); cursor: pointer;
padding: 4px 8px; border-radius: 6px; transition: background .15s;
user-select: none;
}
.ar-wrap:hover { background: #f1f5f9; }
.ar-dot {
width: 8px; height: 8px; border-radius: 50%; background: #cbd5e1;
flex-shrink: 0; transition: background .3s;
}
.ar-dot.on { background: #10b981; animation: pulse-dot 2s infinite; }
@keyframes pulse-dot { 0%,100%{opacity:1} 50%{opacity:.35} }
.ar-countdown { font-family:'DM Mono',monospace; font-size:11px; min-width:16px; }
/* ── Log table ───────────────────────────────────────────────────────────────── */
#log-wrap {
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 12px; overflow: hidden;
}
#log-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
#log-table thead th {
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: .07em; color: var(--muted); padding: 9px 14px;
border-bottom: 1px solid var(--border); background: #f8fafc;
white-space: nowrap;
}
#log-table tbody tr { cursor: pointer; transition: background .1s; }
#log-table tbody tr:hover > td { background: #f1f5f9 !important; }
#log-table tbody td {
padding: 6px 14px; border-bottom: 1px solid #f1f5f9;
vertical-align: top;
}
#log-table tbody tr:last-child > td { border-bottom: none; }
tr.row-ERROR > td { background: #fff8f8; }
tr.row-CRITICAL > td { background: #fdf4fb; }
tr.row-WARNING > td { background: #fffdf0; }
tr.row-RAW > td { background: #fafafa; }
/* ── Column widths ───────────────────────────────────────────────────────────── */
.col-ts { width: 145px; white-space: nowrap; }
.col-lvl { width: 86px; }
.col-mod { width: 160px; }
.col-msg { }
.ts-text { font-family:'DM Mono',monospace; font-size:11px; color:var(--muted); }
.mod-text { font-family:'DM Mono',monospace; font-size:11px; color:#64748b;
max-width:150px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.msg-text { font-family:'DM Mono',monospace; font-size:12px; word-break:break-word; }
.msg-text.nowrap { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:580px; }
/* ── Level badge ─────────────────────────────────────────────────────────────── */
.lvl-badge {
display: inline-block; font-size: 10px; font-weight: 700;
padding: 1px 6px; border-radius: 4px; font-family:'DM Mono',monospace;
letter-spacing: .03em; white-space: nowrap;
}
.lb-INFO { background:#dbeafe; color:#1e40af; }
.lb-WARNING { background:#fef9c3; color:#854d0e; }
.lb-ERROR { background:#fee2e2; color:#991b1b; }
.lb-CRITICAL { background:#fce7f3; color:#9d174d; }
.lb-DEBUG { background:#f0fdf4; color:#166534; }
.lb-RAW { background:#f1f5f9; color:#64748b; }
/* ── Expanded row ────────────────────────────────────────────────────────────── */
tr.expanded-row > td {
background: #0f172a !important; padding: 0;
}
.expand-panel {
padding: 12px 16px; font-family:'DM Mono',monospace; font-size: 12px;
color: #e2e8f0; white-space: pre-wrap; word-break: break-all;
line-height: 1.6;
}
.expand-panel .ep-label {
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: .08em; color: #64748b; display: block; margin-bottom: 4px;
}
.expand-panel .ep-val { color: #e2e8f0; }
.expand-panel .ep-mod { color: #7dd3fc; }
.expand-panel .ep-ts { color: #a3e635; }
.expand-panel .ep-err { color: #fca5a5; }
.copy-btn {
font-size:11px; padding:2px 8px; border-radius:4px;
background:#1e293b; color:#94a3b8; border:1px solid #334155;
cursor:pointer; transition:all .15s; margin-top:8px;
}
.copy-btn:hover { background:#334155; color:#e2e8f0; }
/* ── Empty / loading states ──────────────────────────────────────────────────── */
.log-empty { text-align:center; padding:56px 20px; color:var(--muted); }
.log-empty i { font-size:2.5rem; opacity:.3; display:block; margin-bottom:10px; }
/* ── Spinner ─────────────────────────────────────────────────────────────────── */
#spinner { display:none; }
#spinner.on { display:inline-block; }
/* ── Wrap toggle ─────────────────────────────────────────────────────────────── */
.wrap-btn.active { background:#e0f2fe; color:#0369a1; border-color:#7dd3fc; }
/* ── Footer bar ──────────────────────────────────────────────────────────────── */
.log-footer {
display:flex; justify-content:space-between; align-items:center;
font-size:11px; color:var(--muted); padding:6px 2px; margin-top:8px;
}
</style>
{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('settings.index') }}" class="btn btn-sm btn-outline-secondary me-1" style="font-size:12px;"><i class="bi bi-arrow-left me-1"></i>Settings</a>
{% endblock %}
{% block content %}
<!-- Header -->
<div class="log-header">
<div class="log-meta">
{% if file_exists %}
<span class="log-meta-chip"><i class="bi bi-hdd"></i>{{ file_size_fmt }}</span>
<span class="log-meta-chip"><i class="bi bi-clock"></i>{{ file_mtime }}</span>
{% else %}
<span class="log-meta-chip" style="color:#ef4444;"><i class="bi bi-exclamation-triangle"></i>Log file not found</span>
{% endif %}
<span class="log-meta-chip"><i class="bi bi-database"></i>{{ db_count }} entries in DB</span>
</div>
<div class="ms-auto">
<span id="spinner" class="spinner-border spinner-border-sm text-secondary"></span>
</div>
</div>
<!-- Level filter pills -->
<div class="level-pills" id="level-pills">
<span class="lvl-pill lp-ALL active" data-level="ALL">All <span class="cnt" id="cnt-ALL"></span></span>
<span class="lvl-pill lp-ERROR" data-level="ERROR">Error <span class="cnt" id="cnt-ERROR">0</span></span>
<span class="lvl-pill lp-CRITICAL" data-level="CRITICAL">Critical <span class="cnt" id="cnt-CRITICAL">0</span></span>
<span class="lvl-pill lp-WARNING" data-level="WARNING">Warning <span class="cnt" id="cnt-WARNING">0</span></span>
<span class="lvl-pill lp-INFO" data-level="INFO">Info <span class="cnt" id="cnt-INFO">0</span></span>
<span class="lvl-pill lp-DEBUG" data-level="DEBUG">Debug <span class="cnt" id="cnt-DEBUG">0</span></span>
</div>
<!-- Toolbar -->
<div class="log-toolbar">
<div class="input-group input-group-sm" style="max-width:240px;">
<span class="input-group-text" style="background:#f8fafc;"><i class="bi bi-search" style="font-size:11px;"></i></span>
<input id="search-input" type="text" class="form-control form-control-sm"
placeholder="Search message…" autocomplete="off">
</div>
<select id="module-select" class="form-select form-select-sm" style="max-width:180px;">
<option value="">All modules</option>
</select>
<select id="limit-select" class="form-select form-select-sm" style="max-width:110px;">
<option value="100">Last 100</option>
<option value="200" selected>Last 200</option>
<option value="500">Last 500</option>
<option value="1000">Last 1 000</option>
<option value="2000">Last 2 000</option>
</select>
<button class="btn btn-sm btn-outline-secondary wrap-btn" id="wrap-btn" title="Toggle message wrapping">
<i class="bi bi-text-wrap"></i>
</button>
<div class="toolbar-sep"></div>
<div class="ar-wrap" id="ar-toggle" title="Toggle auto-refresh every 5 seconds">
<span class="ar-dot" id="ar-dot"></span>
<span>Auto-refresh</span>
<span class="ar-countdown" id="ar-countdown"></span>
</div>
<a href="{{ url_for('logs.download') }}" class="btn btn-sm btn-outline-secondary" title="Download full log file">
<i class="bi bi-download me-1"></i><span class="d-none d-sm-inline">Download</span>
</a>
<div class="dropdown">
<button class="btn btn-sm btn-outline-warning dropdown-toggle" type="button"
data-bs-toggle="dropdown" title="Purge old log entries from database">
<i class="bi bi-clock-history me-1"></i><span class="d-none d-sm-inline">Purge</span>
</button>
<ul class="dropdown-menu dropdown-menu-end" style="font-size:13px;">
<li><h6 class="dropdown-header">Delete entries older than…</h6></li>
<li><a class="dropdown-item purge-item" href="#" data-days="7">7 days</a></li>
<li><a class="dropdown-item purge-item" href="#" data-days="30">30 days</a></li>
<li><a class="dropdown-item purge-item" href="#" data-days="90">90 days</a></li>
</ul>
</div>
<button class="btn btn-sm btn-outline-danger" id="clear-btn" title="Clear all logs (DB + file)">
<i class="bi bi-trash me-1"></i><span class="d-none d-sm-inline">Clear All</span>
</button>
</div>
<!-- Log table -->
<div id="log-wrap">
<table id="log-table">
<thead>
<tr>
<th class="col-ts">Timestamp</th>
<th class="col-lvl">Level</th>
<th class="col-mod d-mob-none">Module</th>
<th class="col-msg">Message</th>
</tr>
</thead>
<tbody id="log-body">
<tr><td colspan="4"><div class="log-empty">
<i class="bi bi-hourglass-split"></i>Loading…
</div></td></tr>
</tbody>
</table>
</div>
<!-- Footer -->
<div class="log-footer">
<span id="result-count"></span>
<span id="last-refresh"></span>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
'use strict';
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content || '';
let activeLevel = 'ALL';
let wrapMessages = false;
let arEnabled = false;
let arTimer = null;
let arCountdown = 0;
let arTick = null;
let allModules = [];
let expandedRows = new Set(); // track which row indices are expanded
// ── Fetch & render ──────────────────────────────────────────────────────────
function load() {
const search = document.getElementById('search-input').value.trim();
const module = document.getElementById('module-select').value;
const limit = document.getElementById('limit-select').value;
document.getElementById('spinner').classList.add('on');
const params = new URLSearchParams({ level: activeLevel, search, module, limit });
fetch(`{{ url_for('logs.api') }}?${params}`)
.then(r => r.json())
.then(data => {
document.getElementById('spinner').classList.remove('on');
expandedRows.clear();
renderTable(data.entries || []);
renderCounts(data.counts || {});
populateModules(data.modules || []);
document.getElementById('last-refresh').textContent =
'Refreshed ' + new Date().toLocaleTimeString();
const shown = (data.entries || []).length;
const total = data.total_raw || 0;
document.getElementById('result-count').textContent =
shown + ' entr' + (shown === 1 ? 'y' : 'ies') +
(total ? ' · ' + total + ' lines in file' : '');
})
.catch(() => document.getElementById('spinner').classList.remove('on'));
}
// ── Table rendering ─────────────────────────────────────────────────────────
function esc(t) {
return (t || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function lvlBadge(lvl) {
const cls = { INFO:'lb-INFO', WARNING:'lb-WARNING', ERROR:'lb-ERROR',
CRITICAL:'lb-CRITICAL', DEBUG:'lb-DEBUG', RAW:'lb-RAW' }[lvl] || 'lb-RAW';
return `<span class="lvl-badge ${cls}">${esc(lvl)}</span>`;
}
function shortMod(name) {
// Show last two segments: app.services.teller_service → services.teller_service
const parts = (name || '').split('.');
return parts.length > 2 ? parts.slice(-2).join('.') : name;
}
function renderTable(entries) {
const tbody = document.getElementById('log-body');
if (!entries.length) {
tbody.innerHTML = `<tr><td colspan="4"><div class="log-empty">
<i class="bi bi-inbox"></i>No entries match your filters.
</div></td></tr>`;
return;
}
const wrapCls = wrapMessages ? '' : ' nowrap';
const rows = entries.map((e, i) => {
const lvl = e.level || 'RAW';
return `<tr class="row-${esc(lvl)}" data-idx="${i}" data-ts="${esc(e.ts)}" data-lvl="${esc(lvl)}" data-name="${esc(e.name)}" data-msg="${esc(e.message).replace(/"/g,'&quot;')}">
<td class="col-ts"><span class="ts-text">${esc(e.ts)}</span></td>
<td class="col-lvl">${lvlBadge(lvl)}</td>
<td class="col-mod d-mob-none"><span class="mod-text" title="${esc(e.name)}">${esc(shortMod(e.name))}</span></td>
<td class="col-msg"><span class="msg-text${wrapCls}">${esc(e.message)}</span></td>
</tr>`;
});
tbody.innerHTML = rows.join('');
}
// ── Expand row on click ─────────────────────────────────────────────────────
document.getElementById('log-body').addEventListener('click', function(e) {
const row = e.target.closest('tr[data-idx]');
if (!row) return;
// If clicking copy button inside an expanded panel, let it work
if (e.target.closest('.copy-btn')) return;
const idx = row.dataset.idx;
const next = row.nextElementSibling;
// Collapse if already expanded
if (next && next.classList.contains('expanded-row')) {
next.remove();
row.classList.remove('_expanded');
return;
}
// Collapse any other open row first
document.querySelectorAll('.expanded-row').forEach(r => r.remove());
document.querySelectorAll('._expanded').forEach(r => r.classList.remove('_expanded'));
// Build expanded panel
const ts = row.dataset.ts;
const lvl = row.dataset.lvl;
const name = row.dataset.name;
const msg = row.dataset.msg.replace(/&quot;/g, '"');
const raw = `${ts}|${lvl}|${name}|${msg}`;
const panel = document.createElement('tr');
panel.className = 'expanded-row';
panel.innerHTML = `<td colspan="4">
<div class="expand-panel">
<span class="ep-label">Timestamp</span><span class="ep-ts ep-val">${esc(ts)}</span>
<span class="ep-label" style="margin-top:8px;">Module</span><span class="ep-mod ep-val">${esc(name) || '—'}</span>
<span class="ep-label" style="margin-top:8px;">Message</span><span class="${lvl === 'ERROR' || lvl === 'CRITICAL' ? 'ep-err' : ''} ep-val">${esc(msg)}</span>
<button class="copy-btn" onclick="copyRaw(this, ${JSON.stringify(raw)})">
<i class="bi bi-clipboard me-1"></i>Copy raw line
</button>
</div>
</td>`;
row.after(panel);
row.classList.add('_expanded');
});
window.copyRaw = function(btn, text) {
navigator.clipboard.writeText(text).then(() => {
btn.innerHTML = '<i class="bi bi-check2 me-1"></i>Copied!';
setTimeout(() => { btn.innerHTML = '<i class="bi bi-clipboard me-1"></i>Copy raw line'; }, 1500);
});
};
// ── Counts & module dropdown ────────────────────────────────────────────────
function renderCounts(counts) {
const total = Object.values(counts).reduce((s, v) => s + v, 0);
document.getElementById('cnt-ALL').textContent = total || 0;
document.getElementById('cnt-ERROR').textContent = counts['ERROR'] || 0;
document.getElementById('cnt-CRITICAL').textContent = counts['CRITICAL'] || 0;
document.getElementById('cnt-WARNING').textContent = counts['WARNING'] || 0;
document.getElementById('cnt-INFO').textContent = counts['INFO'] || 0;
document.getElementById('cnt-DEBUG').textContent = counts['DEBUG'] || 0;
}
function populateModules(modules) {
if (JSON.stringify(modules) === JSON.stringify(allModules)) return;
allModules = modules;
const sel = document.getElementById('module-select');
const cur = sel.value;
sel.innerHTML = '<option value="">All modules</option>' +
modules.map(m => `<option value="${esc(m)}"${m === cur ? ' selected' : ''}>${esc(m)}</option>`).join('');
}
// ── Level pill clicks ───────────────────────────────────────────────────────
document.getElementById('level-pills').addEventListener('click', function(e) {
const pill = e.target.closest('.lvl-pill');
if (!pill) return;
document.querySelectorAll('.lvl-pill').forEach(p => p.classList.remove('active'));
pill.classList.add('active');
activeLevel = pill.dataset.level;
load();
});
// ── Search / filter / limit ─────────────────────────────────────────────────
let debounce;
['search-input', 'module-select', 'limit-select'].forEach(id => {
const el = document.getElementById(id);
const ev = id === 'limit-select' || id === 'module-select' ? 'change' : 'input';
el.addEventListener(ev, () => {
clearTimeout(debounce);
debounce = setTimeout(load, 280);
});
});
// ── Wrap toggle ─────────────────────────────────────────────────────────────
document.getElementById('wrap-btn').addEventListener('click', function() {
wrapMessages = !wrapMessages;
this.classList.toggle('active', wrapMessages);
document.querySelectorAll('.msg-text').forEach(el => {
el.classList.toggle('nowrap', !wrapMessages);
});
});
// ── Auto-refresh ────────────────────────────────────────────────────────────
const AR_INTERVAL = 5;
function startAR() {
arEnabled = true;
document.getElementById('ar-dot').classList.add('on');
arCountdown = AR_INTERVAL;
document.getElementById('ar-countdown').textContent = arCountdown + 's';
arTick = setInterval(() => {
arCountdown--;
if (arCountdown <= 0) {
arCountdown = AR_INTERVAL;
load();
}
document.getElementById('ar-countdown').textContent = arCountdown + 's';
}, 1000);
}
function stopAR() {
arEnabled = false;
clearInterval(arTick);
document.getElementById('ar-dot').classList.remove('on');
document.getElementById('ar-countdown').textContent = '';
}
document.getElementById('ar-toggle').addEventListener('click', () => {
arEnabled ? stopAR() : startAR();
});
// ── Clear ───────────────────────────────────────────────────────────────────
document.getElementById('clear-btn').addEventListener('click', function() {
if (!confirm('Clear ALL log entries from the database and log file?\nThis cannot be undone.')) return;
fetch('{{ url_for("logs.clear") }}', {
method: 'POST',
headers: { 'X-CSRFToken': CSRF, 'Content-Type': 'application/json' },
})
.then(r => r.json())
.then(d => {
if (d.status === 'ok') { load(); location.reload(); }
else alert('Clear failed: ' + d.error);
});
});
// ── Purge ───────────────────────────────────────────────────────────────────
document.querySelectorAll('.purge-item').forEach(function(el) {
el.addEventListener('click', function(e) {
e.preventDefault();
const days = this.dataset.days;
if (!confirm(`Delete all log entries older than ${days} days?\nThis cannot be undone.`)) return;
const fd = new FormData();
fd.append('days', days);
fetch('{{ url_for("logs.purge") }}', {
method: 'POST',
headers: { 'X-CSRFToken': CSRF },
body: fd,
})
.then(r => r.json())
.then(d => {
if (d.status === 'ok') {
load();
// Update DB count chip
const chip = document.querySelector('.log-meta-chip .bi-database');
if (chip) chip.parentElement.innerHTML = `<i class="bi bi-database"></i> refreshed`;
} else {
alert('Purge failed: ' + d.error);
}
});
});
});
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if (e.key === 'r' || e.key === 'R') load();
if (e.key === 'a' || e.key === 'A') {
arEnabled ? stopAR() : startAR();
}
if (e.key === 'Escape') {
document.querySelectorAll('.expanded-row').forEach(r => r.remove());
document.querySelectorAll('._expanded').forEach(r => r.classList.remove('_expanded'));
}
});
// ── Init ────────────────────────────────────────────────────────────────────
load();
})();
</script>
{% endblock %}