// 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. // // The browser is its own card, not a section inside a volume card. It used to // live inside, behind `volumeCard.open` -- so opening a point in time from a // collapsed card set the browsing state, drew nothing, and hid the timeline as // well. Whether a volume's older points are showing has nothing to do with // whether you are looking inside one of them. // // Going back to a previous state of the whole machine is deliberately absent. // snapper's version of it 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." // Points in time shown without asking. The page used to show none: the // timeline and its Delete buttons sat behind a row labelled "Browse…", a // word that promises a file browser, so a page about points in time // appeared to list only how many there were. readonly property int previewCount: 3 property string openConfig: "" property string confirmingDelete: "" property string confirmingRestore: "" // The service's own answer, kept under the page's name so every `visible:` // below reads as "because something is being browsed" and never as // "because a volume card happens to be expanded". readonly property bool browsingOpen: Snapshots.browserOpen readonly property var browsingConfigRecord: Snapshots.configs.find( config => String(config.name ?? "") === Snapshots.browsingConfig) ?? null // How many of each horizon the timeline keeps. A subset of the 0-50 the // service accepts, because a dropdown of fifty-one numbers is a list, not a // choice -- and whatever the configuration is actually set to is added to // it, so a snapper default of 24 hourly is a row you can see rather than a // value the picker cannot display. readonly property var retentionCounts: [0, 1, 2, 3, 5, 7, 10, 14, 21, 30, 50] function retentionOptions(unit: string, current: int): var { const counts = root.retentionCounts.slice(); if (counts.indexOf(current) < 0 && current >= 0 && current <= Snapshots.retentionMax) counts.push(current); counts.sort((left, right) => left - right); return counts.map(count => ({ value: count, label: count === 0 ? "None kept" : count + " " + unit, detail: count === 0 ? "Nothing is kept on this horizon" : "" })); } function retentionLimit(config: var, horizon: string): int { return Number((config?.limits ?? ({}))[horizon] ?? 0); } 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 readonly property bool editable: !Snapshots.busy && volumeCard.modelData.readable === true readonly property int shownCount: volumeCard.open ? volumeCard.snapshots.length : Math.min(root.previewCount, volumeCard.snapshots.length) 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: volumeCard.editable onToggled: value => Snapshots.setTimeline(volumeCard.configName, value) } // The three horizons, editable. This was a read-only summary of // numbers only snapper's config file could change, sitting one // line above a service function nothing ever called. TextRow { label: "Keep" detail: "Older points are removed automatically once these counts are exceeded" value: Snapshots.retentionSummary(volumeCard.modelData) } OptionPickerRow { id: hourlyRow width: parent.width label: "Hourly" detail: "Points taken on the hour, and the first to be removed" options: root.retentionOptions( "hourly", root.retentionLimit(volumeCard.modelData, "hourly")) current: root.retentionLimit(volumeCard.modelData, "hourly") enabled: volumeCard.editable onPicked: value => Snapshots.setRetention( volumeCard.configName, Number(value), root.retentionLimit(volumeCard.modelData, "daily"), root.retentionLimit(volumeCard.modelData, "weekly")) } OptionPickerRow { id: dailyRow width: parent.width label: "Daily" detail: "One point kept per day, for this many days" options: root.retentionOptions( "daily", root.retentionLimit(volumeCard.modelData, "daily")) current: root.retentionLimit(volumeCard.modelData, "daily") enabled: volumeCard.editable onPicked: value => Snapshots.setRetention( volumeCard.configName, root.retentionLimit(volumeCard.modelData, "hourly"), Number(value), root.retentionLimit(volumeCard.modelData, "weekly")) } OptionPickerRow { id: weeklyRow width: parent.width label: "Weekly" detail: "One point kept per week, for this many weeks — the longest reach back" options: root.retentionOptions( "weekly", root.retentionLimit(volumeCard.modelData, "weekly")) current: root.retentionLimit(volumeCard.modelData, "weekly") enabled: volumeCard.editable onPicked: value => Snapshots.setRetention( volumeCard.configName, root.retentionLimit(volumeCard.modelData, "hourly"), root.retentionLimit(volumeCard.modelData, "daily"), Number(value)) } ActionRow { label: "Take one now" detail: "Kept until you remove it, unlike the automatic ones" action: "Take snapshot" enabled: volumeCard.editable onTriggered: Snapshots.take(volumeCard.configName, "Taken from Settings") } TextRow { visible: volumeCard.snapshots.length === 0 label: "No points in time yet" detail: "One is taken automatically on the schedule above." value: "" divider: false } // ── The timeline ───────────────────────────────────────────────── Column { width: parent.width Repeater { model: volumeCard.open ? volumeCard.snapshots : volumeCard.snapshots.slice(0, root.previewCount) 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 readonly property bool browsingThis: root.browsingOpen && Snapshots.browsingConfig === volumeCard.configName && Snapshots.browsingSnapshot === Number(pointRow.modelData.number) 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 ?? "") // Armed, the row names the loss instead of describing // the snapshot: this is the only record of what these // files looked like then, and deleting it is the one // thing on this page nothing else can undo. detail: pointRow.confirming ? "The only copy of these files as of " + String(pointRow.modelData.date ?? "this point in time") + " is deleted." : String(pointRow.modelData.description ?? "") + " · #" + pointRow.modelData.number + (pointRow.modelData.kept ? " · kept" : "") + (pointRow.browsingThis ? " · open below" : "") controlWidth: 250 divider: pointRow.index < volumeCard.shownCount - 1 Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 SettingsButton { text: pointRow.browsingThis ? "Open again" : "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)); } } } } } SettingRow { width: parent.width visible: volumeCard.snapshots.length > root.previewCount activatable: true divider: false label: volumeCard.open ? "Showing all " + volumeCard.snapshots.length + " points in time" : (volumeCard.snapshots.length - root.previewCount) + " older point" + (volumeCard.snapshots.length - root.previewCount === 1 ? "" : "s") + " in time" detail: volumeCard.open ? "" : "Oldest is " + String(volumeCard.snapshots[volumeCard.snapshots.length - 1]?.date ?? "") controlWidth: 110 onActivated: { root.confirmingDelete = ""; root.openConfig = volumeCard.open ? "" : volumeCard.configName; } Text { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: volumeCard.open ? "Show fewer ▴" : "Show all ▾" color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall } } } } } // ── Inside one point in time ───────────────────────────────────────────── // // Its own card, rendered on the browsing state alone. Nothing about which // volume card happens to be expanded can hide it. SettingsCard { visible: root.browsingOpen title: "Browsing " + (root.browsingConfigRecord ? Snapshots.labelFor(root.browsingConfigRecord) : Snapshots.browsingConfig) + " · #" + Snapshots.browsingSnapshot subtitle: Snapshots.browsingPath === "" ? "The top of that point in time." : "Inside " + Snapshots.browsingPath + "." ActionRow { width: parent.width label: Snapshots.browsingPath === "" ? "Close this point in time" : "…/" + Snapshots.browsingPath detail: "Choosing Restore puts a copy back where it came from, keeping whatever is there now" action: Snapshots.browsingPath === "" ? "Close" : "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: "" divider: false } 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 } } }