Add owner-only logging and Brickset API call tracking

LIM configured no logging at all, so the instruction fetcher had no trail to follow. Adds a rotating file handler on the root logger plus two owner-only admin pages.

- Admin > Logs: filter by level, source and text; tracebacks kept with their error; auto-refresh, raw download, rotated-file browsing and rotate-now

- Admin > Logs > Brickset API calls: every outbound call recorded with method, params, caller, duration, result count and error, plus daily quota accounting (only getSets counts) and a comparison against getKeyUsageStats

- New owner concept (services/ownership.py): a single account, distinct from is_admin, since these pages expose other admins activity. Defaults to the first admin; flask owner show/set to change

- Services now log through module loggers; retention setting and pruning for call records

- New CLI: flask owner show/set, flask logs path/prune

- New table brickset_api_calls, created automatically at boot; LOG_* env vars, /data/logs in Docker
This commit is contained in:
jessikitty committed 2026-09-27 10:42:49 +10:00
1 parent 89f65e2d74
commit f7c20de5ac
27 files changed
+1914 -35

No files matched your search

+222
View File
@@ -0,0 +1,222 @@
{# Application log viewer - owner only.
Created by: Jess Rogerson (yelling commands at Claude.AI) #}
{% extends "base.html" %}
{% block title %}Logs - Admin{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col">
<h1><i class="bi bi-journal-text"></i> Logs</h1>
<p class="text-muted mb-0">
<code>{{ log_path }}</code>
</p>
</div>
<div class="col-auto">
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back
</a>
</div>
</div>
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link active" href="{{ url_for('logs.index') }}">
<i class="bi bi-journal-text"></i> Application log
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('logs.brickset') }}">
<i class="bi bi-cloud"></i> Brickset API calls
</a>
</li>
</ul>
{% if not configured %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
No log file handler is active — check <code>LOG_FOLDER</code> is writable,
then restart LIM.
</div>
{% endif %}
<div class="card mb-3">
<div class="card-body">
<form method="GET" class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label small mb-1" for="level">Minimum level</label>
<select class="form-select form-select-sm" id="level" name="level">
{% for option in levels %}
<option value="{{ option }}" {% if option == level %}selected{% endif %}>
{{ option }}
</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="source">Source</label>
<select class="form-select form-select-sm" id="source" name="source">
<option value="">Everything</option>
<option value="app.services.instruction_fetcher"
{% if source == 'app.services.instruction_fetcher' %}selected{% endif %}>
Instruction fetcher
</option>
<option value="app.services.fetch_worker"
{% if source == 'app.services.fetch_worker' %}selected{% endif %}>
Fetch worker
</option>
<option value="app.services.brickset_api"
{% if source == 'app.services.brickset_api' %}selected{% endif %}>
Brickset API
</option>
<option value="app.services"
{% if source == 'app.services' %}selected{% endif %}>
All services
</option>
</select>
</div>
<div class="col">
<label class="form-label small mb-1" for="q">Contains</label>
<input type="text" class="form-control form-control-sm" id="q"
name="q" value="{{ search }}" placeholder="text to match">
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="lines">Lines</label>
<input type="number" class="form-control form-control-sm" id="lines"
name="lines" value="{{ lines }}" min="25" max="2000"
style="width: 100px;">
</div>
{% if files|length > 1 %}
<div class="col-auto">
<label class="form-label small mb-1" for="file">File</label>
<select class="form-select form-select-sm" id="file" name="file">
{% for item in files %}
<option value="{{ item.name }}" {% if item.name == filename %}selected{% endif %}>
{{ item.name }} ({{ (item.size / 1024)|round(1) }} KB)
</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="col-auto">
<button type="submit" class="btn btn-sm btn-primary">
<i class="bi bi-funnel"></i> Apply
</button>
</div>
</form>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="text-muted small">
Showing <strong id="shown">{{ shown }}</strong> of {{ total }} entries in view
<span class="ms-3">
<input class="form-check-input" type="checkbox" id="autorefresh">
<label class="form-check-label" for="autorefresh">auto-refresh</label>
</span>
</div>
<div>
<a href="{{ url_for('logs.download', file=filename) }}"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-download"></i> Download
</a>
<form method="POST" action="{{ url_for('logs.rotate') }}" class="d-inline"
onsubmit="return confirm('Start a fresh log file?');">
<button class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-clockwise"></i> Rotate now
</button>
</form>
</div>
</div>
<div class="card">
<div class="table-responsive" style="max-height: 70vh; overflow-y: auto;">
<table class="table table-sm table-hover mb-0" style="font-family: var(--bs-font-monospace); font-size: 0.82rem;">
<thead class="sticky-top bg-body">
<tr>
<th style="width: 150px;">Time</th>
<th style="width: 90px;">Level</th>
<th style="width: 220px;">Source</th>
<th>Message</th>
</tr>
</thead>
<tbody id="logbody">
{% for entry in entries %}
<tr>
<td class="text-nowrap text-muted">{{ entry.time }}</td>
<td><span class="badge bg-{{ entry.badge }}">{{ entry.level }}</span></td>
<td class="text-muted text-truncate" style="max-width: 220px;"
title="{{ entry.logger }}">{{ entry.logger }}</td>
<td>
{{ entry.message }}
{% if entry.extra %}
<pre class="mb-0 mt-1 small text-danger">{{ entry.extra|join('\n') }}</pre>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center text-muted py-4">
Nothing matches. Try a lower level or clear the filter.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
(function () {
const params = new URLSearchParams(window.location.search);
const url = "{{ url_for('logs.tail_json') }}" + '?' + params.toString();
const body = document.getElementById('logbody');
const shown = document.getElementById('shown');
const toggle = document.getElementById('autorefresh');
let timer = null;
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
const badges = {DEBUG: 'secondary', INFO: 'info', WARNING: 'warning',
ERROR: 'danger', CRITICAL: 'danger'};
async function refresh() {
try {
const res = await fetch(url, {headers: {'Accept': 'application/json'}});
if (!res.ok) return;
const data = await res.json();
shown.textContent = data.shown;
body.innerHTML = data.entries.map(function (e) {
const extra = e.extra && e.extra.length
? '<pre class="mb-0 mt-1 small text-danger">' +
escapeHtml(e.extra.join('\n')) + '</pre>'
: '';
return '<tr>' +
'<td class="text-nowrap text-muted">' + escapeHtml(e.time) + '</td>' +
'<td><span class="badge bg-' + (badges[e.level] || 'secondary') +
'">' + escapeHtml(e.level) + '</span></td>' +
'<td class="text-muted text-truncate" style="max-width:220px" title="' +
escapeHtml(e.logger) + '">' + escapeHtml(e.logger) + '</td>' +
'<td>' + escapeHtml(e.message) + extra + '</td>' +
'</tr>';
}).join('') ||
'<tr><td colspan="4" class="text-center text-muted py-4">Nothing matches.</td></tr>';
} catch (err) { /* try again next tick */ }
}
toggle.addEventListener('change', function () {
if (toggle.checked) {
refresh();
timer = setInterval(refresh, 10000);
} else if (timer) {
clearInterval(timer);
timer = null;
}
});
})();
</script>
{% endblock %}