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

+16
View File
@@ -19,6 +19,10 @@ def create_app(config_name='default'):
# Load configuration
app.config.from_object(config[config_name])
# Logging first, so anything below can report problems.
from app.services.logging_setup import configure_logging
configure_logging(app)
# Initialize extensions
db.init_app(app)
@@ -48,6 +52,7 @@ def create_app(config_name='default'):
from app.routes.moc import bp as moc_bp
from app.routes.backup import backup_bp
from app.routes.instruction_fetch import instruction_fetch_bp
from app.routes.logs import logs_bp
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
@@ -58,6 +63,7 @@ def create_app(config_name='default'):
app.register_blueprint(moc_bp)
app.register_blueprint(backup_bp)
app.register_blueprint(instruction_fetch_bp)
app.register_blueprint(logs_bp)
# Import models to ensure they're registered with SQLAlchemy
from app.models.user import User
@@ -67,6 +73,7 @@ def create_app(config_name='default'):
from app.models.set_share import SetShare
from app.models.site_setting import SiteSetting
from app.models.instruction_fetch import InstructionFetchJob
from app.models.api_call import BricksetApiCall
# Add any columns missing from an older database (additive only).
if app.config.get('AUTO_SCHEMA_UPGRADE', True):
@@ -103,6 +110,15 @@ def create_app(config_name='default'):
from app.cli import register_cli
register_cli(app)
# Templates need to know whether to show owner-only links.
@app.context_processor
def inject_ownership():
from app.services.ownership import is_owner
try:
return {'current_user_is_owner': is_owner()}
except Exception:
return {'current_user_is_owner': False}
# Background ticker for the automatic instruction fetcher. It does
# nothing at all until an admin enables it on the fetcher page.
from app.services import fetch_worker
+87
View File
@@ -12,6 +12,11 @@ Flask CLI commands for LIM backup and restore.
flask instructions run [--count N] [--force]
flask instructions status
flask owner show
flask owner set USERNAME
flask logs path [--tail N]
flask logs prune [--days N]
`restore` is the path to use on a fresh Docker instance: no login required,
and it works before any admin user exists.
@@ -267,6 +272,88 @@ def instructions_status():
f"{summary['sets_missing_pdfs']} without a PDF")
owner_cli = AppGroup('owner', help='Who owns this instance.')
@owner_cli.command('show')
def owner_show():
"""Show the owner account (the only one who can read the logs)."""
from app.services.ownership import get_owner
owner = get_owner()
if owner is None:
click.secho('No users exist yet.', fg='yellow')
return
click.secho(f'Owner: {owner.username} <{owner.email}> (id {owner.id})',
fg='green')
@owner_cli.command('set')
@click.argument('username')
def owner_set(username):
"""Hand ownership to USERNAME (promotes them to admin if needed)."""
from app.models.user import User
from app.services.ownership import set_owner
user = User.query.filter_by(username=username).first()
if user is None:
click.secho(f'No user called {username!r}.', fg='red')
raise SystemExit(1)
set_owner(user)
click.secho(f'{user.username} is now the owner.', fg='green')
logs_cli = AppGroup('logs', help='Application log file.')
@logs_cli.command('path')
@click.option('--tail', default=0, type=int,
help='Also print the last N lines.')
def logs_path(tail):
"""Where the log lives, and optionally its tail."""
import os
from flask import current_app
from app.services import logging_setup
path = logging_setup.log_path(current_app)
exists = os.path.isfile(path)
click.echo(f' {path}')
click.echo(f' exists: {exists}'
+ (f' size: {os.path.getsize(path)} bytes' if exists else ''))
click.echo(f' handler active: {logging_setup.get_handler() is not None}')
if tail and exists:
entries, total = logging_setup.read_entries(path, max_entries=tail)
click.echo(f'\n last {len(entries)} of {total} entries:\n')
for entry in entries:
click.echo(f" {entry['time']} {entry['level']:<8} "
f"{entry['logger']} | {entry['message']}")
@logs_cli.command('prune')
@click.option('--days', default=None, type=int,
help='Override the configured retention.')
def logs_prune(days):
"""Delete old Brickset API call records."""
from app.models.api_call import BricksetApiCall
from app.models.site_setting import SiteSetting
if days is None:
try:
days = int(SiteSetting.get('api_log_retention_days', '90'))
except (TypeError, ValueError):
days = 90
removed = BricksetApiCall.prune(days)
click.secho(f'Removed {removed} record(s) older than {days} days.',
fg='green')
def register_cli(app):
app.cli.add_command(backup_cli)
app.cli.add_command(instructions_cli)
app.cli.add_command(owner_cli)
app.cli.add_command(logs_cli)
+11
View File
@@ -59,6 +59,17 @@ class Config:
# stream straight to disk and never go through Flask's form parser.
BACKUP_MAX_UPLOAD = int(os.environ.get('BACKUP_MAX_UPLOAD', 0))
# Logging
# Rotating file handler; the admin log viewer reads these files.
# Docker points LOG_FOLDER at the mounted volume (/data/logs).
LOG_FOLDER = _resolve_path(os.environ.get('LOG_FOLDER'),
os.path.join(projectdir, 'logs'))
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
LOG_MAX_BYTES = int(os.environ.get('LOG_MAX_BYTES', 2097152)) # 2MB
LOG_BACKUP_COUNT = int(os.environ.get('LOG_BACKUP_COUNT', 5))
# Per-request access logging is noisy; off unless you ask for it.
LOG_ACCESS = os.environ.get('LOG_ACCESS', '0') == '1'
# Automatic instruction fetcher
# Pacing (interval, delays, on/off) lives in site_settings so it can be
# changed from the admin page. These are the hard limits and the switch
+2 -1
View File
@@ -5,6 +5,7 @@ from app.models.extra_file import ExtraFile
from app.models.set_share import SetShare
from app.models.site_setting import SiteSetting
from app.models.instruction_fetch import InstructionFetchJob
from app.models.api_call import BricksetApiCall
__all__ = ['User', 'Set', 'Instruction', 'ExtraFile', 'SetShare',
'SiteSetting', 'InstructionFetchJob']
'SiteSetting', 'InstructionFetchJob', 'BricksetApiCall']
+157
View File
@@ -0,0 +1,157 @@
"""
A record of every call LIM makes to the Brickset API.
Two reasons this exists. First, Brickset's daily key limit applies to getSets
and nothing else, so knowing how many of those went out today is the only
number that matters for staying under it — and Brickset's own
getKeyUsageStats only reports whole days, after the fact. Second, when the
instruction fetcher comes back empty it is useful to see exactly what was
asked and what came back.
Rows are small and pruned on a retention setting (90 days by default).
Created by: Jess Rogerson (yelling commands at Claude.AI)
"""
from datetime import datetime, timedelta
from app import db
# The only method that counts against the daily API key quota.
QUOTA_METHODS = {'getSets'}
class BricksetApiCall(db.Model):
"""One outbound request to brickset.com."""
__tablename__ = 'brickset_api_calls'
id = db.Column(db.Integer, primary_key=True)
called_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow,
index=True)
method = db.Column(db.String(40), nullable=False, index=True)
# Short human-readable summary of what was asked, never credentials.
params = db.Column(db.String(300), nullable=True)
# Which part of LIM made the call: fetcher, search, bulk-import, ...
context = db.Column(db.String(40), nullable=True, index=True)
ok = db.Column(db.Boolean, nullable=False, default=True)
http_status = db.Column(db.Integer, nullable=True)
result_count = db.Column(db.Integer, nullable=True)
duration_ms = db.Column(db.Integer, nullable=True)
quota_counted = db.Column(db.Boolean, nullable=False, default=False,
index=True)
message = db.Column(db.Text, nullable=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
user = db.relationship('User', foreign_keys=[user_id])
def __repr__(self):
return f'<BricksetApiCall {self.method} {"ok" if self.ok else "fail"}>'
# -- writing ------------------------------------------------------------
@classmethod
def record(cls, method, params=None, ok=True, http_status=None,
result_count=None, duration_ms=None, message=None,
context=None, user_id=None):
"""
Log a call. Never raises — a logging failure must not break a fetch.
"""
try:
call = cls(
method=method,
params=(params or '')[:300] or None,
context=(context or '')[:40] or None,
ok=bool(ok),
http_status=http_status,
result_count=result_count,
duration_ms=duration_ms,
quota_counted=method in QUOTA_METHODS,
message=(message or None),
user_id=user_id,
)
db.session.add(call)
db.session.commit()
return call
except Exception:
try:
db.session.rollback()
except Exception:
pass
return None
# -- reading ------------------------------------------------------------
@classmethod
def stats(cls, days=7):
"""Totals for the dashboard at the top of the Brickset log page."""
from sqlalchemy import func
now = datetime.utcnow()
midnight = datetime(now.year, now.month, now.day)
since = now - timedelta(days=days)
def count(query):
return query.scalar() or 0
today = db.session.query(func.count(cls.id)).filter(
cls.called_at >= midnight)
today_quota = db.session.query(func.count(cls.id)).filter(
cls.called_at >= midnight, cls.quota_counted.is_(True))
window = db.session.query(func.count(cls.id)).filter(
cls.called_at >= since)
window_failed = db.session.query(func.count(cls.id)).filter(
cls.called_at >= since, cls.ok.is_(False))
by_method = db.session.query(
cls.method, func.count(cls.id), func.avg(cls.duration_ms)
).filter(cls.called_at >= since).group_by(cls.method).order_by(
func.count(cls.id).desc()).all()
per_day = db.session.query(
func.date(cls.called_at), func.count(cls.id)
).filter(cls.called_at >= since, cls.quota_counted.is_(True)
).group_by(func.date(cls.called_at)).all()
return {
'days': days,
'today': count(today),
'today_quota': count(today_quota),
'window': count(window),
'window_failed': count(window_failed),
'total': count(db.session.query(func.count(cls.id))),
'by_method': [
{'method': method, 'count': number,
'avg_ms': int(avg) if avg else None}
for method, number, avg in by_method
],
'quota_per_day': [
{'date': str(day), 'count': number} for day, number in per_day
],
}
@classmethod
def prune(cls, days=90):
"""Delete records older than `days`. Returns how many went."""
cutoff = datetime.utcnow() - timedelta(days=days)
removed = cls.query.filter(cls.called_at < cutoff).delete(
synchronize_session=False)
db.session.commit()
return removed
def to_dict(self):
return {
'id': self.id,
'called_at': self.called_at.isoformat() if self.called_at else None,
'method': self.method,
'params': self.params,
'context': self.context,
'ok': self.ok,
'http_status': self.http_status,
'result_count': self.result_count,
'duration_ms': self.duration_ms,
'quota_counted': self.quota_counted,
'message': self.message,
}
+4
View File
@@ -32,6 +32,10 @@ DEFAULTS = {
'fetch_skip_sets_with_pdfs': 'true',
'fetch_allow_setid_lookup': 'true',
'fetch_last_run': '',
# Logging / API call history
'owner_user_id': '',
'api_log_retention_days': '90',
}
VISIBILITIES = ('private', 'shared', 'public')
+2 -1
View File
@@ -4,6 +4,7 @@ from app.routes.sets import sets_bp
from app.routes.instructions import instructions_bp
from app.routes.moc import bp as moc_bp
from app.routes.instruction_fetch import instruction_fetch_bp
from app.routes.logs import logs_bp
__all__ = ['auth_bp', 'main_bp', 'sets_bp', 'instructions_bp', 'moc_bp',
'instruction_fetch_bp']
'instruction_fetch_bp', 'logs_bp']
+223
View File
@@ -0,0 +1,223 @@
"""
Owner-only log viewer and Brickset API call history.
These pages show everything happening inside the instance, including other
admins' activity, so they sit behind owner_required rather than
admin_required.
Created by: Jess Rogerson (yelling commands at Claude.AI)
"""
import os
from flask import (Blueprint, current_app, flash, jsonify, redirect,
render_template, request, send_file, url_for)
from flask_login import login_required
from app import db
from app.models.api_call import BricksetApiCall
from app.models.site_setting import SiteSetting
from app.services import logging_setup
from app.services.ownership import owner_required
logs_bp = Blueprint('logs', __name__, url_prefix='/admin/logs')
DEFAULT_LINES = 300
MAX_LINES = 2000
def _int_arg(name, default, low, high):
try:
value = int(request.args.get(name, default))
except (TypeError, ValueError):
value = default
return max(low, min(high, value))
def _retention_days():
raw = SiteSetting.get('api_log_retention_days', '90')
try:
return max(1, min(3650, int(str(raw).strip())))
except (TypeError, ValueError):
return 90
# ---------------------------------------------------------------------------
# application log
# ---------------------------------------------------------------------------
@logs_bp.route('/')
@login_required
@owner_required
def index():
"""Tail of the application log, filtered."""
level = request.args.get('level', 'INFO')
search = request.args.get('q', '').strip()
source = request.args.get('source', '')
lines = _int_arg('lines', DEFAULT_LINES, 25, MAX_LINES)
filename = request.args.get('file', logging_setup.LOG_FILENAME)
path = logging_setup.resolve_file(current_app, filename)
entries, total = logging_setup.read_entries(
path, max_entries=lines, level=level, search=search or None,
logger_prefix=source or None)
files = logging_setup.available_files(current_app)
configured = logging_setup.get_handler() is not None
return render_template(
'admin/logs.html',
entries=list(reversed(entries)), # newest first on screen
shown=len(entries),
total=total,
level=level,
search=search,
source=source,
lines=lines,
filename=filename,
files=files,
levels=logging_setup.LEVELS,
configured=configured,
log_path=logging_setup.log_path(current_app),
)
@logs_bp.route('/tail.json')
@login_required
@owner_required
def tail_json():
"""Same data as the page, for auto-refresh."""
level = request.args.get('level', 'INFO')
search = request.args.get('q', '').strip()
source = request.args.get('source', '')
lines = _int_arg('lines', DEFAULT_LINES, 25, MAX_LINES)
path = logging_setup.resolve_file(
current_app, request.args.get('file', logging_setup.LOG_FILENAME))
entries, total = logging_setup.read_entries(
path, max_entries=lines, level=level, search=search or None,
logger_prefix=source or None)
return jsonify({
'entries': list(reversed(entries)),
'shown': len(entries),
'total': total,
})
@logs_bp.route('/download')
@login_required
@owner_required
def download():
path = logging_setup.resolve_file(
current_app, request.args.get('file', logging_setup.LOG_FILENAME))
if not path:
flash('No such log file.', 'warning')
return redirect(url_for('logs.index'))
return send_file(path, as_attachment=True,
download_name=os.path.basename(path))
@logs_bp.route('/rotate', methods=['POST'])
@login_required
@owner_required
def rotate():
"""Start a fresh log file, keeping the current one as a backup."""
if logging_setup.rotate_now():
current_app.logger.info('Log rotated by the owner')
flash('Log rotated — the previous contents are now in lim.log.1.',
'success')
else:
flash('No file handler is active, so there is nothing to rotate.',
'warning')
return redirect(url_for('logs.index'))
# ---------------------------------------------------------------------------
# Brickset API calls
# ---------------------------------------------------------------------------
@logs_bp.route('/brickset')
@login_required
@owner_required
def brickset():
"""Every call LIM has made to the Brickset API."""
days = _int_arg('days', 7, 1, 90)
page = _int_arg('page', 1, 1, 10000)
method = request.args.get('method', '')
only_failed = request.args.get('failed') == '1'
query = BricksetApiCall.query
if method:
query = query.filter(BricksetApiCall.method == method)
if only_failed:
query = query.filter(BricksetApiCall.ok.is_(False))
pagination = query.order_by(BricksetApiCall.id.desc()).paginate(
page=page, per_page=50, error_out=False)
methods = [row[0] for row in db.session.query(
BricksetApiCall.method).distinct().order_by(
BricksetApiCall.method).all()]
return render_template(
'admin/brickset_calls.html',
stats=BricksetApiCall.stats(days),
calls=pagination.items,
pagination=pagination,
days=days,
method=method,
only_failed=only_failed,
methods=methods,
retention_days=_retention_days(),
quota_note=None,
)
@logs_bp.route('/brickset/quota', methods=['POST'])
@login_required
@owner_required
def brickset_quota():
"""
Ask Brickset what it thinks our usage has been.
getKeyUsageStats is free — it doesn't count against the quota it reports.
"""
from app.services.brickset_api import BricksetAPI
if not BricksetAPI.is_configured():
flash('Brickset credentials are not configured.', 'warning')
return redirect(url_for('logs.brickset'))
usage = BricksetAPI(context='quota-check').get_key_usage()
if not usage:
flash('Brickset returned no usage data.', 'warning')
else:
recent = ', '.join(
f"{row.get('dateStamp', '?')}: {row.get('count', 0)}"
for row in usage[:7])
flash(f'Brickset reports (most recent first) — {recent}', 'info')
return redirect(url_for('logs.brickset'))
@logs_bp.route('/brickset/retention', methods=['POST'])
@login_required
@owner_required
def brickset_retention():
try:
days = max(1, min(3650, int(request.form.get('days', 90))))
except (TypeError, ValueError):
days = 90
SiteSetting.set('api_log_retention_days', str(days))
db.session.commit()
flash(f'API call records are now kept for {days} days.', 'success')
return redirect(url_for('logs.brickset'))
@logs_bp.route('/brickset/prune', methods=['POST'])
@login_required
@owner_required
def brickset_prune():
removed = BricksetApiCall.prune(_retention_days())
flash(f'Removed {removed} old API call record(s).', 'success')
return redirect(url_for('logs.brickset'))
+108 -19
View File
@@ -17,10 +17,15 @@ Created by: Jess Rogerson (yelling commands at Claude.AI)
"""
import json
import logging
import time
from typing import Any, Dict, List, Optional
import requests
from flask import current_app
from flask import current_app, has_request_context, request
from flask_login import current_user
logger = logging.getLogger(__name__)
class BricksetAPI:
@@ -28,11 +33,49 @@ class BricksetAPI:
BASE_URL = 'https://brickset.com/api/v3.asmx'
def __init__(self):
def __init__(self, context: Optional[str] = None):
self.api_key = current_app.config.get('BRICKSET_API_KEY')
self.username = current_app.config.get('BRICKSET_USERNAME')
self.password = current_app.config.get('BRICKSET_PASSWORD')
self._user_hash = None
# Recorded against every call so the log shows which part of LIM
# was responsible. Falls back to the endpoint name in a request.
self.context = context or self._default_context()
@staticmethod
def _default_context() -> str:
if has_request_context() and request.endpoint:
return request.endpoint.split('.')[-1][:40]
return 'background'
def _log_call(self, method, params=None, ok=True, http_status=None,
result_count=None, started=None, message=None):
"""Record one call. Never raises."""
duration = None
if started is not None:
duration = int((time.monotonic() - started) * 1000)
if ok:
logger.info('Brickset %s (%s) -> %s result(s) in %sms',
method, params or '-',
'?' if result_count is None else result_count,
duration)
else:
logger.warning('Brickset %s (%s) failed: %s',
method, params or '-', message)
try:
from app.models.api_call import BricksetApiCall
user_id = None
if has_request_context() and getattr(current_user,
'is_authenticated', False):
user_id = current_user.id
BricksetApiCall.record(
method=method, params=params, ok=ok, http_status=http_status,
result_count=result_count, duration_ms=duration,
message=message, context=self.context, user_id=user_id)
except Exception:
logger.debug('Could not record Brickset call', exc_info=True)
@staticmethod
def is_configured() -> bool:
@@ -52,6 +95,7 @@ class BricksetAPI:
current_app.logger.warning('Brickset API credentials not configured')
return None
started = time.monotonic()
try:
response = requests.get(
f'{self.BASE_URL}/login',
@@ -67,13 +111,19 @@ class BricksetAPI:
if data.get('status') == 'success':
self._user_hash = data.get('hash')
self._log_call('login', self.username, ok=True,
http_status=response.status_code,
started=started)
return self._user_hash
else:
current_app.logger.error(f"Brickset login failed: {data.get('message')}")
return None
self._log_call('login', self.username, ok=False,
http_status=response.status_code, started=started,
message=str(data.get('message')))
return None
except Exception as e:
current_app.logger.error(f'Brickset API authentication error: {str(e)}')
self._log_call('login', self.username, ok=False, started=started,
message=f'{type(e).__name__}: {e}')
return None
def search_sets(self,
@@ -123,6 +173,9 @@ class BricksetAPI:
'params': json.dumps(search_params),
}
summary = json.dumps(search_params, separators=(',', ':'))
started = time.monotonic()
try:
response = requests.get(
f'{self.BASE_URL}/getSets',
@@ -133,10 +186,16 @@ class BricksetAPI:
data = response.json()
if data.get('status') == 'success':
return data.get('sets', [])
sets = data.get('sets', [])
self._log_call('getSets', summary, ok=True,
http_status=response.status_code,
result_count=len(sets), started=started)
return sets
message = data.get('message', 'unknown error')
current_app.logger.error(f'Brickset search failed: {message}')
self._log_call('getSets', summary, ok=False,
http_status=response.status_code, started=started,
message=message)
if 'limit exceeded' in message.lower():
raise RuntimeError('Brickset API limit exceeded')
return []
@@ -144,7 +203,8 @@ class BricksetAPI:
except RuntimeError:
raise
except Exception as e:
current_app.logger.error(f'Brickset API search error: {str(e)}')
self._log_call('getSets', summary, ok=False, started=started,
message=f'{type(e).__name__}: {e}')
return []
def get_set_by_number(self, set_number: str) -> Optional[Dict[str, Any]]:
@@ -174,6 +234,7 @@ class BricksetAPI:
if not user_hash:
return []
started = time.monotonic()
try:
response = requests.get(
f'{self.BASE_URL}/getThemes',
@@ -188,19 +249,31 @@ class BricksetAPI:
if data.get('status') == 'success':
themes = data.get('themes', [])
return [theme.get('theme') for theme in themes if theme.get('theme')]
else:
return []
names = [theme.get('theme') for theme in themes
if theme.get('theme')]
self._log_call('getThemes', ok=True,
http_status=response.status_code,
result_count=len(names), started=started)
return names
self._log_call('getThemes', ok=False,
http_status=response.status_code, started=started,
message=str(data.get('message')))
return []
except Exception as e:
current_app.logger.error(f'Brickset API themes error: {str(e)}')
self._log_call('getThemes', ok=False, started=started,
message=f'{type(e).__name__}: {e}')
return []
# -- instructions -------------------------------------------------------
def _instructions_request(self, method: str,
params: Dict[str, Any]) -> List[Dict[str, Any]]:
summary = ', '.join(f'{k}={v}' for k, v in params.items())
params = dict(params, apiKey=self.api_key)
started = time.monotonic()
try:
response = requests.get(f'{self.BASE_URL}/{method}',
params=params, timeout=20)
@@ -208,14 +281,20 @@ class BricksetAPI:
data = response.json()
if data.get('status') == 'success':
return data.get('instructions', []) or []
instructions = data.get('instructions', []) or []
self._log_call(method, summary, ok=True,
http_status=response.status_code,
result_count=len(instructions), started=started)
return instructions
current_app.logger.error(
f"Brickset {method} failed: {data.get('message')}")
self._log_call(method, summary, ok=False,
http_status=response.status_code, started=started,
message=str(data.get('message')))
return []
except Exception as e:
current_app.logger.error(f'Brickset {method} error: {str(e)}')
self._log_call(method, summary, ok=False, started=started,
message=f'{type(e).__name__}: {e}')
return []
def get_instructions(self, set_number: str) -> List[Dict[str, Any]]:
@@ -246,6 +325,7 @@ class BricksetAPI:
"""
if not self.api_key:
return []
started = time.monotonic()
try:
response = requests.get(f'{self.BASE_URL}/getKeyUsageStats',
params={'apiKey': self.api_key},
@@ -253,8 +333,17 @@ class BricksetAPI:
response.raise_for_status()
data = response.json()
if data.get('status') == 'success':
return data.get('apiKeyUsage', []) or []
usage = data.get('apiKeyUsage', []) or []
self._log_call('getKeyUsageStats', ok=True,
http_status=response.status_code,
result_count=len(usage), started=started)
return usage
self._log_call('getKeyUsageStats', ok=False,
http_status=response.status_code, started=started,
message=str(data.get('message')))
return []
except Exception as e:
current_app.logger.error(f'Brickset key usage error: {str(e)}')
self._log_call('getKeyUsageStats', ok=False, started=started,
message=f'{type(e).__name__}: {e}')
return []
+8 -4
View File
@@ -18,10 +18,13 @@ with `flask instructions run` instead.
Created by: Jess Rogerson (yelling commands at Claude.AI)
"""
import logging
import os
import sys
import threading
logger = logging.getLogger(__name__)
_thread = None
_stop = threading.Event()
_lock = threading.Lock()
@@ -38,7 +41,7 @@ def _is_cli_command():
def start(app):
"""Start the ticker unless it's disabled or already running."""
if not app.config.get('INSTRUCTION_FETCH_WORKER', True):
app.logger.info('Instruction fetch worker disabled by config')
logger.info('Instruction fetch worker disabled by config')
return False
# `flask instructions run`, `flask backup restore` and friends shouldn't
@@ -61,7 +64,8 @@ def start(app):
daemon=True)
_thread.start()
app.logger.info('Instruction fetch worker started')
logger.info('Instruction fetch worker started (tick %ss)',
app.config.get('INSTRUCTION_FETCH_TICK_SECONDS', 20))
return True
@@ -86,7 +90,7 @@ def _loop(app):
with app.app_context():
fetcher.run_one()
except Exception:
app.logger.exception('Instruction fetch worker tick failed')
logger.exception('Instruction fetch worker tick failed')
def run_once_async(app, force=False):
@@ -102,7 +106,7 @@ def run_once_async(app, force=False):
from app.services import instruction_fetcher as fetcher
fetcher.run_one(force=force)
except Exception:
app.logger.exception('Manual instruction fetch failed')
logger.exception('Manual instruction fetch failed')
thread = threading.Thread(target=_work, name='lim-fetch-once', daemon=True)
thread.start()
+15 -5
View File
@@ -21,6 +21,7 @@ Created by: Jess Rogerson (yelling commands at Claude.AI)
"""
import hashlib
import logging
import os
import re
import tempfile
@@ -30,6 +31,8 @@ from datetime import datetime, timedelta
import requests
from flask import current_app
logger = logging.getLogger(__name__)
from app import db
from app.models.instruction import Instruction
from app.models.instruction_fetch import InstructionFetchJob
@@ -375,8 +378,11 @@ def fetch_for_set(lego_set, delay_between_files=None):
pause = (file_delay_seconds() if delay_between_files is None
else delay_between_files)
api = BricksetAPI()
api = BricksetAPI(context='fetcher')
entries, source, note = _instruction_urls(api, lego_set)
logger.info('Set %s: Brickset lists %d instruction file(s)%s',
lego_set.set_number, len(entries),
f' ({note})' if note else '')
if not entries:
return 0, 0, source, note or 'no instructions listed on Brickset'
@@ -395,6 +401,8 @@ def fetch_for_set(lego_set, delay_between_files=None):
temp_path, size, sha = _download_pdf(entry['url'], max_bytes,
timeout)
except Exception as exc:
logger.warning('Set %s: download failed for %s - %s',
lego_set.set_number, entry['url'], exc)
problems.append(f'{entry["description"] or entry["url"]}: {exc}')
continue
@@ -427,6 +435,8 @@ def fetch_for_set(lego_set, delay_between_files=None):
known.add(sha)
page += 1
added += 1
logger.info('Set %s: saved %s (%d bytes)',
lego_set.set_number, filename, file_size or size)
except Exception as exc:
db.session.rollback()
@@ -519,8 +529,8 @@ def run_one(force=False):
job.status = 'empty'
except Exception as exc:
current_app.logger.exception('Instruction fetch failed for %s',
lego_set.set_number)
logger.exception('Instruction fetch failed for %s',
lego_set.set_number)
job.message = str(exc)[:500]
if job.attempts >= max_attempts():
job.status = 'failed'
@@ -533,8 +543,8 @@ def run_one(force=False):
job.finished_at = datetime.utcnow()
db.session.commit()
current_app.logger.info('Instruction fetch %s for %s: %s', job.status,
lego_set.set_number, job.message)
logger.info('Job #%s %s for %s: %s', job.id, job.status,
lego_set.set_number, job.message)
return job.to_dict()
+218
View File
@@ -0,0 +1,218 @@
"""
Logging setup and log reading.
LIM previously configured no logging at all, which left Flask's logger at
WARNING and wrote nothing to disk — so a quiet background feature like the
instruction fetcher had no trail to follow. This attaches a rotating file
handler to the root logger, so every module logger (app.services.*, and the
fetcher in particular) lands in one file, and provides the parsing the admin
log viewer needs.
Rotation is by size, not date: LOG_MAX_BYTES per file, LOG_BACKUP_COUNT old
copies, so disk use is bounded no matter how chatty things get.
Created by: Jess Rogerson (yelling commands at Claude.AI)
"""
import io
import logging
import os
import re
from logging.handlers import RotatingFileHandler
LOG_FILENAME = 'lim.log'
LEVELS = ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL')
LEVEL_BADGES = {
'DEBUG': 'secondary',
'INFO': 'info',
'WARNING': 'warning',
'ERROR': 'danger',
'CRITICAL': 'danger',
}
# 2026-09-26 13:38:00 INFO app.services.instruction_fetcher | message
LINE_RE = re.compile(
r'^(?P<time>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+'
r'(?P<level>[A-Z]+)\s+'
r'(?P<logger>\S+)\s+\|\s'
r'(?P<message>.*)$'
)
_FORMAT = '%(asctime)s %(levelname)-8s %(name)s | %(message)s'
_DATEFMT = '%Y-%m-%d %H:%M:%S'
def log_path(app):
return os.path.join(app.config['LOG_FOLDER'], LOG_FILENAME)
def configure_logging(app):
"""
Attach the rotating file handler. Idempotent — safe on a reloader restart.
"""
folder = app.config['LOG_FOLDER']
try:
os.makedirs(folder, exist_ok=True)
except Exception as exc:
# A read-only or missing volume shouldn't stop the app booting.
app.logger.warning('Could not create log folder %s: %s', folder, exc)
return None
level = getattr(logging, str(app.config.get('LOG_LEVEL', 'INFO')).upper(),
logging.INFO)
path = log_path(app)
root = logging.getLogger()
for existing in root.handlers:
if getattr(existing, '_lim_handler', False):
existing.setLevel(level)
root.setLevel(level)
app.logger.setLevel(level)
return path
try:
handler = RotatingFileHandler(
path,
maxBytes=int(app.config.get('LOG_MAX_BYTES', 2 * 1024 * 1024)),
backupCount=int(app.config.get('LOG_BACKUP_COUNT', 5)),
encoding='utf-8',
)
except Exception as exc:
app.logger.warning('Could not open log file %s: %s', path, exc)
return None
handler.setFormatter(logging.Formatter(_FORMAT, datefmt=_DATEFMT))
handler.setLevel(level)
handler._lim_handler = True
root.addHandler(handler)
root.setLevel(level)
app.logger.setLevel(level)
# Request access logs are noisy and rarely what you're looking for.
if not app.config.get('LOG_ACCESS', False):
logging.getLogger('werkzeug').setLevel(logging.WARNING)
app.logger.info('Logging to %s at %s', path,
logging.getLevelName(level))
return path
def get_handler():
for handler in logging.getLogger().handlers:
if getattr(handler, '_lim_handler', False):
return handler
return None
def rotate_now():
"""Force a rollover. Returns True if it happened."""
handler = get_handler()
if handler is None:
return False
handler.doRollover()
return True
def available_files(app):
"""The live log plus its rotated backups, newest first."""
folder = app.config['LOG_FOLDER']
if not os.path.isdir(folder):
return []
found = []
for name in os.listdir(folder):
if name == LOG_FILENAME or name.startswith(LOG_FILENAME + '.'):
full = os.path.join(folder, name)
try:
found.append({
'name': name,
'size': os.path.getsize(full),
'current': name == LOG_FILENAME,
})
except OSError:
continue
# lim.log first, then lim.log.1, lim.log.2, ...
found.sort(key=lambda item: (not item['current'], item['name']))
return found
def resolve_file(app, name):
"""Turn a user-supplied file name into a safe path inside LOG_FOLDER."""
if not name:
name = LOG_FILENAME
if name != LOG_FILENAME and not name.startswith(LOG_FILENAME + '.'):
return None
if os.path.sep in name or '/' in name or '..' in name:
return None
path = os.path.join(app.config['LOG_FOLDER'], name)
return path if os.path.isfile(path) else None
def _tail_bytes(path, limit):
"""Last `limit` bytes of a file, decoded leniently."""
size = os.path.getsize(path)
with io.open(path, 'rb') as handle:
if size > limit:
handle.seek(size - limit)
handle.readline() # discard the partial first line
return handle.read().decode('utf-8', errors='replace')
def read_entries(path, max_entries=300, level=None, search=None,
logger_prefix=None, byte_limit=1024 * 1024):
"""
Parse the tail of a log file into entries, newest last.
Lines that don't match the format (traceback continuations) are attached
to the entry above them, so a stack trace stays with its error.
"""
if not path or not os.path.isfile(path):
return [], 0
text = _tail_bytes(path, byte_limit)
entries = []
for line in text.splitlines():
match = LINE_RE.match(line)
if match:
entries.append({
'time': match.group('time'),
'level': match.group('level'),
'logger': match.group('logger'),
'message': match.group('message'),
'extra': [],
})
elif entries:
entries[-1]['extra'].append(line)
total = len(entries)
if level:
wanted = str(level).upper()
if wanted in LEVELS:
floor = LEVELS.index(wanted)
entries = [entry for entry in entries
if entry['level'] in LEVELS
and LEVELS.index(entry['level']) >= floor]
if logger_prefix:
entries = [entry for entry in entries
if entry['logger'].startswith(logger_prefix)]
if search:
needle = search.lower()
entries = [
entry for entry in entries
if needle in entry['message'].lower()
or needle in entry['logger'].lower()
or any(needle in line.lower() for line in entry['extra'])
]
for entry in entries:
entry['badge'] = LEVEL_BADGES.get(entry['level'], 'secondary')
return entries[-max_entries:], total
+86
View File
@@ -0,0 +1,86 @@
"""
Who owns this LIM instance.
LIM only had is_admin, which is fine for day-to-day administration but too
broad for things that expose the whole instance: the log viewer shows every
request, every error and every API call, including other admins' activity.
The owner is a single account:
* whatever `owner_user_id` in site_settings points at, if that user exists;
* otherwise the lowest-numbered admin (in practice the account created by
`flask init_db`), which is then remembered.
Change it with `flask owner set <username>`.
Created by: Jess Rogerson (yelling commands at Claude.AI)
"""
from functools import wraps
from flask import flash, redirect, url_for
from flask_login import current_user
from app import db
from app.models.site_setting import SiteSetting
from app.models.user import User
OWNER_SETTING = 'owner_user_id'
def get_owner():
"""The User who owns this instance, or None on an empty database."""
raw = SiteSetting.get(OWNER_SETTING, '')
if raw and str(raw).strip().isdigit():
owner = db.session.get(User, int(str(raw).strip()))
if owner is not None:
return owner
# Nothing stored (or it points at a deleted account): fall back to the
# first admin and remember the answer.
owner = (User.query.filter_by(is_admin=True).order_by(User.id).first()
or User.query.order_by(User.id).first())
if owner is not None:
try:
SiteSetting.set(OWNER_SETTING, str(owner.id))
db.session.commit()
except Exception:
db.session.rollback()
return owner
def get_owner_id():
owner = get_owner()
return owner.id if owner else None
def set_owner(user):
"""Hand ownership to a user. Returns the user."""
SiteSetting.set(OWNER_SETTING, str(user.id))
if not user.is_admin:
user.is_admin = True
db.session.commit()
return user
def is_owner(user=None):
"""Is this user (default: the logged-in one) the owner?"""
user = user or current_user
if user is None or not getattr(user, 'is_authenticated', False):
return False
owner_id = get_owner_id()
return owner_id is not None and user.id == owner_id
def owner_required(f):
"""Stricter than admin_required: this account only."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
flash('Please log in to access this page.', 'warning')
return redirect(url_for('auth.login'))
if not is_owner(current_user):
flash('That area is restricted to the instance owner.', 'danger')
return redirect(url_for('main.index'))
return f(*args, **kwargs)
return decorated_function
+4 -1
View File
@@ -34,7 +34,10 @@ ADDITIVE_COLUMNS = {
# Tables added by later versions. create_all() is not called at boot, so new
# tables need an explicit (and idempotent) nudge.
NEW_TABLES = ('app.models.instruction_fetch.InstructionFetchJob',)
NEW_TABLES = (
'app.models.instruction_fetch.InstructionFetchJob',
'app.models.api_call.BricksetApiCall',
)
def ensure_schema(app):
+298
View File
@@ -0,0 +1,298 @@
{# 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 %}
+7
View File
@@ -260,6 +260,13 @@
<i class="bi bi-cloud-arrow-down"></i> Instruction Fetcher
</a>
</div>
{% if current_user_is_owner %}
<div class="col-md-3 mb-2">
<a href="{{ url_for('logs.index') }}" class="btn btn-outline-dark w-100">
<i class="bi bi-journal-text"></i> Logs
</a>
</div>
{% endif %}
<div class="col-md-3 mb-2">
<a href="{{ url_for('admin.users') }}" class="btn btn-outline-primary w-100">
<i class="bi bi-people"></i> Manage Users
@@ -15,6 +15,15 @@
</p>
</div>
<div class="col-auto">
{% if current_user_is_owner %}
<a href="{{ url_for('logs.index', source='app.services.instruction_fetcher') }}"
class="btn btn-outline-dark">
<i class="bi bi-journal-text"></i> Fetcher log
</a>
<a href="{{ url_for('logs.brickset') }}" class="btn btn-outline-dark">
<i class="bi bi-cloud"></i> API calls
</a>
{% endif %}
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back
</a>
+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 %}
+5
View File
@@ -11,6 +11,11 @@
<a href="{{ url_for('instruction_fetch.index') }}" class="btn btn-outline-warning me-2">
<i class="bi bi-cloud-arrow-down"></i> Instruction Fetcher
</a>
{% if current_user_is_owner %}
<a href="{{ url_for('logs.index') }}" class="btn btn-outline-dark me-2">
<i class="bi bi-journal-text"></i> Logs
</a>
{% endif %}
<a href="{{ url_for('backup.index') }}" class="btn btn-outline-danger me-2">
<i class="bi bi-archive"></i> Backup &amp; Restore
</a>