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:
@@ -121,6 +121,7 @@ Rectangle {
|
||||
case "datetime": return dateTimePage;
|
||||
case "applications": return applicationsPage;
|
||||
case "storage": return storagePage;
|
||||
case "snapshots": return snapshotsPage;
|
||||
case "users": return usersPage;
|
||||
case "sharing": return sharingPage;
|
||||
case "printers": return printersPage;
|
||||
@@ -163,6 +164,7 @@ Rectangle {
|
||||
Component { id: homePage; HomePage {} }
|
||||
Component { id: applicationsPage; ApplicationsPage {} }
|
||||
Component { id: storagePage; StoragePage {} }
|
||||
Component { id: snapshotsPage; SnapshotsPage {} }
|
||||
Component { id: usersPage; UsersPage {} }
|
||||
Component { id: sharingPage; SharingPage {} }
|
||||
Component { id: printersPage; PrintersPage {} }
|
||||
|
||||
@@ -43,6 +43,7 @@ Rectangle {
|
||||
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
||||
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
|
||||
{ page: "storage", label: "Storage", icon: "\u{F02CA}" },
|
||||
{ page: "snapshots", label: "Snapshots", icon: "\u{F0954}" },
|
||||
{ page: "users", label: "Users", icon: "\u{F0004}" },
|
||||
{ page: "services", label: "System Health", icon: "\u{F0493}" },
|
||||
{ page: "about", label: "About", icon: "\u{F02FD}" }
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
// Snapshots: what is protected, and how to get something back.
|
||||
//
|
||||
// Per volume, because on this machine the news was that one volume was covered
|
||||
// and the important one was not -- six hundred snapshots of the system, none of
|
||||
// anyone's documents. A timeline that opened on all those snapshots would have
|
||||
// buried that.
|
||||
//
|
||||
// Inside a volume, the timeline is the Time Machine view: points in time,
|
||||
// newest first, each one openable as a folder tree you can take a file out of.
|
||||
//
|
||||
// Rollback is deliberately absent. snapper's rollback changes the btrfs default
|
||||
// subvolume, and this system's fstab pins subvol= explicitly, which overrides
|
||||
// it -- so it would report success and change nothing after a reboot.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "snapshots"
|
||||
title: "Snapshots"
|
||||
lede: "Points in time you can go back to, taken automatically for each volume."
|
||||
|
||||
property string openConfig: ""
|
||||
property string confirmingDelete: ""
|
||||
property string confirmingRestore: ""
|
||||
|
||||
readonly property bool browsingOpen: Snapshots.browsingConfig !== ""
|
||||
|
||||
Component.onCompleted: Snapshots.refresh()
|
||||
|
||||
TextRow {
|
||||
visible: Snapshots.lastError !== ""
|
||||
label: "Snapshots need attention"
|
||||
detail: Snapshots.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// What just happened to the file that was already there.
|
||||
TextRow {
|
||||
visible: Snapshots.lastRestore !== null
|
||||
label: "Restored"
|
||||
detail: Snapshots.lastRestore
|
||||
? String(Snapshots.lastRestore.restored ?? "")
|
||||
+ (String(Snapshots.lastRestore.keptAs ?? "") !== ""
|
||||
? " — the version that was there was kept as "
|
||||
+ String(Snapshots.lastRestore.keptAs).split("/").pop()
|
||||
: "")
|
||||
: ""
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── Anything unprotected is the headline ─────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Snapshots.unprotected.length > 0
|
||||
title: "Not protected"
|
||||
subtitle: "These volumes have no snapshot configuration, so nothing on them can be recovered."
|
||||
|
||||
Repeater {
|
||||
model: Snapshots.unprotected
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.path ?? "")
|
||||
detail: "btrfs subvolume " + String(modelData.subvolume ?? "")
|
||||
+ " · needs a configuration, which takes a password once"
|
||||
value: "Unprotected"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── One card per volume ──────────────────────────────────────────────────
|
||||
|
||||
Repeater {
|
||||
model: Snapshots.configs
|
||||
|
||||
delegate: SettingsCard {
|
||||
id: volumeCard
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property string configName: String(volumeCard.modelData.name ?? "")
|
||||
readonly property var snapshots: volumeCard.modelData.snapshots ?? []
|
||||
readonly property bool open: root.openConfig === volumeCard.configName
|
||||
|
||||
title: Snapshots.labelFor(volumeCard.modelData)
|
||||
subtitle: String(volumeCard.modelData.subvolume ?? "")
|
||||
|
||||
SwitchRow {
|
||||
label: "Take snapshots automatically"
|
||||
detail: volumeCard.modelData.timelineEnabled
|
||||
? Snapshots.describe(volumeCard.modelData)
|
||||
: "Nothing is being taken for this volume"
|
||||
checked: volumeCard.modelData.timelineEnabled === true
|
||||
enabled: !Snapshots.busy && volumeCard.modelData.readable === true
|
||||
onToggled: value => Snapshots.setTimeline(volumeCard.configName, value)
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Keep"
|
||||
detail: "Older points are removed automatically once these counts are exceeded"
|
||||
value: Snapshots.retentionSummary(volumeCard.modelData)
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Take one now"
|
||||
detail: "Kept until you remove it, unlike the automatic ones"
|
||||
action: "Take snapshot"
|
||||
enabled: !Snapshots.busy && volumeCard.modelData.readable === true
|
||||
onTriggered: Snapshots.take(volumeCard.configName, "Taken from Settings")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "History"
|
||||
detail: volumeCard.snapshots.length === 0
|
||||
? "Nothing taken yet"
|
||||
: volumeCard.snapshots.length + " point"
|
||||
+ (volumeCard.snapshots.length === 1 ? "" : "s") + " in time"
|
||||
action: volumeCard.open ? "Hide" : "Browse…"
|
||||
enabled: volumeCard.snapshots.length > 0
|
||||
divider: volumeCard.open
|
||||
onTriggered: {
|
||||
root.confirmingDelete = "";
|
||||
Snapshots.closeBrowser();
|
||||
root.openConfig = volumeCard.open ? "" : volumeCard.configName;
|
||||
}
|
||||
}
|
||||
|
||||
// ── The timeline ─────────────────────────────────────────────────
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: volumeCard.open && !root.browsingOpen
|
||||
|
||||
Repeater {
|
||||
model: volumeCard.snapshots
|
||||
|
||||
delegate: SettingRow {
|
||||
id: pointRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string token: volumeCard.configName + ":" + pointRow.modelData.number
|
||||
readonly property bool confirming: root.confirmingDelete === pointRow.token
|
||||
|
||||
width: parent.width
|
||||
// A kept snapshot is one the timeline will not remove,
|
||||
// which is the distinction that matters when choosing
|
||||
// what to rely on later.
|
||||
icon: pointRow.modelData.kept ? "\u{F0A22}" : "\u{F0954}"
|
||||
label: String(pointRow.modelData.date ?? "")
|
||||
detail: String(pointRow.modelData.description ?? "")
|
||||
+ " · #" + pointRow.modelData.number
|
||||
+ (pointRow.modelData.kept ? " · kept" : "")
|
||||
controlWidth: 250
|
||||
divider: pointRow.index < volumeCard.snapshots.length - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Open"
|
||||
enabled: !Snapshots.browsing
|
||||
onClicked: Snapshots.browse(volumeCard.configName,
|
||||
Number(pointRow.modelData.number), "")
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: pointRow.confirming ? "Keep" : "Delete"
|
||||
enabled: !Snapshots.busy
|
||||
onClicked: root.confirmingDelete =
|
||||
pointRow.confirming ? "" : pointRow.token
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: pointRow.confirming
|
||||
text: "Delete it"
|
||||
tone: "danger"
|
||||
enabled: !Snapshots.busy
|
||||
onClicked: {
|
||||
root.confirmingDelete = "";
|
||||
Snapshots.remove(volumeCard.configName,
|
||||
Number(pointRow.modelData.number));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inside one point in time ─────────────────────────────────────
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: volumeCard.open && root.browsingOpen
|
||||
&& Snapshots.browsingConfig === volumeCard.configName
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
label: Snapshots.browsingPath === ""
|
||||
? "Snapshot #" + Snapshots.browsingSnapshot
|
||||
: "…/" + Snapshots.browsingPath
|
||||
detail: "Choosing Restore puts a copy back where it came from, keeping whatever is there now"
|
||||
action: "Back"
|
||||
enabled: !Snapshots.browsing
|
||||
onTriggered: Snapshots.browseUp()
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: Snapshots.browsing
|
||||
label: "Reading the snapshot…"
|
||||
detail: "Listing a folder from a point in time"
|
||||
value: ""
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Snapshots.browsing ? [] : Snapshots.browseEntries
|
||||
|
||||
delegate: SettingRow {
|
||||
id: entryRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string entryPath: Snapshots.browsingPath === ""
|
||||
? String(entryRow.modelData.name)
|
||||
: Snapshots.browsingPath + "/" + String(entryRow.modelData.name)
|
||||
readonly property bool confirming: root.confirmingRestore === entryRow.entryPath
|
||||
|
||||
width: parent.width
|
||||
icon: entryRow.modelData.directory ? "\u{F024B}" : "\u{F0214}"
|
||||
label: String(entryRow.modelData.name ?? "")
|
||||
detail: entryRow.modelData.directory
|
||||
? "Folder"
|
||||
: Snapshots.formatBytes(entryRow.modelData.bytes ?? 0)
|
||||
controlWidth: 230
|
||||
divider: entryRow.index < Snapshots.browseEntries.length - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
visible: entryRow.modelData.directory === true
|
||||
text: "Open"
|
||||
enabled: !Snapshots.browsing
|
||||
onClicked: Snapshots.browse(Snapshots.browsingConfig,
|
||||
Snapshots.browsingSnapshot,
|
||||
entryRow.entryPath)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: entryRow.confirming ? "Cancel" : "Restore"
|
||||
enabled: !Snapshots.busy
|
||||
onClicked: root.confirmingRestore =
|
||||
entryRow.confirming ? "" : entryRow.entryPath
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: entryRow.confirming
|
||||
text: "Put it back"
|
||||
tone: "danger"
|
||||
enabled: !Snapshots.busy
|
||||
onClicked: {
|
||||
root.confirmingRestore = "";
|
||||
Snapshots.restore(Snapshots.browsingConfig,
|
||||
Snapshots.browsingSnapshot,
|
||||
entryRow.entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: !Snapshots.browsing && Snapshots.browseTruncated
|
||||
label: "Only the first entries are shown"
|
||||
detail: "This folder holds more than this list can usefully show"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: !Snapshots.browsing && Snapshots.browseEntries.length === 0
|
||||
label: "Nothing here"
|
||||
detail: "This folder was empty at that point in time"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── What it costs ────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Space"
|
||||
subtitle: "A snapshot shares its data with the live filesystem and grows only as files change afterwards."
|
||||
|
||||
TextRow {
|
||||
label: "Free space"
|
||||
detail: "Snapshots are removed oldest-first when this runs low"
|
||||
value: Snapshots.formatBytes(Snapshots.space?.freeBytes ?? 0)
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Automatic snapshots"
|
||||
detail: Snapshots.timelineRunning
|
||||
? "The hourly timer is running"
|
||||
: "The hourly timer is not running, so nothing new is being taken"
|
||||
value: Snapshots.timelineRunning ? "Running" : "Stopped"
|
||||
}
|
||||
|
||||
// Honest about what cannot be measured: per-snapshot size needs btrfs
|
||||
// quota groups, which cost performance on every write. Reporting a
|
||||
// made-up number would be worse than saying so.
|
||||
TextRow {
|
||||
label: "Space used by snapshots"
|
||||
detail: "Measuring this per snapshot needs btrfs quotas, which slow down every write. Free space above is the number that matters."
|
||||
value: "Not measured"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ SettingsToggle 1.0 SettingsToggle.qml
|
||||
SettingsWindow 1.0 SettingsWindow.qml
|
||||
SharingPage 1.0 SharingPage.qml
|
||||
ShortcutsPage 1.0 ShortcutsPage.qml
|
||||
SnapshotsPage 1.0 SnapshotsPage.qml
|
||||
SoundPage 1.0 SoundPage.qml
|
||||
SettingsPage 1.0 SettingsPage.qml
|
||||
StoragePage 1.0 StoragePage.qml
|
||||
|
||||
Reference in New Issue
Block a user