Files
Panama/config/dot/quickshell/services/Snapshots.qml
T

248 lines
9.4 KiB
QML

pragma Singleton
// Snapshots: points in time you can go back to, per btrfs subvolume.
//
// snapper was already running on this machine when this was written, hourly,
// for / only -- and /home is a separate subvolume with no configuration, so six
// hundred snapshots existed and not one contained a document. The page leads
// with what is protected and what is not for that reason.
//
// Deliberately absent: rollback. snapper's rollback changes the btrfs default
// subvolume, and an fstab that pins subvol= overrides it, so a rollback would
// report success and change nothing after a reboot. Restoring files and folders
// needs no reboot and cannot affect booting.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-snapshots"
property var configs: []
property var unprotected: []
property bool timelineRunning: false
property var space: ({})
property bool scanned: false
property string lastError: ""
// The last restore, so the page can say what happened to the file that was
// already there rather than leaving someone to wonder.
property var lastRestore: null
// Browsing state: which snapshot is open, where inside it, and what is there.
property string browsingConfig: ""
property int browsingSnapshot: 0
property string browsingPath: ""
property var browseEntries: []
property bool browseTruncated: false
property bool browsing: false
// Guards read the Process objects directly; a derived binding is stale
// inside the handler that changes it. See DefaultApps.qml.
readonly property bool busy: query.running || mutation.running
// How many snapshots a horizon may keep. The page offers a dropdown rather
// than a number field, and this is what fills it; the helper refuses
// anything above it too. 999 hourly snapshots is not a retention policy,
// it is a typo that fills a drive.
//
// The monthly and yearly limits are not editable here and are never
// written, so a configuration with longer horizons keeps them.
readonly property int retentionMax: 50
readonly property var retentionChoices: {
const values = [];
for (let index = 0; index <= root.retentionMax; index += 1)
values.push(index);
return values;
}
// The card the browser lives in is not the card it was opened from: the
// browse state below is independent of any config row's expanded state, so
// opening a snapshot from a collapsed row still shows the files.
readonly property bool browserOpen: root.browsingConfig !== ""
function labelFor(config: var): string {
const subvolume = String(config?.subvolume ?? "");
if (subvolume === "/")
return "System";
if (subvolume === "/home")
return "Home";
return subvolume;
}
function describe(config: var): string {
const snapshots = config?.snapshots ?? [];
if (!config?.readable)
return "This account cannot read this configuration";
if (snapshots.length === 0)
return config?.timelineEnabled
? "Protected — the first snapshot is taken on the hour"
: "Configured, but automatic snapshots are off";
const oldest = snapshots[snapshots.length - 1];
return snapshots.length + " snapshot" + (snapshots.length === 1 ? "" : "s")
+ " · oldest " + String(oldest.date ?? "").replace(/^\w{3} /, "");
}
function retentionSummary(config: var): string {
const limits = config?.limits ?? ({});
const parts = [];
if (Number(limits.hourly ?? 0) > 0) parts.push(limits.hourly + " hourly");
if (Number(limits.daily ?? 0) > 0) parts.push(limits.daily + " daily");
if (Number(limits.weekly ?? 0) > 0) parts.push(limits.weekly + " weekly");
return parts.length > 0 ? parts.join(" · ") : "Nothing kept automatically";
}
function formatBytes(bytes: real): string {
if (!(bytes > 0))
return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let index = 0;
while (value >= 1000 && index < units.length - 1) {
value /= 1000;
index += 1;
}
return value.toFixed(value < 10 && index > 1 ? 1 : 0) + " " + units[index];
}
function refresh(): void {
if (query.running)
return;
query.command = [root.helperPath, "snapshot"];
query.running = true;
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.configs = Array.isArray(parsed.configs) ? parsed.configs : [];
root.unprotected = Array.isArray(parsed.unprotected) ? parsed.unprotected : [];
root.timelineRunning = parsed.timelineRunning === true;
root.space = parsed.space ?? ({});
root.lastError = String(parsed.error ?? "");
if (parsed.restored)
root.lastRestore = parsed.restored;
} catch (error) {
root.lastError = "Could not read the snapshot service's answer.";
console.warn("Snapshots: could not parse helper output:", error);
}
root.scanned = true;
}
function run(arguments: var): void {
if (mutation.running)
return;
root.lastError = "";
mutation.command = [root.helperPath].concat(arguments);
mutation.running = true;
}
function take(config: string, description: string): void {
root.run(["create", config, description]);
}
function remove(config: string, number: int): void {
root.run(["delete", config, String(number)]);
}
// Three horizons, each a whole number the dropdown offered. Checked here as
// well as in the helper because the caller is a page that can be wrong, and
// a keep count that arrives as "5.5" or "-1" would be written straight into
// snapper's config file.
function setRetention(config: string, hourly: int, daily: int, weekly: int): void {
const values = [hourly, daily, weekly];
for (const value of values) {
const number = Number(value);
if (!Number.isInteger(number) || number < 0 || number > root.retentionMax) {
root.lastError = "Keep counts go from 0 to " + root.retentionMax + ".";
return;
}
}
root.run(["set-retention", config,
String(values[0]), String(values[1]), String(values[2])]);
}
function setTimeline(config: string, enabled: bool): void {
root.run(["set-timeline", config, enabled ? "true" : "false"]);
}
function restore(config: string, number: int, path: string): void {
root.lastRestore = null;
root.run(["restore", config, String(number), path]);
}
// Browsing is a separate process from the snapshot, because a directory
// listing inside a terabyte-scale subvolume is not something to do while
// opening a page.
function browse(config: string, number: int, path: string): void {
if (browseProcess.running)
return;
root.browsingConfig = config;
root.browsingSnapshot = number;
root.browsingPath = path;
root.browsing = true;
browseProcess.command = [root.helperPath, "browse", config, String(number), path];
browseProcess.running = true;
}
function closeBrowser(): void {
root.browsingConfig = "";
root.browsingSnapshot = 0;
root.browsingPath = "";
root.browseEntries = [];
root.browseTruncated = false;
}
// One level up, or out of the browser at the top.
function browseUp(): void {
const trimmed = String(root.browsingPath).replace(/\/+$/, "");
if (trimmed === "") {
root.closeBrowser();
return;
}
const parent = trimmed.indexOf("/") < 0 ? "" : trimmed.slice(0, trimmed.lastIndexOf("/"));
root.browse(root.browsingConfig, root.browsingSnapshot, parent);
}
Process {
id: query
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: mutation
// Answers with the fresh state, so the page updates from the change
// itself rather than asking again afterwards.
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: browseProcess
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.browseEntries = Array.isArray(parsed.entries) ? parsed.entries : [];
root.browseTruncated = parsed.truncated === true;
if (String(parsed.error ?? "") !== "") {
root.lastError = String(parsed.error);
root.browseEntries = [];
}
} catch (error) {
root.lastError = "Could not read that folder from the snapshot.";
}
}
}
onExited: root.browsing = false
}
}