0d413cfeaf
修复 4 个相关 bug:
1. **缓存目录配置保存后 404 (核心 bug)**
- 原因: _show_config_preview 把 endpoint 名 'config_cache' 存进 session 作
为 return_to,config_preview 把它当 URL 跳到 'config_cache' 报 404
- 修法: _show_config_preview 自动用 url_for() 解析 endpoint 名
2. **日志时间显示 UTC 而不是本地时间 (次生 bug)**
- 原因: log_parser 把 timestamp 解析成 UTC datetime,模板直接
e.timestamp.strftime('%H:%M:%S') 显示 UTC
- 修法: LogEntry 新增 timestamp_local 属性 (astimezone()),
3 个模板改用 e.timestamp_local
3. **DB 同步永远不工作 (次生 bug)**
- 原因: _should_auto_sync 调 get_config('log_sync_interval') ->
_resolve_active_instance -> session.get 在 app_context 里炸,
try/except 静默吞,永远返回 False
- 修法: 新增 _resolve_access_log_path() 不走 session 链,
_should_auto_sync 直接查 AppConfig 表
4. **default_struct 规则引用 Safe_ports/SSL_ports 但 ACL 是 safeports/sslports**
- 大小写不一致导致 squid -k parse 报 FATAL
- 修法: 改成小写统一
测试: cache save 流程全链路跑通 (POST -> preview -> confirm -> /config/cache 200)
所有 config 页面 (main/acls/rules/cache/refresh/auth/peers) 的 preview+confirm
return_to 字段都正确解析为 URL
454 lines
14 KiB
Python
454 lines
14 KiB
Python
"""Squid access.log parser and analyzer.
|
|
|
|
Squid native access.log line format (default):
|
|
time elapsed client action/code size method URL ident hierarchy content-type
|
|
|
|
Example:
|
|
1783841223.438 2488 172.16.12.232 TCP_MISS_ABORTED/000 0 GET \
|
|
http://169.254.169.254/metadata/instance/compute - HIER_DIRECT/169.254.169.254 -
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from collections import Counter, defaultdict
|
|
from datetime import datetime, timezone
|
|
from urllib.parse import urlsplit
|
|
|
|
# --- Result code taxonomy ---------------------------------------------------
|
|
# Squid result codes split into categories for analysis.
|
|
|
|
RESULT_CATEGORIES = {
|
|
# cache hits
|
|
"TCP_HIT": "Hit",
|
|
"TCP_MEM_HIT": "Hit",
|
|
"TCP_NEGATIVE_HIT": "Hit",
|
|
"TCP_IMS_HIT": "Hit",
|
|
"TCP_REFRESH_HIT": "Hit",
|
|
"TCP_REFRESH_FAIL_HIT": "Hit",
|
|
"TCP_REF_FAIL_HIT": "Hit",
|
|
"TCP_IMS_HIT": "Hit",
|
|
"TCP_DENIED_REPLY": "Denied",
|
|
# cache misses
|
|
"TCP_MISS": "Miss",
|
|
"TCP_MISS_ABORTED": "Aborted",
|
|
"TCP_REFRESH_MISS": "Miss",
|
|
"TCP_CLIENT_REFRESH_MISS": "Miss",
|
|
"TCP_SWAPFAIL_MISS": "Miss",
|
|
# tunnels / connect
|
|
"TCP_TUNNEL": "Tunnel",
|
|
"CONNECT": "Tunnel",
|
|
# denied
|
|
"TCP_DENIED": "Denied",
|
|
"TCP_REDIRECT": "Redirect",
|
|
"NONE": "Error",
|
|
# udp
|
|
"UDP_HIT": "Hit",
|
|
"UDP_MISS": "Miss",
|
|
"UDP_DENIED": "Denied",
|
|
"UDP_INVALID": "Error",
|
|
"UDP_MISS_NO_FETCH": "Miss",
|
|
}
|
|
|
|
RESULT_CODE_DESCRIPTION = {
|
|
"TCP_HIT": "Cache hit - served from cache",
|
|
"TCP_MEM_HIT": "Memory cache hit",
|
|
"TCP_NEGATIVE_HIT": "Negative cached (e.g. 404)",
|
|
"TCP_IMS_HIT": "IMS hit (If-Modified-Since, 304)",
|
|
"TCP_MISS": "Cache miss - fetched from origin",
|
|
"TCP_MISS_ABORTED": "Miss - client aborted the request",
|
|
"TCP_TUNNEL": "CONNECT tunnel established (HTTPS proxy)",
|
|
"TCP_DENIED": "Request denied by ACL",
|
|
"TCP_DENIED_REPLY": "Reply denied by ACL",
|
|
"TCP_REFRESH_HIT": "Revalidation: still fresh",
|
|
"TCP_REFRESH_MISS": "Revalidation: stale, re-fetched",
|
|
"TCP_CLIENT_REFRESH_MISS": "Client forced refresh (Ctrl+F5)",
|
|
"TCP_REDIRECT": "Redirected by redirector",
|
|
"TCP_SWAPFAIL_MISS": "Swapfile miss (cache corruption?)",
|
|
"NONE": "Error before request was processed",
|
|
"UDP_HIT": "ICP cache hit",
|
|
"UDP_MISS": "ICP cache miss",
|
|
"UDP_DENIED": "ICP request denied",
|
|
"UDP_INVALID": "Invalid ICP request",
|
|
}
|
|
|
|
# hierarchy code -> short label
|
|
HIERARCHY_LABELS = {
|
|
"HIER_DIRECT": "Direct (no peer)",
|
|
"HIER_NONE": "None",
|
|
"HIER_SIBLING_HIT": "Sibling hit",
|
|
"HIER_PARENT_HIT": "Parent hit",
|
|
"HIER_DEFAULT_PARENT": "Default parent",
|
|
"HIER_SINGLE_PARENT": "Single parent",
|
|
"HIER_FIRST_UP_PARENT": "First up parent",
|
|
"HIER_NO_DIRECT_FAIL": "No direct fail",
|
|
"HIER_SRC_RTT_FASTEST": "Fastest RTT",
|
|
"HIER_NEAREST_NEIGHBOR_HIT": "Nearest neighbor hit",
|
|
"HIER_CD_SIBLING_HIT": "CARP sibling hit",
|
|
"HIER_CD_PARENT_HIT": "CARP parent hit",
|
|
"HIER_CARPLIST": "CARP list",
|
|
"HIER_MULTICAST_SIBLING": "Multicast sibling",
|
|
"HIER_ANY_PARENT": "Any parent",
|
|
"HIER_INVALID_CODE": "Invalid",
|
|
}
|
|
|
|
_LOG_LINE_RE = re.compile(
|
|
r"""^
|
|
(?P<time>\d+(?:\.\d+)?)\s+
|
|
(?P<elapsed>\d+)\s+
|
|
(?P<client>\S+)\s+
|
|
(?P<result>\S+)\s+
|
|
(?P<size>\d+)\s+
|
|
(?P<method>\S+)\s+
|
|
(?P<url>\S+)\s+
|
|
(?P<ident>\S+)\s+
|
|
(?P<hierarchy>\S+)\s+
|
|
(?P<content_type>.*)
|
|
$""",
|
|
re.VERBOSE,
|
|
)
|
|
|
|
|
|
class LogEntry(dict):
|
|
"""Parsed log entry as a dict with attribute access."""
|
|
|
|
@property
|
|
def category(self) -> str:
|
|
return RESULT_CATEGORIES.get(self.result_code, "Other")
|
|
|
|
@property
|
|
def result_description(self) -> str:
|
|
return RESULT_CODE_DESCRIPTION.get(self.result_code, self.result_code)
|
|
|
|
@property
|
|
def timestamp_local(self):
|
|
"""Return a datetime converted to the server's local timezone (CST for cn)."""
|
|
ts = self.get("timestamp")
|
|
if ts is None:
|
|
return None
|
|
try:
|
|
return ts.astimezone() # use system local tz
|
|
except Exception:
|
|
return ts
|
|
|
|
|
|
def parse_line(line: str) -> LogEntry | None:
|
|
"""Parse a single access.log line. Returns None on unparseable lines."""
|
|
if not line or not line.strip():
|
|
return None
|
|
m = _LOG_LINE_RE.match(line.strip())
|
|
if not m:
|
|
return None
|
|
g = m.groupdict()
|
|
# split result code into squid_code / http_code
|
|
raw_result = g["result"]
|
|
if "/" in raw_result:
|
|
squid_code, _, http_code = raw_result.partition("/")
|
|
else:
|
|
squid_code, http_code = raw_result, ""
|
|
# split hierarchy into hier_code / peer
|
|
raw_hier = g["hierarchy"]
|
|
if "/" in raw_hier:
|
|
hier_code, _, peer = raw_hier.partition("/")
|
|
else:
|
|
hier_code, peer = raw_hier, ""
|
|
# parse timestamp
|
|
try:
|
|
ts = float(g["time"])
|
|
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
except (ValueError, OSError):
|
|
dt = None
|
|
# parse URL into domain
|
|
url = g["url"]
|
|
host = _extract_host(url)
|
|
return LogEntry(
|
|
time=g["time"],
|
|
timestamp=dt,
|
|
elapsed_ms=int(g["elapsed"]),
|
|
client=g["client"],
|
|
result=raw_result,
|
|
result_code=squid_code,
|
|
http_code=http_code,
|
|
category=RESULT_CATEGORIES.get(squid_code, "Other"),
|
|
size=int(g["size"]),
|
|
method=g["method"],
|
|
url=url,
|
|
host=host,
|
|
ident=g["ident"] if g["ident"] != "-" else "",
|
|
hierarchy=raw_hier,
|
|
hier_code=hier_code,
|
|
peer=peer,
|
|
content_type=g["content_type"].strip(),
|
|
)
|
|
|
|
|
|
def parse_lines(text: str) -> list[LogEntry]:
|
|
"""Parse a multi-line string. Returns list of LogEntry."""
|
|
out = []
|
|
for line in text.splitlines():
|
|
e = parse_line(line)
|
|
if e:
|
|
out.append(e)
|
|
return out
|
|
|
|
|
|
def _extract_host(url: str) -> str:
|
|
"""Extract hostname from a Squid log URL.
|
|
|
|
Squid logs URLs in two forms:
|
|
- http(s)://host/path?query (normal GET/POST)
|
|
- host:port (CONNECT tunnel, no scheme)
|
|
|
|
urlsplit() fails on the second form (treats 'host' as scheme).
|
|
"""
|
|
if not url or url == "-":
|
|
return ""
|
|
# CONNECT method URLs look like 'host:port' with no scheme
|
|
# Detect by absence of '://' and presence of ':'
|
|
if "://" not in url:
|
|
# could be 'host:port' or just 'host'
|
|
if ":" in url:
|
|
host, _, _port = url.rpartition(":")
|
|
return host.strip()
|
|
return url.strip()
|
|
try:
|
|
h = urlsplit(url).hostname
|
|
return h or ""
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _top_n(counter: Counter, n: int = 20) -> list[tuple[str, int]]:
|
|
"""Return top-N (value, count) tuples."""
|
|
return counter.most_common(n)
|
|
|
|
|
|
def aggregate_stats(entries: list[LogEntry]) -> dict:
|
|
"""Build aggregate statistics from a list of LogEntry."""
|
|
if not entries:
|
|
return _empty_stats()
|
|
|
|
total = len(entries)
|
|
total_bytes = sum(e["size"] for e in entries)
|
|
total_elapsed = sum(e["elapsed_ms"] for e in entries)
|
|
|
|
# result code distribution
|
|
by_result = Counter(e["result_code"] for e in entries)
|
|
# http code distribution
|
|
by_http = Counter(e["http_code"] for e in entries if e["http_code"])
|
|
# method distribution
|
|
by_method = Counter(e["method"] for e in entries)
|
|
# category distribution (hit/miss/tunnel/denied/...)
|
|
by_category = Counter(e["category"] for e in entries)
|
|
# top clients (count + bytes)
|
|
client_count = Counter()
|
|
client_bytes = defaultdict(int)
|
|
for e in entries:
|
|
client_count[e["client"]] += 1
|
|
client_bytes[e["client"]] += e["size"]
|
|
# top destinations (host)
|
|
host_count = Counter()
|
|
host_bytes = defaultdict(int)
|
|
for e in entries:
|
|
if e["host"]:
|
|
host_count[e["host"]] += 1
|
|
host_bytes[e["host"]] += e["size"]
|
|
# top URLs
|
|
url_count = Counter(e["url"] for e in entries)
|
|
# hierarchy codes
|
|
by_hierarchy = Counter(e["hier_code"] for e in entries)
|
|
# content-type
|
|
by_ctype = Counter()
|
|
for e in entries:
|
|
ct = e["content_type"] or "-"
|
|
# collapse common types
|
|
ct_main = ct.split(";")[0].strip()
|
|
by_ctype[ct_main] += 1
|
|
# hourly distribution (UTC hour of day)
|
|
by_hour = defaultdict(int)
|
|
by_hour_bytes = defaultdict(int)
|
|
for e in entries:
|
|
if e["timestamp"]:
|
|
h = e["timestamp"].hour
|
|
by_hour[h] += 1
|
|
by_hour_bytes[h] += e["size"]
|
|
|
|
# cache hit ratio: hits / (hits + misses), excluding tunnels/denied
|
|
hits = sum(c for code, c in by_result.items() if RESULT_CATEGORIES.get(code) == "Hit")
|
|
misses = sum(c for code, c in by_result.items() if RESULT_CATEGORIES.get(code) == "Miss")
|
|
hit_ratio = hits / (hits + misses) if (hits + misses) > 0 else 0.0
|
|
# denied rate
|
|
denied = sum(c for code, c in by_result.items() if RESULT_CATEGORIES.get(code) == "Denied")
|
|
denied_rate = denied / total if total > 0 else 0.0
|
|
# aborted rate
|
|
aborted = by_result.get("TCP_MISS_ABORTED", 0)
|
|
aborted_rate = aborted / total if total > 0 else 0.0
|
|
# unique clients
|
|
unique_clients = len(client_count)
|
|
# average response time
|
|
avg_elapsed = total_elapsed / total if total > 0 else 0.0
|
|
# average object size
|
|
avg_size = total_bytes / total if total > 0 else 0.0
|
|
|
|
# error analysis - HTTP 4xx / 5xx
|
|
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 > 0 else 0.0
|
|
errors_5xx_rate = errors_5xx / total if total > 0 else 0.0
|
|
|
|
# time range
|
|
timestamps = [e["timestamp"] for e in entries if e["timestamp"]]
|
|
if timestamps:
|
|
time_start = min(timestamps)
|
|
time_end = max(timestamps)
|
|
else:
|
|
time_start = time_end = None
|
|
|
|
return {
|
|
"total_requests": total,
|
|
"total_bytes": total_bytes,
|
|
"total_elapsed_ms": total_elapsed,
|
|
"unique_clients": unique_clients,
|
|
"unique_hosts": len(host_count),
|
|
"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": round(avg_elapsed, 2),
|
|
"avg_size_bytes": round(avg_size, 2),
|
|
"hits": hits,
|
|
"misses": misses,
|
|
"denied": denied,
|
|
"aborted": aborted,
|
|
"time_start": time_start,
|
|
"time_end": time_end,
|
|
"by_result": _top_n(by_result, 50),
|
|
"by_http": _top_n(by_http, 50),
|
|
"by_method": _top_n(by_method, 20),
|
|
"by_category": _top_n(by_category, 20),
|
|
"by_hierarchy": _top_n(by_hierarchy, 20),
|
|
"by_ctype": _top_n(by_ctype, 20),
|
|
"top_clients": [
|
|
{"client": c, "count": n, "bytes": client_bytes[c]}
|
|
for c, n in _top_n(client_count, 50)
|
|
],
|
|
"top_hosts": [
|
|
{"host": h, "count": n, "bytes": host_bytes[h]}
|
|
for h, n in _top_n(host_count, 50)
|
|
],
|
|
"top_urls": [{"url": u, "count": n} for u, n in _top_n(url_count, 50)],
|
|
"hourly": [
|
|
{"hour": h, "requests": by_hour.get(h, 0), "bytes": by_hour_bytes.get(h, 0)}
|
|
for h in range(24)
|
|
],
|
|
"result_code_legend": RESULT_CODE_DESCRIPTION,
|
|
"hierarchy_legend": HIERARCHY_LABELS,
|
|
}
|
|
|
|
|
|
def _empty_stats() -> dict:
|
|
return {
|
|
"total_requests": 0,
|
|
"total_bytes": 0,
|
|
"total_elapsed_ms": 0,
|
|
"unique_clients": 0,
|
|
"unique_hosts": 0,
|
|
"hit_ratio": 0.0,
|
|
"denied_rate": 0.0,
|
|
"aborted_rate": 0.0,
|
|
"errors_4xx_rate": 0.0,
|
|
"errors_5xx_rate": 0.0,
|
|
"errors_4xx_count": 0,
|
|
"errors_5xx_count": 0,
|
|
"avg_elapsed_ms": 0.0,
|
|
"avg_size_bytes": 0.0,
|
|
"hits": 0,
|
|
"misses": 0,
|
|
"denied": 0,
|
|
"aborted": 0,
|
|
"time_start": None,
|
|
"time_end": None,
|
|
"by_result": [],
|
|
"by_http": [],
|
|
"by_method": [],
|
|
"by_category": [],
|
|
"by_hierarchy": [],
|
|
"by_ctype": [],
|
|
"top_clients": [],
|
|
"top_hosts": [],
|
|
"top_urls": [],
|
|
"hourly": [
|
|
{"hour": h, "requests": 0, "bytes": 0} for h in range(24)
|
|
],
|
|
"result_code_legend": RESULT_CODE_DESCRIPTION,
|
|
"hierarchy_legend": HIERARCHY_LABELS,
|
|
}
|
|
|
|
|
|
# --- Filters for log search -------------------------------------------------
|
|
|
|
def filter_entries(
|
|
entries: list[LogEntry],
|
|
client: str | None = None,
|
|
method: str | None = None,
|
|
result_code: str | None = None,
|
|
host: str | None = None,
|
|
url: str | None = None,
|
|
min_size: int | None = None,
|
|
max_size: int | None = None,
|
|
min_elapsed: int | None = None,
|
|
max_elapsed: int | None = None,
|
|
limit: int = 500,
|
|
) -> list[LogEntry]:
|
|
out = []
|
|
for e in entries:
|
|
if client and client not in e["client"]:
|
|
continue
|
|
if method and e["method"].upper() != method.upper():
|
|
continue
|
|
if result_code and result_code.upper() not in e["result_code"].upper():
|
|
continue
|
|
if host and host.lower() not in (e["host"] or "").lower():
|
|
continue
|
|
if url and url.lower() not in e["url"].lower():
|
|
continue
|
|
if min_size is not None and e["size"] < min_size:
|
|
continue
|
|
if max_size is not None and e["size"] > max_size:
|
|
continue
|
|
if min_elapsed is not None and e["elapsed_ms"] < min_elapsed:
|
|
continue
|
|
if max_elapsed is not None and e["elapsed_ms"] > max_elapsed:
|
|
continue
|
|
out.append(e)
|
|
if len(out) >= limit:
|
|
break
|
|
return out
|
|
|
|
|
|
def format_bytes(n: float) -> str:
|
|
"""Human-readable byte size."""
|
|
if n < 1024:
|
|
return f"{n:.0f} B"
|
|
if n < 1024 * 1024:
|
|
return f"{n / 1024:.1f} KB"
|
|
if n < 1024 * 1024 * 1024:
|
|
return f"{n / (1024 * 1024):.1f} MB"
|
|
if n < 1024 * 1024 * 1024 * 1024:
|
|
return f"{n / (1024 * 1024 * 1024):.2f} GB"
|
|
return f"{n / (1024 * 1024 * 1024 * 1024):.2f} TB"
|
|
|
|
|
|
def format_duration(ms: float) -> str:
|
|
"""Format millisecond duration."""
|
|
if ms < 1000:
|
|
return f"{ms:.0f} ms"
|
|
s = ms / 1000.0
|
|
if s < 60:
|
|
return f"{s:.2f} s"
|
|
m = int(s // 60)
|
|
s = s % 60
|
|
return f"{m}m {s:.1f}s"
|