Files

484 lines
21 KiB
QML

// 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: if (!Snapshots.scanned) Snapshots.refresh()
ErrorRow {
label: "Snapshots need attention"
message: Snapshots.lastError
}
// 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.
NotMeasuredRow {
label: "Space used by snapshots"
value: "Not measured"
because: "Measuring this per snapshot needs btrfs quotas, which slow down every write. Free space above is the number that matters."
divider: false
}
}
}