182 lines
7.2 KiB
QML
182 lines
7.2 KiB
QML
pragma Singleton
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// How much of each agent subscription this account has used.
|
|
//
|
|
// A collector per agent writes one display-ready record into
|
|
// $XDG_STATE_HOME/panama/agents/usage/<agent>.json, and this only ever reads
|
|
// that directory. The split is the point: adding a third agent is a collector,
|
|
// not a change here, in the widget, or in the panel -- and nothing in QML ever
|
|
// sees a credential.
|
|
//
|
|
// Discovery is by listing the directory rather than by naming the agents, so a
|
|
// record that appears is an agent that appears. Each record gets its own
|
|
// FileView with watchChanges, so a collector finishing mid-session updates the
|
|
// panel without waiting for the next tick.
|
|
//
|
|
// Every record carries its own honesty: `ready` says whether it has anything
|
|
// worth showing, `usageStatusText` says why not when it does not, and
|
|
// `retryAdvised` says a transport failure is worth retrying sooner than the
|
|
// interval. The widget hides when nothing is ready, which is right -- a bar
|
|
// indicator that says "unknown" is worse than an empty space.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
import qs.config
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
readonly property string usageDir:
|
|
(Quickshell.env("XDG_STATE_HOME") || `${Quickshell.env("HOME")}/.local/state`)
|
|
+ "/panama/agents/usage"
|
|
|
|
readonly property string updaterPath: Quickshell.shellDir + "/scripts/panama-agent-usage-update"
|
|
|
|
// Agent ids with a record file on disk, in the order the directory listed
|
|
// them. Reassigned rather than mutated: QML does not notify on in-place
|
|
// changes to a var property's contents.
|
|
property var agentIds: []
|
|
|
|
// id -> parsed record, for the ids above. Same reassignment rule.
|
|
property var records: ({})
|
|
|
|
// The records worth drawing. A collector that ran but has nothing to say
|
|
// (never signed in, never used) reports ready:false rather than zeros.
|
|
readonly property var readyRecords: root.agentIds
|
|
.map(id => root.records[id])
|
|
.filter(record => record && record.ready === true)
|
|
|
|
// The number worth showing when there is only room for one: whichever
|
|
// window across every agent is closest to its limit is the one about to
|
|
// interrupt you. -1 when nothing has a limit to report.
|
|
readonly property int headline: {
|
|
let fullest = -1;
|
|
for (const record of root.readyRecords) {
|
|
const limits = Array.isArray(record.limits) ? record.limits : [];
|
|
for (const limit of limits) {
|
|
const percent = Number(limit?.percent);
|
|
if (Number.isFinite(percent))
|
|
fullest = Math.max(fullest, percent);
|
|
}
|
|
}
|
|
return fullest < 0 ? -1 : Math.round(Math.min(1, Math.max(0, fullest)) * 100);
|
|
}
|
|
|
|
readonly property bool available: root.headline >= 0
|
|
|
|
// Any agent asking to be retried sooner than the interval. A first probe
|
|
// after login often fires before DHCP has handed out a route, and waiting a
|
|
// quarter of an hour to find out is not an answer.
|
|
readonly property bool retryAdvised: root.agentIds
|
|
.some(id => root.records[id]?.retryAdvised === true)
|
|
|
|
// Minutes, never a repaint. Usage moves slowly and the collectors make a
|
|
// network call; anything faster would spend someone's battery watching a
|
|
// number that changes a few times an hour. Clamped so a hand-edited
|
|
// settings.json cannot turn the bar into a request loop.
|
|
readonly property int refreshMinutes: Math.max(5, Math.min(60, Settings.agentUsageRefreshMinutes))
|
|
|
|
function refresh(force: bool): void {
|
|
if (collect.running)
|
|
return;
|
|
collect.command = force ? [root.updaterPath, "--force"] : [root.updaterPath];
|
|
collect.running = true;
|
|
}
|
|
|
|
// Called by the panel when it opens: the local scans may be reused, but the
|
|
// limits are what the panel is being opened to read.
|
|
function refreshLimits(): void {
|
|
if (collect.running)
|
|
return;
|
|
collect.command = [root.updaterPath, "--limits-only"];
|
|
collect.running = true;
|
|
}
|
|
|
|
function absorb(id: string, text: string): void {
|
|
const next = Object.assign({}, root.records);
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
next[id] = (parsed && typeof parsed === "object") ? parsed : null;
|
|
} catch (error) {
|
|
next[id] = null;
|
|
}
|
|
root.records = next;
|
|
}
|
|
|
|
function forget(id: string): void {
|
|
const next = Object.assign({}, root.records);
|
|
delete next[id];
|
|
root.records = next;
|
|
}
|
|
|
|
Timer {
|
|
id: tick
|
|
interval: root.refreshMinutes * 60 * 1000
|
|
running: Settings.showAgentUsage
|
|
repeat: true
|
|
triggeredOnStart: true
|
|
onTriggered: root.refresh(false)
|
|
}
|
|
|
|
// The one short timer in here, and it only ever runs while a collector has
|
|
// said its failure was a transport failure rather than an answer.
|
|
Timer {
|
|
interval: 30 * 1000
|
|
running: Settings.showAgentUsage && root.retryAdvised
|
|
repeat: true
|
|
onTriggered: root.refresh(false)
|
|
}
|
|
|
|
Process {
|
|
id: collect
|
|
command: [root.updaterPath]
|
|
onExited: scan.running = true
|
|
}
|
|
|
|
// Which records exist. `sh -c` with the directory passed as an argument
|
|
// rather than interpolated: a path is data, and a state directory is not
|
|
// somewhere to build a command string from.
|
|
Process {
|
|
id: scan
|
|
command: ["sh", "-c", 'ls -1 "$1" 2>/dev/null', "sh", root.usageDir]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
const found = [];
|
|
for (const line of String(this.text ?? "").split("\n")) {
|
|
const name = line.trim();
|
|
if (name.endsWith(".json") && name.length > 5)
|
|
found.push(name.slice(0, -5));
|
|
}
|
|
for (const id of root.agentIds)
|
|
if (found.indexOf(id) < 0)
|
|
root.forget(id);
|
|
root.agentIds = found;
|
|
}
|
|
}
|
|
}
|
|
|
|
Instantiator {
|
|
model: root.agentIds
|
|
|
|
delegate: FileView {
|
|
required property var modelData
|
|
|
|
path: root.usageDir + "/" + modelData + ".json"
|
|
printErrors: false
|
|
watchChanges: true
|
|
onFileChanged: this.reload()
|
|
onLoaded: root.absorb(modelData, this.text())
|
|
onLoadFailed: root.forget(modelData)
|
|
}
|
|
}
|
|
|
|
// Whatever the collectors last left behind is worth showing before the
|
|
// first tick lands, so the panel is not empty for the length of a probe.
|
|
// The directory may not exist yet on a machine where no collector has ever
|
|
// run; the listing fails quietly and the next fan-out creates it.
|
|
Component.onCompleted: scan.running = true
|
|
}
|