06/01 Add log viewer

This commit is contained in:
2026-06-01 11:09:06 -04:00
parent 82ea589b41
commit acf4d9bb43
5 changed files with 454 additions and 24 deletions
+7
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="csrf-token" content="{{ csrf_token() }}" />
<title>{% block title %}PFM{% endblock %} — Personal Finance</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
@@ -260,6 +261,12 @@
>
<i class="bi bi-tags"></i><span class="lt">Categories</span>
</a>
<a
href="{{ url_for('logs.index') }}"
class="sb-link {% if request.blueprint == 'logs' %}active{% endif %}"
>
<i class="bi bi-terminal"></i><span class="lt">System Logs</span>
</a>
<a
href="{{ url_for('settings.index') }}"
class="sb-link {% if request.blueprint == 'settings' %}active{% endif %}"
+272
View File
@@ -0,0 +1,272 @@
{% extends 'base.html' %}
{% block title %}System Logs{% endblock %}
{% block page_title %}System Logs{% endblock %}
{% block extra_css %}
<style>
.log-toolbar {
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 12px; padding: 14px 16px; margin-bottom: 16px;
}
.log-toolbar .sep { flex: 1; }
.level-badge {
display: inline-block; font-size: 11px; font-weight: 600;
padding: 2px 7px; border-radius: 4px; font-family: 'DM Mono', monospace;
white-space: nowrap;
}
.level-INFO { background: #dbeafe; color: #1e40af; }
.level-WARNING { background: #fef9c3; color: #854d0e; }
.level-ERROR { background: #fee2e2; color: #991b1b; }
.level-CRITICAL { background: #fce7f3; color: #9d174d; }
.level-DEBUG { background: #f0fdf4; color: #166534; }
.level-RAW { background: #f1f5f9; color: #475569; }
.stat-pill {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px; border-radius: 20px; font-size: 12px;
font-weight: 600; cursor: pointer; border: 1.5px solid transparent;
transition: all .15s;
}
.stat-pill:hover, .stat-pill.active { border-color: currentColor; opacity: 1; }
.stat-pill { opacity: .75; }
.pill-ALL { background: #f1f5f9; color: #475569; }
.pill-INFO { background: #dbeafe; color: #1e40af; }
.pill-WARNING { background: #fef9c3; color: #854d0e; }
.pill-ERROR { background: #fee2e2; color: #991b1b; }
.pill-CRITICAL { background: #fce7f3; color: #9d174d; }
.pill-DEBUG { background: #f0fdf4; color: #166534; }
#log-table-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: 600; text-transform: uppercase;
letter-spacing: .07em; color: var(--muted); padding: 10px 14px;
border-bottom: 1px solid var(--border); white-space: nowrap;
background: #f8fafc;
}
#log-table tbody td {
padding: 7px 14px; border-bottom: 1px solid var(--border);
vertical-align: top; word-break: break-word;
}
#log-table tbody tr:last-child td { border-bottom: none; }
#log-table tbody tr:hover { background: #f8fafc; }
#log-table tbody tr.row-ERROR td { background: #fff5f5; }
#log-table tbody tr.row-CRITICAL td { background: #fdf2f8; }
#log-table tbody tr.row-WARNING td { background: #fffbeb; }
.ts-col { white-space: nowrap; color: var(--muted); font-family: 'DM Mono', monospace; font-size: 11px; width: 148px; }
.name-col { color: var(--muted); font-family: 'DM Mono', monospace; font-size: 11px; width: 220px; }
.msg-col { font-family: 'DM Mono', monospace; }
#empty-state {
text-align: center; padding: 60px 20px; color: var(--muted);
}
.auto-refresh-dot {
width: 8px; height: 8px; border-radius: 50%;
background: #94a3b8; display: inline-block;
transition: background .3s;
}
.auto-refresh-dot.on { background: #10b981; animation: pulse-dot 2s infinite; }
@keyframes pulse-dot {
0%, 100% { opacity: 1; } 50% { opacity: .4; }
}
#spinner { display: none; }
#spinner.on { display: inline-block; }
</style>
{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('logs.download') }}" class="btn btn-sm btn-outline-secondary" title="Download log file">
<i class="bi bi-download"></i>
</a>
{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-3">
<h5 class="mb-0 fw-semibold">Application Logs</h5>
<span id="spinner" class="spinner-border spinner-border-sm text-secondary ms-1"></span>
<span class="small text-muted ms-auto mono" id="log-file-path">
{% if file_exists %}{{ log_file }}{% else %}Log file not found{% endif %}
</span>
</div>
<!-- Stats bar -->
<div class="d-flex flex-wrap gap-2 mb-3" id="stats-bar">
<span class="stat-pill pill-ALL active" data-level="ALL">All <span class="ms-1" id="cnt-ALL"></span></span>
<span class="stat-pill pill-ERROR" data-level="ERROR">Error <span class="ms-1" id="cnt-ERROR">0</span></span>
<span class="stat-pill pill-WARNING" data-level="WARNING">Warning <span class="ms-1" id="cnt-WARNING">0</span></span>
<span class="stat-pill pill-INFO" data-level="INFO">Info <span class="ms-1" id="cnt-INFO">0</span></span>
<span class="stat-pill pill-DEBUG" data-level="DEBUG">Debug <span class="ms-1" id="cnt-DEBUG">0</span></span>
</div>
<!-- Toolbar -->
<div class="log-toolbar">
<input id="search-input" type="text" class="form-control form-control-sm"
placeholder="Search message or module…" style="max-width:240px;">
<input id="module-input" type="text" class="form-control form-control-sm"
placeholder="Module filter (e.g. teller)" style="max-width:180px;">
<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 1000</option>
</select>
<div class="sep"></div>
<label class="d-flex align-items-center gap-2 small text-muted mb-0" style="cursor:pointer;">
<span class="auto-refresh-dot" id="ar-dot"></span>
<input type="checkbox" id="auto-refresh" class="d-none"> Auto-refresh
</label>
<button class="btn btn-sm btn-outline-danger" id="clear-btn" title="Clear log file">
<i class="bi bi-trash"></i> Clear
</button>
</div>
<!-- Log table -->
<div id="log-table-wrap">
<table id="log-table">
<thead>
<tr>
<th class="ts-col">Timestamp</th>
<th style="width:90px">Level</th>
<th class="name-col">Module</th>
<th>Message</th>
</tr>
</thead>
<tbody id="log-body">
<tr><td colspan="4" id="empty-state">
<i class="bi bi-hourglass-split fs-3 d-block mb-2 opacity-25"></i>
Loading…
</td></tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center mt-2 small text-muted px-1">
<span id="result-count"></span>
<span id="last-refresh"></span>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content || '';
let activeLevel = 'ALL';
let arTimer = null;
// ── fetch & render ────────────────────────────────────────────────────────
function load() {
const search = document.getElementById('search-input').value.trim();
const module = document.getElementById('module-input').value.trim();
const limit = document.getElementById('limit-select').value;
const spinner = document.getElementById('spinner');
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 => {
spinner.classList.remove('on');
renderTable(data.entries || []);
renderCounts(data.counts || {});
document.getElementById('last-refresh').textContent =
'Updated ' + new Date().toLocaleTimeString();
document.getElementById('result-count').textContent =
`Showing ${(data.entries||[]).length} of ${data.total_raw || 0} lines`;
})
.catch(() => spinner.classList.remove('on'));
}
function renderTable(entries) {
const tbody = document.getElementById('log-body');
if (!entries.length) {
tbody.innerHTML = `<tr><td colspan="4" id="empty-state">
<i class="bi bi-inbox fs-3 d-block mb-2 opacity-25"></i>
No log entries match your filters.
</td></tr>`;
return;
}
const rows = entries.map(e => {
const lvl = e.level || 'RAW';
const safe = t => (t||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return `<tr class="row-${lvl}">
<td class="ts-col">${safe(e.ts)}</td>
<td><span class="level-badge level-${lvl}">${lvl}</span></td>
<td class="name-col" title="${safe(e.name)}">${safe(e.name.split('.').pop())}</td>
<td class="msg-col">${safe(e.message)}</td>
</tr>`;
});
tbody.innerHTML = rows.join('');
}
function renderCounts(counts) {
const total = Object.values(counts).reduce((s,v)=>s+v, 0);
document.getElementById('cnt-ALL').textContent = total;
document.getElementById('cnt-ERROR').textContent = counts['ERROR'] || 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;
}
// ── level pills ───────────────────────────────────────────────────────────
document.getElementById('stats-bar').addEventListener('click', function (e) {
const pill = e.target.closest('.stat-pill');
if (!pill) return;
document.querySelectorAll('.stat-pill').forEach(p => p.classList.remove('active'));
pill.classList.add('active');
activeLevel = pill.dataset.level;
load();
});
// ── search / filter inputs ────────────────────────────────────────────────
let debounce;
['search-input','module-input','limit-select'].forEach(id => {
document.getElementById(id).addEventListener('input', function () {
clearTimeout(debounce);
debounce = setTimeout(load, 300);
});
});
// ── auto-refresh ──────────────────────────────────────────────────────────
const arCheckbox = document.getElementById('auto-refresh');
const arDot = document.getElementById('ar-dot');
document.querySelector('label[for="auto-refresh"]') ||
document.querySelector('label').addEventListener; // noop
// toggle by clicking the label area
document.querySelector('.auto-refresh-dot').parentElement.addEventListener('click', function () {
arCheckbox.checked = !arCheckbox.checked;
arDot.classList.toggle('on', arCheckbox.checked);
if (arCheckbox.checked) {
arTimer = setInterval(load, 5000);
} else {
clearInterval(arTimer);
}
});
// ── clear ─────────────────────────────────────────────────────────────────
document.getElementById('clear-btn').addEventListener('click', function () {
if (!confirm('Clear all log entries from the file? This 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();
else alert('Clear failed: ' + d.error);
});
});
// ── init ──────────────────────────────────────────────────────────────────
load();
})();
</script>
{% endblock %}