You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
105 lines
3.8 KiB
105 lines
3.8 KiB
|
1 month ago
|
"""
|
||
|
|
support_bundle.py — Assemble a diagnostics zip for the Home dashboard.
|
||
|
|
|
||
|
|
Bundles status snapshot, configs (WiFi passwords masked), the newest dpworldapp
|
||
|
|
log, and OS diagnostics text. Intended to be emailed to support.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import copy
|
||
|
|
import io
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import zipfile
|
||
|
|
|
||
|
|
import system_status
|
||
|
|
|
||
|
|
MAX_LOG_BYTES = 5 * 1024 * 1024 # cap the bundled log at 5 MB
|
||
|
|
|
||
|
|
|
||
|
|
def mask_passwords(device_config):
|
||
|
|
"""Return a deep copy of device_config with every wifi_passwd masked.
|
||
|
|
|
||
|
|
Coerces a non-dict device_config (e.g. a corrupted board_config row that
|
||
|
|
decoded to a string, int, or list) to an empty dict rather than propagating
|
||
|
|
an AttributeError from .get().
|
||
|
|
"""
|
||
|
|
cfg = copy.deepcopy(device_config if isinstance(device_config, dict) else {})
|
||
|
|
for entry in cfg.get("WIFI_SSID", []) or []:
|
||
|
|
if isinstance(entry, dict) and "wifi_passwd" in entry:
|
||
|
|
entry["wifi_passwd"] = "***"
|
||
|
|
return cfg
|
||
|
|
|
||
|
|
|
||
|
|
def _newest_log_path(log_dir):
|
||
|
|
if not os.path.isdir(log_dir):
|
||
|
|
return None
|
||
|
|
newest, newest_mtime = None, 0
|
||
|
|
try:
|
||
|
|
for name in os.listdir(log_dir):
|
||
|
|
if not name.endswith(".log"):
|
||
|
|
continue
|
||
|
|
p = os.path.join(log_dir, name)
|
||
|
|
if os.path.isfile(p):
|
||
|
|
m = os.stat(p).st_mtime
|
||
|
|
if m > newest_mtime:
|
||
|
|
newest, newest_mtime = p, m
|
||
|
|
except OSError:
|
||
|
|
return None
|
||
|
|
return newest
|
||
|
|
|
||
|
|
|
||
|
|
def build_support_bundle(db):
|
||
|
|
"""Build the diagnostics zip. Returns the zip file content as bytes."""
|
||
|
|
buf = io.BytesIO()
|
||
|
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||
|
|
# status snapshot
|
||
|
|
try:
|
||
|
|
status = system_status.get_system_status(db)
|
||
|
|
except Exception as e: # never let one failure abort the bundle
|
||
|
|
status = {"error": str(e)}
|
||
|
|
z.writestr("status.json", json.dumps(status, indent=2, ensure_ascii=False))
|
||
|
|
|
||
|
|
# configs (passwords masked)
|
||
|
|
device = mask_passwords(db.get_config("device_config") or {})
|
||
|
|
z.writestr("config-device.json",
|
||
|
|
json.dumps(device, indent=2, ensure_ascii=False))
|
||
|
|
protocol = db.get_config("protocol_config") or {}
|
||
|
|
z.writestr("config-protocol.json",
|
||
|
|
json.dumps(protocol, indent=2, ensure_ascii=False))
|
||
|
|
|
||
|
|
# newest dpworldapp log (capped)
|
||
|
|
log_path = _newest_log_path(system_status.LOG_DIR)
|
||
|
|
if log_path:
|
||
|
|
try:
|
||
|
|
with open(log_path, "rb") as f:
|
||
|
|
z.writestr("dpworldapp-latest.log", f.read(MAX_LOG_BYTES))
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
# OS diagnostics text
|
||
|
|
z.writestr("ip-addr.txt",
|
||
|
|
system_status._run(["ip", "addr"]) +
|
||
|
|
"\n--- routes ---\n" +
|
||
|
|
system_status._run(["ip", "route"]))
|
||
|
|
z.writestr("systemctl-status.txt",
|
||
|
|
system_status._run(["systemctl", "status",
|
||
|
|
system_status.DPWORLDAPP_UNIT]) +
|
||
|
|
"\n--- web-configurator ---\n" +
|
||
|
|
system_status._run(["systemctl", "status",
|
||
|
|
"web-configurator.service"]))
|
||
|
|
z.writestr("dmesg.txt", system_status._run(["dmesg"])[-200000:])
|
||
|
|
|
||
|
|
# v1.6.0 (§9): 네트워크 저널 tail — 부재 시 항목 생략 (기존 fail-soft 패턴)
|
||
|
|
try:
|
||
|
|
from network.journal import Journal
|
||
|
|
journal_path = os.path.join(os.environ.get("LOG_DIR", "/opt/log/dpworldapp"),
|
||
|
|
"network_journal.jsonl")
|
||
|
|
sections_net = Journal(journal_path).tail(200)
|
||
|
|
z.writestr("network_journal_tail.json",
|
||
|
|
json.dumps(sections_net, indent=2, ensure_ascii=False))
|
||
|
|
except Exception: # noqa: BLE001
|
||
|
|
z.writestr("network_journal_tail.json", "[]")
|
||
|
|
|
||
|
|
buf.seek(0)
|
||
|
|
return buf.read()
|