248 lines
9.4 KiB
QML
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
|
|
}
|
|
}
|