0.4.0: JSON/HTML snapshot output via freeze-snapshot-convert; GUI 'Open in browser' button
This commit is contained in:
parent
4c485b2af9
commit
ea6a0ea675
7 changed files with 466 additions and 5 deletions
2
Makefile
2
Makefile
|
|
@ -11,6 +11,7 @@ LOG_GROUP ?= $(shell id -gn)
|
||||||
install:
|
install:
|
||||||
install -Dm755 src/freeze-watcher.sh $(DESTDIR)$(BINDIR)/freeze-watcher.sh
|
install -Dm755 src/freeze-watcher.sh $(DESTDIR)$(BINDIR)/freeze-watcher.sh
|
||||||
install -Dm755 src/freeze-monitor-gui $(DESTDIR)$(BINDIR)/freeze-monitor-gui
|
install -Dm755 src/freeze-monitor-gui $(DESTDIR)$(BINDIR)/freeze-monitor-gui
|
||||||
|
install -Dm755 src/freeze-snapshot-convert $(DESTDIR)$(BINDIR)/freeze-snapshot-convert
|
||||||
install -Dm644 systemd/freeze-watcher.service $(DESTDIR)$(SYSTEMDDIR)/freeze-watcher.service
|
install -Dm644 systemd/freeze-watcher.service $(DESTDIR)$(SYSTEMDDIR)/freeze-watcher.service
|
||||||
install -Dm644 desktop/freeze-monitor.desktop $(DESTDIR)$(APPDIR)/freeze-monitor.desktop
|
install -Dm644 desktop/freeze-monitor.desktop $(DESTDIR)$(APPDIR)/freeze-monitor.desktop
|
||||||
@if [ ! -e "$(DESTDIR)$(CONFDIR)/freeze-watcher.conf" ]; then \
|
@if [ ! -e "$(DESTDIR)$(CONFDIR)/freeze-watcher.conf" ]; then \
|
||||||
|
|
@ -26,6 +27,7 @@ install:
|
||||||
uninstall:
|
uninstall:
|
||||||
rm -f $(DESTDIR)$(BINDIR)/freeze-watcher.sh
|
rm -f $(DESTDIR)$(BINDIR)/freeze-watcher.sh
|
||||||
rm -f $(DESTDIR)$(BINDIR)/freeze-monitor-gui
|
rm -f $(DESTDIR)$(BINDIR)/freeze-monitor-gui
|
||||||
|
rm -f $(DESTDIR)$(BINDIR)/freeze-snapshot-convert
|
||||||
rm -f $(DESTDIR)$(SYSTEMDDIR)/freeze-watcher.service
|
rm -f $(DESTDIR)$(SYSTEMDDIR)/freeze-watcher.service
|
||||||
rm -f $(DESTDIR)$(APPDIR)/freeze-monitor.desktop
|
rm -f $(DESTDIR)$(APPDIR)/freeze-monitor.desktop
|
||||||
rm -f $(DESTDIR)$(CONFDIR)/freeze-watcher.conf.new
|
rm -f $(DESTDIR)$(CONFDIR)/freeze-watcher.conf.new
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -150,6 +150,21 @@ The GUI's **Live graphs** tab shows the last hour of load, PSI io/cpu, and D-sta
|
||||||
|
|
||||||
Click "📸 Capture Now" in the GUI status bar (or in the tray menu) to trigger a snapshot immediately, regardless of whether thresholds are crossed. Useful when you suspect something is brewing but it hasn't tripped the alarms.
|
Click "📸 Capture Now" in the GUI status bar (or in the tray menu) to trigger a snapshot immediately, regardless of whether thresholds are crossed. Useful when you suspect something is brewing but it hasn't tripped the alarms.
|
||||||
|
|
||||||
|
## JSON / HTML output
|
||||||
|
|
||||||
|
Set `OUTPUT_JSON=1` and/or `OUTPUT_HTML=1` in `/etc/freeze-watcher.conf` to write structured sidecars next to each text snapshot:
|
||||||
|
|
||||||
|
- **JSON** (`snap-*.json`) — fully structured: PSI, dstate procs, kernel stacks, memory, top procs, cgroup PSI, softirqs, etc. Easy to consume programmatically.
|
||||||
|
- **HTML** (`snap-*.html`) — self-contained styled report with color-coded metrics, collapsible sections, and clickable kernel stacks per D-state proc. Useful for sharing or pasting into a bug report.
|
||||||
|
|
||||||
|
You can also convert any existing snapshot manually:
|
||||||
|
|
||||||
|
```
|
||||||
|
freeze-snapshot-convert /var/log/freeze-watcher/snap-20260510-214207.log --json --html
|
||||||
|
```
|
||||||
|
|
||||||
|
The GUI's "Open in browser" button on the Snapshots tab opens the HTML sidecar of the selected snapshot (generating it on demand if missing).
|
||||||
|
|
||||||
## Future ideas
|
## Future ideas
|
||||||
|
|
||||||
- Web UI option (no Qt requirement)
|
- Web UI option (no Qt requirement)
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,10 @@ NOTIFY_URGENCY=normal # low, normal, critical
|
||||||
CAPTURE_KERNEL_STACKS=1 # dump /proc/[pid]/stack for D-state procs (free, very useful)
|
CAPTURE_KERNEL_STACKS=1 # dump /proc/[pid]/stack for D-state procs (free, very useful)
|
||||||
CAPTURE_BPFTRACE=0 # run bpftrace one-shot during capture (requires bpftrace)
|
CAPTURE_BPFTRACE=0 # run bpftrace one-shot during capture (requires bpftrace)
|
||||||
CAPTURE_CGROUP_PSI=1 # walk /sys/fs/cgroup for top PSI cgroups
|
CAPTURE_CGROUP_PSI=1 # walk /sys/fs/cgroup for top PSI cgroups
|
||||||
|
|
||||||
|
# --- output formats ------------------------------------------------------
|
||||||
|
# Text snapshot is always written. These add sidecar files (.json / .html)
|
||||||
|
# next to each snap-*.log. The HTML version has color-coded metrics and
|
||||||
|
# collapsible sections, useful for sharing or browsing.
|
||||||
|
OUTPUT_JSON=0 # write snap-*.json
|
||||||
|
OUTPUT_HTML=0 # write snap-*.html
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Maintainer: luna <anassaeneroi@pm.me>
|
# Maintainer: luna <anassaeneroi@pm.me>
|
||||||
pkgname=freeze-watcher
|
pkgname=freeze-watcher
|
||||||
pkgver=0.3.0
|
pkgver=0.4.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Continuously samples system state and snapshots detailed diagnostics when freeze conditions are detected"
|
pkgdesc="Continuously samples system state and snapshots detailed diagnostics when freeze conditions are detected"
|
||||||
arch=('any')
|
arch=('any')
|
||||||
|
|
|
||||||
|
|
@ -339,11 +339,15 @@ class FreezeMonitor(QMainWindow):
|
||||||
filter_row.addWidget(self.filter_edit)
|
filter_row.addWidget(self.filter_edit)
|
||||||
self.btn_refresh = QPushButton("Refresh")
|
self.btn_refresh = QPushButton("Refresh")
|
||||||
self.btn_refresh.clicked.connect(self.load_events)
|
self.btn_refresh.clicked.connect(self.load_events)
|
||||||
|
self.btn_html = QPushButton("Open in browser")
|
||||||
|
self.btn_html.setToolTip("Open the HTML sidecar of the selected snapshot (requires OUTPUT_HTML=1)")
|
||||||
|
self.btn_html.clicked.connect(self.open_html)
|
||||||
self.btn_open = QPushButton("Open log dir")
|
self.btn_open = QPushButton("Open log dir")
|
||||||
self.btn_open.clicked.connect(lambda: subprocess.Popen(["xdg-open", str(LOG_DIR)]))
|
self.btn_open.clicked.connect(lambda: subprocess.Popen(["xdg-open", str(LOG_DIR)]))
|
||||||
self.btn_clear = QPushButton("Delete all")
|
self.btn_clear = QPushButton("Delete all")
|
||||||
self.btn_clear.clicked.connect(self.clear_snapshots)
|
self.btn_clear.clicked.connect(self.clear_snapshots)
|
||||||
filter_row.addWidget(self.btn_refresh)
|
filter_row.addWidget(self.btn_refresh)
|
||||||
|
filter_row.addWidget(self.btn_html)
|
||||||
filter_row.addWidget(self.btn_open)
|
filter_row.addWidget(self.btn_open)
|
||||||
filter_row.addWidget(self.btn_clear)
|
filter_row.addWidget(self.btn_clear)
|
||||||
v.addLayout(filter_row)
|
v.addLayout(filter_row)
|
||||||
|
|
@ -492,12 +496,43 @@ class FreezeMonitor(QMainWindow):
|
||||||
items = self.event_list.selectedItems()
|
items = self.event_list.selectedItems()
|
||||||
if not items:
|
if not items:
|
||||||
return
|
return
|
||||||
path = items[0].data(Qt.UserRole)
|
path = Path(items[0].data(Qt.UserRole))
|
||||||
try:
|
try:
|
||||||
self.detail.setPlainText(Path(path).read_text())
|
text = path.read_text()
|
||||||
|
html_sidecar = path.with_suffix(".html")
|
||||||
|
json_sidecar = path.with_suffix(".json")
|
||||||
|
extras = []
|
||||||
|
if html_sidecar.exists():
|
||||||
|
extras.append(f"[HTML available: {html_sidecar.name} — right-click 'Open in browser']")
|
||||||
|
if json_sidecar.exists():
|
||||||
|
extras.append(f"[JSON available: {json_sidecar.name}]")
|
||||||
|
if extras:
|
||||||
|
text = "\n".join(extras) + "\n" + ("─" * 60) + "\n" + text
|
||||||
|
self.detail.setPlainText(text)
|
||||||
|
self._current_snapshot_path = path
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.detail.setPlainText(f"Error: {e}")
|
self.detail.setPlainText(f"Error: {e}")
|
||||||
|
|
||||||
|
def open_html(self):
|
||||||
|
items = self.event_list.selectedItems()
|
||||||
|
if not items:
|
||||||
|
QMessageBox.information(self, "Select a snapshot",
|
||||||
|
"Pick a snapshot from the list first.")
|
||||||
|
return
|
||||||
|
path = Path(items[0].data(Qt.UserRole))
|
||||||
|
html = path.with_suffix(".html")
|
||||||
|
if not html.exists():
|
||||||
|
# Try generating on demand
|
||||||
|
try:
|
||||||
|
subprocess.run(["freeze-snapshot-convert", "--html", str(path)],
|
||||||
|
check=True, capture_output=True)
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "No HTML",
|
||||||
|
f"HTML sidecar not found and on-demand generation failed:\n{e}\n\n"
|
||||||
|
"Set OUTPUT_HTML=1 in /etc/freeze-watcher.conf to auto-generate.")
|
||||||
|
return
|
||||||
|
subprocess.Popen(["xdg-open", str(html)])
|
||||||
|
|
||||||
def clear_snapshots(self):
|
def clear_snapshots(self):
|
||||||
if not LOG_DIR.exists():
|
if not LOG_DIR.exists():
|
||||||
return
|
return
|
||||||
|
|
|
||||||
391
src/freeze-snapshot-convert
Executable file
391
src/freeze-snapshot-convert
Executable file
|
|
@ -0,0 +1,391 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Convert a freeze-watcher text snapshot to JSON and/or HTML.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
freeze-snapshot-convert PATH.log [--json] [--html]
|
||||||
|
|
||||||
|
Output files are written alongside the input with .json / .html suffixes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import html as html_lib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- parser
|
||||||
|
|
||||||
|
def parse_snapshot(text):
|
||||||
|
"""Parse a freeze-watcher text snapshot into a structured dict."""
|
||||||
|
data = {
|
||||||
|
"trigger_reason": "",
|
||||||
|
"timestamp": "",
|
||||||
|
"diagnoses": [],
|
||||||
|
"loadavg": {},
|
||||||
|
"procs_running": None,
|
||||||
|
"procs_blocked": None,
|
||||||
|
"psi": {},
|
||||||
|
"cgroup_psi": {"io": [], "cpu": []},
|
||||||
|
"dstate_processes": [],
|
||||||
|
"kernel_stacks": {},
|
||||||
|
"top_cpu": [],
|
||||||
|
"top_rss": [],
|
||||||
|
"memory": {},
|
||||||
|
"iostat_raw": "",
|
||||||
|
"interrupts": [],
|
||||||
|
"softirqs": {},
|
||||||
|
"dmesg_recent": [],
|
||||||
|
"journal_warnings": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
sections = _split_sections(text)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
for ln in sections.get("__header__", []):
|
||||||
|
m = re.match(r"=== FREEZE EVENT TRIGGERED:\s*(.+?)\s*===", ln)
|
||||||
|
if m:
|
||||||
|
data["trigger_reason"] = m.group(1)
|
||||||
|
m = re.match(r"Time:\s*(.+)", ln)
|
||||||
|
if m:
|
||||||
|
data["timestamp"] = m.group(1).strip()
|
||||||
|
|
||||||
|
# Diagnosis
|
||||||
|
for ln in sections.get("Diagnosis (auto-classified)", []):
|
||||||
|
s = ln.strip()
|
||||||
|
if s.startswith("•"):
|
||||||
|
data["diagnoses"].append(s.lstrip("• ").strip())
|
||||||
|
|
||||||
|
# /proc/loadavg
|
||||||
|
for ln in sections.get("/proc/loadavg", []):
|
||||||
|
parts = ln.split()
|
||||||
|
if len(parts) >= 3:
|
||||||
|
try:
|
||||||
|
data["loadavg"] = {"1m": float(parts[0]), "5m": float(parts[1]), "15m": float(parts[2])}
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
|
# /proc/stat
|
||||||
|
for ln in sections.get("/proc/stat (procs_running, procs_blocked)", []):
|
||||||
|
m = re.match(r"procs_running\s+(\d+)", ln)
|
||||||
|
if m:
|
||||||
|
data["procs_running"] = int(m.group(1))
|
||||||
|
m = re.match(r"procs_blocked\s+(\d+)", ln)
|
||||||
|
if m:
|
||||||
|
data["procs_blocked"] = int(m.group(1))
|
||||||
|
|
||||||
|
# PSI
|
||||||
|
for ln in sections.get("PSI cpu/io/memory", []):
|
||||||
|
m = re.match(r"\[(\w+)\]", ln)
|
||||||
|
if m:
|
||||||
|
current_psi = m.group(1)
|
||||||
|
data["psi"][current_psi] = {}
|
||||||
|
continue
|
||||||
|
m = re.match(r"(some|full)\s+(.+)", ln)
|
||||||
|
if m and "psi" in data and data["psi"]:
|
||||||
|
kind = m.group(1)
|
||||||
|
kvs = dict(re.findall(r"(\w+)=([\d.]+)", m.group(2)))
|
||||||
|
last_key = list(data["psi"].keys())[-1]
|
||||||
|
data["psi"][last_key][kind] = {k: float(v) for k, v in kvs.items()}
|
||||||
|
|
||||||
|
# Cgroup PSI
|
||||||
|
for header, key in [("Top cgroups by PSI io.some avg10", "io"),
|
||||||
|
("Top cgroups by PSI cpu.some avg10", "cpu")]:
|
||||||
|
for ln in sections.get(header, []):
|
||||||
|
ln = ln.strip()
|
||||||
|
if not ln:
|
||||||
|
continue
|
||||||
|
parts = ln.split(None, 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
try:
|
||||||
|
data["cgroup_psi"][key].append({"value": float(parts[0]), "cgroup": parts[1]})
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# D-state processes
|
||||||
|
for ln in sections.get("D-state processes (with kernel wchan)", []):
|
||||||
|
parts = ln.split(None, 5)
|
||||||
|
if len(parts) >= 5 and parts[0] == "D":
|
||||||
|
entry = {
|
||||||
|
"pid": int(parts[1]) if parts[1].isdigit() else parts[1],
|
||||||
|
"user": parts[2],
|
||||||
|
"wchan": parts[3],
|
||||||
|
"comm": parts[4],
|
||||||
|
"args": parts[5] if len(parts) > 5 else "",
|
||||||
|
}
|
||||||
|
data["dstate_processes"].append(entry)
|
||||||
|
|
||||||
|
# Kernel stacks
|
||||||
|
current_pid = None
|
||||||
|
for ln in sections.get("Kernel stack traces of D-state procs", []):
|
||||||
|
m = re.match(r"--- pid=(\d+) comm=(\S+) ---", ln)
|
||||||
|
if m:
|
||||||
|
current_pid = int(m.group(1))
|
||||||
|
data["kernel_stacks"][current_pid] = {"comm": m.group(2), "stack": []}
|
||||||
|
elif current_pid and ln.strip().startswith("[<"):
|
||||||
|
data["kernel_stacks"][current_pid]["stack"].append(ln.strip())
|
||||||
|
elif current_pid and ln.strip():
|
||||||
|
data["kernel_stacks"][current_pid]["stack"].append(ln.strip())
|
||||||
|
|
||||||
|
# Top CPU
|
||||||
|
for ln in sections.get("Top 15 by CPU", []):
|
||||||
|
parts = ln.split()
|
||||||
|
if len(parts) >= 6 and parts[1] != "user":
|
||||||
|
try:
|
||||||
|
data["top_cpu"].append({
|
||||||
|
"pid": int(parts[0]), "user": parts[1],
|
||||||
|
"pcpu": float(parts[2]), "pmem": float(parts[3]),
|
||||||
|
"state": parts[4], "comm": parts[5],
|
||||||
|
})
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Top RSS
|
||||||
|
for ln in sections.get("Top 15 by RSS memory", []):
|
||||||
|
parts = ln.split()
|
||||||
|
if len(parts) >= 6 and parts[1] != "user":
|
||||||
|
try:
|
||||||
|
data["top_rss"].append({
|
||||||
|
"pid": int(parts[0]), "user": parts[1],
|
||||||
|
"pcpu": float(parts[2]), "pmem": float(parts[3]),
|
||||||
|
"rss_kb": int(parts[4]), "comm": parts[5],
|
||||||
|
})
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# /proc/meminfo
|
||||||
|
for ln in sections.get("/proc/meminfo summary", []):
|
||||||
|
m = re.match(r"(\w+):\s+(\d+)\s*kB", ln)
|
||||||
|
if m:
|
||||||
|
data["memory"][m.group(1)] = int(m.group(2))
|
||||||
|
|
||||||
|
# iostat raw
|
||||||
|
data["iostat_raw"] = "\n".join(sections.get("iostat (1s snapshot)", []))
|
||||||
|
|
||||||
|
# Interrupts (top by total)
|
||||||
|
for ln in sections.get("interrupts (top by total)", []):
|
||||||
|
parts = ln.split()
|
||||||
|
if len(parts) >= 3 and parts[0].isdigit():
|
||||||
|
data["interrupts"].append({
|
||||||
|
"total": int(parts[0]),
|
||||||
|
"raw": " ".join(parts[1:]),
|
||||||
|
})
|
||||||
|
|
||||||
|
# softirqs - keep raw
|
||||||
|
softirq_lines = sections.get("softirq", [])
|
||||||
|
if softirq_lines:
|
||||||
|
cpu_header = softirq_lines[0].split() if softirq_lines else []
|
||||||
|
for ln in softirq_lines[1:]:
|
||||||
|
m = re.match(r"\s*(\w+):\s+(.+)", ln)
|
||||||
|
if m:
|
||||||
|
vals = m.group(2).split()
|
||||||
|
try:
|
||||||
|
data["softirqs"][m.group(1)] = [int(v) for v in vals]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
data["dmesg_recent"] = sections.get("Recent dmesg (last 50 lines)", [])
|
||||||
|
data["journal_warnings"] = sections.get("journal warnings/errors last 60s", [])
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _split_sections(text):
|
||||||
|
"""Split a snapshot into sections keyed by '## Header' titles."""
|
||||||
|
sections = {"__header__": []}
|
||||||
|
current = "__header__"
|
||||||
|
for ln in text.splitlines():
|
||||||
|
m = re.match(r"## (.+)", ln)
|
||||||
|
if m:
|
||||||
|
current = m.group(1).strip()
|
||||||
|
sections[current] = []
|
||||||
|
else:
|
||||||
|
sections[current].append(ln)
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- HTML
|
||||||
|
|
||||||
|
def render_html(data, source_text):
|
||||||
|
def metric_class(value, warn, crit):
|
||||||
|
if value is None:
|
||||||
|
return "ok"
|
||||||
|
if value >= crit:
|
||||||
|
return "crit"
|
||||||
|
if value >= warn:
|
||||||
|
return "warn"
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
def fmt_psi(kind, key="some"):
|
||||||
|
block = data["psi"].get(kind, {}).get(key, {})
|
||||||
|
return block.get("avg10", 0.0)
|
||||||
|
|
||||||
|
psi_io = fmt_psi("io")
|
||||||
|
psi_cpu = fmt_psi("cpu")
|
||||||
|
psi_mem = fmt_psi("memory")
|
||||||
|
load_1m = data["loadavg"].get("1m", 0)
|
||||||
|
|
||||||
|
diag_html = "".join(f"<li>{html_lib.escape(d)}</li>" for d in data["diagnoses"]) \
|
||||||
|
or "<li><i>uncertain</i></li>"
|
||||||
|
|
||||||
|
dstate_html = ""
|
||||||
|
for p in data["dstate_processes"]:
|
||||||
|
dstate_html += (
|
||||||
|
f'<div class="dstate-proc">'
|
||||||
|
f'<b>pid {p["pid"]}</b> ({html_lib.escape(p["comm"])}) '
|
||||||
|
f'user={html_lib.escape(p["user"])} '
|
||||||
|
f'<code>wchan={html_lib.escape(p["wchan"])}</code>'
|
||||||
|
)
|
||||||
|
if p["pid"] in data["kernel_stacks"]:
|
||||||
|
stack = "\n".join(data["kernel_stacks"][p["pid"]]["stack"])
|
||||||
|
dstate_html += f'<details><summary>kernel stack</summary><pre>{html_lib.escape(stack)}</pre></details>'
|
||||||
|
dstate_html += "</div>"
|
||||||
|
if not dstate_html:
|
||||||
|
dstate_html = "<p><i>None at capture time.</i></p>"
|
||||||
|
|
||||||
|
top_cpu_rows = "".join(
|
||||||
|
f"<tr><td>{p['pid']}</td><td>{html_lib.escape(p['user'])}</td>"
|
||||||
|
f"<td>{p['pcpu']}</td><td>{p['pmem']}</td>"
|
||||||
|
f"<td>{p['state']}</td><td>{html_lib.escape(p['comm'])}</td></tr>"
|
||||||
|
for p in data["top_cpu"][:10]
|
||||||
|
)
|
||||||
|
|
||||||
|
top_rss_rows = "".join(
|
||||||
|
f"<tr><td>{p['pid']}</td><td>{html_lib.escape(p['user'])}</td>"
|
||||||
|
f"<td>{p['rss_kb']/1024:.0f} MB</td>"
|
||||||
|
f"<td>{html_lib.escape(p['comm'])}</td></tr>"
|
||||||
|
for p in data["top_rss"][:10]
|
||||||
|
)
|
||||||
|
|
||||||
|
cg_io_rows = "".join(
|
||||||
|
f"<tr><td>{c['value']}</td><td><code>{html_lib.escape(c['cgroup'])}</code></td></tr>"
|
||||||
|
for c in data["cgroup_psi"]["io"][:10]
|
||||||
|
) or "<tr><td colspan=2><i>none</i></td></tr>"
|
||||||
|
|
||||||
|
cg_cpu_rows = "".join(
|
||||||
|
f"<tr><td>{c['value']}</td><td><code>{html_lib.escape(c['cgroup'])}</code></td></tr>"
|
||||||
|
for c in data["cgroup_psi"]["cpu"][:10]
|
||||||
|
) or "<tr><td colspan=2><i>none</i></td></tr>"
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html><head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Freeze snapshot — {html_lib.escape(data['timestamp'])}</title>
|
||||||
|
<style>
|
||||||
|
:root {{ color-scheme: dark; }}
|
||||||
|
body {{ font-family: -apple-system, sans-serif; max-width: 1200px;
|
||||||
|
margin: 2em auto; padding: 1em; background: #1a1a1a; color: #ddd; }}
|
||||||
|
h1 {{ color: #88ccff; margin-bottom: 0; }}
|
||||||
|
h2 {{ color: #88ccff; border-bottom: 1px solid #333; padding-bottom: 0.3em; }}
|
||||||
|
.subtitle {{ color: #888; margin-top: 0.3em; }}
|
||||||
|
.diagnosis {{ background: #2a2a3a; padding: 1em 1.5em; border-left: 4px solid #f5a623;
|
||||||
|
margin: 1em 0; border-radius: 4px; }}
|
||||||
|
.diagnosis ul {{ margin: 0.3em 0; padding-left: 1.5em; }}
|
||||||
|
.diagnosis li {{ margin: 0.3em 0; }}
|
||||||
|
.metric {{ display: inline-block; padding: 0.6em 1em; margin: 0.3em;
|
||||||
|
border-radius: 6px; font-weight: bold; min-width: 6em; text-align: center; }}
|
||||||
|
.metric.warn {{ background: #665022; color: #ffd866; }}
|
||||||
|
.metric.crit {{ background: #5a2222; color: #ff8888; }}
|
||||||
|
.metric.ok {{ background: #2d5a3d; color: #88ddaa; }}
|
||||||
|
.metric .label {{ display: block; font-size: 0.75em; opacity: 0.8; font-weight: normal; }}
|
||||||
|
section {{ margin: 1.5em 0; padding: 1em 1.5em; background: #222; border-radius: 6px; }}
|
||||||
|
pre {{ white-space: pre-wrap; font-family: monospace; font-size: 0.85em;
|
||||||
|
background: #111; padding: 0.8em; border-radius: 4px; overflow-x: auto; }}
|
||||||
|
table {{ border-collapse: collapse; width: 100%; font-size: 0.9em; }}
|
||||||
|
th, td {{ padding: 0.4em 0.8em; text-align: left; border-bottom: 1px solid #333; }}
|
||||||
|
th {{ background: #2a2a2a; color: #88ccff; }}
|
||||||
|
code {{ background: #111; padding: 0.1em 0.4em; border-radius: 3px; color: #ffcc66; }}
|
||||||
|
.dstate-proc {{ background: #2a1f1f; padding: 0.7em 1em; margin: 0.5em 0;
|
||||||
|
border-radius: 4px; border-left: 3px solid #ff5555; }}
|
||||||
|
details summary {{ cursor: pointer; padding: 0.4em 0; color: #88ccff; }}
|
||||||
|
details[open] summary {{ margin-bottom: 0.5em; }}
|
||||||
|
</style>
|
||||||
|
</head><body>
|
||||||
|
<h1>Freeze snapshot</h1>
|
||||||
|
<p class="subtitle">{html_lib.escape(data['timestamp'])} —
|
||||||
|
trigger: <code>{html_lib.escape(data['trigger_reason'])}</code></p>
|
||||||
|
|
||||||
|
<div class="diagnosis">
|
||||||
|
<h2 style="border:none; margin-top:0;">Diagnosis</h2>
|
||||||
|
<ul>{diag_html}</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>System pressure</h2>
|
||||||
|
<div>
|
||||||
|
<span class="metric {metric_class(psi_io, 30, 60)}">{psi_io:.1f}%<span class="label">PSI io.some</span></span>
|
||||||
|
<span class="metric {metric_class(psi_cpu, 30, 60)}">{psi_cpu:.1f}%<span class="label">PSI cpu.some</span></span>
|
||||||
|
<span class="metric {metric_class(psi_mem, 20, 50)}">{psi_mem:.1f}%<span class="label">PSI mem.some</span></span>
|
||||||
|
<span class="metric {metric_class(load_1m, 8, 16)}">{load_1m:.2f}<span class="label">load 1m</span></span>
|
||||||
|
<span class="metric {metric_class(len(data['dstate_processes']), 3, 6)}">{len(data['dstate_processes'])}<span class="label">D-state procs</span></span>
|
||||||
|
<span class="metric {metric_class(data['procs_blocked'], 3, 6)}">{data['procs_blocked'] or 0}<span class="label">procs_blocked</span></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>D-state processes — where they're stuck</h2>
|
||||||
|
{dstate_html}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Top processes by CPU</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>PID</th><th>User</th><th>%CPU</th><th>%MEM</th><th>State</th><th>Command</th></tr>
|
||||||
|
{top_cpu_rows}
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Top processes by RSS memory</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>PID</th><th>User</th><th>RSS</th><th>Command</th></tr>
|
||||||
|
{top_rss_rows}
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Top cgroups by io.pressure</h2>
|
||||||
|
<table><tr><th>avg10 %</th><th>cgroup</th></tr>{cg_io_rows}</table>
|
||||||
|
<h2 style="margin-top:1em;">Top cgroups by cpu.pressure</h2>
|
||||||
|
<table><tr><th>avg10 %</th><th>cgroup</th></tr>{cg_cpu_rows}</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Raw snapshot text (full contents)</summary>
|
||||||
|
<pre>{html_lib.escape(source_text)}</pre>
|
||||||
|
</details>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- main
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("snapshot", help="Path to snap-*.log file")
|
||||||
|
ap.add_argument("--json", action="store_true", help="Write JSON sidecar")
|
||||||
|
ap.add_argument("--html", action="store_true", help="Write HTML sidecar")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not (args.json or args.html):
|
||||||
|
ap.error("Specify --json and/or --html")
|
||||||
|
|
||||||
|
src = Path(args.snapshot)
|
||||||
|
text = src.read_text(errors="replace")
|
||||||
|
data = parse_snapshot(text)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
out = src.with_suffix(".json")
|
||||||
|
out.write_text(json.dumps(data, indent=2))
|
||||||
|
print(f"wrote {out}", file=sys.stderr)
|
||||||
|
|
||||||
|
if args.html:
|
||||||
|
out = src.with_suffix(".html")
|
||||||
|
out.write_text(render_html(data, text))
|
||||||
|
print(f"wrote {out}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -23,6 +23,8 @@ NOTIFY_URGENCY=normal
|
||||||
CAPTURE_KERNEL_STACKS=1
|
CAPTURE_KERNEL_STACKS=1
|
||||||
CAPTURE_BPFTRACE=0
|
CAPTURE_BPFTRACE=0
|
||||||
CAPTURE_CGROUP_PSI=1
|
CAPTURE_CGROUP_PSI=1
|
||||||
|
OUTPUT_JSON=0
|
||||||
|
OUTPUT_HTML=0
|
||||||
|
|
||||||
[ -f "$CONFIG" ] && . "$CONFIG"
|
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||||
|
|
||||||
|
|
@ -274,6 +276,15 @@ capture_snapshot() {
|
||||||
|
|
||||||
rm -f "$body"
|
rm -f "$body"
|
||||||
echo "$(date -Iseconds) Captured snapshot to $final (reason: $reason)" >> "$LOG_DIR/events.log"
|
echo "$(date -Iseconds) Captured snapshot to $final (reason: $reason)" >> "$LOG_DIR/events.log"
|
||||||
|
|
||||||
|
# Optional sidecar formats
|
||||||
|
local convert_args=""
|
||||||
|
[ "${OUTPUT_JSON:-0}" = "1" ] && convert_args="$convert_args --json"
|
||||||
|
[ "${OUTPUT_HTML:-0}" = "1" ] && convert_args="$convert_args --html"
|
||||||
|
if [ -n "$convert_args" ] && command -v freeze-snapshot-convert >/dev/null 2>&1; then
|
||||||
|
freeze-snapshot-convert $convert_args "$final" 2>/dev/null &
|
||||||
|
fi
|
||||||
|
|
||||||
notify_users "$reason"
|
notify_users "$reason"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue