diff options
| -rw-r--r-- | hosts/Hosts.qml | 123 | ||||
| -rw-r--r-- | hosts/README.md | 11 | ||||
| -rwxr-xr-x | hosts/hosts.py | 88 |
3 files changed, 198 insertions, 24 deletions
diff --git a/hosts/Hosts.qml b/hosts/Hosts.qml index fb2c0e1..6dc4ea2 100644 --- a/hosts/Hosts.qml +++ b/hosts/Hosts.qml @@ -20,6 +20,9 @@ Scope { property bool open: false property string monitor: "DP-1" property var hosts: [] + // name -> reachable, from `hosts.py --probe`; a host missing from it is + // not probed yet and its dot stays grey. + property var up: ({}) property string query: "" property int current: 0 // Non-empty while loading or after a failure; the list shows only when @@ -30,13 +33,36 @@ Scope { readonly property var shown: { const words = root.query.toLowerCase().split(/\s+/).filter(w => w); return root.hosts.filter(h => { - const hay = [h.name, h.ip, h.port, h.hostname, h.node, h.ct_id] + const hay = [h.name, h.ip, h.port, h.hostname, h.node, h.ct_id, h.group] .concat(h.aliases).join(" ").toLowerCase(); return words.every(w => hay.includes(w)); }); } onShownChanged: root.current = 0 + // The grid's rows: a group starts a new row, which carries the group's + // title, and no row holds more than `columns` cards. `start` is the + // index in `shown` of the row's first card. + readonly property int columns: 5 + readonly property var rows: { + const out = []; + root.shown.forEach((h, i) => { + const last = out[out.length - 1]; + if (last && last.group === h.group && last.items.length < root.columns) + last.items.push(h); + else + out.push({ group: h.group, title: last && last.group === h.group ? "" : h.group, + start: i, items: [h] }); + }); + return out; + } + + function rowOf(i) { + for (let r = root.rows.length - 1; r >= 0; r--) + if (root.rows[r].start <= i) return r; + return 0; + } + readonly property var screenObj: Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] @@ -67,6 +93,14 @@ Scope { root.current = Math.max(0, Math.min(root.shown.length - 1, root.current + step)); } + // Up and Down keep the column, clamped to a shorter row's last card. + function moveRow(step) { + const r = root.rowOf(root.current); + const to = root.rows[r + step]; + if (to) + root.current = to.start + Math.min(root.current - root.rows[r].start, to.items.length - 1); + } + function runFirst(h) { const a = h ? root.actions(h) : []; if (a.length) root.run(a[0]); @@ -77,9 +111,24 @@ Scope { onOpenChanged: if (open) { root.status = "Loading..."; root.query = ""; + root.up = {}; // A Process already running ignores running = true. proc.running = false; proc.running = true; + probe.running = false; + probe.running = true; + } + + // Takes about a second, every host at once, so it runs beside the list + // rather than holding it up. + Process { + id: probe + command: ["/usr/bin/python3", `${Quickshell.shellDir}/hosts.py`, "--probe"] + stdout: StdioCollector { + onStreamFinished: { + try { root.up = JSON.parse(text); } catch (e) {} + } + } } Process { @@ -164,8 +213,8 @@ Scope { // text: a query is short enough to retype. Keys.onLeftPressed: root.move(-1) Keys.onRightPressed: root.move(1) - Keys.onUpPressed: root.move(-grid.columns) - Keys.onDownPressed: root.move(grid.columns) + Keys.onUpPressed: root.moveRow(-1) + Keys.onDownPressed: root.moveRow(1) Keys.onReturnPressed: root.runFirst(root.shown[root.current]) Keys.onEnterPressed: root.runFirst(root.shown[root.current]) @@ -187,14 +236,14 @@ Scope { font.pixelSize: 18 } - // Five columns in rows, each row as tall as its - // busiest card, so one with many chips grows its own row - // and no other. The margins keep the selected card's + // Five columns in rows grouped by node, then Devices and + // Remote, each row as tall as its busiest card, so one + // with many chips grows its own row and no other. The margins keep the selected card's // glow inside the clip. Rows rather than a GridView, // whose cells all share one height. Flickable { id: grid - readonly property int columns: 5 + readonly property int columns: root.columns visible: root.status === "" width: parent.width height: parent.height - y @@ -204,7 +253,7 @@ Scope { // Keep the selected card in view as the arrows move. function reveal(i) { - const r = rowRepeater.itemAt(Math.floor(i / columns)); + const r = rowRepeater.itemAt(root.rowOf(i)); if (!r) return; const top = rows.y + r.y - 24; const bottom = rows.y + r.y + r.height + 24; @@ -229,17 +278,40 @@ Scope { Repeater { id: rowRepeater - model: { - const out = []; - for (let i = 0; i < root.shown.length; i += grid.columns) - out.push(root.shown.slice(i, i + grid.columns)); - return out; - } + model: root.rows + + Column { + id: section + required property var modelData + spacing: 12 + // A group's first row opens it with a + // title and a rule; later rows are bare. + topPadding: modelData.title && modelData.start > 0 ? 20 : 0 + + Column { + visible: section.modelData.title !== "" + spacing: 6 + + Text { + text: section.modelData.title + color: section.modelData.group === "Devices" + || section.modelData.group === "Remote" + ? Theme.subtext : Theme.accent + font.family: Theme.fontFamily + font.pixelSize: 18 + font.bold: true + } + Rectangle { + width: rows.width + height: 1 + color: Theme.overlay + } + } Row { id: row - required property var modelData - required property int index + readonly property var modelData: section.modelData.items + readonly property int start: section.modelData.start readonly property real tallest: { let m = 0; for (const c of children) @@ -255,7 +327,7 @@ Scope { id: card required property var modelData required property int index - readonly property int at: row.index * grid.columns + index + readonly property int at: row.start + index readonly property bool selected: at === root.current // What this card needs; the row takes the most. readonly property real need: info.implicitHeight + 24 @@ -279,11 +351,25 @@ Scope { onClicked: root.runFirst(card.modelData) } + // Reachable now: green, not: red, + // not probed yet: grey. + Rectangle { + readonly property var state: root.up[card.modelData.name] + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: 14 + width: 10 + height: 10 + radius: 5 + color: state === true ? Theme.green + : state === false ? Theme.red : Theme.overlay + } + Column { id: info x: 16 y: 12 - width: parent.width - 32 + width: parent.width - 48 spacing: 4 Text { @@ -351,6 +437,7 @@ Scope { } } } + } } } } diff --git a/hosts/README.md b/hosts/README.md index 1be1dd7..72eb5e1 100644 --- a/hosts/README.md +++ b/hosts/README.md @@ -12,8 +12,17 @@ it runs on, and the actions its data allows: - `open`, when the note has a `url`; - `copy <ip:port>`, through `wl-copy`. +Cards are grouped: one section per Proxmox node, its own card first and +then every note whose `node` names it; then `Devices`, anything else on a +private address (phone, printers, IoT); then `Remote`, the rest of the ssh +aliases. A dot top-right says whether the host answers now: green yes, red +no, grey while `hosts.py --probe` runs (about a second, all hosts at once, +started with the list on each opening). A host with a `port`, or a `url`, +is probed by a TCP connect to it, so a service whose CT is up but whose +daemon is not reads red; anything else by one ping. + Type to filter (every word must match name, ip, port, hostname, node, CT id -or alias), Up/Down to move, Enter runs the first action, chips run their own. +alias or group), arrows to move, Enter runs the first action, chips run their own. `hosts.py` builds the list on every opening, so there is no cache. It reads the frontmatter of each note in the vault's `Local Network/services/` diff --git a/hosts/hosts.py b/hosts/hosts.py index 424a674..0d1b664 100755 --- a/hosts/hosts.py +++ b/hosts/hosts.py @@ -15,13 +15,23 @@ the ~/.ssh/config aliases that point at it, plus one entry per alias that points at no note, so every alias the rofi menu listed is still here. hosts.py print the list + hosts.py --probe print {name: reachable} for the same list hosts.py --check run the self-check + +Each entry carries a `group`: the Proxmox node it runs on, its own name if +other notes name it as their node, "Devices" for anything else on a private +address, "Remote" for the rest. """ +import ipaddress import json import os +import socket +import subprocess import sys +from concurrent.futures import ThreadPoolExecutor from pathlib import Path +from urllib.parse import urlsplit VAULT = os.environ.get( "HOSTS_DIR", "~/Documents/Obsidian/my KB/Knowledge base/Local Network/services") @@ -95,15 +105,69 @@ def merge(notes, aliases): "hostname": target, "node": "", "ct_id": "", "aliases": [a["alias"]], "user": a["user"], }) - return out - - -def main(): + return group(out) + + +def private(ip): + try: + return ipaddress.ip_address(ip).is_private + except ValueError: + return False + + +def group(hosts): + """Set each host's group and sort: nodes in name order, each with its own + card first, then Devices, then Remote.""" + nodes = sorted({h["node"] for h in hosts if h["node"]}) + for h in hosts: + h["group"] = (h["node"] or (h["name"] if h["name"] in nodes else "") + or ("Devices" if private(h["ip"]) else "Remote")) + order = nodes + ["Devices", "Remote"] + # sorted() is stable, so within a group the note order holds. + return sorted(hosts, key=lambda h: (order.index(h["group"]), + h["name"] != h["group"])) + + +def target(h): + """What to probe: (host, tcp port), port None meaning ICMP. A service is + up when its port answers, not merely when its CT does.""" + url = urlsplit(h["url"]) if h["url"] else None + port = h["port"] or (url and (url.port or {"http": 80, "https": 443}.get(url.scheme))) + return h["ip"] or h["hostname"], int(port) if port else None + + +def reachable(h): + host, port = target(h) + if not host: + return False + if port: + try: + socket.create_connection((host, port), timeout=1).close() + return True + except OSError: + return False + return subprocess.run(["ping", "-c1", "-W1", host], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL).returncode == 0 + + +def probe(hosts): + # ponytail: one thread per host, ~1s for the whole list; a pool cap + # if the list ever reaches the hundreds. + with ThreadPoolExecutor(max_workers=max(1, len(hosts))) as pool: + return dict(zip((h["name"] for h in hosts), pool.map(reachable, hosts))) + + +def load(): notes = [(p.stem, frontmatter(p.read_text(errors="replace"))) for p in sorted(Path(VAULT).expanduser().glob("*.md"))] cfg = Path(SSH_CONFIG).expanduser() aliases = ssh_hosts(cfg.read_text(errors="replace")) if cfg.exists() else [] - json.dump(merge(notes, aliases), sys.stdout) + return merge(notes, aliases) + + +def main(): + hosts = load() + json.dump(probe(hosts) if "--probe" in sys.argv else hosts, sys.stdout) def check(): @@ -128,6 +192,20 @@ def check(): assert by["Media"]["aliases"] == ["media"], "short hostname match" assert by["lone"]["port"] == "2222" and by["lone"]["node"] == "" assert len(hosts) == 4 + assert by["lone"]["group"] == "Remote" and by["Box"]["group"] == "HP server" + + g = group([{"name": "dev", "node": "", "ip": "10.0.0.3"}, + {"name": "svc", "node": "N", "ip": "10.0.0.4"}, + {"name": "far", "node": "", "ip": "example.org"}, + {"name": "N", "node": "", "ip": "10.0.0.2"}]) + assert [(h["name"], h["group"]) for h in g] == [ + ("N", "N"), ("svc", "N"), ("dev", "Devices"), ("far", "Remote")], g + + t = lambda **kw: target({"ip": "", "hostname": "", "port": "", "url": "", **kw}) + assert t(ip="10.0.0.7", port="5000") == ("10.0.0.7", 5000) + assert t(ip="10.0.0.7", url="https://x.example.org") == ("10.0.0.7", 443) + assert t(ip="10.0.0.7", url="http://x.example.org:81") == ("10.0.0.7", 81) + assert t(hostname="example.org") == ("example.org", None) print("ok") |
