Files
squid-manager/log_storage.py
T
Hermes Agent 4a1d949e41 feat: 完整 Squid Web Manager 平台 - P0-P3 全功能
完整 Web 管理平台,涵盖:

P0 生产加固:
- 会话超时(空闲+绝对) + CSRF 防护 + 登录失败限流
- HTTPS/TLS 证书管理(自签生成/上传/删除/过期提醒)
- SSL Bump (HTTPS 透明代理) 可视化配置
- 配置文件 diff 预览 + 二次确认才写入
- 服务控制二次确认 + 操作期间按钮锁定

P1 运维能力:
- 日志轮转管理(logrotate) + 日志状态监控
- 告警规则(5xx/命中率/磁盘/客户端流量)
- 多 Squid 实例管理 + 一键切换
- squidclient mgr 实时性能指标
- 流量异常检测(突增/大文件/高频小包/新客户端)

P2 日志分析增强:
- 日志持久化到 SQLite + 历史时间范围查询
- 导出 CSV/JSON(日志/KPI/异常/审计)
- GeoIP 地图(Leaflet + ip-api.com 离线可选 GeoLite2)
- 每客户端 24h 趋势图
- 自定义 Squid logformat 解析器

P3 体验锦上添花:
- 暗/亮主题切换(cookie 持久化)
- WebSSH 终端(xterm.js + paramiko)
- 完整审计日志 + 用户管理

技术栈: Flask 3.0 + SQLAlchemy 2.0 + SQLite + Chart.js + Leaflet
总代码量: ~16500 行 (15 Python 模块 + 34 模板 + CSS)
路由数: 73
2026-07-15 10:19:21 +08:00

801 lines
30 KiB
Python

