- 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
403 lines
17 KiB
Python
403 lines
17 KiB
Python
"""Persistence: per-minute rows, hourly rollups, tests and events.
|
|
|
|
sqlite from the standard library, in WAL mode so the CLI can read a window
|
|
of history while the daemon is mid-write. Raw half-second samples never
|
|
reach the disk — they are folded into a minute row and discarded, which is
|
|
what keeps a month of continuous monitoring under about 12 MB.
|
|
"""
|
|
|
|
import functools
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from .probes import nearest_rank
|
|
|
|
SAMPLE_COLUMNS = [
|
|
"local_p50", "local_p95", "local_jitter", "local_loss",
|
|
"wan_p50", "wan_p95", "wan_jitter", "wan_loss",
|
|
# 0.2.20: the two order statistics the scored fold and its critics
|
|
# actually turn on. Lag leans on p75; Orb headlines a high-water max;
|
|
# LibreQoS takes a phase percentile. Comparing those against our own
|
|
# history was only possible as an upper bound because neither was ever
|
|
# written down — p50 and p95 alone cannot reconstruct either. Recorded
|
|
# now, scored never: the decision needs real days behind it, the same
|
|
# rule loaded latency was held to in 0.1.11.
|
|
"local_p75", "local_max", "wan_p75", "wan_max",
|
|
"lag", "rx_bps", "tx_bps", "signal_dbm",
|
|
"resp", "rel", "spd", "idx",
|
|
# Latency split by what the link was doing at the time. The gap between
|
|
# them is bufferbloat, and it only accumulates into something worth
|
|
# scoring if it is recorded minute by minute first.
|
|
"lag_idle", "lag_loaded",
|
|
# 0.2.0: what ICMP alone would have scored, beside the instrument-
|
|
# scored lag — the basis switch stays auditable per minute.
|
|
"lag_icmp",
|
|
]
|
|
|
|
# Minute-only, and deliberately not in SAMPLE_COLUMNS: `rollup_hours` averages
|
|
# everything in that list, and a mean of drains destroys the one thing the
|
|
# drain is being stored for. Its value is quantised by probe cadence, so what
|
|
# has to survive is the DISTRIBUTION — sixty of them averaged is a number with
|
|
# none of that in it.
|
|
#
|
|
# 0.2.37. Until now the drain was published to live.json and stored nowhere,
|
|
# so a figure on screen could never be checked afterwards; the distribution
|
|
# that showed it was quantised had to come from the other implementation
|
|
# because this one had no history to look at.
|
|
#
|
|
# Three numbers rather than one. `drain_settled` says whether the link
|
|
# recovered or the window merely ended, so a censored floor is not read as a
|
|
# measurement. `drain_min_ms` is the tightest bound across the seated
|
|
# instruments beside the loosest, which is what is published today — carrying
|
|
# both is what lets the choice between them be settled from history instead of
|
|
# argued. `drain_src` names the instrument the published value came from,
|
|
# because each instrument's value is floored at its own cadence and knowing
|
|
# which one won is the difference between an auditable figure and a guess.
|
|
MINUTE_ONLY_REAL = ["drain_ms", "drain_min_ms", "drain_settled"]
|
|
MINUTE_ONLY_TEXT = ["drain_src"]
|
|
|
|
_COLS_SQL = ", ".join(f"{c} REAL" for c in SAMPLE_COLUMNS)
|
|
_MINUTE_EXTRA_SQL = ", ".join(
|
|
[f"{c} REAL" for c in MINUTE_ONLY_REAL] + [f"{c} TEXT" for c in MINUTE_ONLY_TEXT])
|
|
|
|
SCHEMA = f"""
|
|
CREATE TABLE IF NOT EXISTS minute (
|
|
ts INTEGER PRIMARY KEY, {_COLS_SQL}, {_MINUTE_EXTRA_SQL},
|
|
iface TEXT, network TEXT, probes TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS hour (
|
|
ts INTEGER PRIMARY KEY, {_COLS_SQL}, iface TEXT, network TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS tests (
|
|
ts INTEGER PRIMARY KEY, kind TEXT, engine TEXT,
|
|
down_mbps REAL, up_mbps REAL, ping_idle REAL, ping_loaded REAL,
|
|
jitter REAL, bytes INTEGER, server TEXT, ok INTEGER, detail TEXT,
|
|
network TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ts INTEGER NOT NULL, ended_ts INTEGER, kind TEXT, severity TEXT,
|
|
leg TEXT, detail TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS events_ts ON events(ts);
|
|
CREATE INDEX IF NOT EXISTS tests_kind_ts ON tests(kind, ts);
|
|
"""
|
|
|
|
|
|
def _locked(method):
|
|
"""Serialise access to the one connection — see Store."""
|
|
@functools.wraps(method)
|
|
def wrapper(self, *args, **kwargs):
|
|
with self._lock:
|
|
return method(self, *args, **kwargs)
|
|
return wrapper
|
|
|
|
|
|
class Store:
|
|
"""One connection, one lock.
|
|
|
|
Three threads reach this object: the daemon loop (minute rows, events,
|
|
a read for Reliability on every tick), the content-test worker and the
|
|
peak-test worker (one row each when they finish). The connection is
|
|
opened with `check_same_thread=False`, which only tells the sqlite3
|
|
module to allow that — it does not make concurrent use of one
|
|
connection safe, and a forced overlap reproduces "bad parameter or
|
|
other API misuse" and a lost row. Every transaction here is a few
|
|
milliseconds, so a mutex is the whole fix; a writer thread with a
|
|
queue was considered and is more machinery than three callers need.
|
|
"""
|
|
|
|
def __init__(self, path: Path, read_only: bool = False):
|
|
self.path = Path(path)
|
|
self._lock = threading.RLock()
|
|
if read_only:
|
|
uri = f"file:{self.path}?mode=ro"
|
|
self.db = sqlite3.connect(uri, uri=True, timeout=5.0)
|
|
else:
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
self.db = sqlite3.connect(self.path, timeout=5.0,
|
|
check_same_thread=False)
|
|
self.db.executescript(SCHEMA)
|
|
self._migrate()
|
|
self.db.execute("PRAGMA journal_mode=WAL")
|
|
self.db.execute("PRAGMA synchronous=NORMAL")
|
|
self.db.commit()
|
|
self.db.row_factory = sqlite3.Row
|
|
|
|
def _migrate(self):
|
|
"""Additive migrations for databases created by older versions."""
|
|
for table, column in (("tests", "network"),
|
|
("minute", "lag_idle"), ("minute", "lag_loaded"),
|
|
("hour", "lag_idle"), ("hour", "lag_loaded"),
|
|
("minute", "lag_icmp"), ("hour", "lag_icmp"),
|
|
("minute", "probes"),
|
|
("minute", "local_p75"), ("hour", "local_p75"),
|
|
("minute", "local_max"), ("hour", "local_max"),
|
|
("minute", "wan_p75"), ("hour", "wan_p75"),
|
|
("minute", "wan_max"), ("hour", "wan_max"),
|
|
("minute", "drain_ms"),
|
|
("minute", "drain_min_ms"),
|
|
("minute", "drain_settled"),
|
|
("minute", "drain_src")):
|
|
try:
|
|
self.db.execute(
|
|
f"ALTER TABLE {table} ADD COLUMN {column} "
|
|
f"{'TEXT' if column in ('network', 'probes', 'drain_src') else 'REAL'}")
|
|
except sqlite3.OperationalError:
|
|
pass # column already there
|
|
|
|
@_locked
|
|
def close(self):
|
|
try:
|
|
self.db.close()
|
|
except sqlite3.Error:
|
|
pass
|
|
|
|
# ---------------------------------------------------------------- writes
|
|
|
|
@_locked
|
|
def put_minute(self, ts: int, values: dict, iface: str = "",
|
|
network: str = "", probes: str = ""):
|
|
extra = MINUTE_ONLY_REAL + MINUTE_ONLY_TEXT
|
|
cols = ["ts"] + SAMPLE_COLUMNS + extra + ["iface", "network", "probes"]
|
|
row = ([int(ts)]
|
|
+ [values.get(c) for c in SAMPLE_COLUMNS]
|
|
+ [values.get(c) for c in extra]
|
|
+ [iface, network, probes])
|
|
placeholders = ", ".join("?" * len(cols))
|
|
self.db.execute(
|
|
f"INSERT OR REPLACE INTO minute ({', '.join(cols)}) VALUES ({placeholders})",
|
|
row,
|
|
)
|
|
self.db.commit()
|
|
|
|
@_locked
|
|
def put_test(self, ts: int, kind: str, engine: str, **kw):
|
|
self.db.execute(
|
|
"""INSERT OR REPLACE INTO tests
|
|
(ts, kind, engine, down_mbps, up_mbps, ping_idle, ping_loaded,
|
|
jitter, bytes, server, ok, detail, network)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(int(ts), kind, engine, kw.get("down_mbps"), kw.get("up_mbps"),
|
|
kw.get("ping_idle"), kw.get("ping_loaded"), kw.get("jitter"),
|
|
kw.get("bytes"), kw.get("server"), 1 if kw.get("ok", True) else 0,
|
|
kw.get("detail", ""), kw.get("network", "")),
|
|
)
|
|
self.db.commit()
|
|
|
|
@_locked
|
|
def open_event(self, ts: int, kind: str, severity: str, leg: str, detail: str) -> int:
|
|
cur = self.db.execute(
|
|
"INSERT INTO events (ts, kind, severity, leg, detail) VALUES (?,?,?,?,?)",
|
|
(int(ts), kind, severity, leg, detail),
|
|
)
|
|
self.db.commit()
|
|
return cur.lastrowid
|
|
|
|
@_locked
|
|
def close_event(self, event_id: int, ended_ts: int, detail: str = None):
|
|
if detail is None:
|
|
self.db.execute("UPDATE events SET ended_ts=? WHERE id=?",
|
|
(int(ended_ts), event_id))
|
|
else:
|
|
self.db.execute("UPDATE events SET ended_ts=?, detail=? WHERE id=?",
|
|
(int(ended_ts), detail, event_id))
|
|
self.db.commit()
|
|
|
|
# ------------------------------------------------------------- maintenance
|
|
|
|
@_locked
|
|
def rollup_hours(self, now: float = None):
|
|
"""Fold complete minutes into hour rows.
|
|
|
|
Averages the averages, which is fair because every minute row covers
|
|
the same span. Percentiles do not survive that — an hourly p95 built
|
|
from sixty per-minute p95s is a mean of p95s, and it is labelled as
|
|
such wherever it is displayed. The same caveat binds harder to the
|
|
new max columns: an hourly `local_max` is a mean of sixty maxima,
|
|
which is not the hour's worst sample and must never be shown as one.
|
|
Use the minute rows for anything that reasons about the tail.
|
|
|
|
An hour that spanned two networks is labelled with neither: MAX()
|
|
would pick whichever name sorts last and file the other network's
|
|
minutes under it. Blank is "mixed", which no consumer can mistake
|
|
for a network.
|
|
"""
|
|
now = now or time.time()
|
|
current_hour = int(now // 3600) * 3600
|
|
avg = ", ".join(f"AVG({c}) AS {c}" for c in SAMPLE_COLUMNS)
|
|
self.db.execute(
|
|
f"""INSERT OR REPLACE INTO hour
|
|
(ts, {', '.join(SAMPLE_COLUMNS)}, iface, network)
|
|
SELECT (ts / 3600) * 3600 AS bucket, {avg},
|
|
CASE WHEN COUNT(DISTINCT iface) > 1 THEN ''
|
|
ELSE MAX(iface) END,
|
|
CASE WHEN COUNT(DISTINCT network) > 1 THEN ''
|
|
ELSE MAX(network) END
|
|
FROM minute WHERE ts < ? GROUP BY bucket""",
|
|
(current_hour,),
|
|
)
|
|
self.db.commit()
|
|
|
|
@_locked
|
|
def prune(self, minute_days: int = 7, hour_days: int = 400, now: float = None):
|
|
now = now or time.time()
|
|
self.db.execute("DELETE FROM minute WHERE ts < ?",
|
|
(int(now - minute_days * 86400),))
|
|
self.db.execute("DELETE FROM hour WHERE ts < ?",
|
|
(int(now - hour_days * 86400),))
|
|
# Events were never pruned before 0.2.21 — about sixty rows a day,
|
|
# unbounded. They keep the hourly history's horizon.
|
|
self.db.execute("DELETE FROM events WHERE ts < ?",
|
|
(int(now - hour_days * 86400),))
|
|
self.db.commit()
|
|
|
|
@_locked
|
|
def close_orphans(self, now: float = None) -> int:
|
|
"""Close events a previous daemon left open. Returns how many.
|
|
|
|
Only the daemon that opened an event can close it, so one that
|
|
died mid-outage — or was retired by the version handover with a
|
|
rate-drop open — leaves `ended_ts` NULL for good. Two readers
|
|
treat NULL as "still happening": `outage_stats` would charge such
|
|
an outage against every Reliability window forever, and `events`
|
|
would list it as ongoing. When it actually ended is unknowable,
|
|
so it is closed at the shortest span the store accepts rather
|
|
than at a guessed later time: undercharging by the lost tail is
|
|
the safe direction, and inventing a duration is not.
|
|
|
|
Called once, after the lock is held — a second daemon that loses
|
|
the flock must not close the running one's events on its way out.
|
|
"""
|
|
cur = self.db.execute(
|
|
"UPDATE events SET ended_ts = ts + 1 WHERE ended_ts IS NULL")
|
|
self.db.commit()
|
|
return cur.rowcount
|
|
|
|
# ---------------------------------------------------------------- reads
|
|
|
|
@_locked
|
|
def series(self, seconds: float, now: float = None,
|
|
resolution: str = "auto") -> list:
|
|
"""History over a window, at whichever resolution suits it.
|
|
|
|
Auto: under six hours reads per-minute rows; anything longer reads
|
|
hourly ones, so a seven-day graph is 168 points rather than 10,080.
|
|
Callers that genuinely want the fine rows (the 24 h experience
|
|
ribbon) ask for "minute" explicitly.
|
|
"""
|
|
now = now or time.time()
|
|
if resolution in ("minute", "hour"):
|
|
table = resolution
|
|
else:
|
|
table = "minute" if seconds <= 6 * 3600 else "hour"
|
|
rows = self.db.execute(
|
|
f"SELECT * FROM {table} WHERE ts >= ? ORDER BY ts",
|
|
(int(now - seconds),),
|
|
).fetchall()
|
|
return [dict(r) for r in rows], table
|
|
|
|
@_locked
|
|
def tests(self, limit: int = 20, kind: str = None) -> list:
|
|
if kind:
|
|
rows = self.db.execute(
|
|
"SELECT * FROM tests WHERE kind=? ORDER BY ts DESC LIMIT ?",
|
|
(kind, limit)).fetchall()
|
|
else:
|
|
rows = self.db.execute(
|
|
"SELECT * FROM tests ORDER BY ts DESC LIMIT ?", (limit,)).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
@_locked
|
|
def events(self, seconds: float = 7 * 86400, limit: int = 100,
|
|
now: float = None) -> list:
|
|
"""Events overlapping the window, newest first.
|
|
|
|
Filtering on start time alone dropped an outage that began before
|
|
the window and ended inside it — the one the user opens the list
|
|
to see. Same overlap rule `outage_stats` has always used.
|
|
"""
|
|
now = now or time.time()
|
|
start = int(now - seconds)
|
|
rows = self.db.execute(
|
|
"""SELECT * FROM events
|
|
WHERE ts >= ? OR ended_ts IS NULL OR ended_ts >= ?
|
|
ORDER BY ts DESC LIMIT ?""",
|
|
(start, start, limit)).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
@_locked
|
|
def baseline_speed(self, days: int = 30, network: str = "",
|
|
min_samples: int = 5, now: float = None,
|
|
fallback: bool = True):
|
|
"""This connection's own normal: the p90 of recent content downloads.
|
|
|
|
p90 rather than max so one lucky quiet-hour run does not set a bar
|
|
the line can never reach again. Scoped to the current network when
|
|
it has enough samples — the office's normal is not the home's —
|
|
falling back to all networks, and to None until there is enough
|
|
history to mean anything.
|
|
"""
|
|
now = now or time.time()
|
|
since = int(now - days * 86400)
|
|
|
|
def p90(rows):
|
|
vals = sorted(r["down_mbps"] for r in rows
|
|
if r["down_mbps"] is not None)
|
|
if len(vals) < min_samples:
|
|
return None
|
|
return nearest_rank(vals, 0.9)
|
|
|
|
if network:
|
|
rows = self.db.execute(
|
|
"""SELECT down_mbps FROM tests
|
|
WHERE kind='content' AND ok=1 AND ts >= ? AND network = ?""",
|
|
(since, network)).fetchall()
|
|
result = p90(rows)
|
|
if result is not None:
|
|
return result
|
|
# The caller decides whether a cross-network baseline is meaningful.
|
|
# For the degradation penalty it is not: "is it normal here" cannot
|
|
# be answered with another network's normal.
|
|
if not fallback:
|
|
return None
|
|
rows = self.db.execute(
|
|
"""SELECT down_mbps FROM tests
|
|
WHERE kind='content' AND ok=1 AND ts >= ?""",
|
|
(since,)).fetchall()
|
|
return p90(rows)
|
|
|
|
@_locked
|
|
def outage_stats(self, seconds: float, now: float = None):
|
|
"""(fraction fully down, count of disruptions, fraction disrupted).
|
|
|
|
Disruptions carry their duration as well as their count because
|
|
reliability charges both kinds of interruption in the same currency —
|
|
time. Counting alone made three brief blips outweigh an hour offline.
|
|
"""
|
|
now = now or time.time()
|
|
start = now - seconds
|
|
rows = self.db.execute(
|
|
"""SELECT ts, ended_ts, kind FROM events
|
|
WHERE kind IN ('outage', 'disruption') AND (ended_ts IS NULL OR ended_ts >= ?)""",
|
|
(int(start),)).fetchall()
|
|
down = 0.0
|
|
disrupted = 0.0
|
|
disruptions = 0
|
|
for r in rows:
|
|
begin = max(r["ts"], start)
|
|
end = r["ended_ts"] if r["ended_ts"] else now
|
|
end = min(end, now)
|
|
if end <= begin:
|
|
continue
|
|
if r["kind"] == "outage":
|
|
down += end - begin
|
|
else:
|
|
disruptions += 1
|
|
disrupted += end - begin
|
|
span = seconds if seconds else 0.0
|
|
return ((down / span if span else 0.0), disruptions,
|
|
(disrupted / span if span else 0.0))
|