Sync config from arch
- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
"""`nexthop` — the query CLI the panel (and you) use for history.
|
||||
|
||||
Everything answers in JSON on stdout, because the consumer is a QML
|
||||
Process { } as often as it is a person. The daemon is not involved: reads go
|
||||
straight to the files — sqlite for history (WAL mode makes that safe), the
|
||||
JSON state files for the rest.
|
||||
|
||||
nexthop query --window 24h history series at the right resolution
|
||||
nexthop live the current live.json
|
||||
nexthop events --window 7d outages, disruptions, changes
|
||||
nexthop tests [--kind peak] speed test results
|
||||
nexthop report --window 24h plain-text summary for an ISP ticket
|
||||
nexthop peak ask the running daemon for a peak test
|
||||
nexthop stream <keys> the shell's bounded reader: one JSON line
|
||||
per named state file, re-emitted on change
|
||||
nexthop retire --pid --start SIGTERM a stale daemon, identity-checked
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
|
||||
from .paths import (apps_path, db_path, live_path, lock_path, manifest_path,
|
||||
recent_path)
|
||||
from .state import read_json, read_text_bounded
|
||||
|
||||
WINDOWS = {"m": 60, "h": 3600, "d": 86400}
|
||||
|
||||
|
||||
def parse_window(text: str) -> float:
|
||||
text = (text or "30m").strip().lower()
|
||||
unit = text[-1]
|
||||
if unit in WINDOWS:
|
||||
try:
|
||||
return float(text[:-1]) * WINDOWS[unit]
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return 1800.0
|
||||
|
||||
|
||||
def emit(obj):
|
||||
json.dump(obj, sys.stdout, separators=(",", ":"))
|
||||
print()
|
||||
|
||||
|
||||
def cmd_live(_args):
|
||||
emit(read_json(live_path(), {"state": "no-daemon"}))
|
||||
return 0
|
||||
|
||||
|
||||
# The QML side never opens a state file itself. It runs `nexthop stream`
|
||||
# and reads whole lines, so the only code that touches these paths is the
|
||||
# bounded no-follow non-blocking read in state.py — an oversized file, a
|
||||
# FIFO or a symlink swap is refused here, in a small short-lived process,
|
||||
# instead of allocating or stalling inside the long-lived shell.
|
||||
#
|
||||
# Keys, never paths: the caller picks from this table, so no argument it
|
||||
# passes can widen what gets opened. Caps match each file's real size
|
||||
# (live ~3 KB, apps ~8 KB, recent ~30 KB) with generous headroom.
|
||||
STREAMABLE = {
|
||||
"live": (live_path, 256 * 1024),
|
||||
"apps": (apps_path, 1024 * 1024),
|
||||
"recent": (recent_path, 1024 * 1024),
|
||||
"manifest": (manifest_path, 256 * 1024),
|
||||
}
|
||||
|
||||
|
||||
def cmd_stream(args):
|
||||
"""Emit `<key> <json>` lines whenever a watched file's contents change.
|
||||
|
||||
The payload is re-serialised here rather than forwarded verbatim: it
|
||||
guarantees one line per record whatever the file's own formatting
|
||||
(manifest.json is indented, the state files are not), and it means
|
||||
only JSON this process already parsed successfully is ever handed to
|
||||
the shell.
|
||||
"""
|
||||
keys = [k for k in dict.fromkeys(args.keys) if k in STREAMABLE]
|
||||
if not keys:
|
||||
print("stream: nothing to watch", file=sys.stderr)
|
||||
return 2
|
||||
interval = min(max(args.interval, 0.1), 60.0)
|
||||
last = {}
|
||||
while True:
|
||||
for key in keys:
|
||||
resolve, cap = STREAMABLE[key]
|
||||
got = read_text_bounded(resolve(), cap)
|
||||
if got is None:
|
||||
continue
|
||||
text, stamp = got
|
||||
if last.get(key) == stamp:
|
||||
continue
|
||||
last[key] = stamp
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except ValueError:
|
||||
continue # a half-written or foreign file; skip it
|
||||
# ensure_ascii escapes any newline inside a string, so the
|
||||
# record cannot break the line framing.
|
||||
line = json.dumps(payload, separators=(",", ":"))
|
||||
try:
|
||||
sys.stdout.write(f"{key} {line}\n")
|
||||
sys.stdout.flush()
|
||||
except (BrokenPipeError, ValueError):
|
||||
# The shell went away; so do we. _exit skips the
|
||||
# interpreter's final flush, which would only raise the
|
||||
# same broken pipe again and print it to stderr.
|
||||
os._exit(0)
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def authorized_to_retire(pid: int, want_start: int) -> bool:
|
||||
"""Is this pid really our daemon, and the same one live.json named?
|
||||
|
||||
Three independent facts, all read from /proc and none of them a name
|
||||
match: the process must belong to this user, its argv must be exactly
|
||||
a python interpreter running `-m nexthopd`, and its start time must
|
||||
equal the one the daemon published. A recycled pid can reproduce the
|
||||
number but never the start time.
|
||||
|
||||
This lives here rather than in a shell one-liner because the one-liner
|
||||
could not be tested and, as it turned out, did not run at all: the
|
||||
NUL it passed to `tr` truncated the script at execve.
|
||||
"""
|
||||
try:
|
||||
if os.stat(f"/proc/{pid}").st_uid != os.getuid():
|
||||
return False
|
||||
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
||||
argv = [a.decode("utf-8", "replace")
|
||||
for a in f.read(4096).split(b"\0") if a]
|
||||
with open(f"/proc/{pid}/stat", "rb") as f:
|
||||
data = f.read(4096)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
if len(argv) < 3 or "python" not in os.path.basename(argv[0]):
|
||||
return False
|
||||
if argv[1] != "-m" or argv[2] != "nexthopd" or len(argv) > 3:
|
||||
return False
|
||||
if want_start:
|
||||
try:
|
||||
start = int(data[data.rindex(b")") + 2:].split()[19])
|
||||
except (ValueError, IndexError):
|
||||
return False
|
||||
if start != want_start:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def cmd_retire(args):
|
||||
"""SIGTERM a stale daemon, but only once its identity checks out."""
|
||||
if args.pid <= 0 or args.pid == os.getpid():
|
||||
return 1
|
||||
if not authorized_to_retire(args.pid, args.start):
|
||||
return 1
|
||||
try:
|
||||
os.kill(args.pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def open_store():
|
||||
# Imported here, not at module scope: `stream` runs for the life of the
|
||||
# shell and has no use for sqlite3, so it should not pay to load it.
|
||||
from .store import Store
|
||||
path = db_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return Store(path, read_only=True)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def cmd_query(args):
|
||||
store = open_store()
|
||||
if not store:
|
||||
emit({"error": "no history yet"})
|
||||
return 1
|
||||
seconds = parse_window(args.window)
|
||||
rows, table = store.series(seconds, resolution=args.resolution)
|
||||
emit({"window_s": seconds, "resolution": table, "rows": rows})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_events(args):
|
||||
store = open_store()
|
||||
if not store:
|
||||
emit({"events": []})
|
||||
return 0
|
||||
emit({"events": store.events(parse_window(args.window))})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_tests(args):
|
||||
store = open_store()
|
||||
if not store:
|
||||
emit({"tests": []})
|
||||
return 0
|
||||
emit({"tests": store.tests(limit=args.limit, kind=args.kind)})
|
||||
return 0
|
||||
|
||||
|
||||
def _lock_holder_pid() -> int:
|
||||
"""The pid written by whoever holds the daemon lock, or 0.
|
||||
|
||||
The lock file outlives a daemon that died hard and pids are recycled,
|
||||
so the number in the file is not evidence on its own. The flock is:
|
||||
if this process can take it, nobody holds it and nobody is listening,
|
||||
whatever the file says. Opened the way the daemon opens it — no
|
||||
symlink following, a regular file or nothing — and never truncated.
|
||||
"""
|
||||
try:
|
||||
fd = os.open(lock_path(), os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||
except OSError:
|
||||
return 0
|
||||
try:
|
||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
return 0
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
# Held: that is the daemon, and the pid it wrote after taking
|
||||
# the lock is the one to ring.
|
||||
try:
|
||||
return int(os.read(fd, 32).strip())
|
||||
except ValueError:
|
||||
return 0
|
||||
# We got it, so nobody was holding it. Give it straight back.
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
return 0
|
||||
except OSError:
|
||||
return 0
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def cmd_peak(_args):
|
||||
"""Ring the daemon's doorbell. SIGUSR1 is the whole protocol.
|
||||
|
||||
SIGUSR1's default disposition is *terminate*, so signalling the wrong
|
||||
pid is not a harmless no-op — and this is what a middle-click on the
|
||||
bar runs. Two checks stand before the signal: the lock must actually
|
||||
be held (see `_lock_holder_pid`), and the holder must pass the same
|
||||
owner-and-argv test the retire path applies, with the start time
|
||||
live.json published when it names the same pid. The same rule as
|
||||
`nexthop retire`: a process is authorised by identity, never by the
|
||||
number a file happens to contain.
|
||||
"""
|
||||
pid = _lock_holder_pid()
|
||||
if pid <= 0 or pid == os.getpid():
|
||||
emit({"ok": False, "error": "daemon not running"})
|
||||
return 1
|
||||
live = read_json(live_path(), {}) or {}
|
||||
want_start = 0
|
||||
if live.get("pid") == pid:
|
||||
try:
|
||||
want_start = int(live.get("pid_start") or 0)
|
||||
except (TypeError, ValueError):
|
||||
want_start = 0
|
||||
if not authorized_to_retire(pid, want_start):
|
||||
emit({"ok": False, "error": "lock holder is not nexthopd"})
|
||||
return 1
|
||||
try:
|
||||
os.kill(pid, signal.SIGUSR1)
|
||||
except OSError:
|
||||
emit({"ok": False, "error": "daemon not running"})
|
||||
return 1
|
||||
emit({"ok": True})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_report(args):
|
||||
"""The paste-into-a-ticket summary. Plain text by design."""
|
||||
store = open_store()
|
||||
live = read_json(live_path(), {})
|
||||
seconds = parse_window(args.window)
|
||||
lines = []
|
||||
link = live.get("link", {})
|
||||
lines.append(f"Nexthop report — last {args.window}")
|
||||
lines.append(f"generated {time.strftime('%Y-%m-%d %H:%M %Z')}")
|
||||
if link:
|
||||
what = link.get("ssid") or link.get("name") or link.get("iface", "?")
|
||||
lines.append(f"connection: {what} ({link.get('kind', '?')}), "
|
||||
f"gateway {link.get('gateway', '?')}")
|
||||
lines.append("")
|
||||
if store:
|
||||
rows, table = store.series(seconds)
|
||||
vals = lambda k: [r[k] for r in rows if r.get(k) is not None]
|
||||
|
||||
def block(name, p50key, p95key, losskey):
|
||||
p50, p95, loss = vals(p50key), vals(p95key), vals(losskey)
|
||||
if not p50:
|
||||
lines.append(f"{name}: no data")
|
||||
return
|
||||
lines.append(
|
||||
f"{name}: median {sum(p50)/len(p50):.1f} ms, "
|
||||
f"p95 {max(p95) if p95 else 0:.1f} ms (worst {table} bucket), "
|
||||
f"loss {sum(loss)/len(loss)*100 if loss else 0:.2f}%")
|
||||
|
||||
block("local leg (to router)", "local_p50", "local_p95", "local_loss")
|
||||
block("wan leg (past router)", "wan_p50", "wan_p95", "wan_loss")
|
||||
lines.append("")
|
||||
events = store.events(seconds)
|
||||
if events:
|
||||
lines.append("events:")
|
||||
for e in events:
|
||||
start = time.strftime("%a %H:%M", time.localtime(e["ts"]))
|
||||
dur = (f"{e['ended_ts'] - e['ts']}s" if e["ended_ts"]
|
||||
else "ongoing")
|
||||
lines.append(f" {start} {e['kind']} on {e['leg']} leg, {dur}"
|
||||
f" — {e['detail']}")
|
||||
else:
|
||||
lines.append("events: none")
|
||||
tests = store.tests(limit=5)
|
||||
if tests:
|
||||
lines.append("")
|
||||
lines.append("speed tests:")
|
||||
for t in tests:
|
||||
when = time.strftime("%a %H:%M", time.localtime(t["ts"]))
|
||||
down = f"{t['down_mbps']:.0f}" if t["down_mbps"] else "--"
|
||||
up = f"{t['up_mbps']:.0f}" if t["up_mbps"] else "--"
|
||||
lines.append(f" {when} {t['kind']:<8} {down}/{up} Mbps"
|
||||
f" ({t['engine']})")
|
||||
print("\n".join(lines))
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(prog="nexthop")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
sub.add_parser("live")
|
||||
q = sub.add_parser("query")
|
||||
q.add_argument("--window", default="30m")
|
||||
q.add_argument("--resolution", default="auto",
|
||||
choices=["auto", "minute", "hour"])
|
||||
e = sub.add_parser("events")
|
||||
e.add_argument("--window", default="7d")
|
||||
t = sub.add_parser("tests")
|
||||
t.add_argument("--kind", default=None)
|
||||
t.add_argument("--limit", type=int, default=20)
|
||||
sub.add_parser("peak")
|
||||
r = sub.add_parser("report")
|
||||
r.add_argument("--window", default="24h")
|
||||
s = sub.add_parser("stream")
|
||||
s.add_argument("keys", nargs="+", choices=sorted(STREAMABLE))
|
||||
s.add_argument("--interval", type=float, default=0.5)
|
||||
rt = sub.add_parser("retire")
|
||||
rt.add_argument("--pid", type=int, required=True)
|
||||
rt.add_argument("--start", type=int, default=0)
|
||||
args = ap.parse_args(argv)
|
||||
return {"live": cmd_live, "query": cmd_query, "events": cmd_events,
|
||||
"tests": cmd_tests, "peak": cmd_peak, "report": cmd_report,
|
||||
"stream": cmd_stream, "retire": cmd_retire}[args.cmd](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user