581 lines
25 KiB
QML
581 lines
25 KiB
QML
import QtQuick
|
|
import Quickshell
|
|
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;
|
|
}
|
|
|
|
// ── The Health rung of the escalation ladder ────────────────────────────
|
|
//
|
|
// A red check with nothing to press is where this page used to end. It can
|
|
// tell you the portals are down; it cannot tell you why, and the honest
|
|
// next step -- read the journal, correlate against recent updates -- is
|
|
// precisely the work an agent is good at. So the row grows one more button
|
|
// carrying what the check knows.
|
|
//
|
|
// Offered only where it is the LAST resort. A check that offers a repair
|
|
// has a better answer than a conversation, right up until that repair has
|
|
// actually been run and failed.
|
|
readonly property bool agentHandoffAvailable: {
|
|
if (DesktopPreferences.get("healthAgentHandoff") !== true)
|
|
return false;
|
|
// No agent chosen is the shipped default, and it means what it says:
|
|
// no button, no offer, the page exactly as it was.
|
|
const agent = String(DesktopPreferences.get("preferredAgent") ?? "none");
|
|
return agent !== "" && agent !== "none";
|
|
}
|
|
|
|
// `repairFailed` is the row's own answer rather than a second computation
|
|
// of it here: the status text beside the button already says "Repair
|
|
// failed", and two independent readings of one fact is how a button starts
|
|
// disagreeing with the words next to it.
|
|
function canAskAgent(check: var, repairFailed: bool): bool {
|
|
if (!root.agentHandoffAvailable || !check || check.status !== "error")
|
|
return false;
|
|
return check.action?.kind !== "repair" || repairFailed === true;
|
|
}
|
|
|
|
// Built from the snapshot this page already holds rather than by shelling
|
|
// the doctor a second time: these are the same fields `panama doctor check
|
|
// <id>` returns, and asking twice would only create a way for the two to
|
|
// disagree. The agent is handed that command anyway, so its first move is
|
|
// a fresh reading rather than trust in ours.
|
|
function agentPrompt(check: var, repairFailed: bool): string {
|
|
const lines = [
|
|
"A System Health check on this Panama machine is red and I want to know why.",
|
|
"",
|
|
"What panama doctor reported:",
|
|
" check: " + String(check.id) + " (" + String(check.group) + ")",
|
|
" title: " + String(check.title),
|
|
" status: " + String(check.status),
|
|
" detail: " + String(check.detail ?? "")
|
|
];
|
|
if (check.repairCommand)
|
|
lines.push(" repair: " + String(check.repairCommand)
|
|
+ (repairFailed ? " — run, and it failed" : " — offered, not yet run"));
|
|
else
|
|
lines.push(" repair: none offered");
|
|
lines.push("");
|
|
lines.push("Start with `panama doctor check " + String(check.id) + "` for the current");
|
|
lines.push("snapshot, then find the cause: the journal first, then whether a recent");
|
|
lines.push("package update or configuration change explains it.");
|
|
lines.push("");
|
|
lines.push("Diagnosis reads; it does not fix. Anything needing root goes through");
|
|
lines.push("`panama-sudo --reason \"why\" -- <command>`, so the password prompt says why.");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function askAgent(check: var, repairFailed: bool): void {
|
|
if (!root.canAskAgent(check, repairFailed))
|
|
return;
|
|
// Reached by path rather than by name: the shell is started by systemd,
|
|
// whose environment does not carry the repository's bin directory on
|
|
// PATH. Same expansion the panama-crash-watch unit uses.
|
|
Quickshell.execDetached(["sh", "-c",
|
|
'"${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-agent" --prompt '
|
|
+ root.shellQuote(root.agentPrompt(check, repairFailed))]);
|
|
}
|
|
|
|
// POSIX single-quoting: everything between the quotes is literal, and the
|
|
// only character needing care is the quote itself. A check's detail is
|
|
// helper output, not a command, and this keeps it that way.
|
|
function shellQuote(text: string): string {
|
|
return "'" + String(text).replace(/'/g, "'\\''") + "'";
|
|
}
|
|
|
|
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);
|
|
const agentHandoffs = root.descendants(root, "health-ask-agent:").filter(button => button.visible);
|
|
return {
|
|
// Empty whenever no agent is chosen, which is the shipped default
|
|
// and the state this page has to keep behaving exactly as it did.
|
|
agentHandoffs: agentHandoffs.map(button =>
|
|
String(button.objectName).slice("health-ask-agent:".length)),
|
|
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
|
|
|
|
// The row plus, where the check has run out of answers,
|
|
// the handoff button beside it. The row keeps its own
|
|
// layout and yields the width the button takes, so the
|
|
// trailing controls never stack on top of each other.
|
|
Item {
|
|
id: issueEntry
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
readonly property bool offersAgent:
|
|
root.canAskAgent(issueEntry.modelData, issueRow.repairFailed)
|
|
|
|
width: issueRows.width
|
|
implicitHeight: issueRow.implicitHeight
|
|
|
|
HealthCheckRow {
|
|
id: issueRow
|
|
|
|
width: issueEntry.width
|
|
- (issueEntry.offersAgent ? askAgentButton.width + 12 : 0)
|
|
check: issueEntry.modelData
|
|
detailText: root.repairDetail(issueEntry.modelData)
|
|
issue: true
|
|
divider: issueEntry.index < issueRepeater.count - 1
|
|
onActionRequested: check => root.handleAction(check)
|
|
}
|
|
|
|
SettingsButton {
|
|
id: askAgentButton
|
|
|
|
objectName: `health-ask-agent:${issueEntry.modelData.id}`
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: issueEntry.offersAgent
|
|
text: "Ask the agent"
|
|
enabled: visible && !Health.busy
|
|
activeFocusOnTab: enabled
|
|
border.width: activeFocus ? 2 : 1
|
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
|
onClicked: root.askAgent(issueEntry.modelData, issueRow.repairFailed)
|
|
Keys.onReturnPressed: if (enabled)
|
|
root.askAgent(issueEntry.modelData, issueRow.repairFailed)
|
|
Keys.onSpacePressed: if (enabled)
|
|
root.askAgent(issueEntry.modelData, issueRow.repairFailed)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
}
|