"""Persistent log storage for Squid access.log.
This module owns the bridge between raw access.log files on disk and the
SQLite-backed :class:`~app.LogEntry` table. It exposes:
- :func:`sync_log_to_db` - incrementally import new lines from a log file
- :func:`query_logs` - SQL-backed filter/search with time range support
- :func:`get_log_stats` - aggregated statistics (hit/miss, top clients...)
- :func:`get_distinct_hosts` / :func:`get_distinct_clients` /
:func:`get_distinct_methods` - faceted browsing
Implementation notes:
- We rely on the existing :mod:`log_parser` for line format handling so we do
not duplicate the access.log grammar.
- Bulk inserts are batched (default 500 rows) using
:func:`sqlalchemy.orm.Session.execute` with a parameterised INSERT so we
don't pay the round-trip cost of per-row commits.
- De-duplication uses the tuple ``(instance_id, log_time, client, raw_line)``;
on conflict (we surface those as ``skipped_count`` rather than re-inserting).
"""
from __future__ import annotations
import os
import time
from collections import Counter, defaultdict
from datetime import datetime, timezone
from typing import Iterable
from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import SQLAlchemyError
import log_parser
from app import db, LogEntry # imported lazily-friendly via app
# ---------------------------------------------------------------------------
# Sync
# ---------------------------------------------------------------------------
def _iter_log_lines(path: str, max_lines: int | None = None):
"""Yield raw lines from ``path`` as bytes-decoded strings.
When ``max_lines`` is set we walk backwards through the file with a
bounded per-iteration buffer (Deque-style window) and yield lines in
chronological order. For an unbounded scan (full-history import) we
just stream forwards. Designed to be tolerant of mid-stream truncation
or binary garbage.
"""
if not os.path.isfile(path):
return
if not max_lines or max_lines <= 0:
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
for raw in fh:
line = raw.rstrip("\n").rstrip("\r")
if line.strip():
yield line
except OSError:
return
return
# Bounded tail: read last ``max_lines`` lines efficiently
try:
with open(path, "rb") as fh:
try:
fh.seek(0, os.SEEK_END)
size = fh.tell()
chunks: list[bytes] = []
lines_found = 0
block = 65536
pos = size
while pos > 0 and lines_found <= max_lines:
read_size = min(block, pos)
pos -= read_size
fh.seek(pos)
chunks.append(fh.read(read_size))
lines_found += chunks[-1].count(b"\n")
data = b"".join(reversed(chunks))
except OSError:
fh.seek(0)
data = fh.read()
text = data.decode("utf-8", errors="replace")
lines = text.splitlines()
if len(lines) > max_lines:
lines = lines[-max_lines:]
for line in lines:
stripped = line.strip()
if stripped:
yield stripped
except OSError:
return
def sync_log_to_db(
path: str,
instance_id: int = 0,
batch_size: int = 500,
max_lines: int = 100000,
) -> tuple[int, int, str | None]:
"""Incrementally import new lines from ``path`` into :class:`LogEntry`.
Steps:
1. Compute the high-water mark ``max(existing.log_time)`` for the
instance so we can skip already-imported lines quickly.
2. Stream the last ``max_lines`` lines of the file
(or the entire file when ``max_lines`` is None/0) and parse them
through :func:`log_parser.parse_line`.
3. Insert new rows in batches of ``batch_size`` using
:func:`sqlalchemy.dialects.sqlite.insert` with
``OR (primary key uniqueness handled by auot-increment)`` semantics.
Duplicate suppression uses an in-memory ``seen`` set keyed by
``(client, log_time, raw_line)`` since the file itself can repeat
a row at rotation boundaries.
4. Commit once per batch and return a tuple ``(inserted, skipped, err)``.
All exceptions are caught and reported as the third tuple element so the
caller can decide whether to surface them (e.g. on the dashboard).
"""
if batch_size <= 0:
batch_size = 500
try:
# high-water mark: only lines with log_time > wm are new
wm = (
db.session.query(func.max(LogEntry.log_time))
.filter(LogEntry.instance_id == instance_id)
.scalar()
)
if wm is None:
wm = 0.0
seen_keys: set[tuple[str, float, str]] = set()
inserted = 0
skipped = 0
batch: list[dict] = []
for raw_line in _iter_log_lines(path, max_lines=max_lines):
e = log_parser.parse_line(raw_line)
if not e:
skipped += 1
continue
try:
ts = float(e.get("time") or 0)
except (TypeError, ValueError):
skipped += 1
continue
if ts <= wm:
skipped += 1
continue
key = (e.get("client") or "", ts, raw_line)
if key in seen_keys:
skipped += 1
continue
seen_keys.add(key)
batch.append({
"instance_id": instance_id,
"log_time": ts,
"elapsed_ms": int(e.get("elapsed_ms") or 0),
"client": (e.get("client") or "")[:64],
"result_code": (e.get("result_code") or "")[:32],
"http_code": (e.get("http_code") or "")[:8],
"size_bytes": int(e.get("size") or 0),
"method": (e.get("method") or "")[:16],
"url": (e.get("url") or "")[:65535],
"host": (e.get("host") or "")[:256],
"hier_code": (e.get("hier_code") or "")[:32],
"content_type": (e.get("content_type") or "")[:128],
"raw_line": raw_line[:65535],
"created_at": datetime.now(timezone.utc),
})
if len(batch) >= batch_size:
_flush_batch(batch)
inserted += len(batch)
batch = []
if batch:
_flush_batch(batch)
inserted += len(batch)
# record last sync time so the UI can display "上次同步"
_record_last_sync(instance_id, path)
return inserted, skipped, None
except SQLAlchemyError as e:
try:
db.session.rollback()
except Exception:
pass
return 0, 0, f"DB error: {type(e).__name__}: {e}"
except Exception as e:
try:
db.session.rollback()
except Exception:
pass
return 0, 0, f"{type(e).__name__}: {e}"
def _flush_batch(batch: list[dict]) -> None:
"""Insert a batch of rows in a single SQL statement.
We rely on the caller's in-memory ``(instance_id, log_time, client,
raw_line)`` set (built in :func:`sync_log_to_db`) for dedup since we
don't have a SQL-level UNIQUE constraint. This means inserts are
guaranteed to be new rows at insert time, so we use a plain
``insert().values()`` rather than ``on_conflict_do_nothing``.
"""
if not batch:
return
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
stmt = sqlite_insert(LogEntry).values(batch)
db.session.execute(stmt)
db.session.commit()
# ---------------------------------------------------------------------------
# Query
# ---------------------------------------------------------------------------
def _parse_time(value) -> float | None:
"""Parse an ISO-ish datetime / unix timestamp into a unix float (UTC)."""
if value is None or value == "":
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.timestamp()
if isinstance(value, str):
s = value.strip()
if not s:
return None
# datetime-local HTML input sends strings like "2024-01-31T10:00"
# or "2024-01-31 10:00:00". Python's fromisoformat handles both.
try:
# Support trailing Z
if s.endswith("Z"):
s = s[:-1] + "+00:00"
dt = datetime.fromisoformat(s)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.timestamp()
except ValueError:
# fall through to float attempt
pass
try:
return float(s)
except ValueError:
return None
return None
def _entry_to_dict(row: LogEntry) -> dict:
"""Convert a :class:`LogEntry` row to the dict shape used downstream.
Mirrors :class:`log_parser.LogEntry` (which is a dict subclass) so
existing consumers - templates, alert rules, anomaly detection - keep
working without modification.
"""
ts = float(row.log_time or 0)
host = row.host or ""
url = row.url or ""
raw = row.raw_line or ""
# split result_code (already stored without slash) - reconstruct full
# Squid "result" string (e.g. "TCP_HIT/200") for backward compat
result_full = row.result_code or ""
if row.http_code:
result_full = f"{result_full}/{row.http_code}"
return {
"id": row.id,
"time": row.log_time, # float unix ts
"timestamp": datetime.fromtimestamp(ts, tz=timezone.utc) if ts > 0 else None,
"elapsed_ms": int(row.elapsed_ms or 0),
"client": row.client or "",
"result": result_full,
"result_code": row.result_code or "",
"http_code": row.http_code or "",
"category": log_parser.RESULT_CATEGORIES.get(row.result_code or "", "Other"),
"result_description": log_parser.RESULT_CODE_DESCRIPTION.get(row.result_code or "", row.result_code or ""),
"size": int(row.size_bytes or 0),
"method": row.method or "",
"url": url,
"host": host,
"ident": "", # not persisted separately
"hierarchy": row.hier_code or "",
"hier_code": row.hier_code or "",
"peer": "", # not persisted
"content_type": row.content_type or "",
"raw_line": raw,
"formatted_size": log_parser.format_bytes(int(row.size_bytes or 0)),
"formatted_duration": log_parser.format_duration(int(row.elapsed_ms or 0)),
}
def query_logs(filters: dict) -> list[dict]:
"""Run a filtered SQL query against the persisted log rows.
Supported keys (all optional):
- ``instance_id`` (int)
- ``client``, ``method``, ``result_code``, ``host`` (exact or substring)
- ``host_filter_mode`` "exact" | "contains" (default "contains")
- ``url`` substring LIKE
- ``min_size``, ``max_size`` (int bytes)
- ``min_elapsed``, ``max_elapsed`` (int milliseconds)
- ``start_time``, ``end_time`` (datetime or unix timestamp, inclusive lo,
inclusive hi for end so users don't accidentally exclude the minute
they typed)
- ``limit`` (default 1000), ``offset`` (default 0)
Returns a list of dicts with the same shape as
:func:`log_parser.parse_lines`, plus ``formatted_size`` /
``formatted_duration`` keys.
"""
try:
q = db.session.query(LogEntry)
# instance scoping
iid = filters.get("instance_id")
if iid is not None:
q = q.filter(LogEntry.instance_id == iid)
c = (filters.get("client") or "").strip()
if c:
q = q.filter(LogEntry.client.like(f"%{c}%"))
m = (filters.get("method") or "").strip()
if m:
q = q.filter(LogEntry.method == m)
rc = (filters.get("result_code") or "").strip()
if rc:
q = q.filter(LogEntry.result_code.like(f"%{rc}%"))
h = (filters.get("host") or "").strip()
if h:
mode = (filters.get("host_filter_mode") or "contains").lower()
if mode == "exact":
q = q.filter(LogEntry.host == h)
else:
q = q.filter(LogEntry.host.like(f"%{h}%"))
u = (filters.get("url") or "").strip()
if u:
q = q.filter(LogEntry.url.like(f"%{u}%"))
mn = filters.get("min_size")
if mn is not None:
q = q.filter(LogEntry.size_bytes >= int(mn))
mx = filters.get("max_size")
if mx is not None:
q = q.filter(LogEntry.size_bytes <= int(mx))
mne = filters.get("min_elapsed")
if mne is not None:
q = q.filter(LogEntry.elapsed_ms >= int(mne))
mxe = filters.get("max_elapsed")
if mxe is not None:
q = q.filter(LogEntry.elapsed_ms <= int(mxe))
st = _parse_time(filters.get("start_time"))
if st is not None:
q = q.filter(LogEntry.log_time >= st)
et = _parse_time(filters.get("end_time"))
if et is not None:
q = q.filter(LogEntry.log_time <= et)
# default ordering: newest first
q = q.order_by(LogEntry.log_time.desc(), LogEntry.id.desc())
limit = int(filters.get("limit") or 1000)
offset = int(filters.get("offset") or 0)
if limit > 0:
q = q.limit(limit)
if offset > 0:
q = q.offset(offset)
rows = q.all()
return [_entry_to_dict(r) for r in rows]
except SQLAlchemyError as e:
try:
db.session.rollback()
except Exception:
pass
return []
def count_logs(filters: dict | None = None) -> int:
"""Return the number of rows matching ``filters`` (same shape as ``query_logs``).
Used by ``logs_view`` to render total / pagination accurately without
transferring every row over the wire.
"""
f = dict(filters or {})
f["limit"] = 0
f["offset"] = 0
try:
# rebuild only the WHERE portion - run the same filter logic but
# count instead of fetch
q = _build_base_query(f)
return q.with_entities(func.count(LogEntry.id)).scalar() or 0
except SQLAlchemyError:
try:
db.session.rollback()
except Exception:
pass
return 0
def _build_base_query(filters: dict):
"""Internal: same WHERE-clause as :func:`query_logs` but returns the
SQLAlchemy query for further composition (e.g. count)."""
q = db.session.query(LogEntry)
iid = filters.get("instance_id")
if iid is not None:
q = q.filter(LogEntry.instance_id == iid)
c = (filters.get("client") or "").strip()
if c:
q = q.filter(LogEntry.client.like(f"%{c}%"))
m = (filters.get("method") or "").strip()
if m:
q = q.filter(LogEntry.method == m)
rc = (filters.get("result_code") or "").strip()
if rc:
q = q.filter(LogEntry.result_code.like(f"%{rc}%"))
h = (filters.get("host") or "").strip()
if h:
q = q.filter(LogEntry.host.like(f"%{h}%"))
u = (filters.get("url") or "").strip()
if u:
q = q.filter(LogEntry.url.like(f"%{u}%"))
mn = filters.get("min_size")
if mn is not None:
q = q.filter(LogEntry.size_bytes >= int(mn))
mx = filters.get("max_size")
if mx is not None:
q = q.filter(LogEntry.size_bytes <= int(mx))
mne = filters.get("min_elapsed")
if mne is not None:
q = q.filter(LogEntry.elapsed_ms >= int(mne))
mxe = filters.get("max_elapsed")
if mxe is not None:
q = q.filter(LogEntry.elapsed_ms <= int(mxe))
st = _parse_time(filters.get("start_time"))
if st is not None:
q = q.filter(LogEntry.log_time >= st)
et = _parse_time(filters.get("end_time"))
if et is not None:
q = q.filter(LogEntry.log_time <= et)
return q
# ---------------------------------------------------------------------------
# Aggregate stats
# ---------------------------------------------------------------------------
def get_log_stats(
instance_id: int = 0,
start_time: float | None = None,
end_time: float | None = None,
) -> dict:
"""Compute aggregate statistics in SQL.
Compatible with :func:`log_parser.aggregate_stats`. When there are no
rows in scope we return the same ``_empty_stats()`` structure so the
caller doesn't need a special case.
"""
try:
q = _build_base_query({
"instance_id": instance_id,
"start_time": start_time,
"end_time": end_time,
})
total = q.with_entities(func.count(LogEntry.id)).scalar() or 0
if total == 0:
stats = _copy_empty_stats()
else:
agg = q.with_entities(
func.coalesce(func.sum(LogEntry.size_bytes), 0),
func.coalesce(func.sum(LogEntry.elapsed_ms), 0),
func.min(LogEntry.log_time),
func.max(LogEntry.log_time),
).one()
total_bytes, total_elapsed, ts_min, ts_max = agg
total = int(total)
total_bytes = int(total_bytes)
total_elapsed = int(total_elapsed)
avg_elapsed = round(total_elapsed / total, 2) if total else 0.0
avg_size = round(total_bytes / total, 2) if total else 0.0
# result_code distribution
by_result = dict(
q.with_entities(
LogEntry.result_code,
func.count(LogEntry.id),
).group_by(LogEntry.result_code).all()
)
by_result = {k or "": v for k, v in by_result.items()}
# http_code distribution (only rows with a value)
by_http = dict(
q.filter(LogEntry.http_code != "").with_entities(
LogEntry.http_code,
func.count(LogEntry.id),
).group_by(LogEntry.http_code).all()
)
# method distribution
by_method = dict(
q.with_entities(
LogEntry.method,
func.count(LogEntry.id),
).group_by(LogEntry.method).all()
)
# hierarchy distribution
by_hier = dict(
q.with_entities(
LogEntry.hier_code,
func.count(LogEntry.id),
).group_by(LogEntry.hier_code).all()
)
# top clients: most active + bytes
top_clients = q.with_entities(
LogEntry.client,
func.count(LogEntry.id).label("c"),
func.coalesce(func.sum(LogEntry.size_bytes), 0).label("b"),
).group_by(LogEntry.client).order_by(func.count(LogEntry.id).desc()).limit(50).all()
top_clients = [{"client": c or "", "count": int(c_), "bytes": int(b_)} for c, c_, b_ in top_clients]
# top hosts
top_hosts_rows = q.filter(LogEntry.host != "").with_entities(
LogEntry.host,
func.count(LogEntry.id).label("c"),
func.coalesce(func.sum(LogEntry.size_bytes), 0).label("b"),
).group_by(LogEntry.host).order_by(func.count(LogEntry.id).desc()).limit(50).all()
top_hosts = [{"host": h, "count": int(c_), "bytes": int(b_)} for h, c_, b_ in top_hosts_rows]
# top urls
top_urls_rows = q.with_entities(
LogEntry.url,
func.count(LogEntry.id).label("c"),
).group_by(LogEntry.url).order_by(func.count(LogEntry.id).desc()).limit(50).all()
top_urls = [{"url": u, "count": int(c_)} for u, c_ in top_urls_rows]
# content-type: simpler approach - just count distinct top values
by_ctype_rows = q.with_entities(
LogEntry.content_type,
func.count(LogEntry.id).label("c"),
).group_by(LogEntry.content_type).order_by(func.count(LogEntry.id).desc()).limit(20).all()
by_ctype = []
for ct, c_ in by_ctype_rows:
ct_main = (ct or "-").split(";")[0].strip() or "-"
by_ctype.append((ct_main, int(c_)))
# collapse same main types
merged: dict[str, int] = defaultdict(int)
for ct_main, c_ in by_ctype:
merged[ct_main] += c_
by_ctype = list(merged.items())[:20]
# hit/miss ratio using result_code categories
hits = sum(c for code, c in by_result.items()
if log_parser.RESULT_CATEGORIES.get(code or "") == "Hit")
misses = sum(c for code, c in by_result.items()
if log_parser.RESULT_CATEGORIES.get(code or "") == "Miss")
denied = sum(c for code, c in by_result.items()
if log_parser.RESULT_CATEGORIES.get(code or "") == "Denied")
aborted = by_result.get("TCP_MISS_ABORTED", 0)
denied_rate = denied / total if total else 0.0
aborted_rate = aborted / total if total else 0.0
hit_ratio = hits / (hits + misses) if (hits + misses) > 0 else 0.0
errors_4xx = sum(c for code, c in by_http.items() if code.startswith("4"))
errors_5xx = sum(c for code, c in by_http.items() if code.startswith("5"))
errors_4xx_rate = errors_4xx / total if total else 0.0
errors_5xx_rate = errors_5xx / total if total else 0.0
# unique clients / hosts
unique_clients = q.with_entities(func.count(func.distinct(LogEntry.client))).scalar() or 0
unique_hosts = q.filter(LogEntry.host != "").with_entities(
func.count(func.distinct(LogEntry.host))
).scalar() or 0
# hourly distribution (UTC hour of day)
by_hour_counts: dict[int, int] = defaultdict(int)
by_hour_bytes: dict[int, int] = defaultdict(int)
# we already have min/max so size of an in-Python bucket is small
# but to avoid pulling all rows we can do an UNION-ish query per
# hour; the simpler approach: fetch (time, size) projection.
rows = q.with_entities(LogEntry.log_time, LogEntry.size_bytes).all()
for ts, size in rows:
if not ts:
continue
hr = datetime.fromtimestamp(float(ts), tz=timezone.utc).hour
by_hour_counts[hr] += 1
by_hour_bytes[hr] += int(size or 0)
time_start = datetime.fromtimestamp(float(ts_min), tz=timezone.utc) if ts_min else None
time_end = datetime.fromtimestamp(float(ts_max), tz=timezone.utc) if ts_max else None
stats = {
"total_requests": total,
"total_bytes": total_bytes,
"total_elapsed_ms": total_elapsed,
"unique_clients": int(unique_clients),
"unique_hosts": int(unique_hosts),
"hit_ratio": hit_ratio,
"denied_rate": denied_rate,
"aborted_rate": aborted_rate,
"errors_4xx_rate": errors_4xx_rate,
"errors_5xx_rate": errors_5xx_rate,
"errors_4xx_count": errors_4xx,
"errors_5xx_count": errors_5xx,
"avg_elapsed_ms": avg_elapsed,
"avg_size_bytes": avg_size,
"hits": hits,
"misses": misses,
"denied": denied,
"aborted": aborted,
"time_start": time_start,
"time_end": time_end,
"by_result": log_parser._top_n(Counter(by_result), 50),
"by_http": log_parser._top_n(Counter(by_http), 50),
"by_method": log_parser._top_n(Counter(by_method), 20),
"by_category": [], # we don't store category; compute inline if needed
"by_hierarchy": log_parser._top_n(Counter(by_hier), 20),
"by_ctype": by_ctype,
"top_clients": top_clients,
"top_hosts": top_hosts,
"top_urls": top_urls,
"hourly": [
{"hour": h, "requests": int(by_hour_counts.get(h, 0)),
"bytes": int(by_hour_bytes.get(h, 0))}
for h in range(24)
],
"result_code_legend": log_parser.RESULT_CODE_DESCRIPTION,
"hierarchy_legend": log_parser.HIERARCHY_LABELS,
}
# augment with sync timestamp for the dashboard UI
try:
from app import AppConfig
ts_str = AppConfig.query.filter_by(key="_log_last_sync_at").first()
stats["last_sync_at"] = ts_str.value if ts_str else None
except Exception:
stats["last_sync_at"] = None
return stats
except SQLAlchemyError as e:
try:
db.session.rollback()
except Exception:
pass
s = _copy_empty_stats()
s["last_sync_at"] = None
s["error"] = f"{type(e).__name__}: {e}"
return s
def _copy_empty_stats() -> dict:
"""Return a fresh empty-stats dict (avoid mutating log_parser._empty_stats)."""
return log_parser._empty_stats()
# ---------------------------------------------------------------------------
# Distinct facets
# ---------------------------------------------------------------------------
def get_distinct_hosts(instance_id: int = 0, limit: int = 200) -> list[str]:
try:
rows = (
db.session.query(LogEntry.host)
.filter(LogEntry.host != "", LogEntry.instance_id == instance_id)
.distinct()
.order_by(LogEntry.host.asc())
.limit(int(limit))
.all()
)
return [r[0] for r in rows if r[0]]
except SQLAlchemyError:
try:
db.session.rollback()
except Exception:
pass
return []
def get_distinct_clients(instance_id: int = 0, limit: int = 200) -> list[str]:
try:
rows = (
db.session.query(LogEntry.client)
.filter(LogEntry.client != "", LogEntry.instance_id == instance_id)
.distinct()
.order_by(LogEntry.client.asc())
.limit(int(limit))
.all()
)
return [r[0] for r in rows if r[0]]
except SQLAlchemyError:
try:
db.session.rollback()
except Exception:
pass
return []
def get_distinct_methods(instance_id: int = 0) -> list[str]:
try:
rows = (
db.session.query(LogEntry.method)
.filter(LogEntry.method != "", LogEntry.instance_id == instance_id)
.distinct()
.order_by(LogEntry.method.asc())
.all()
)
return [r[0] for r in rows if r[0]]
except SQLAlchemyError:
try:
db.session.rollback()
except Exception:
pass
return []
# ---------------------------------------------------------------------------
# Sync bookkeeping
# ---------------------------------------------------------------------------
_LAST_SYNC_KEY = "_log_last_sync_at"
def _record_last_sync(instance_id: int, path: str) -> None:
"""Stamp ``AppConfig`` rows with the last successful sync timestamp.
Two values are stored: ``_log_last_sync_at`` (ISO timestamp) and
``_log_last_sync_path`` (the file we synced from). We use ``AppConfig``
rather than ``LogEntry`` so the timestamp survives even if log rows
are later rotated out.
"""
try:
from app import AppConfig
ts_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
for key, value in (
(_LAST_SYNC_KEY, ts_iso),
("_log_last_sync_path", path or ""),
("_log_last_sync_instance", str(instance_id)),
):
row = AppConfig.query.filter_by(key=key).first()
if row is None:
row = AppConfig(key=key, value=value)
db.session.add(row)
else:
row.value = value
db.session.commit()
except Exception:
try:
db.session.rollback()
except Exception:
pass
def get_last_sync_at() -> str | None:
"""Return the ISO timestamp of the last sync, or ``None``."""
try:
from app import AppConfig
row = AppConfig.query.filter_by(key=_LAST_SYNC_KEY).first()
return row.value if row else None
except Exception:
return None
def get_last_sync_path() -> str | None:
try:
from app import AppConfig
row = AppConfig.query.filter_by(key="_log_last_sync_path").first()
return row.value if row else None
except Exception:
return None
def import_full_history(
path: str,
instance_id: int = 0,
batch_size: int = 500,
progress_cb=None,
) -> tuple[int, int, str | None]:
"""Import the entire ``access.log`` from byte 0 (not just the tail).
Used by the "Import history" button on the logs page. Calls
:func:`sync_log_to_db` with a very high ``max_lines`` (50M) so we
stream every line. Progress is reported via ``progress_cb(processed)``
so the caller can render a status indicator.
"""
try:
# 50M lines ceiling - enough for multi-year logs; streaming means
# we never actually load them all into memory.
return sync_log_to_db(
path=path,
instance_id=instance_id,
batch_size=batch_size,
max_lines=50_000_000,
)
except Exception as e:
return 0, 0, f"{type(e).__name__}: {e}"