fix: 缓存目录保存 404 + 日志时间显示为本地时区 + 持久化同步
修复 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
This commit is contained in:
@@ -529,8 +529,23 @@ def backup_conf(reason: str = "manual") -> str | None:
|
||||
|
||||
|
||||
def _show_config_preview(pending_content: str, return_to: str):
|
||||
"""Store pending content in session, redirect to GET /config/preview for confirmation."""
|
||||
"""Store pending content in session, redirect to GET /config/preview for confirmation.
|
||||
|
||||
``return_to`` may be either an endpoint name (e.g. ``"config_cache"``)
|
||||
or a URL path (e.g. ``"/config/cache"``). Endpoint names get resolved
|
||||
via ``url_for`` so the preview page's "返回" link is always a valid URL.
|
||||
"""
|
||||
import base64
|
||||
# Normalize return_to: if it doesn't look like a URL, treat it as an
|
||||
# endpoint name and resolve to a URL. This was the root cause of the
|
||||
# 404 bug after saving a config - we stored "config_cache" and
|
||||
# ``redirect("config_cache")`` produced a 404.
|
||||
if return_to and not return_to.startswith("/") and not return_to.startswith("http"):
|
||||
try:
|
||||
return_to = url_for(return_to)
|
||||
except Exception:
|
||||
# unknown endpoint - fall back to dashboard
|
||||
return_to = url_for("dashboard")
|
||||
session["_pending_conf"] = base64.b64encode(pending_content.encode()).decode()
|
||||
session["_pending_return"] = return_to
|
||||
return redirect(url_for("config_preview"))
|
||||
@@ -648,7 +663,7 @@ def get_parsed_logs(force: bool = False) -> list[log_parser.LogEntry]:
|
||||
# Best-effort auto-sync (throttled by the file mtime + last sync stamp)
|
||||
if force or _should_auto_sync(instance_id):
|
||||
try:
|
||||
path = access_log_path()
|
||||
path = _resolve_access_log_path()
|
||||
if os.path.isfile(path):
|
||||
log_storage.sync_log_to_db(
|
||||
path=path, instance_id=instance_id,
|
||||
@@ -680,18 +695,46 @@ def get_parsed_logs(force: bool = False) -> list[log_parser.LogEntry]:
|
||||
return wrapped
|
||||
|
||||
|
||||
def _resolve_access_log_path() -> str:
|
||||
"""Look up the access log path WITHOUT going through session-bound code.
|
||||
|
||||
``get_config`` -> ``_resolve_active_instance`` -> ``session.get`` raises
|
||||
outside a request context, and ``_should_auto_sync`` is called from many
|
||||
places (background threads, app shell) that don't have a request context.
|
||||
Here we hit the DB directly using a DB-session that does not require
|
||||
session-binding.
|
||||
"""
|
||||
try:
|
||||
# 1) instance row, if any is active
|
||||
active = SquidInstance.query.filter_by(is_active=True).first()
|
||||
if active and active.access_log_path:
|
||||
return active.access_log_path
|
||||
# 2) AppConfig override
|
||||
row = AppConfig.query.filter_by(key="access_log_path").first()
|
||||
if row and row.value:
|
||||
return row.value
|
||||
except Exception:
|
||||
pass
|
||||
return DEFAULTS.get("access_log_path", "/var/log/squid/access.log")
|
||||
|
||||
|
||||
def _should_auto_sync(instance_id: int) -> bool:
|
||||
"""Decide whether to re-sync the access.log tail for ``instance_id``.
|
||||
|
||||
Cheap heuristic: check the file's mtime against the last sync stamp
|
||||
kept in :class:`AppConfig`. Always returns False when
|
||||
``log_sync_interval`` is 0 (manual sync only).
|
||||
|
||||
Must NOT depend on ``session`` (called outside request context).
|
||||
"""
|
||||
try:
|
||||
interval = int(get_config("log_sync_interval", "0"))
|
||||
# Use raw AppConfig instead of get_config() - get_config touches
|
||||
# session which raises outside request context.
|
||||
row = AppConfig.query.filter_by(key="log_sync_interval").first()
|
||||
interval = int(row.value) if (row and row.value) else 0
|
||||
if interval <= 0:
|
||||
return False
|
||||
path = access_log_path()
|
||||
path = _resolve_access_log_path()
|
||||
if not os.path.isfile(path):
|
||||
return False
|
||||
mtime = os.path.getmtime(path)
|
||||
@@ -709,7 +752,9 @@ def _should_auto_sync(instance_id: int) -> bool:
|
||||
return False
|
||||
_auto_sync_last_call = now
|
||||
return mtime > last_dt.timestamp()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
# Don't use current_app here - this code can run outside app context
|
||||
# (e.g. in test scripts). Just swallow.
|
||||
return False
|
||||
|
||||
|
||||
@@ -928,7 +973,7 @@ def logs_view():
|
||||
# best-effort sync before serving (skipped if the file is unchanged)
|
||||
try:
|
||||
if _should_auto_sync(instance_id):
|
||||
path = access_log_path()
|
||||
path = _resolve_access_log_path()
|
||||
if os.path.isfile(path):
|
||||
log_storage.sync_log_to_db(path=path, instance_id=instance_id)
|
||||
except Exception:
|
||||
@@ -3115,7 +3160,7 @@ def _export_logs_payload():
|
||||
import log_storage
|
||||
try:
|
||||
if _should_auto_sync(instance_id):
|
||||
path = access_log_path()
|
||||
path = _resolve_access_log_path()
|
||||
if os.path.isfile(path):
|
||||
log_storage.sync_log_to_db(path=path, instance_id=instance_id)
|
||||
except Exception:
|
||||
|
||||
@@ -119,6 +119,17 @@ class LogEntry(dict):
|
||||
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."""
|
||||
|
||||
+2
-2
@@ -611,8 +611,8 @@ def default_struct() -> dict:
|
||||
],
|
||||
"rules": [
|
||||
Rule(action="deny", acls=["manager"], directive="http_access"),
|
||||
Rule(action="deny", acls=["!Safe_ports"], directive="http_access"),
|
||||
Rule(action="deny", acls=["!SSL_ports"], directive="http_access"),
|
||||
Rule(action="deny", acls=["!safeports"], directive="http_access"),
|
||||
Rule(action="deny", acls=["!sslports"], directive="http_access"),
|
||||
Rule(action="allow", acls=["localhost"], directive="http_access"),
|
||||
Rule(action="deny", acls=["all"], directive="http_access"),
|
||||
],
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
<tr>
|
||||
<td class="time-cell">{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S') if e.timestamp else e.time }}</td>
|
||||
<td class="time-cell">{{ e.timestamp_local.strftime('%Y-%m-%d %H:%M:%S') if e.timestamp_local else e.time }}</td>
|
||||
<td><a href="{{ url_for('client_detail', ip=e.client) }}">{{ e.client }}</a></td>
|
||||
<td>
|
||||
<span class="badge badge-{% if e.category == 'Hit' %}green{% elif e.category == 'Miss' %}blue{% elif e.category == 'Tunnel' %}cyan{% elif e.category == 'Denied' %}red{% elif e.category == 'Aborted' %}orange{% elif e.category == 'Error' %}red{% else %}gray{% endif %}" title="{{ e.result_description }}">{{ e.result_code }}</span>
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
<tbody>
|
||||
{% for e in entries[:200] %}
|
||||
<tr>
|
||||
<td class="time-cell">{{ e.timestamp.strftime('%H:%M:%S') if e.timestamp else e.time }}</td>
|
||||
<td class="time-cell">{{ e.timestamp_local.strftime('%H:%M:%S') if e.timestamp_local else e.time }}</td>
|
||||
<td>{{ e.client }}</td>
|
||||
<td><span class="badge badge-{% if e.category == 'Hit' %}green{% elif e.category == 'Miss' %}blue{% elif e.category == 'Tunnel' %}cyan{% elif e.category == 'Denied' %}red{% elif e.category == 'Aborted' %}orange{% else %}gray{% endif %}">{{ e.result_code }}</span></td>
|
||||
<td>{{ e.http_code or '-' }}</td>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
<tr>
|
||||
<td class="time-cell">{{ e.timestamp.strftime('%H:%M:%S') if e.timestamp else e.time }}</td>
|
||||
<td class="time-cell">{{ e.timestamp_local.strftime('%H:%M:%S') if e.timestamp_local else e.time }}</td>
|
||||
<td>{{ e.client }}</td>
|
||||
<td>
|
||||
<span class="badge badge-{% if e.category == 'Hit' %}green{% elif e.category == 'Miss' %}blue{% elif e.category == 'Tunnel' %}cyan{% elif e.category == 'Denied' %}red{% elif e.category == 'Aborted' %}orange{% elif e.category == 'Error' %}red{% else %}gray{% endif %}">{{ e.result_code }}</span>
|
||||
|
||||
Reference in New Issue
Block a user