Files
lego-instructions-manager/app/templates/admin/brickset_calls.html
T
jessikitty f7c20de5ac 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
2026-09-27 10:42:49 +10:00

299 lines
12 KiB
HTML

{# Brickset API call history - owner only.
Created by: Jess Rogerson (yelling commands at Claude.AI) #}
{% extends "base.html" %}
{% block title %}Brickset API calls - Admin{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col">
<h1><i class="bi bi-cloud"></i> Brickset API calls</h1>
<p class="text-muted mb-0">
Every request LIM has made. Only <code>getSets</code> counts
against the daily key limit.
</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" href="{{ url_for('logs.index') }}">
<i class="bi bi-journal-text"></i> Application log
</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="{{ url_for('logs.brickset') }}">
<i class="bi bi-cloud"></i> Brickset API calls
</a>
</li>
</ul>
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small text-uppercase">Quota calls today</div>
<div class="h3 mb-0">{{ stats.today_quota }}</div>
<div class="small text-muted">getSets only</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small text-uppercase">All calls today</div>
<div class="h3 mb-0">{{ stats.today }}</div>
<div class="small text-muted">quota-free included</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small text-uppercase">Last {{ stats.days }} days</div>
<div class="h3 mb-0">{{ stats.window }}</div>
<div class="small text-muted">{{ stats.total }} recorded in total</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small text-uppercase">Failures</div>
<div class="h3 mb-0 {% if stats.window_failed %}text-danger{% endif %}">
{{ stats.window_failed }}
</div>
<div class="small text-muted">in the last {{ stats.days }} days</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-5 mb-4">
<div class="card h-100">
<div class="card-header">
<h6 class="mb-0">By method, last {{ stats.days }} days</h6>
</div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Method</th>
<th class="text-end">Calls</th>
<th class="text-end">Avg</th>
<th class="text-center">Quota</th>
</tr>
</thead>
<tbody>
{% for row in stats.by_method %}
<tr>
<td><code>{{ row.method }}</code></td>
<td class="text-end">{{ row.count }}</td>
<td class="text-end text-muted">
{% if row.avg_ms %}{{ row.avg_ms }} ms{% else %}&mdash;{% endif %}
</td>
<td class="text-center">
{% if row.method == 'getSets' %}
<span class="badge bg-warning">yes</span>
{% else %}
<span class="text-muted">no</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center text-muted py-3">
No calls recorded yet.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-4 mb-4">
<div class="card h-100">
<div class="card-header">
<h6 class="mb-0">Quota calls per day</h6>
</div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<tbody>
{% for row in stats.quota_per_day %}
<tr>
<td>{{ row.date }}</td>
<td class="text-end"><strong>{{ row.count }}</strong></td>
</tr>
{% else %}
<tr>
<td class="text-center text-muted py-3">
No quota-counting calls in this window.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card-footer">
<form method="POST" action="{{ url_for('logs.brickset_quota') }}">
<button class="btn btn-sm btn-outline-primary w-100">
<i class="bi bi-arrow-repeat"></i> Compare with Brickset's own figures
</button>
</form>
</div>
</div>
</div>
<div class="col-lg-3 mb-4">
<div class="card h-100">
<div class="card-header">
<h6 class="mb-0">Retention</h6>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('logs.brickset_retention') }}"
class="mb-3">
<label class="form-label small" for="days">Keep records for</label>
<div class="input-group input-group-sm">
<input type="number" class="form-control" id="days" name="days"
value="{{ retention_days }}" min="1" max="3650">
<span class="input-group-text">days</span>
</div>
<button class="btn btn-sm btn-primary mt-2 w-100">Save</button>
</form>
<form method="POST" action="{{ url_for('logs.brickset_prune') }}"
onsubmit="return confirm('Delete records older than {{ retention_days }} days?');">
<button class="btn btn-sm btn-outline-danger w-100">
<i class="bi bi-trash"></i> Prune now
</button>
</form>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<form method="GET" class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label small mb-1" for="method">Method</label>
<select class="form-select form-select-sm" id="method" name="method">
<option value="">All</option>
{% for name in methods %}
<option value="{{ name }}" {% if name == method %}selected{% endif %}>
{{ name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="dayswin">Window</label>
<select class="form-select form-select-sm" id="dayswin" name="days">
{% for option in [1, 7, 30, 90] %}
<option value="{{ option }}" {% if option == days %}selected{% endif %}>
{{ option }} day{{ 's' if option > 1 }}
</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="failed"
name="failed" value="1" {% if only_failed %}checked{% endif %}>
<label class="form-check-label small" for="failed">
failures only
</label>
</div>
</div>
<div class="col-auto">
<button class="btn btn-sm btn-primary">Filter</button>
</div>
</form>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0" style="font-size: 0.85rem;">
<thead>
<tr>
<th>When (UTC)</th>
<th>Method</th>
<th>Asked</th>
<th>From</th>
<th class="text-center">Result</th>
<th class="text-end">Took</th>
</tr>
</thead>
<tbody>
{% for call in calls %}
<tr class="{% if not call.ok %}table-danger{% endif %}">
<td class="text-nowrap text-muted">
{{ call.called_at.strftime('%Y-%m-%d %H:%M:%S') }}
</td>
<td>
<code>{{ call.method }}</code>
{% if call.quota_counted %}
<span class="badge bg-warning" title="Counts against the daily limit">q</span>
{% endif %}
</td>
<td class="text-truncate" style="max-width: 280px;"
title="{{ call.params }}">{{ call.params or '—' }}</td>
<td class="text-muted">{{ call.context or '—' }}</td>
<td class="text-center">
{% if call.ok %}
<span class="badge bg-success">
{% if call.result_count is not none %}{{ call.result_count }}{% else %}ok{% endif %}
</span>
{% else %}
<span class="badge bg-danger" title="{{ call.message }}">failed</span>
{% endif %}
</td>
<td class="text-end text-muted">
{% if call.duration_ms %}{{ call.duration_ms }} ms{% else %}&mdash;{% endif %}
</td>
</tr>
{% if not call.ok and call.message %}
<tr class="table-danger">
<td></td>
<td colspan="5" class="small text-danger">{{ call.message }}</td>
</tr>
{% endif %}
{% else %}
<tr>
<td colspan="6" class="text-center text-muted py-4">
No calls recorded yet.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.pages > 1 %}
<div class="card-footer d-flex justify-content-between align-items-center">
<span class="text-muted small">
Page {{ pagination.page }} of {{ pagination.pages }}
</span>
<div>
{% if pagination.has_prev %}
<a class="btn btn-sm btn-outline-secondary"
href="{{ url_for('logs.brickset', page=pagination.prev_num, method=method, days=days, failed='1' if only_failed else None) }}">
Newer
</a>
{% endif %}
{% if pagination.has_next %}
<a class="btn btn-sm btn-outline-secondary"
href="{{ url_for('logs.brickset', page=pagination.next_num, method=method, days=days, failed='1' if only_failed else None) }}">
Older
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock %}