479 lines
20 KiB
QML
479 lines
20 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: 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 ?? "")
|
|
detail: 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
|
|
}
|
|
}
|
|
}
|