Add a snapshot manager, and start covering home
The machine already had snapper running hourly on btrfs, so the tool was never missing. What was missing is that snapper's only configuration covered / -- and /home is a separate subvolume with no configuration at all. Six hundred and forty-three snapshots existed and not one of them contained a document. Anyone reaching for file history would have found their system and none of their files. /home now has a configuration on the same hourly timeline, with deliberately conservative retention: Steam's 1.2 TB lives on that subvolume and churns on every game update, so keeping five hourly and seven daily bounds what those updates can pin. Per volume, because on this machine "one is covered and the important one is not" was the news, and a timeline opening on system snapshots would have buried it. Inside a volume the timeline is the familiar view: points in time, newest first, each openable as a folder tree to take a file out of. Restoring sets the current version aside as .before-restore-N rather than overwriting it. A restore that destroys the thing you were about to compare against is how someone loses the work they were trying to save. Rollback is deliberately absent. snapper's rollback changes the btrfs default subvolume, and this system's fstab pins subvol= explicitly, which overrides it -- so a rollback would report success and change nothing after a reboot. A recovery feature that silently does nothing is worse than not having one, and making it work means editing fstab and the bootloader, whose failure cannot be repaired from inside the desktop. Per-snapshot size is reported as not measured, because measuring it needs btrfs quotas that cost performance on every write. Free space is shown instead, which is the number that decides anything. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -78,6 +78,11 @@ Singleton {
|
||||
{ label: "Remote desktop", detail: "See and control this desktop from elsewhere", page: "sharing" },
|
||||
{ label: "Network name", detail: "The name other machines see", page: "sharing" },
|
||||
{ label: "File sharing", detail: "Share folders on the network", page: "sharing" },
|
||||
{ label: "Snapshots", detail: "Points in time you can go back to", page: "snapshots" },
|
||||
{ label: "Restore a file", detail: "Take a file or folder back out of a snapshot", page: "snapshots" },
|
||||
{ label: "Backups", detail: "Automatic snapshots of the system and your home folder", page: "snapshots" },
|
||||
{ label: "File history", detail: "Earlier versions of your files", page: "snapshots" },
|
||||
{ label: "Undo a change", detail: "Put back a file as it was at an earlier point", page: "snapshots" },
|
||||
{ label: "Free space", detail: "How full each drive and filesystem is", page: "storage" },
|
||||
{ label: "Disk usage", detail: "What is using the space on this machine", page: "storage" },
|
||||
{ label: "Drive health", detail: "Temperature, hours powered on, and reported warnings", page: "storage" },
|
||||
|
||||
@@ -92,7 +92,7 @@ Singleton {
|
||||
}
|
||||
|
||||
function openSettings(page: string): void {
|
||||
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "storage", "users", "sharing", "printers", "services", "about"];
|
||||
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "storage", "snapshots", "users", "sharing", "printers", "services", "about"];
|
||||
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
||||
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||
root.settingsOpen = true;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
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
|
||||
|
||||
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)]);
|
||||
}
|
||||
|
||||
function setRetention(config: string, hourly: int, daily: int, weekly: int): void {
|
||||
root.run(["set-retention", config, String(hourly), String(daily), String(weekly)]);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user