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 // ` 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\" -- `, 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") } } }