Files
Panama/config/dot/quickshell/modules/settings/HealthPage.qml
T

461 lines
18 KiB
QML

import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "system-health-page"
title: "System Health"
lede: root.ledeText
readonly property string ledeText: {
if (Health.checks.length === 0)
return "Checks the parts of the desktop this application owns, and explains what needs attention.";
const count = Health.checks.length + (Health.checks.length === 1 ? " check" : " checks");
if (root.issueChecks.length === 0)
return count + " · everything healthy.";
return count + " · " + root.issueChecks.length
+ (root.issueChecks.length === 1 ? " needs" : " need") + " a look.";
}
property var pendingConfirmation: null
property string instructionTarget: ""
readonly property var issueChecks: Health.checks.filter(check => check.status === "warning" || check.status === "error")
readonly property var groups: [
{
group: "desktop-foundation",
title: "Desktop foundation",
subtitle: "Compositor, shell, portals, wallpaper, idle policy, and launcher."
},
{
group: "input-media",
title: "Input & media",
subtitle: "Sound, clipboard, capture, OCR, and display controls."
},
{
group: "integrations",
title: "Integrations",
subtitle: "Only configured integrations affect health."
},
{
group: "panama-tools",
title: "Desktop tools",
subtitle: "Tracked links, launcher commands, applications, and inhibitors."
}
]
readonly property var applicationTargets: ({
"integration.nextcloud": "nextcloud",
"integration.rustdesk": "rustdesk",
"integration.kdeconnect": "kdeconnect",
"integration.bluebubbles": "bluebubbles"
})
// What a row says under its title. For a repair, that includes the command
// the button would run -- said before it runs rather than in a log
// afterwards. "Restart Vicinae" is what the button says; what it does is
// run something as the person pressing it, and by the time a log could
// tell them, the decision is already made.
function repairDetail(check: var): string {
const detail = String(check.detail ?? "");
const command = String(check.repairCommand ?? "");
if (command === "" || check.action?.kind !== "repair")
return detail;
return detail + " · Repair runs: " + command;
}
function checksForGroup(group: string): var {
return Health.checks.filter(check => check.group === group
&& (check.status === "ok" || check.status === "unconfigured"));
}
function handleAction(check: var): void {
if (!check || !check.action)
return;
if (check.action.kind === "open") {
if (check.action.target) {
ShellState.openSettings(check.action.target);
return;
}
const application = root.applicationTargets[check.id];
if (application)
SystemSettings.openApplication(application);
return;
}
if (check.action.kind === "instructions") {
root.instructionTarget = check.action.target || "";
return;
}
if (check.action.kind !== "repair")
return;
if (check.action.confirm) {
root.pendingConfirmation = check;
return;
}
Health.repair(check.id, false);
}
function confirmRepair(): void {
const check = root.pendingConfirmation;
root.pendingConfirmation = null;
if (check)
Health.repair(check.id, false);
}
function descendants(item: var, prefix: string): var {
let matches = [];
if (!item)
return matches;
if (String(item.objectName || "").indexOf(prefix) === 0)
matches.push(item);
for (const child of item.children || [])
matches = matches.concat(root.descendants(child, prefix));
return matches;
}
function activateRenderedAction(id: string): bool {
const suffix = `:${id}`;
const rows = root.descendants(root, "health-check-row:").filter(row =>
row.visible && String(row.objectName).endsWith(suffix));
return rows.length === 1 && rows[0].activateAction();
}
function renderedFocusChain(): var {
// Repeater delegates enter Qt's tab chain lazily after their enabled
// binding changes at the end of a scan. Touch each rendered action's
// real next-focus link before traversing from the first hero control.
const renderedActions = root.descendants(root, "health-row-action:").filter(item =>
item.visible && item.enabled && item.activeFocusOnTab);
for (const action of renderedActions)
action.nextItemInFocusChain(true);
const starts = root.descendants(root, "health-copy-report-button").filter(item => item.visible && item.enabled);
if (starts.length !== 1)
return [];
const names = [];
const start = starts[0];
let current = start;
for (let index = 0; index < 128; index++) {
const name = String(current.objectName || "");
if (name !== "" && names.indexOf(name) < 0)
names.push(name);
current = current.nextItemInFocusChain(true);
if (!current || current === start)
break;
}
return names;
}
// Deterministic, read-only fixture seam used by the offscreen contract.
function uiDiagnostics(): var {
const summaries = root.descendants(root, "health-summary");
const rows = root.descendants(root, "health-check-row:").filter(row => row.visible);
const checkingLabels = root.descendants(root, "health-checking-label").filter(label => label.visible);
const confirmationSheets = root.descendants(root, "health-confirmation-sheet:").filter(sheet => sheet.visible);
const emptyGroups = root.descendants(root, "health-empty-group:").filter(label => label.visible);
const fedoraHandoffs = root.descendants(root, "health-fedora-handoff:").filter(row => row.visible);
return {
renderedRows: rows.map(row => {
const objectName = String(row.objectName);
const parts = objectName.split(":");
const statusTexts = root.descendants(row, "health-status-text:").filter(text => text.visible);
return {
objectName: objectName,
id: parts.slice(2).join(":"),
section: parts[1],
statusText: statusTexts.length === 1 ? statusTexts[0].text : ""
};
}),
summaryHeight: summaries.length > 0 ? summaries[0].height : 0,
rowHeights: rows.map(row => row.height),
checking: Health.busy,
checkingText: checkingLabels.length > 0 ? checkingLabels[0].text : "",
focusChain: root.renderedFocusChain(),
activatedRows: rows.filter(row => row.actionActivationCount > 0).map(row => String(row.objectName)),
emptyQuietGroups: emptyGroups.map(label => String(label.objectName).slice("health-empty-group:".length)),
fedoraHandoffs: fedoraHandoffs.map(row => ({
id: String(row.objectName).slice("health-fedora-handoff:".length),
label: row.label,
action: row.action
})),
confirmationVisible: confirmationSheets.length === 1,
confirmationId: confirmationSheets.length === 1
? String(confirmationSheets[0].objectName).slice("health-confirmation-sheet:".length)
: ""
};
}
Component.onCompleted: Health.refresh()
header: Component {
Rectangle {
objectName: `health-confirmation-sheet:${root.pendingConfirmation ? root.pendingConfirmation.id : ""}`
visible: root.pendingConfirmation !== null
implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0
radius: Theme.cardRadius
color: Theme.mix(Theme.bgPanel, Theme.warn, 0.1)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.34)
Row {
id: confirmRow
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 16
spacing: 14
Column {
width: parent.width - cancelButton.width - repairButton.width - 28
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.pendingConfirmation
? `${root.pendingConfirmation.action.label}?`
: "Restart the shell?"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: "The desktop chrome will disappear briefly and return when Quickshell restarts."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
SettingsButton {
id: cancelButton
text: "Cancel"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.pendingConfirmation = null
Keys.onReturnPressed: root.pendingConfirmation = null
Keys.onSpacePressed: root.pendingConfirmation = null
}
SettingsButton {
id: repairButton
text: root.pendingConfirmation ? root.pendingConfirmation.action.label : "Restart the shell"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.confirmRepair()
Keys.onReturnPressed: root.confirmRepair()
Keys.onSpacePressed: root.confirmRepair()
}
}
}
}
HealthSummary {}
SettingsCard {
visible: root.issueChecks.length > 0
title: Health.status === "error" ? "Action required" : "Needs attention"
subtitle: Health.status === "error"
? "Resolve these items first. Healthy systems remain listed below."
: "Nothing here prevents you from using the desktop."
Item {
width: parent.width
implicitHeight: issueRows.implicitHeight
Rectangle {
anchors.left: parent.left
anchors.leftMargin: 4
anchors.top: parent.top
anchors.topMargin: 25
anchors.bottom: parent.bottom
anchors.bottomMargin: 25
width: 1
visible: root.issueChecks.length > 1
color: Theme.alpha(Health.status === "error" ? Theme.danger : Theme.warn, 0.44)
}
Column {
id: issueRows
width: parent.width
Repeater {
id: issueRepeater
model: root.issueChecks
HealthCheckRow {
required property var modelData
required property int index
width: issueRows.width
check: modelData
detailText: root.repairDetail(modelData)
issue: true
divider: index < issueRepeater.count - 1
onActionRequested: check => root.handleAction(check)
}
}
}
}
}
SettingsCard {
visible: root.instructionTarget === "ddc-permissions"
title: "External monitor brightness"
subtitle: "The monitor reports DDC/CI support, but this session cannot reach the monitor bus."
Item {
width: parent.width
implicitHeight: Math.max(instructionCopy.implicitHeight, doneButton.implicitHeight) + 8
Text {
id: instructionCopy
anchors.left: parent.left
anchors.right: doneButton.left
anchors.rightMargin: 18
anchors.verticalCenter: parent.verticalCenter
text: "Reload the installed udev rules and trigger the i2c-dev and DRM devices. Sign out and back in if monitor access is still unavailable."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
SettingsButton {
id: doneButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Done"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.instructionTarget = ""
Keys.onReturnPressed: root.instructionTarget = ""
Keys.onSpacePressed: root.instructionTarget = ""
}
}
}
Grid {
id: groupGrid
width: parent.width
columns: width >= 700 ? 2 : 1
columnSpacing: 16
rowSpacing: 16
Repeater {
model: root.groups
SettingsCard {
required property var modelData
readonly property var quietChecks: root.checksForGroup(modelData.group)
width: groupGrid.columns === 2
? (groupGrid.width - groupGrid.columnSpacing) / 2
: groupGrid.width
title: modelData.title
subtitle: modelData.subtitle
Column {
id: groupRows
width: parent.width
Text {
objectName: `health-empty-group:${modelData.group}`
width: parent.width
visible: quietChecks.length === 0
text: "Items needing attention are listed above."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
topPadding: 8
bottomPadding: 8
wrapMode: Text.WordWrap
}
Repeater {
id: groupRepeater
model: quietChecks
HealthCheckRow {
required property var modelData
required property int index
width: groupRows.width
check: modelData
detailText: root.repairDetail(modelData)
divider: index < groupRepeater.count - 1
onActionRequested: check => root.handleAction(check)
}
}
}
}
}
}
// Copying the report lives on the hero, where it has always been. Saving
// one is the other half: a clipboard survives until the next copy, and a
// report somebody is going to attach to a message has to be a file.
SettingsCard {
title: "Report"
subtitle: "Everything the last check saw, with the repair commands, redacted the same way the clipboard copy is."
ActionRow {
objectName: "health-save-report-row"
label: "Save the report to a file"
detail: Health.lastSaveResult !== ""
? Health.lastSaveResult
: Health.defaultReportPath
action: "Save"
enabled: Health.checks.length > 0
divider: false
onTriggered: Health.saveReport(Health.defaultReportPath)
}
}
// The one thing left that GNOME genuinely owns.
//
// This card used to be headed "Fedora system settings" and led with an
// umbrella button reading "Open GNOME Settings", which landed on the System
// panel. That button was the last door of its kind, and by the end it was
// pointing at a house Panama had bought: Users, Sharing, Printers, Online
// Accounts, Privacy, Region, Color and the whole of Connections are pages
// here now. A generic front door to a settings app you no longer need is
// not a boundary, it is a habit -- so it is gone, and gnome-handoff-contract
// holds the door shut by naming `system` in its OWNED map.
//
// Screen time is the exception, and it is a real one: GNOME's wellbeing
// panel does something Panama does not, and that button genuinely works.
// Color profiles used to sit here too, with a detail line explaining that
// pressing the button changed nothing, because the colord daemon that
// applies an ICC profile is not running under Hyprland. A handoff that
// documents its own uselessness is a dead button with an apology attached,
// so that one went first.
SettingsCard {
title: "Digital wellbeing"
subtitle: "Screen time and break reminders are GNOME's, and this is the one panel of theirs that still does something Panama does not."
ActionRow {
objectName: "health-fedora-handoff:wellbeing"
label: "Digital wellbeing"
detail: "Screen time and break reminders"
action: "Open wellbeing"
divider: false
onTriggered: SystemSettings.openGnomePanel("wellbeing")
}
}
}