From 8dbc713db33fd20804650dbd963bd3046d1fef4f Mon Sep 17 00:00:00 2001 From: John Pals <7024725+DigitalPals@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:14:16 +0200 Subject: [PATCH 1/5] Add CybexOS authentication dialog in Quickshell Render Polkit requests with shell styling, account selection, keyboard controls, password visibility, and retry feedback. Coordinate focus with other overlays and clear responses throughout the conversation lifecycle. Replace standalone agent startup in source and image installs, retain legacy runtime fallback, and add authentication lifecycle and registration checks. --- assets/scripts/cybexos-runtime | 14 + docs/authentication-dialog.md | 75 +++++ docs/quickshell-notes.md | 4 + image/cybexos-desktop.spec | 3 +- image/repair-installed | 2 +- .../lib/systemd/user/hyprland-session.target | 2 +- .../hyprpolkitagent.service.d/50-cybexos.conf | 2 - image/test_installed_policy.py | 2 +- image/vm_testing.py | 4 +- .../quickshell/Common/NetworkOverlayState.qml | 2 + .../files/quickshell/Common/PolkitHelpers.js | 18 + .../files/quickshell/Common/TablerGlyphs.js | 4 + .../files/quickshell/NetworkOverlayWindow.qml | 11 +- .../desktop/files/quickshell/PolkitPrompt.qml | 314 ++++++++++++++++++ .../desktop/files/quickshell/PolkitWindow.qml | 106 ++++++ .../quickshell/Settings/SettingsSelect.qml | 2 + .../quickshell/assets/tabler/aliases.json | 1 + .../quickshell/assets/tabler/outline.ttf | Bin 59656 -> 59984 bytes roles/desktop/files/quickshell/qmldir | 2 + roles/desktop/files/quickshell/shell.qml | 1 + roles/desktop/tasks/main.yml | 17 +- .../templates/hyprpolkitagent.service.j2 | 12 - roles/finalize/tasks/main.yml | 2 - tests/ownership-layering.py | 53 +++ tests/qml-lifecycle/shell.qml | 109 ++++++ tests/quickshell/input-method.test.cjs | 2 +- tests/quickshell/polkit.test.cjs | 24 ++ tests/verify-system | 13 +- tests/vm/assert-converged | 2 +- 29 files changed, 765 insertions(+), 38 deletions(-) create mode 100644 docs/authentication-dialog.md delete mode 100644 image/rootfs/usr/lib/systemd/user/hyprpolkitagent.service.d/50-cybexos.conf create mode 100644 roles/desktop/files/quickshell/Common/PolkitHelpers.js create mode 100644 roles/desktop/files/quickshell/PolkitPrompt.qml create mode 100644 roles/desktop/files/quickshell/PolkitWindow.qml delete mode 100644 roles/desktop/templates/hyprpolkitagent.service.j2 create mode 100644 tests/quickshell/polkit.test.cjs diff --git a/assets/scripts/cybexos-runtime b/assets/scripts/cybexos-runtime index b1a19a97..fd690c8f 100755 --- a/assets/scripts/cybexos-runtime +++ b/assets/scripts/cybexos-runtime @@ -224,6 +224,20 @@ exec_runtime() { "$selected" >&2 return 1 } + # The authentication UI belongs to the selected shell. Stop the old + # agent synchronously before QML registers its listener; this also + # handles an upgrade while the previous agent is still running. + # Older releases remain usable when rolling back or leaving dev mode. + if [[ ${CYBEXOS_RUNTIME_TESTING:-0} != 1 ]] && { + systemctl --user is-active --quiet hyprpolkitagent.service || + [[ $(systemctl --user show hyprpolkitagent.service -p LoadState --value 2>/dev/null) == loaded ]]; + }; then + if [[ -f $selected/PolkitWindow.qml ]]; then + systemctl --user stop hyprpolkitagent.service + else + systemctl --user start --no-block hyprpolkitagent.service + fi + fi export CYBEXOS_USER_CONFIG_ROOT=$config_root export CYBEXOS_THEME_ROOT=$data_root/themes export CYBEXOS_PLUGIN_ROOT=$data_root/plugins diff --git a/docs/authentication-dialog.md b/docs/authentication-dialog.md new file mode 100644 index 00000000..035767e9 --- /dev/null +++ b/docs/authentication-dialog.md @@ -0,0 +1,75 @@ +# Authentication dialog + +CybexOS handles Polkit requests in the managed Quickshell process. The built-in +`PolkitWindow.qml` owns one `Quickshell.Services.Polkit.PolkitAgent`, including +when a plugin replaces the bar. `PolkitPrompt.qml` presents its current flow. +Polkit and the system's PAM configuration still decide which identities may +authenticate and whether their responses succeed; the shell changes no policy. + +The compact card uses `Common/Theme.qml` fonts, density, light/dark colors and +wallpaper palette. It opens on the focused monitor and stays there until the +request ends, with a fallback if that output disappears. Account names wrap, +multiple eligible identities get a selector, and the action ID is available +under Show details. The card scrolls on small outputs or with large text. + +Enter submits the current response, Escape or Cancel aborts the request, and +Tab reaches the controls. Clicking the dimmed background does not dismiss it. +The launcher, drawers and shortcut sheet close during authentication so they +cannot take the password field's keyboard focus. The Network overlay temporarily +hides and releases its focus grab while keeping its requesting helper alive; +it returns when the authentication flow ends. A normal settings window can +remain open behind a request it initiated. + +Passwords start masked, never echo the last character, and can be revealed +with the eye button. PAM prompts that request visible responses are supported, +as are informational messages while waiting for fingerprint or other methods. +The response is cleared on submission, cancellation, account/prompt changes, +completion and replacement by another request. Closing the window destroys +the input. Responses go directly to the backend; no shell command, IPC method, +log or persistent setting carries them. This does not promise secure erasure +of Qt's or the authentication library's internal memory. + +## Startup and rollback + +`cybexos-runtime exec quickshell` stops a loaded or active +`hyprpolkitagent.service` before registering the integrated agent. Source +deployment removes the old session enablement and managed unit; new images +neither require nor start the standalone agent. Existing RPMs are left +installed. Selecting an older runtime without `PolkitWindow.qml` starts the +legacy agent when its unit is available, including after a deployment rollback. + +`cybexos-runtime ipc polkit status` returns only `registered` and `active`. +There is no diagnostic method to read or submit a response. Registration must +be true before considering a deployment healthy. After switching from an old +installed runtime resolver to development source, use the checkout's resolver +for the managed service too, or stop the old agent before restarting Quickshell. +Do not start a second `qs` instance to test a dialog. + +## Verification + +`tests/run` checks account-label handling, QML, startup integration and icon +coverage. `tests/ownership-layering.py` verifies the agent transition and legacy +fallback with isolated service/executable fixtures. The production-component +lifecycle harness exercises submission, duplicate-submit prevention, retry, +account switching, visible PAM responses, local/remote cancellation, completion +and clearing state between requests. Its Polkit fixture never authenticates +against the host. The real-engine harness runs in CI without an active shell; +on a workstation use `tests/lib/quickshell-live` before and after any controlled +test that stops and restores the service. + +For live acceptance, verify registration, trigger a real Polkit authorization +check, cancel with the keyboard and confirm the requesting process is denied. +Also check details expansion, password visibility, Tab order, background clicks, +blocked competing overlays, large text, both color modes and output removal. +Successful authentication and fingerprint/other PAM methods require an operator +with the relevant credential or hardware; never collect their password through +agent tools. + +On 2026-09-26 the installed Quickshell 0.2.1 build registered successfully and +displayed a real `com.1password.1Password.unlock` authorization check. Escape +returned a dismissed result to `pkcheck`; Tab navigation, visibility, details +and blocking the competing launcher were exercised. The isolated real-engine +conversation fixture and the image's 29 installed-policy/user-parity tests +passed. The managed service finished as the sole `qs` process, with no QML +errors in its current invocation and the old agent inactive. Successful real +authentication, fingerprint hardware and monitor removal were not exercised. diff --git a/docs/quickshell-notes.md b/docs/quickshell-notes.md index a93797f3..d61062b2 100644 --- a/docs/quickshell-notes.md +++ b/docs/quickshell-notes.md @@ -33,6 +33,10 @@ you need the reasoning behind a particular change; `git log --oneline ## Testing without a GUI +The built-in [authentication dialog](authentication-dialog.md) uses Quickshell's +Polkit service. Its presentation and startup checks are covered below; its +authentication backend remains the system Polkit/PAM stack. + Run `./tests/run` first; it needs no live shell. External widget tests require `sway` for a disposable headless Wayland compositor (also installed by CI); this is a test dependency, not a change to the desktop's compositor. diff --git a/image/cybexos-desktop.spec b/image/cybexos-desktop.spec index 09f138c1..87c22978 100644 --- a/image/cybexos-desktop.spec +++ b/image/cybexos-desktop.spec @@ -20,7 +20,7 @@ Obsoletes: fedora-config-desktop < %{epoch}:%{version}-%{release} %global _binary_filedigest_algorithm 8 Requires: bash coreutils util-linux systemd python3 ansible-core gnupg2 Requires: sddm sddm-wayland-generic systemd-pam gnome-keyring-pam -Requires: hyprland hyprland-guiutils quickshell hypridle hyprlock hyprpolkitagent hyprsunset +Requires: hyprland hyprland-guiutils quickshell hypridle hyprlock hyprsunset Requires: xdg-desktop-portal-hyprland xdg-desktop-portal-gtk xdg-utils Requires: qt6-qtwebsockets-devel qt6-qt5compat qt6-qtsvg Requires: qt6-qtbase qt6-qtdeclarative qt6-qtwayland @@ -72,7 +72,6 @@ cp -a usr opt etc %{buildroot}/ /usr/libexec/cybexos-* /usr/lib/systemd/user/*.service /usr/lib/systemd/user/hypridle.service.d/ -/usr/lib/systemd/user/hyprpolkitagent.service.d/ /usr/lib/systemd/user/voxtype.service.d/ /usr/lib/systemd/user/hyprland-session.target /usr/lib/systemd/system/sddm.service.d/ diff --git a/image/repair-installed b/image/repair-installed index ed9433d8..4c5aaa92 100755 --- a/image/repair-installed +++ b/image/repair-installed @@ -152,7 +152,7 @@ def apply(payload, account, packages, hardware=False): if session: run(user_command(account, ['systemctl', '--user', 'daemon-reload'])) run(user_command(account, ['systemctl', '--user', 'start', - 'hyprpolkitagent.service', 'hypridle.service', 'voxtype.service'])) + 'hypridle.service', 'voxtype.service'])) finally: if shell_active: run(user_command(account, ['systemctl', '--user', 'start', 'quickshell.service'])) diff --git a/image/rootfs/usr/lib/systemd/user/hyprland-session.target b/image/rootfs/usr/lib/systemd/user/hyprland-session.target index 426977e0..11401da8 100644 --- a/image/rootfs/usr/lib/systemd/user/hyprland-session.target +++ b/image/rootfs/usr/lib/systemd/user/hyprland-session.target @@ -4,6 +4,6 @@ Description=CybexOS graphical session # target after every Wanted service, which would close a dependency cycle. DefaultDependencies=no BindsTo=graphical-session.target -Wants=graphical-session-pre.target quickshell.service hyprpolkitagent.service hypridle.service voxtype.service hermes-menubar-bridge.service cybexos-welcome.service cybexos-app-seed.service cybexos-input-method.service external-monitor-toggle.service +Wants=graphical-session-pre.target quickshell.service hypridle.service voxtype.service hermes-menubar-bridge.service cybexos-welcome.service cybexos-app-seed.service cybexos-input-method.service external-monitor-toggle.service After=graphical-session-pre.target Before=graphical-session.target diff --git a/image/rootfs/usr/lib/systemd/user/hyprpolkitagent.service.d/50-cybexos.conf b/image/rootfs/usr/lib/systemd/user/hyprpolkitagent.service.d/50-cybexos.conf deleted file mode 100644 index 2244e6b2..00000000 --- a/image/rootfs/usr/lib/systemd/user/hyprpolkitagent.service.d/50-cybexos.conf +++ /dev/null @@ -1,2 +0,0 @@ -[Unit] -PartOf=hyprland-session.target diff --git a/image/test_installed_policy.py b/image/test_installed_policy.py index 17cedbf3..dffd8afb 100644 --- a/image/test_installed_policy.py +++ b/image/test_installed_policy.py @@ -190,7 +190,7 @@ def test_session_target_can_start_services_ordered_after_graphical_session(self) (units / target.name).write_text(target.read_text()) for unit in ('graphical-session', 'graphical-session-pre'): (units / (unit + '.target')).write_text('[Unit]\nDescription=Fixture\n') - for name in ('quickshell', 'hyprpolkitagent', 'hypridle', 'voxtype', + for name in ('quickshell', 'hypridle', 'voxtype', 'hermes-menubar-bridge', 'cybexos-welcome', 'cybexos-app-seed'): (units / (name + '.service')).write_text( '[Unit]\nAfter=graphical-session.target\n[Service]\nExecStart=/usr/bin/true\n') diff --git a/image/vm_testing.py b/image/vm_testing.py index 2489f7c3..189d5530 100644 --- a/image/vm_testing.py +++ b/image/vm_testing.py @@ -107,8 +107,10 @@ def audit_script(): import pwd assert pwd.getpwuid(os.getuid()).pw_shell == '/usr/bin/fish' assert Path('/etc/cybexos/hardware.json').is_file() - for unit in ('hyprpolkitagent', 'hypridle', 'voxtype'): + for unit in ('quickshell', 'hypridle', 'voxtype'): subprocess.run(['systemctl', '--user', 'is-active', unit], check=True) + status = json.loads(subprocess.check_output(['cybexos-runtime', 'ipc', 'polkit', 'status'], text=True)) + assert status['registered'], 'Quickshell Polkit agent did not register' for unit in ('tuned-ppd', 'fwupd-refresh.timer', 'cybexos-hardware-setup.timer'): subprocess.run(['systemctl', 'is-enabled', unit], check=True) aliases = subprocess.check_output(['fish', '-ic', 'functions codex claude'], text=True) diff --git a/roles/desktop/files/quickshell/Common/NetworkOverlayState.qml b/roles/desktop/files/quickshell/Common/NetworkOverlayState.qml index c77ac438..f6224435 100644 --- a/roles/desktop/files/quickshell/Common/NetworkOverlayState.qml +++ b/roles/desktop/files/quickshell/Common/NetworkOverlayState.qml @@ -9,6 +9,8 @@ Singleton { id: root property bool open: false + // Keep an authorizing helper alive while Polkit owns the keyboard. + property bool authenticationActive: false property string page: "" property var screen: null property string interfaceName: "" diff --git a/roles/desktop/files/quickshell/Common/PolkitHelpers.js b/roles/desktop/files/quickshell/Common/PolkitHelpers.js new file mode 100644 index 00000000..33406fe4 --- /dev/null +++ b/roles/desktop/files/quickshell/Common/PolkitHelpers.js @@ -0,0 +1,18 @@ +// Identity names come from NSS; GECOS may also contain phone/office fields. +function identityLabel(identity) { + if (!identity) + return ""; + const login = String(identity.string || identity.name || (identity.id ?? "")); + const display = String(identity.displayName || login).split(",")[0].trim() || login; + const label = display === login ? login : display + " (" + login + ")"; + return identity.isGroup ? "Group: " + label : label; +} + +if (typeof module !== "undefined" && module.exports) + module.exports = { identityLabel, identityOptions }; + +function identityOptions(identities) { + return (identities || []).map((identity, index) => ({ + value: index, label: identityLabel(identity) + })); +} diff --git a/roles/desktop/files/quickshell/Common/TablerGlyphs.js b/roles/desktop/files/quickshell/Common/TablerGlyphs.js index 535c5ef1..a3852cab 100644 --- a/roles/desktop/files/quickshell/Common/TablerGlyphs.js +++ b/roles/desktop/files/quickshell/Common/TablerGlyphs.js @@ -237,6 +237,7 @@ var ALIASES = { "videocam": "video", "view_quilt": "layout", "visibility": "eye", + "visibility_off": "eye-off", "volume_down": "volume-2", "volume_mute": "volume-3", "volume_off": "volume-off", @@ -516,6 +517,9 @@ var GLYPHS = { "eye": { "outline": "\uea9a" }, + "eye-off": { + "outline": "\uecf0" + }, "file-text": { "outline": "\ueaa2" }, diff --git a/roles/desktop/files/quickshell/NetworkOverlayWindow.qml b/roles/desktop/files/quickshell/NetworkOverlayWindow.qml index 5af63259..2ea274ab 100644 --- a/roles/desktop/files/quickshell/NetworkOverlayWindow.qml +++ b/roles/desktop/files/quickshell/NetworkOverlayWindow.qml @@ -21,7 +21,8 @@ PanelWindow { readonly property string speedHelper: Quickshell.shellDir + "/scripts/network-speedtest.py" readonly property bool qrPageActive: NetworkOverlayState.page === "qr" - visible: NetworkOverlayState.open || scrim.opacity > 0.001 + visible: !NetworkOverlayState.authenticationActive + && (NetworkOverlayState.open || scrim.opacity > 0.001) screen: NetworkOverlayState.screen ?? Screens.focused anchors { top: true; left: true; right: true; bottom: true } exclusionMode: ExclusionMode.Ignore @@ -29,13 +30,13 @@ PanelWindow { WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "qs-network-overlay" - WlrLayershell.keyboardFocus: NetworkOverlayState.open + WlrLayershell.keyboardFocus: NetworkOverlayState.open && !NetworkOverlayState.authenticationActive ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None HyprlandFocusGrab { - active: NetworkOverlayState.open + active: NetworkOverlayState.open && !NetworkOverlayState.authenticationActive windows: [root] - onCleared: NetworkOverlayState.close() + onCleared: if (!NetworkOverlayState.authenticationActive) NetworkOverlayState.close() } Rectangle { @@ -57,7 +58,7 @@ PanelWindow { FocusScope { anchors.fill: parent - focus: NetworkOverlayState.open + focus: NetworkOverlayState.open && !NetworkOverlayState.authenticationActive Keys.onEscapePressed: NetworkOverlayState.close() Rectangle { diff --git a/roles/desktop/files/quickshell/PolkitPrompt.qml b/roles/desktop/files/quickshell/PolkitPrompt.qml new file mode 100644 index 00000000..c0d0e02e --- /dev/null +++ b/roles/desktop/files/quickshell/PolkitPrompt.qml @@ -0,0 +1,314 @@ +pragma ComponentBehavior: Bound +import QtQuick +import "Common" +import "Common/PolkitHelpers.js" as PolkitHelpers +import "Settings" as SettingsUi + +// Only the active conversation owns an input. The window destroys this view +// when it closes; responses go directly to Polkit, never through IPC or a +// command line. Keeping the view separate also permits real-engine fixtures. +FocusScope { + id: root + + required property var flow + property bool detailsOpen: false + property bool revealed: false + property bool attemptFailed: false + property string feedback: "" + property bool feedbackError: false + readonly property bool finished: !flow || flow.isCompleted || flow.isCancelled + readonly property bool responseRequired: !finished && flow.isResponseRequired + readonly property bool secret: !flow || !flow.responseVisible + readonly property string prompt: flow && flow.inputPrompt + ? flow.inputPrompt.trim() : secret ? "Password" : "Response" + readonly property string account: PolkitHelpers.identityLabel(flow ? flow.selectedIdentity : null) + readonly property bool hasError: attemptFailed || feedbackError + + implicitHeight: content.implicitHeight + focus: true + Accessible.role: Accessible.Dialog + Accessible.name: "Authentication required" + + function clearResponse() { + response.clear(); + revealed = false; + } + + function focusResponse() { + if (responseRequired) + response.forceActiveFocus(); + else + cancelButton.forceActiveFocus(); + } + + function submit() { + // Empty responses are valid for some PAM conversations. Polkit, not + // presentation code, decides whether a response is acceptable. + if (!responseRequired) + return; + feedback = ""; + feedbackError = false; + attemptFailed = false; + flow.submit(response.text); + clearResponse(); + } + + function cancel() { + clearResponse(); + if (!finished) + flow.cancelAuthenticationRequest(); + } + + onFlowChanged: { + clearResponse(); + detailsOpen = false; + attemptFailed = false; + feedback = flow ? flow.supplementaryMessage : ""; + feedbackError = !!(flow && flow.supplementaryIsError); + Qt.callLater(focusResponse); + } + onResponseRequiredChanged: { + clearResponse(); + Qt.callLater(focusResponse); + } + onFinishedChanged: if (finished) clearResponse() + Component.onCompleted: Qt.callLater(focusResponse) + Component.onDestruction: clearResponse() + Keys.onEscapePressed: cancel() + + Connections { + target: root.flow + function onAuthenticationFailed() { + root.attemptFailed = true; + root.clearResponse(); + Qt.callLater(root.focusResponse); + } + function onSelectedIdentityChanged() { + root.clearResponse(); + root.attemptFailed = false; + root.feedback = ""; + root.feedbackError = false; + Qt.callLater(root.focusResponse); + } + function onInputPromptChanged() { root.clearResponse(); } + function onResponseVisibleChanged() { root.clearResponse(); } + function onSupplementaryMessageChanged() { + root.feedback = root.flow.supplementaryMessage; + root.feedbackError = root.flow.supplementaryIsError; + if (!root.feedbackError) + root.attemptFailed = false; + } + function onSupplementaryIsErrorChanged() { + root.feedbackError = root.flow.supplementaryIsError; + } + } + + Column { + id: content + width: root.width + spacing: Theme.scaled(18) + + Row { + width: parent.width + spacing: Theme.scaled(12) + + Rectangle { + width: Theme.scaled(40) + height: width + radius: Theme.chipRadius + 3 + color: Theme.chip + Sym { + anchors.centerIn: parent + name: "lock" + size: Theme.iconLarge + color: Theme.accentText + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - Theme.scaled(52) + spacing: 3 + Text { + width: parent.width + text: "Authentication required" + textFormat: Text.PlainText + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.heading + font.weight: Theme.weightSemibold + color: Theme.textHi + wrapMode: Text.Wrap + } + Text { + width: parent.width + text: root.account + textFormat: Text.PlainText + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + color: Theme.textMid + wrapMode: Text.Wrap + } + } + } + + Text { + width: parent.width + text: root.flow && root.flow.message ? root.flow.message : "Authenticate to continue." + textFormat: Text.PlainText + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.primary + color: Theme.textHi + wrapMode: Text.Wrap + } + + SettingsUi.SettingsSelect { + id: accountSelect + objectName: "polkitAccount" + visible: model.length > 1 + width: parent.width + maximumWidth: width + accessibleName: "Authenticate as" + model: PolkitHelpers.identityOptions(root.flow ? root.flow.identities : []) + current: root.flow ? root.flow.identities.indexOf(root.flow.selectedIdentity) : -1 + enabled: !root.finished + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.tab: root.responseRequired ? response : cancelButton + KeyNavigation.backtab: detailsButton + onPicked: value => { + root.clearResponse(); + root.flow.selectedIdentity = root.flow.identities[value]; + } + } + + Column { + visible: root.responseRequired + width: parent.width + spacing: Theme.scaled(7) + + Text { + width: parent.width + text: root.prompt + textFormat: Text.PlainText + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + color: Theme.textMid + wrapMode: Text.Wrap + } + + SettingsUi.SettingsField { + id: response + objectName: "polkitResponse" + width: parent.width + implicitHeight: Theme.scaled(42) + enabled: root.responseRequired + invalid: root.hasError + rightPadding: root.secret ? revealButton.width + Theme.controlSpacing * 2 : Theme.controlSpacing + echoMode: root.secret && !root.revealed ? TextInput.Password : TextInput.Normal + passwordMaskDelay: 0 + // Revealing the password must not enable IME learning/prediction. + inputMethodHints: root.secret + ? Qt.ImhHiddenText | Qt.ImhSensitiveData | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase + : Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase + Accessible.name: root.prompt + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.tab: root.secret ? revealButton : authenticateButton + KeyNavigation.backtab: accountSelect.visible ? accountSelect : detailsButton + onAccepted: root.submit() + + SettingsUi.SettingsAction { + id: revealButton + objectName: "polkitReveal" + anchors.right: parent.right + anchors.rightMargin: Theme.controlSpacing + anchors.verticalCenter: parent.verticalCenter + visible: root.secret + compact: true + text: root.revealed ? "Hide password" : "Show password" + glyph: root.revealed ? "visibility_off" : "visibility" + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.tab: authenticateButton + KeyNavigation.backtab: response + onTriggered: { + root.revealed = !root.revealed; + response.forceActiveFocus(); + } + } + } + } + + Text { + objectName: "polkitStatus" + width: parent.width + visible: text !== "" + text: root.attemptFailed && !root.feedbackError ? "Authentication failed. Please try again." + : root.feedback ? root.feedback + : root.attemptFailed ? "Authentication failed. Please try again." + : !root.responseRequired && !root.finished ? "Waiting for authentication…" : "" + textFormat: Text.PlainText + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + color: root.hasError ? Theme.redText : Theme.textMid + wrapMode: Text.Wrap + } + + Flow { + width: parent.width + spacing: Theme.controlSpacing + layoutDirection: Qt.RightToLeft + + SettingsUi.SettingsAction { + id: authenticateButton + objectName: "polkitSubmit" + text: root.responseRequired ? "Authenticate" : "Authenticating…" + height: Theme.scaled(36) + primary: true + enabled: root.responseRequired + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.tab: cancelButton + KeyNavigation.backtab: root.secret ? revealButton : response + onTriggered: root.submit() + } + + SettingsUi.SettingsAction { + id: cancelButton + objectName: "polkitCancel" + text: "Cancel" + height: Theme.scaled(36) + enabled: !root.finished + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.tab: detailsButton + KeyNavigation.backtab: root.responseRequired ? authenticateButton + : accountSelect.visible ? accountSelect : detailsButton + onTriggered: root.cancel() + } + } + + Column { + width: parent.width + spacing: Theme.controlSpacing + + SettingsUi.SettingsAction { + id: detailsButton + objectName: "polkitDetails" + text: root.detailsOpen ? "Hide details" : "Show details" + glyph: root.detailsOpen ? "expand_less" : "expand_more" + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.tab: accountSelect.visible ? accountSelect + : root.responseRequired ? response : cancelButton + KeyNavigation.backtab: cancelButton + onTriggered: root.detailsOpen = !root.detailsOpen + } + + Text { + visible: root.detailsOpen + width: parent.width + text: "Action: " + (root.flow ? root.flow.actionId : "") + textFormat: Text.PlainText + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + color: Theme.textMid + wrapMode: Text.WrapAnywhere + } + } + } +} diff --git a/roles/desktop/files/quickshell/PolkitWindow.qml b/roles/desktop/files/quickshell/PolkitWindow.qml new file mode 100644 index 00000000..97427de2 --- /dev/null +++ b/roles/desktop/files/quickshell/PolkitWindow.qml @@ -0,0 +1,106 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls as Controls +import Quickshell +import Quickshell.Io +import Quickshell.Services.Polkit +import Quickshell.Wayland +import "Common" + +PanelWindow { + id: root + + property string hostScreenName: "" + readonly property bool active: agent.flow !== null + && !agent.flow.isCompleted && !agent.flow.isCancelled + onActiveChanged: NetworkOverlayState.authenticationActive = active + + visible: active + screen: Screens.byName(hostScreenName) ?? Screens.focused + anchors { top: true; left: true; right: true; bottom: true } + exclusionMode: ExclusionMode.Ignore + color: "transparent" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "qs-polkit" + WlrLayershell.keyboardFocus: active ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + + function releaseOverlays() { + if (!active) + return; + Launcher.close(); + Popouts.close(); + Session.closeKeys(); + } + + PolkitAgent { + id: agent + onFlowChanged: { + if (!root.active) + return; + root.hostScreenName = Screens.focused ? Screens.focused.name : ""; + // Release other exclusive shell surfaces before taking the keyboard. + root.releaseOverlays(); + } + } + + // A global shortcut must not put another exclusive surface in front of + // the prompt while the user is entering a response. + Connections { + target: Launcher + function onOpenChanged() { if (Launcher.open) root.releaseOverlays(); } + } + Connections { + target: Popouts + function onOpenChanged() { if (Popouts.open) root.releaseOverlays(); } + } + Connections { + target: Session + function onKeysOpenChanged() { if (Session.keysOpen) root.releaseOverlays(); } + } + + // Health checks can inspect registration without exposing identities, + // actions, cookies or responses. There is deliberately no submit IPC. + IpcHandler { + target: "polkit" + function status(): string { + return JSON.stringify({registered: agent.isRegistered, active: root.active}); + } + } + + Rectangle { + anchors.fill: parent + color: Theme.scrim + // An accidental click outside the card must not cancel an app's request. + MouseArea { anchors.fill: parent } + } + + Rectangle { + anchors.centerIn: parent + width: Math.max(1, Math.min(Theme.scaled(440, Theme.typeScale), root.width - 32)) + height: Math.min(root.height - 32, promptLoader.implicitHeight + Theme.scaled(48)) + radius: Theme.popRadius + color: Theme.background + border.width: 1 + border.color: Theme.stroke + + Controls.ScrollView { + anchors.fill: parent + anchors.margins: Theme.scaled(24) + contentWidth: availableWidth + contentHeight: promptLoader.implicitHeight + clip: true + Controls.ScrollBar.horizontal.policy: Controls.ScrollBar.AlwaysOff + + Loader { + id: promptLoader + width: parent.width + active: root.active + focus: true + sourceComponent: PolkitPrompt { + flow: agent.flow + width: promptLoader.width + } + } + } + } +} diff --git a/roles/desktop/files/quickshell/Settings/SettingsSelect.qml b/roles/desktop/files/quickshell/Settings/SettingsSelect.qml index 8ea0501b..c87ca989 100644 --- a/roles/desktop/files/quickshell/Settings/SettingsSelect.qml +++ b/roles/desktop/files/quickshell/Settings/SettingsSelect.qml @@ -48,6 +48,7 @@ Controls.ComboBox { contentItem: Text { text: combo.displayText + textFormat: Text.PlainText color: Theme.textHi font.family: combo.fontFor && combo.currentIndex >= 0 ? combo.fontFor(combo.model[combo.currentIndex].value) : Theme.fontMenu @@ -86,6 +87,7 @@ Controls.ComboBox { anchors.rightMargin: Theme.controlSpacing anchors.verticalCenter: parent.verticalCenter text: option.modelData.label + textFormat: Text.PlainText color: Theme.textHi font.family: combo.fontFor ? combo.fontFor(option.modelData.value) : Theme.fontMenu font.pixelSize: Theme.typography.control diff --git a/roles/desktop/files/quickshell/assets/tabler/aliases.json b/roles/desktop/files/quickshell/assets/tabler/aliases.json index f2e40229..749bb27a 100644 --- a/roles/desktop/files/quickshell/assets/tabler/aliases.json +++ b/roles/desktop/files/quickshell/assets/tabler/aliases.json @@ -235,6 +235,7 @@ "videocam": "video", "view_quilt": "layout", "visibility": "eye", + "visibility_off": "eye-off", "volume_down": "volume-2", "volume_mute": "volume-3", "volume_off": "volume-off", diff --git a/roles/desktop/files/quickshell/assets/tabler/outline.ttf b/roles/desktop/files/quickshell/assets/tabler/outline.ttf index d4d4fb6c42a67603be73b7fb4d93e197682590fc..741649a811b1c3ace3534798aaad6831b42e04e1 100644 GIT binary patch delta 1513 zcmXBTeNYs29LMp`?{@F@_JG^HySf7f33<2^CF1F>fQWHaZ0QG{dy?ctBE z{;L@>0WGvNB{h>$;IvH3%E-v<32|~7o2Gx9qLkB0gktQw{J44T`?t5dzgvE{_u{zl z{e-a7?w*VTkXYhPPR(7q>3x7bO#5~9?Y@r5JwI&(glr(>x8}BInhqs3TnE%q;Og@| zMagRh+P2pFs>@$V1r(Zu9ccGG-NCN23>t^iBo92{Yi|rW659>@zJm78b!_eILKuxO zDQd^TXiH3&W)Js_Rj}^@i&&JdZ-|L~S%MmU4Z{2xaa0o^UV|u}rVA435($ZYt->i5 z3@j3tGRkWG66QZ>>$i2=p0jn@YHVIxnXTBi$~tMiZXLA-tc$I(Y#d5-KV~!E>|B_BUD96QliZ>=0D6A%zMoJ=H8H4Q;&Q@K5rZ_ z?v@6nPRVPS)?X2gYn%~l3cOcT3CHjNFp9DSBSq{Cx4T(b^ z)y`oTbuo$6E~n;jc#XP}tfKe(sjEo=jrUSZIP9ZVbJ$Nk#rg3j4bE^lK>eA+LFyET z0pR8f9Nwb7&fySsgu`KA%FN*im7Wd_-lpbqI7+SL@D8;Fer*FBqn#Zb2C2gw-ld-8 z@E(;OGYyVYr#YOU&T$w5w8hkb<_OX%YCty`4B-%|q_+{e}AM^Qq$2Zm2cQ<4=Zf=}x8ff0uvasdX&0VeSt(Ui4Y|Euzf8d0= zVuxQq>Afl8RUsA8NW%so-XIwgob*c8CAd8vw?`X$+-?W6+vxzaJM3wx^tQ)oa3*Mn zoCZC`nIK69=Yu*r;`XGu9dsJK9Cpb-^BL$&?Gm?c$Z0P=R-B(5xzHgBI=#d1$?uVM zdfDVsbT>m;L`>LRT~1+Tvn#=BGvzs5@fniQQdOXewR`i0PuTW)&NeMBEcHM$KRw*KE*^-sfu6UztmE*mcy|QdktfG0&8Wt15LfNjm z^lVv_#5{{JMaqbGx~j6*mZW7WvcyEj?2E!=Q4}S;DI>+36ln?#wLWPw==8=I#Tc%O K4&2^vFZdtpevsM# delta 1188 zcmXZadrVVj9LDkI*lH2B!`n6@E<+t&(l{c9M6<0-F`1iC5GWVHD}`}ULGhA!i|dU4 zm~3+(M5dyGckwdD(dnG`8z^F?sqv4AOSX8KQK?3y5{*8GpG`jJ`<|ZDw{PE`zXF!E zH!M|A;~v}wFe*JOJ|XGT!ruYyGoBX|Rro6Z-8`rRu<$%0P+Gpc)b z#-}xuFDUZmPl(M1B0t~+jQQwql$*>r(=Hcwy+dqiL>dtCUjje|~s2DVF}5qPp%U^}~6U~~1)5 zPGJI{fFO6!1kM+X5a5D?0|g8=PT;VggCzpop5O`rKIcL1j0s0sF3^Nyz|)@u{$RPq zCU85So)`F&y&-Ud4GEkCo{bbZ#k$DP15R@gFK`BU!7nx8EbuZx;2e8PfM3*Y5x4+^ zh6`L|dA|vlfLAdBtt|fn6WZ9N0+)f&uL3+FTmgEz1=@kvoXmu)z*|maLWdC@IY#qO z?vp$)`A*6gDc^bGJWD-|p3c;dQfH*zNn4n9ZA$SJBmK+ts*LcAw#*&g&%J+VxwG1{ zf6fl&#O8dT8HE-&Lyz=~{{Elgb)2>cmIlX7bv6+dpzL~8T%qh6+i}JM>J}=t& o?e$`BNq9+T>6)_SvfFcR&UMdAn75Z}u)t_lU#&CT4Fgi%0ncJt^#A|> diff --git a/roles/desktop/files/quickshell/qmldir b/roles/desktop/files/quickshell/qmldir index 51f98e8d..b9ec8f77 100644 --- a/roles/desktop/files/quickshell/qmldir +++ b/roles/desktop/files/quickshell/qmldir @@ -11,3 +11,5 @@ NetworkOverlayWindow NetworkOverlayWindow.qml OsdWindow OsdWindow.qml ShortcutsOverlay ShortcutsOverlay.qml SettingsWindow SettingsWindow.qml +PolkitPrompt PolkitPrompt.qml +PolkitWindow PolkitWindow.qml diff --git a/roles/desktop/files/quickshell/shell.qml b/roles/desktop/files/quickshell/shell.qml index 9cf34966..3db3d63a 100644 --- a/roles/desktop/files/quickshell/shell.qml +++ b/roles/desktop/files/quickshell/shell.qml @@ -270,6 +270,7 @@ ShellRoot { NotificationToasts {} OsdWindow {} ShortcutsOverlay {} + PolkitWindow {} // Reading a singleton's property is what constructs it. Notifications // must start collecting, GitHub must start polling — GitHub diff --git a/roles/desktop/tasks/main.yml b/roles/desktop/tasks/main.yml index 099eab46..dadca2ab 100644 --- a/roles/desktop/tasks/main.yml +++ b/roles/desktop/tasks/main.yml @@ -73,7 +73,6 @@ - hypridle - hyprlock - hyprpicker - - hyprpolkitagent - hyprsunset - quickshell - ImageMagick @@ -1261,11 +1260,23 @@ mode: "0644" loop: - hypridle - - hyprpolkitagent notify: - Reload user systemd - Restart hypridle +- name: Retire standalone Polkit agent startup now provided by Quickshell + become: true + become_user: "{{ primary_user }}" + ansible.builtin.file: + path: "{{ primary_home }}/.config/systemd/user/{{ item }}" + state: absent + loop: + - hyprland-session.target.wants/hyprpolkitagent.service + - graphical-session.target.wants/hyprpolkitagent.service + - hyprpolkitagent.service + notify: Reload user systemd + tags: [quickshell] + # Dictation follows the developer tooling feature, which also selects its GPU # backend, downloads its model, and binds its keys. Without that model an # always-on daemon would only hold an idle audio and GPU context. @@ -1351,7 +1362,7 @@ state: link force: true loop: >- - {{ ['quickshell', 'hypridle', 'hyprpolkitagent', 'cybexos-input-method'] + {{ ['quickshell', 'hypridle', 'cybexos-input-method'] + (features.developer_tools | bool | ternary(['voxtype'], [])) }} notify: Reload user systemd diff --git a/roles/desktop/templates/hyprpolkitagent.service.j2 b/roles/desktop/templates/hyprpolkitagent.service.j2 deleted file mode 100644 index c7eb2ee7..00000000 --- a/roles/desktop/templates/hyprpolkitagent.service.j2 +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=Hyprland Polkit authentication agent -PartOf=hyprland-session.target - -[Service] -Type=simple -ExecStart=/usr/libexec/hyprpolkitagent -Restart=on-failure -RestartSec=2 - -[Install] -WantedBy=hyprland-session.target diff --git a/roles/finalize/tasks/main.yml b/roles/finalize/tasks/main.yml index eb995903..e9c695a4 100644 --- a/roles/finalize/tasks/main.yml +++ b/roles/finalize/tasks/main.yml @@ -105,7 +105,6 @@ - start - quickshell.service - hypridle.service - - hyprpolkitagent.service changed_when: true - name: Confirm the restored desktop services are active @@ -120,7 +119,6 @@ loop: - quickshell - hypridle - - hyprpolkitagent changed_when: false rescue: diff --git a/tests/ownership-layering.py b/tests/ownership-layering.py index a5101d60..142f93fe 100755 --- a/tests/ownership-layering.py +++ b/tests/ownership-layering.py @@ -266,8 +266,61 @@ def repository_contract() -> None: assert (ROOT / "docs/architecture/ownership.md").is_file() +def polkit_runtime_contract() -> None: + """Exercise agent handoff without starting a real shell or host service.""" + with tempfile.TemporaryDirectory(prefix="cybexos-polkit-runtime.") as temporary: + root = Path(temporary) + shell = root / "home/.local/share/cybexos/runtime/quickshell" + shell.mkdir(parents=True) + (shell / "shell.qml").write_text("// fixture\n") + native = shell / "PolkitWindow.qml" + native.write_text("// fixture\n") + binary = root / "bin" + binary.mkdir() + log = root / "calls" + # Only the executable is redirected; selection and service handoff + # run through the production resolver. All systemctl calls are mocked. + runtime = root / "runtime" + runtime.write_text(RUNTIME.read_text().replace("exec /usr/bin/qs -p", "exec qs -p")) + runtime.chmod(0o755) + for name, script in { + "systemctl": '''#!/bin/bash +case "$*" in + *is-active*) exit "${FIXTURE_ACTIVE:-1}" ;; + *LoadState*) printf '%s\\n' "${FIXTURE_LOAD:-loaded}" ;; + *stop*) printf 'stop\\n' >>"$FIXTURE_LOG"; exit "${FIXTURE_STOP:-0}" ;; + *start*) printf 'start\\n' >>"$FIXTURE_LOG" ;; + *) exit 9 ;; +esac +''', + "qs": '#!/bin/bash\nprintf "shell\\n" >>"$FIXTURE_LOG"\n', + }.items(): + path = binary / name + path.write_text(script) + path.chmod(0o755) + env = {**os.environ, "HOME": str(root / "home"), + "XDG_DATA_HOME": str(root / "home/.local/share"), + "XDG_CONFIG_HOME": str(root / "home/.config"), + "CYBEXOS_RUNTIME_TESTING": "0", "FIXTURE_LOG": str(log), + "PATH": f"{binary}:{os.environ['PATH']}"} + + def start(**values): + log.write_text("") + result = run(runtime, "exec", "quickshell", env={**env, **values}, check=False) + return result.returncode, log.read_text().splitlines() + + assert start(FIXTURE_ACTIVE="0") == (0, ["stop", "shell"]) + assert start() == (0, ["stop", "shell"]), "cancel pending legacy startup too" + assert start(FIXTURE_LOAD="not-found") == (0, ["shell"]) + assert start(FIXTURE_LOAD="not-found", FIXTURE_ACTIVE="0") == (0, ["stop", "shell"]) + assert start(FIXTURE_STOP="1") == (1, ["stop"]), "do not register competing agents" + native.unlink() + assert start() == (0, ["start", "shell"]), "older runtimes retain their agent" + + if __name__ == "__main__": migration_contract() dev_source_contract() repository_contract() + polkit_runtime_contract() print("Vendor runtime updates preserve every user-owned layer byte-for-byte") diff --git a/tests/qml-lifecycle/shell.qml b/tests/qml-lifecycle/shell.qml index bd7a22f6..a07239b8 100644 --- a/tests/qml-lifecycle/shell.qml +++ b/tests/qml-lifecycle/shell.qml @@ -28,6 +28,7 @@ ShellRoot { } function runLifecycle() { + runPolkitLifecycle(); root.revealer = revealerComponent.createObject(harness, { reveal: false }); root.toggle = toggleComponent.createObject(harness); root.action = actionComponent.createObject(harness); @@ -66,6 +67,114 @@ ShellRoot { "sleep 0.2; kill -TERM -- \"$1\"", "bash", String(Quickshell.processId)]); } + function descendant(item, name) { + if (item.objectName === name) return item; + for (const child of item.children || []) { + const result = descendant(child, name); + if (result) return result; + } + return null; + } + + // Drive the shipped view with a PAM-like conversation: these fixtures + // never register an agent or authenticate against the host system. + function runPolkitLifecycle() { + const conversation = polkitFlow.createObject(harness); + const next = polkitFlow.createObject(harness); + const view = polkitView.createObject(harness, { flow: conversation }); + root.check(view !== null, "Polkit prompt did not construct"); + if (!view) return; + const field = descendant(view, "polkitResponse"); + const reveal = descendant(view, "polkitReveal"); + root.check(field !== null && reveal !== null, "Polkit controls are missing"); + if (field && reveal) { + view.focusResponse(); + root.check(field.focus, "Polkit did not focus the response field"); + root.check(field.echoMode === TextInput.Password, "secret prompt is not masked"); + field.text = "fixture response"; + reveal.triggered(); + root.check(field.echoMode === TextInput.Normal, "reveal does not show the response"); + view.submit(); + root.check(conversation.received === "fixture response", "response was not submitted intact"); + root.check(field.text === "" && !view.revealed, "submit retained a response or reveal state"); + view.submit(); + root.check(conversation.submissions === 1, "double submission was accepted while waiting"); + + conversation.authenticationFailed(); + conversation.isResponseRequired = true; + root.check(view.hasError, "retry lacks an error indication"); + root.check(field.echoMode === TextInput.Password, "retry reused reveal state"); + field.text = "discard on account switch"; + conversation.selectedIdentity = conversation.identities[1]; + root.check(field.text === "" && !view.attemptFailed, "account switch retained response or error"); + root.check(view.account === "Admin (admin)", "selected identity label is incorrect"); + + conversation.inputPrompt = "Verification code:"; + conversation.responseVisible = true; + root.check(field.echoMode === TextInput.Normal && !reveal.visible, + "visible PAM response is incorrectly treated as a password"); + field.text = "discard on prompt change"; + conversation.inputPrompt = "Password:"; + conversation.responseVisible = false; + root.check(field.text === "", "new prompt retained the previous response"); + + field.text = "discard on next request"; + view.detailsOpen = true; + view.flow = next; + root.check(field.text === "" && !view.detailsOpen, "next request retained conversation state"); + field.text = "discard on cancellation"; + view.cancel(); + root.check(next.isCancelled && field.text === "", "cancel did not clear and abort the request"); + + view.flow = conversation; + field.text = "discard on remote cancellation"; + conversation.isCancelled = true; + root.check(field.text === "" && !field.enabled, "remote cancellation retained an enabled input"); + conversation.isCancelled = false; + field.text = "discard on success"; + conversation.isCompleted = true; + root.check(field.text === "" && !field.enabled, "completed flow retained an enabled input"); + view.flow = null; + root.check(view.finished, "removed flow is still actionable"); + } + view.destroy(); + conversation.destroy(); + next.destroy(); + } + + Component { + id: polkitView + PolkitPrompt { width: 392 } + } + Component { + id: polkitFlow + QtObject { + property string message: "Authenticate to unlock the fixture" + property string actionId: "org.cybexos.fixture" + property var identities: [ + { string: "john", displayName: "John Example" }, + { string: "admin", displayName: "Admin" } + ] + property var selectedIdentity: identities[0] + property bool isResponseRequired: true + property bool isCompleted: false + property bool isCancelled: false + property bool responseVisible: false + property string inputPrompt: "Password:" + property string supplementaryMessage: "" + property bool supplementaryIsError: false + property string received: "" + property int submissions: 0 + signal authenticationFailed() + function submit(value) { + received = value; + submissions++; + isResponseRequired = false; + } + function cancelAuthenticationRequest() { isCancelled = true; } + } + } + Component { id: revealerComponent Common.Revealer { diff --git a/tests/quickshell/input-method.test.cjs b/tests/quickshell/input-method.test.cjs index fed23537..d9f74093 100644 --- a/tests/quickshell/input-method.test.cjs +++ b/tests/quickshell/input-method.test.cjs @@ -18,7 +18,7 @@ test("IBus runs as a session unit on Hyprland's input-method v2 protocol", () => test("the workstation and the image start the same IBus unit", () => { const desktop = read("roles/desktop/tasks/main.yml"); assert.match(desktop, /src: cybexos-input-method\.service\s+dest: "\{\{ primary_home \}\}\/\.config\/systemd\/user\/cybexos-input-method\.service"/); - assert.match(desktop, /\['quickshell', 'hypridle', 'hyprpolkitagent', 'cybexos-input-method'\]/); + assert.match(desktop, /\['quickshell', 'hypridle', 'cybexos-input-method'\]/); const target = read("image/rootfs/usr/lib/systemd/user/hyprland-session.target"); assert.match(target, /^Wants=.*\bcybexos-input-method\.service\b/m); assert.match(read("image/package"), diff --git a/tests/quickshell/polkit.test.cjs b/tests/quickshell/polkit.test.cjs new file mode 100644 index 00000000..16487ef7 --- /dev/null +++ b/tests/quickshell/polkit.test.cjs @@ -0,0 +1,24 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { load } = require("./shell.cjs"); +const { identityLabel, identityOptions } = load("PolkitHelpers.js"); + +test("Polkit shows the full account name without leaking the other GECOS fields", () => { + assert.equal(identityLabel({ string: "john", displayName: "John Example,Room 4,555-0100" }), + "John Example (john)"); + assert.equal(identityLabel({ string: "alice", displayName: "alice" }), "alice"); + assert.equal(identityLabel({ string: "wheel", displayName: "", isGroup: true }), "Group: wheel"); + assert.equal(identityLabel({ id: 0 }), "0"); + assert.equal(identityLabel(null), ""); +}); + +test("identity choices preserve order and distinguish accounts with identical names", () => { + assert.deepEqual(identityOptions([ + { string: "alice", displayName: "Alice" }, + { string: "admin", displayName: "Alice" } + ]), [ + { value: 0, label: "Alice (alice)" }, + { value: 1, label: "Alice (admin)" } + ]); + assert.deepEqual(identityOptions(null), []); +}); diff --git a/tests/verify-system b/tests/verify-system index 66d6c981..d5a325fc 100755 --- a/tests/verify-system +++ b/tests/verify-system @@ -622,11 +622,8 @@ check 'Walker command is absent' bash -c '! command -v walker' check 'Elephant command is absent' bash -c '! command -v elephant' check 'Walker user unit is absent' bash -c '! systemctl --user list-unit-files walker.service --no-legend 2>/dev/null | grep -q walker' check 'Elephant user unit is absent' bash -c '! systemctl --user list-unit-files elephant.service --no-legend 2>/dev/null | grep -q elephant' -if command_exists hyprpolkitagent || [[ -x /usr/libexec/hyprpolkitagent ]]; then - pass 'Hyprland Polkit agent executable' -else - fail 'missing Hyprland Polkit agent executable' -fi +check 'Quickshell Polkit dialog is installed' test -r \ + "$HOME/.local/share/cybexos/runtime/quickshell/PolkitWindow.qml" if feature_enabled developer_tools && command_exists node; then major=$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || true) @@ -739,7 +736,7 @@ if [[ ${XDG_CURRENT_DESKTOP:-} == *Hyprland* ]] && command_exists hyprctl && hyp errors=$(hyprctl configerrors 2>/dev/null || true) [[ -z $errors || $errors == 'no errors' ]] && pass 'Hyprland has no config errors' || fail "Hyprland config errors: $errors" check 'retired Noctalia process is absent' bash -c '! pgrep -x noctalia' - user_units=(quickshell hypridle hyprpolkitagent) + user_units=(quickshell hypridle) # The external-monitor watcher is installed only on the gated XPS hardware. $xps_2026 && user_units+=(external-monitor-toggle) feature_enabled developer_tools && user_units+=(voxtype) @@ -761,6 +758,10 @@ if [[ ${XDG_CURRENT_DESKTOP:-} == *Hyprland* ]] && command_exists hyprctl && hyp if $quickshell_live_safe; then check 'Quickshell launcher IPC responds' qs_live_wait_ipc "$QS_LIVE_TIMEOUT" launcher close check 'Quickshell settings IPC responds' qs_live_wait_ipc "$QS_LIVE_TIMEOUT" settings close + polkit_status=$(qs_live_wait_ipc "$QS_LIVE_TIMEOUT" polkit status || true) + check 'Quickshell Polkit agent is registered' jq -e '.registered == true' <<<"$polkit_status" + check 'standalone Polkit agent is inactive' bash -c \ + '! systemctl --user is-active --quiet hyprpolkitagent.service' else fail 'Quickshell IPC checks skipped because live process ownership is ambiguous' fi diff --git a/tests/vm/assert-converged b/tests/vm/assert-converged index 2ad76f62..cdd38204 100755 --- a/tests/vm/assert-converged +++ b/tests/vm/assert-converged @@ -37,7 +37,7 @@ grep -q '^performance=xps-performance$' /etc/tuned/ppd.conf && fail 'the XPS performance mapping was installed on generic hardware' user_id=$(id -u fedora) -for unit in quickshell.service hypridle.service hyprpolkitagent.service; do +for unit in quickshell.service hypridle.service; do sudo -u fedora env XDG_RUNTIME_DIR="/run/user/$user_id" \ systemctl --user is-enabled --quiet "$unit" || fail "$unit is not enabled for fedora" From 3b9cf7d941c65279572471f60ca5f952be18b3a2 Mon Sep 17 00:00:00 2001 From: John Pals <7024725+DigitalPals@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:20:47 +0200 Subject: [PATCH 2/5] Increase Quickshell default text size for readability --- docs/shell-appearance.md | 14 +++--- docs/shell-typography.md | 46 +++++++++++++++---- .../quickshell/Common/SettingsHelpers.js | 2 +- .../files/quickshell/Common/Typography.js | 7 +-- .../files/quickshell/Commons/Style.qml | 6 +-- .../Popovers/Drawer/DrawerOverview.qml | 3 +- tests/quickshell/omarchy-theme.test.cjs | 5 +- tests/quickshell/typography-scale.test.cjs | 22 +++++---- tests/quickshell/typography.test.cjs | 8 ++-- 9 files changed, 76 insertions(+), 37 deletions(-) diff --git a/docs/shell-appearance.md b/docs/shell-appearance.md index 23159ce0..411e5c39 100644 --- a/docs/shell-appearance.md +++ b/docs/shell-appearance.md @@ -42,18 +42,20 @@ settings. Existing explicit plugin border overrides remain valid; choose surface borders. The defaults use Dark mode, a Hug bar, wallpaper colors, opaque surfaces and -numbered workspaces. Typography uses JetBrainsMono Nerd Font at 12px, 100% UI +numbered workspaces. Typography uses JetBrainsMono Nerd Font at 14px, 100% UI scale and standard spacing. Panels have 16px corners and no border (width 0); plugins inherit the shared appearance. Appearance has no preset actions. Existing saved preferences remain in effect; section resets use these defaults. The shared library and usage rules are documented in [Shell typography](shell-typography.md). -Typography follows [Omarchy's default scale](https://github.com/omacom/omarchy/blob/quattro/default/themed/shell.toml.tpl) -and [default monospace family](https://github.com/omacom/omarchy/blob/quattro/default/fontconfig/conf.avail/50-omarchy.conf): -10px captions, 11px secondary copy, 12px body/control/bar text, 14px titles, -16px headings, and 24/28px display values. Settings labels, inputs, pickers, -and actions use the same body role as plugin controls. Regular copy uses +Typography derives from [Omarchy's scale](https://github.com/omacom/omarchy/blob/quattro/default/themed/shell.toml.tpl) +and [default monospace family](https://github.com/omacom/omarchy/blob/quattro/default/fontconfig/conf.avail/50-omarchy.conf), +with a larger default base: 12px captions, 13px secondary copy, +14px body/control/bar text, 16px titles, 18px headings, and 28/33px display +values. The heading multiplier is tuned to 18px at the new base. +Settings labels, inputs, pickers and actions use the same body role as plugin +controls. Regular copy uses weight 400; headings can use medium, semibold or bold. Native body/caption and plugin body/caption share the same reference sizes. diff --git a/docs/shell-typography.md b/docs/shell-typography.md index 6fbe1124..62f875d2 100644 --- a/docs/shell-typography.md +++ b/docs/shell-typography.md @@ -7,7 +7,24 @@ All three resolve through the same library. Existing `Theme.fontBody` and similar aliases, `Style.font.body`, and `api.theme.fontSize` remain compatible. New native views must use the named roles below, not the old aliases. -## Comparison with Omarchy +## Readable defaults + +Since 2026-09-26 the default base is **14 logical pixels**. Normal labels, +controls, navigation and bar readings are 14px; secondary descriptions and +tooltips are 13px; captions, section labels and metadata are 12px. Titles, +notifications and OSD text are 16px, and headings (including launcher queries +and results) are 18px. These are the sizes at 100% interface scale and default +text size; explicit smaller settings and plugin overrides remain available. + +Native and plugin surfaces use the same resolver. The Omarchy multipliers +below are retained except for `heading`, which uses 18/14 of the effective +base. Geometry retains its 12px reference so panels and spacing grow with the +larger text; wrapping rows grow to their content and panels clamp to the output. +Fresh installations and appearance resets use 14px. Existing saved font sizes +are preserved; select 14px under Appearance → Advanced text options to adopt +the new size on an existing installation. + +## Historical comparison with Omarchy (12px base) Audited on 2026-09-21 against Omarchy commit [`961ec7f39fd0d70c7d2944c5b80585a86713693d`](https://github.com/omacom/omarchy/tree/961ec7f39fd0d70c7d2944c5b80585a86713693d). @@ -45,8 +62,8 @@ Sources at the audited revision: [OSD](https://github.com/omacom/omarchy/blob/961ec7f39fd0d70c7d2944c5b80585a86713693d/shell/plugins/osd/Osd.qml), [clock hero](https://github.com/omacom/omarchy/blob/961ec7f39fd0d70c7d2944c5b80585a86713693d/shell/plugins/panels/clock/Panel.qml). -The core scale is caption 10, body-small 11, body 12, subtitle 13, title 14, -heading 16, display 24 and display-large 28. The `clock` role explicitly +At the September 21 audit the core scale was caption 10, body-small 11, body 12, +subtitle 13, title 14, heading 16, display 24 and display-large 28. The `clock` role explicitly centralizes Omarchy's exceptional 52px date treatment for our date/time hero; it is not a general heading. Icons keep their separate optical sizes. Omarchy uses Liberation Sans for notifications; we retain the user's shared @@ -56,7 +73,7 @@ shared size/usage contract, not a claim of identical layout or rendering. ## Implementation contract `ShellMetrics.calculate()` combines base size, UI scale and accessibility -scale once. `Typography.resolve()` applies Omarchy's multipliers and rounds +scale once. `Typography.resolve()` applies the shared multipliers and rounds once per token. Density affects spacing, not font size; Qt applies monitor scaling. Native and plugin adapters no longer maintain independent type scales. At default plugin settings their role values are identical, including @@ -84,15 +101,26 @@ because the available row is short. Reserve `section` for group labels and wrapping, scrolling or elision when space is constrained; do not invent a local smaller size. New exceptional sizes need a documented shared token. -The launcher deliberately uses `heading` (16px at the default base) for its -search query and primary result labels, matching Omarchy v4.0.4's menu. +The launcher deliberately uses `heading` (18px at the default base) for its +search query and primary result labels, following Omarchy v4.0.4's menu hierarchy. The query is regular weight and result labels are medium weight. Provider tabs -use `title` (14px) at medium weight with 16px icons. This prominent search field -is an exception to the ordinary `control` input role and still follows the -shared accessibility scale. +use `title` (16px) at medium weight with icons from the shared scale. This +prominent search field is an exception to the ordinary `control` input role +and still follows the shared accessibility scale. ## Verification +The 2026-09-26 readability update passed 1,163 unit tests and the required +repository check stages, with the updated typography expectations rerun. +Managed-service checks confirmed identical native/plugin role maps for all +nine text-size/density combinations, using effective bases of 14, 16 and 18px. +Overview (including the growing media row), sound, Appearance and the launcher +were inspected at the new default, and Overview at Larger text with Compact +spacing. The service finished as the sole Quickshell process with no QML +errors in its current invocation. Both outputs used 2× device scaling; +fractional output scaling and the isolated full-shell lifecycle harness were +not exercised on this live desktop. + `tests/quickshell/typography-scale.test.cjs` checks reference values, usage roles, native/plugin adapter wiring, the accessibility/density matrix, plugin overrides and icon fallbacks. Source checks reject pixel literals, point diff --git a/roles/desktop/files/quickshell/Common/SettingsHelpers.js b/roles/desktop/files/quickshell/Common/SettingsHelpers.js index 90d7ade9..af6d3289 100644 --- a/roles/desktop/files/quickshell/Common/SettingsHelpers.js +++ b/roles/desktop/files/quickshell/Common/SettingsHelpers.js @@ -359,7 +359,7 @@ function defaults() { glassEnabled: false, highContrast: false, reducedMotion: false, - shellFontSize: 12, + shellFontSize: 14, shellScale: 100, surfaceBorderMode: "accent", surfaceBorderColor: "#9ecbeb", diff --git a/roles/desktop/files/quickshell/Common/Typography.js b/roles/desktop/files/quickshell/Common/Typography.js index 3a36e273..6a04dba2 100644 --- a/roles/desktop/files/quickshell/Common/Typography.js +++ b/roles/desktop/files/quickshell/Common/Typography.js @@ -1,10 +1,11 @@ // Shared native/plugin typography. Logical pixels; output scale belongs to Qt. // Reference: Omarchy 961ec7f39fd0d70c7d2944c5b80585a86713693d, -// shell/Commons/Style.qml. Usage rationale: docs/shell-typography.md. +// shell/Commons/Style.qml. CybexOS uses a 14px default with 18px headings. +// Usage rationale: docs/shell-typography.md. var SCALE = { caption: ["caption", 0.833], bodySmall: ["body-small", 0.917], body: ["body", 1], subtitle: ["subtitle", 1.083], - title: ["title", 1.167], heading: ["heading", 1.333], + title: ["title", 1.167], heading: ["heading", 18 / 14], display: ["display", 2], displayLarge: ["display-large", 2.333], iconSmall: ["icon-small", 0.917], icon: ["icon", 1.167], iconLarge: ["icon-large", 1.5], @@ -25,7 +26,7 @@ function pixels(base, multiplier) { function resolve(base, overrides) { base = Number(base); - if (!isFinite(base) || base <= 0) base = 12; + if (!isFinite(base) || base <= 0) base = 14; overrides = overrides || {}; var sizes = {}; Object.keys(SCALE).forEach(function(name) { diff --git a/roles/desktop/files/quickshell/Commons/Style.qml b/roles/desktop/files/quickshell/Commons/Style.qml index f28664b6..d215d88a 100644 --- a/roles/desktop/files/quickshell/Commons/Style.qml +++ b/roles/desktop/files/quickshell/Commons/Style.qml @@ -25,7 +25,7 @@ import "../Common/Typography.js" as Typography // padding, controls, and panel dimensions while preserving each component's // proportions; by default it also tracks `base-size`. `[bar] // size-horizontal` / `size-vertical` set the cross-axis dimension for -// top/bottom and left/right bars at the default 12px font size; by default +// top/bottom and left/right bars at the 12px reference font size; by default // those dimensions scale with `base-size` so larger fonts don't clip. QtObject { id: root @@ -283,7 +283,7 @@ QtObject { // The only sanity floor is 1px. Themes and users can make this as large // as they like; if the shell gets ridiculous, that's their call. - property int fontBaseSize: 12 + property int fontBaseSize: 14 property var fontOverrides: ({}) property var barOverrides: ({}) @@ -373,7 +373,7 @@ QtObject { var barOut = {} var styleOut = {} var spacingOut = {} - var nextBase = 12 + var nextBase = 14 var nextSpacingScale = 1.0 var nextSpacingScaleWithFont = true var nextBarScaleWithFont = true diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerOverview.qml b/roles/desktop/files/quickshell/Popovers/Drawer/DrawerOverview.qml index 50fc5f0d..33c7bcef 100644 --- a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerOverview.qml +++ b/roles/desktop/files/quickshell/Popovers/Drawer/DrawerOverview.qml @@ -35,7 +35,7 @@ Column { Rectangle { visible: Media.hasTrack && Settings.drawerOverview.media === true width: parent.width - height: 64 + height: Math.max(Theme.scaled(64), mediaText.implicitHeight + Theme.scaled(20)) radius: 10 color: Theme.chip @@ -73,6 +73,7 @@ Column { } Column { + id: mediaText anchors.left: art.right anchors.leftMargin: 12 anchors.right: transport.left diff --git a/tests/quickshell/omarchy-theme.test.cjs b/tests/quickshell/omarchy-theme.test.cjs index dd83e60d..d301a0cf 100644 --- a/tests/quickshell/omarchy-theme.test.cjs +++ b/tests/quickshell/omarchy-theme.test.cjs @@ -16,7 +16,8 @@ test("Omarchy adapter maps the default font, geometry and supplied palette borde assert.equal(p.font, "mono"); assert.equal(p.surfaceCornerRadius, 16); const v = values(p); - assert.equal(Math.round(420 * Number(v["font.base-size"]) / 12), 420); + assert.equal(v["font.base-size"], "14"); + assert.equal(Math.round(420 * Number(v["font.base-size"]) / 12), 490); for (const surface of ["popups", "tooltip", "menu", "launcher", "notifications"]) { assert.equal(v[surface + ".border"], palette.surfaceBorder); assert.equal(v[surface + ".border-width"], "2"); @@ -52,7 +53,7 @@ test("plugin overrides stay independent and shared border opacity is applied onc const p = prefs({ pluginScale: 150, textScale: "large", pluginBorderMode: "custom", pluginBorderColor: "#abcdef", pluginBorderWidth: 4, pluginBorderOpacity: 50, pluginRadius: 0 }); const v = values(p); - assert.equal(v["font.base-size"], "21"); + assert.equal(v["font.base-size"], "24"); assert.equal(v["popups.border"], "#abcdef"); assert.equal(v["popups.border-alpha"], "0.5"); const shared = values(prefs(), { ...palette, surfaceBorder: "#fedcba", diff --git a/tests/quickshell/typography-scale.test.cjs b/tests/quickshell/typography-scale.test.cjs index 45cea7f9..585f3005 100644 --- a/tests/quickshell/typography-scale.test.cjs +++ b/tests/quickshell/typography-scale.test.cjs @@ -8,16 +8,21 @@ const H = load("SettingsHelpers.js"); const M = load("ShellMetrics.js"); const read = file => fs.readFileSync(path.join(shellDir, file), "utf8"); -test("Omarchy reference scale and usage roles stay distinct", () => { - const s = T.resolve(12); - assert.equal(s.clock, 52); +test("readable defaults and usage roles stay distinct", () => { + const s = T.resolve(M.calculate(H.defaults()).fontBase); + assert.equal(s.clock, 61); assert.deepEqual([s.caption, s.bodySmall, s.body, s.subtitle, s.title, - s.heading, s.display, s.displayLarge], [10, 11, 12, 13, 14, 16, 24, 28]); + s.heading, s.display, s.displayLarge], [12, 13, 14, 15, 16, 18, 28, 33]); for (const role of ["bar", "control", "navigation", "primary"]) - assert.equal(s[role], 12, role); - for (const role of ["secondary", "tooltip"]) assert.equal(s[role], 11, role); - for (const role of ["section", "metadata"]) assert.equal(s[role], 10, role); - for (const role of ["notification", "osd"]) assert.equal(s[role], 14, role); + assert.equal(s[role], 14, role); + for (const role of ["secondary", "tooltip"]) assert.equal(s[role], 13, role); + for (const role of ["section", "metadata"]) assert.equal(s[role], 12, role); + for (const role of ["notification", "osd"]) assert.equal(s[role], 16, role); + for (const invalid of [undefined, 0, -1, NaN, Infinity]) + assert.deepEqual(T.resolve(invalid), s); + // Defaults and section resets grow; an explicit saved size stays chosen. + assert.equal(H.merge({}).shellFontSize, 14); + assert.equal(H.merge({ shellFontSize: 12 }).shellFontSize, 12); }); test("native and compatibility adapters use the same resolver, without a second type scale", () => { @@ -41,6 +46,7 @@ test("type scaling follows accessibility and UI scale but not spacing density", assert.deepEqual(T.resolve(M.calculate({ ...p, interfaceDensity }).fontBase), expected); assert.equal(expected.bar, base); assert.equal(expected.title, Math.max(1, Math.round(base * 1.167))); + assert.equal(expected.heading, Math.max(1, Math.round(base * 18 / 14))); assert.equal(expected.secondary, Math.max(1, Math.round(base * 0.917))); } }); diff --git a/tests/quickshell/typography.test.cjs b/tests/quickshell/typography.test.cjs index 00715bf6..a77b472d 100644 --- a/tests/quickshell/typography.test.cjs +++ b/tests/quickshell/typography.test.cjs @@ -59,7 +59,7 @@ function contrast(a, b) { } test("semantic typography tokens retain the intended logical-pixel scale", () => { - // Reference sizes are multiplied by the common font scale. + // Compatibility aliases at an explicit 12px base; headings use 18/14. assert.deepEqual([ intToken("fontMicro"), intToken("fontTiny"), @@ -70,7 +70,7 @@ test("semantic typography tokens retain the intended logical-pixel scale", () => intToken("fontProminent"), intToken("fontDisplay"), intToken("fontHero"), - ], [10, 11, 10, 11, 12, 16, 14, 24, 28]); + ], [10, 11, 10, 11, 12, 15, 14, 24, 28]); }); test("menu typography keeps the bar's own compact metrics", () => { @@ -425,7 +425,7 @@ test("no surface paints an accent field where a chip belongs", () => { test("default body size is shared by settings controls and plugin typography", () => { const d = load("SettingsHelpers.js").defaults(); - assert.equal(d.shellFontSize, 12); + assert.equal(d.shellFontSize, 14); assert.equal(d.shellScale, 100); assert.equal(d.pluginScale, 100); // The plugin install and clone fields are FieldRows. @@ -439,7 +439,7 @@ test("default body size is shared by settings controls and plugin typography", ( assert.match(fs.readFileSync(path.join(shellDir, "Settings", "SettingsTextRow.qml"), "utf8"), /SettingsField \{/); const widgets = fs.readFileSync(path.join(shellDir, "Bar", "UserWidgets.qml"), "utf8"); assert.match(widgets, /fontSize: Theme\.typography\.bar/); - assert.equal(load("ShellMetrics.js").calculate(d).fontBase, 12); + assert.equal(load("ShellMetrics.js").calculate(d).fontBase, 14); for (const name of ["SettingsRow", "SettingsField", "SettingsAction", "PickerRow", "PillRow", "SliderRow"]) { const source = fs.readFileSync(path.join(shellDir, "Settings", name + ".qml"), "utf8"); From 09c787a4118ba550218a762b0f948354904d9ab5 Mon Sep 17 00:00:00 2001 From: John Pals <7024725+DigitalPals@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:33:09 +0200 Subject: [PATCH 3/5] Fix JetBrainsMono defaults for fresh installations --- assets/desktop-contract.json | 1 + docs/omarchy-plugin-compatibility.md | 4 ++-- docs/shell-appearance.md | 3 +++ image/test_desktop_payload.py | 17 +++++++++++++++++ .../files/quickshell/Common/SettingsHelpers.js | 4 +++- roles/dotfiles/tasks/wallpapers.yml | 3 ++- tests/quickshell/settings-helpers.test.cjs | 14 +++++++++++++- 7 files changed, 41 insertions(+), 5 deletions(-) diff --git a/assets/desktop-contract.json b/assets/desktop-contract.json index 704d9310..5d5e0d65 100644 --- a/assets/desktop-contract.json +++ b/assets/desktop-contract.json @@ -1,6 +1,7 @@ { "version": 1, "shell": { + "font": "mono", "wall": "snow-capped-mountains-with-full-moon-lo.jpg", "wallDir": "~/Pictures/Wallpapers" }, diff --git a/docs/omarchy-plugin-compatibility.md b/docs/omarchy-plugin-compatibility.md index 8250dc72..dcf4a97d 100644 --- a/docs/omarchy-plugin-compatibility.md +++ b/docs/omarchy-plugin-compatibility.md @@ -259,10 +259,10 @@ accessibility scale, spacing density, border and corner settings described in [Shell appearance](shell-appearance.md). Appearance settings apply individually; there are no appearance presets. -The defaults use JetBrainsMono Nerd Font at 12px, standard spacing, no panel +The defaults use JetBrainsMono Nerd Font at 14px, standard spacing, no panel border and 16px panel corners. At the default accessibility scale, Model Usage's `Style.space(420)` -is 420 logical pixels (840 image pixels on a 200% output). Qt applies monitor +is 490 logical pixels (980 image pixels on a 200% output). Qt applies monitor scaling; the shell never multiplies geometry by monitor scale itself. Settings → Appearance → Plugins provides an additional interface scale (75–200%). Border mode diff --git a/docs/shell-appearance.md b/docs/shell-appearance.md index 411e5c39..f90fa4a5 100644 --- a/docs/shell-appearance.md +++ b/docs/shell-appearance.md @@ -46,6 +46,9 @@ numbered workspaces. Typography uses JetBrainsMono Nerd Font at 14px, 100% UI scale and standard spacing. Panels have 16px corners and no border (width 0); plugins inherit the shared appearance. Appearance has no preset actions. Existing saved preferences remain in effect; section resets use these defaults. +New ISO-installed accounts explicitly seed the same JetBrainsMono font choice. +Empty or older settings without a font also inherit this default; the historical +Google Sans migration applies only to a stored pre-schema-7 Urbanist value. The shared library and usage rules are documented in [Shell typography](shell-typography.md). diff --git a/image/test_desktop_payload.py b/image/test_desktop_payload.py index 5d346eb9..15492aad 100644 --- a/image/test_desktop_payload.py +++ b/image/test_desktop_payload.py @@ -45,6 +45,23 @@ def test_portable_defaults_and_boot_assets_share_workstation_sources(self): split_seed(vendor, contract) defaults = json.loads((vendor / "essential-seed/.config/cybexos/shell.json").read_text()) self.assertEqual(defaults, contract["shell"]) + # Exercise first-login seeding followed by the real shell merger: + # an unversioned seed must not revive a historical font default. + (vendor / "bin").mkdir() + (vendor / "runtime").mkdir() + home = payload / "installed-user" + with patch.dict(os.environ, {}, clear=True): + INIT.initialize(home, vendor, mode="essential") + preferences = home / ".config/cybexos/shell.json" + result = subprocess.run([ + "node", "-e", + "const fs = require('fs'), H = require(process.argv[1]); " + "const s = H.merge(JSON.parse(fs.readFileSync(process.argv[2], 'utf8'))); " + "console.log(H.FONT_CHOICES.find(f => f.id === s.font).family);", + str(ROOT / "roles/desktop/files/quickshell/Common/SettingsHelpers.js"), + str(preferences), + ], text=True, capture_output=True, check=True) + self.assertEqual(result.stdout.strip(), "JetBrainsMono Nerd Font") wallpaper = vendor / "essential-seed/Pictures/Wallpapers" / defaults["wall"] self.assertEqual(wallpaper.read_bytes(), (ROOT / "assets/wallpapers" / defaults["wall"]).read_bytes()) self.assertTrue(app.is_file()) diff --git a/roles/desktop/files/quickshell/Common/SettingsHelpers.js b/roles/desktop/files/quickshell/Common/SettingsHelpers.js index af6d3289..91850bfa 100644 --- a/roles/desktop/files/quickshell/Common/SettingsHelpers.js +++ b/roles/desktop/files/quickshell/Common/SettingsHelpers.js @@ -1168,12 +1168,14 @@ function adoptRedesign(parsed) { // Schema 7 makes the softer variable face the shell default. As with the // schema-4 redesign, a stored value equal to the previous default is treated // as untouched; every other valid font remains an explicit user choice. +// A missing font must use today's default, including unversioned installer +// seeds. It is not evidence of a saved schema-6 font preference. function adoptSofterTypography(parsed) { if (!parsed || typeof parsed !== "object" || (typeof parsed.v === "number" && parsed.v >= 7)) return parsed; var next = clone(parsed); - if (next.font === undefined || next.font === "urbanist") + if (next.font === "urbanist") next.font = "google"; return next; } diff --git a/roles/dotfiles/tasks/wallpapers.yml b/roles/dotfiles/tasks/wallpapers.yml index 48d154b4..4b43b57c 100644 --- a/roles/dotfiles/tasks/wallpapers.yml +++ b/roles/dotfiles/tasks/wallpapers.yml @@ -44,7 +44,8 @@ ansible.builtin.copy: dest: "{{ primary_home }}/.config/cybexos/shell.json" mode: "0600" - content: "{{ wallpaper_settings | combine(cybexos_desktop_contract.shell) | to_nice_json }}\n" + # Other seeded defaults, including the font, must preserve saved choices. + content: "{{ cybexos_desktop_contract.shell | combine(wallpaper_settings) | combine({'wall': cybexos_desktop_contract.shell.wall}) | to_nice_json }}\n" when: - wallpaper_settings.wall | default('') == '' - wallpaper_settings.wallDir | default('~/Pictures/Wallpapers') in ['~/Pictures/Wallpapers', primary_home + '/Pictures/Wallpapers'] diff --git a/tests/quickshell/settings-helpers.test.cjs b/tests/quickshell/settings-helpers.test.cjs index deee4d97..803608e5 100644 --- a/tests/quickshell/settings-helpers.test.cjs +++ b/tests/quickshell/settings-helpers.test.cjs @@ -566,6 +566,18 @@ test("schema-6 migration preserves module order and adds clock-side indicators", assert.equal(migrated.mods.right[0].id, "batt"); }); +test("missing font preferences use the current default across first-run and legacy settings", () => { + for (const raw of [null, {}, { wall: "mountain.jpg" }, { v: 3 }, { v: 6 }, { v: H.VERSION }]) { + const merged = H.merge(raw); + assert.equal(merged.font, "mono", JSON.stringify(raw)); + assert.equal(H.FONT_CHOICES.find(choice => choice.id === merged.font).family, + "JetBrainsMono Nerd Font"); + } + for (const font of H.FONT_IDS) + assert.equal(H.merge({ v: H.VERSION, font }).font, font, + "an explicitly saved font remains selectable"); +}); + test("schema-7 adopts Google Sans only from the previous default", () => { assert.equal(H.merge({ v: 6, font: "urbanist" }).font, "google", "the old untouched default follows the softer typography pass"); @@ -620,7 +632,7 @@ test("a schema-3 file adopts the redesign only where it was left untouched", () assert.equal(untouched.barRadius, 11); assert.equal(untouched.gap, 8); assert.equal(untouched.accent, "#d3d283"); - assert.equal(untouched.font, "google"); + assert.equal(untouched.font, "mono"); assert.equal(untouched.osd, "bottom"); assert.equal(untouched.modOpts.ws.style, "numbers"); assert.equal(untouched.modOpts.media.maxWidth, 180); From f44c9f6cea923833e7d07cb5e8bc663d8daf00e1 Mon Sep 17 00:00:00 2001 From: John Pals <7024725+DigitalPals@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:33:28 +0200 Subject: [PATCH 4/5] Add SSH remote server menubar widget and dashboard Stream Linux telemetry over a shared SSH connection with configurable bar metrics, history charts, adaptive polling, and reconnect handling. Add native settings, collector tests, and usage documentation. --- docs/remote-server-widget.md | 76 ++++ roles/desktop/files/quickshell/Bar/Bar.qml | 1 + .../files/quickshell/Bar/Modules/Remote.qml | 61 +++ .../files/quickshell/Bar/Modules/qmldir | 1 + .../quickshell/Common/PanelRegistryData.js | 1 + .../files/quickshell/Common/RemoteServer.qml | 138 +++++++ .../quickshell/Common/RemoteServerHelpers.js | 100 +++++ .../quickshell/Common/SettingsHelpers.js | 16 +- .../files/quickshell/Common/WidgetCatalog.js | 1 + roles/desktop/files/quickshell/Common/qmldir | 1 + .../Popovers/RemoteServerPopover.qml | 365 ++++++++++++++++++ .../desktop/files/quickshell/Popovers/qmldir | 1 + .../quickshell/Settings/ModuleDetailView.qml | 87 +++++ .../files/quickshell/Ui/TelemetryChart.qml | 43 +++ roles/desktop/files/quickshell/Ui/qmldir | 1 + .../files/quickshell/scripts/remote-server.py | 45 +++ .../quickshell/scripts/remote_server_probe.py | 222 +++++++++++ tests/quickshell/remote-server.test.cjs | 71 ++++ tests/quickshell/settings-helpers.test.cjs | 8 +- tests/quickshell/settings.test.cjs | 4 +- tests/remote-server.py | 125 ++++++ tests/run | 1 + 22 files changed, 1360 insertions(+), 9 deletions(-) create mode 100644 docs/remote-server-widget.md create mode 100644 roles/desktop/files/quickshell/Bar/Modules/Remote.qml create mode 100644 roles/desktop/files/quickshell/Common/RemoteServer.qml create mode 100644 roles/desktop/files/quickshell/Common/RemoteServerHelpers.js create mode 100644 roles/desktop/files/quickshell/Popovers/RemoteServerPopover.qml create mode 100644 roles/desktop/files/quickshell/Ui/TelemetryChart.qml create mode 100644 roles/desktop/files/quickshell/scripts/remote-server.py create mode 100644 roles/desktop/files/quickshell/scripts/remote_server_probe.py create mode 100644 tests/quickshell/remote-server.test.cjs create mode 100644 tests/remote-server.py diff --git a/docs/remote-server-widget.md b/docs/remote-server-widget.md new file mode 100644 index 00000000..fc9c80ad --- /dev/null +++ b/docs/remote-server-widget.md @@ -0,0 +1,76 @@ +# Remote Server widget + +Add **Remote Server** from Settings → Menubar → Widgets, then open its options. +Set **SSH host** to an existing SSH alias or `user@hostname`, and optionally set +a display name. For example: `john@10.10.0.7`, **The Beast**. These are personal +settings, not distribution defaults; new installations leave the widget disabled +and its host empty. + +The default menubar statistic is CPU utilization. Options include load average, +memory used percent/bytes or available bytes, filesystem used percent/free bytes, +network receive/transmit rate, and the hottest reported temperature. The server +label can be hidden or compacted away on a crowded bar. Select a mount point or +interface in the options; interface buttons in the dashboard also pin that choice. +Automatic networking prefers the default route, then the busiest interface with +an address. It never sums bridges, bonds and members together. + +Click the widget for CPU/memory histories, per-core activity, 1/5/15-minute load, +memory/swap capacities, local filesystems, network traffic and temperatures. +Large filesystem, interface and sensor lists expand on demand. The dashboard +scrolls within the output's available height. Missing sensors and first-sample +rates are unavailable, never zero. Disconnections retain explicitly stale values +and the time since the last successful sample. + +## Connection requirements + +- Linux with readable `/proc` and Python **3.9+** on the server. +- OpenSSH and Python 3 on the desktop. GNU `df` provides local filesystem stats; + `ip` provides optional addresses/default-route discovery on the server. +- Working noninteractive SSH key/agent authentication. First connect in a + terminal, for example `ssh john@10.10.0.7`, to verify its host key. Unknown or + changed keys are rejected; the widget never accepts them automatically. +- Configure ports, identities and jump hosts in `~/.ssh/config`. The host field + accepts a destination, not SSH options or a shell command. + +No root access, remote installation, remote file writes or remote service is +needed. Temperature sensors depend on the host's drivers and permissions. +Physical DIMM type/speed, SMART health and privileged hardware information are +outside the current collector. Network filesystems and temporary/container +overlay mounts are excluded from capacity collection. + +## Implementation and lifecycle + +`Common/RemoteServer.qml` owns a single `Process` for the whole shell, independent +of monitor count. `scripts/remote-server.py` validates the destination and execs +SSH, sending the self-contained `remote_server_probe.py` as a quoted Python +program. One persistent SSH channel streams newline-delimited JSON. Its stdin +accepts cadence/refresh messages, and EOF terminates the probe. The connection +does not create a background SSH master or forward an agent/ports. + +The default interval is five seconds; opening the dashboard or its settings +claims two-second sampling. Closing the last view returns to the configured +interval. Disabling the widget and closing its views stops the connection. +CPU and network rates use counter deltas over actual monotonic elapsed time, +with unknown rates on initial/reset samples. Memory usage uses `MemAvailable`, +not `MemFree`; filesystem free space is what an unprivileged user can use. +Hardware details, addresses and filesystems refresh every minute, or on Refresh; +optional `df`/`ip` commands have bounded execution time. + +History stays in memory (up to ten minutes/300 points) and resets on host change, +reboot or a long sampling gap. Reconnection backs off from five to sixty seconds. +A heartbeat timeout catches a stalled stream. Host changes discard old data and +ignore the previous connection's late output. SSH failures appear in the view. + +Read-only diagnostics and manual refresh use the normal runtime IPC entrypoint: + +```sh +cybexos-runtime ipc remoteServer status +cybexos-runtime ipc remoteServer refresh +cybexos-runtime ipc remoteServer configure +cybexos-runtime ipc popouts open remote +``` + +Tests cover counter resets, memory semantics, cache lifetime, transport quoting, +SSH restrictions, stream cadence/EOF, malformed samples, interface selection, +metric formatting, history bounds and settings migration. Run `./tests/run`. +For live checks, use `tests/lib/quickshell-live` begin/end and the managed service. diff --git a/roles/desktop/files/quickshell/Bar/Bar.qml b/roles/desktop/files/quickshell/Bar/Bar.qml index 3d73a102..a834d793 100644 --- a/roles/desktop/files/quickshell/Bar/Bar.qml +++ b/roles/desktop/files/quickshell/Bar/Bar.qml @@ -664,6 +664,7 @@ PanelWindow { readonly property var moduleSources: ({ ws: "Modules/Workspaces.qml", media: "Modules/Media.qml", clock: "Modules/Clock.qml", weather: "Modules/Weather.qml", + remote: "Modules/Remote.qml", notes: "Modules/Notes.qml", modelusage: "Modules/ModelUsage.qml", indicators: "Modules/Indicators.qml", diff --git a/roles/desktop/files/quickshell/Bar/Modules/Remote.qml b/roles/desktop/files/quickshell/Bar/Modules/Remote.qml new file mode 100644 index 00000000..3680f262 --- /dev/null +++ b/roles/desktop/files/quickshell/Bar/Modules/Remote.qml @@ -0,0 +1,61 @@ +import QtQuick +import ".." +import "../../Common" +import "../../Common/RemoteServerHelpers.js" as Helpers + +BarModule { + id: root + moduleId: "remote" + detailSaving: RemoteServer.options.showLabel ? serverName.width + chip.spacing : 0 + + BarChip { + id: chip + host: root.host + panelName: "remote" + isle: root.isle + anchorItem: root.groupAnchor ?? chip + spacing: 6 + tooltip: RemoteServer.label + " · " + RemoteServer.status + + "\n" + (Helpers.METRICS.find(m => m.value === RemoteServer.options.metric)?.label || "CPU") + + ": " + RemoteServer.barValue + + (RemoteServer.sample ? "\nCPU " + Helpers.percent(RemoteServer.sample.cpu) + + " · RAM " + Helpers.percent(Helpers.memoryPercent(RemoteServer.sample)) + + " · Updated " + RemoteServer.age : "") + + (RemoteServer.error ? "\n" + RemoteServer.error : "") + + Sym { + anchors.verticalCenter: parent.verticalCenter + name: "dns" + size: Theme.barIconSize + color: RemoteServer.error ? Theme.barAmber : chip.fg + } + Text { + id: serverName + anchors.verticalCenter: parent.verticalCenter + visible: RemoteServer.options.showLabel && !root.compact + text: RemoteServer.label + width: Math.min(implicitWidth, 140) + elide: Text.ElideRight + textFormat: Text.PlainText + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.bar + font.weight: Theme.weightMedium + color: Theme.barTextLow + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: RemoteServer.host ? RemoteServer.barValue : "Set up" + font.family: Theme.fontNumeric + font.pixelSize: Theme.typography.bar + font.weight: Theme.weightSemibold + font.features: Theme.tabularNumberFeatures + color: RemoteServer.stale ? Theme.barTextFaint : Theme.barTextHi + } + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 4; height: 4; radius: 2 + color: RemoteServer.error || RemoteServer.stale ? Theme.barAmber + : RemoteServer.sample ? Theme.barAccent : Theme.barTextFaint + } + } +} diff --git a/roles/desktop/files/quickshell/Bar/Modules/qmldir b/roles/desktop/files/quickshell/Bar/Modules/qmldir index 29407b31..01a3630d 100644 --- a/roles/desktop/files/quickshell/Bar/Modules/qmldir +++ b/roles/desktop/files/quickshell/Bar/Modules/qmldir @@ -20,3 +20,4 @@ Volume Volume.qml Weather Weather.qml Wifi Wifi.qml Workspaces Workspaces.qml +Remote Remote.qml diff --git a/roles/desktop/files/quickshell/Common/PanelRegistryData.js b/roles/desktop/files/quickshell/Common/PanelRegistryData.js index 9abf3973..4ada8951 100644 --- a/roles/desktop/files/quickshell/Common/PanelRegistryData.js +++ b/roles/desktop/files/quickshell/Common/PanelRegistryData.js @@ -67,6 +67,7 @@ var PANELS = [ { name: "media", island: "left", moduleId: "media", source: "Popovers/MediaPopover.qml" }, { name: "t3code", island: "right", moduleId: "t3", source: "Popovers/T3CodePopover.qml", attached: true }, { name: "hermes", island: "right", moduleId: "hermes", source: "Popovers/HermesPopover.qml" }, + { name: "remote", island: "right", moduleId: "remote", source: "Popovers/RemoteServerPopover.qml" }, { name: "github", island: "right", moduleId: "gh", source: "Popovers/GitHubPopover.qml" }, { name: "overflow", island: "right", moduleId: "", source: "Popovers/OverflowPopover.qml" }, diff --git a/roles/desktop/files/quickshell/Common/RemoteServer.qml b/roles/desktop/files/quickshell/Common/RemoteServer.qml new file mode 100644 index 00000000..b3b8b294 --- /dev/null +++ b/roles/desktop/files/quickshell/Common/RemoteServer.qml @@ -0,0 +1,138 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io +import "RemoteServerHelpers.js" as Helpers + +Singleton { + id: root + readonly property var options: Settings.modOpts.remote + readonly property string host: options.host + readonly property string label: options.label || host || "Remote Server" + readonly property bool widgetOn: ["left", "center", "right"].some( + column => Settings.mods[column].some(entry => entry.id === "remote" && entry.on)) + property int watchers: 0 + readonly property bool wanted: host !== "" && (widgetOn || watchers > 0) + readonly property int cadence: watchers > 0 ? 2 : options.pollSecs + property var sample: null + property var history: [] + property string error: "" + property double updatedAt: 0 + property double now: Date.now() + property double heartbeat: 0 + property int failures: 0 + readonly property bool busy: worker.running && updatedAt === 0 + readonly property bool stale: sample !== null && (error !== "" || !worker.running + || now - updatedAt > Math.max(15000, cadence * 3000)) + readonly property string age: updatedAt > 0 + ? Math.max(0, Math.floor((now - updatedAt) / 1000)) + "s ago" : "No readings yet" + readonly property string status: !host ? "Set up SSH" : error ? "Disconnected" + : stale ? "Stale" : sample ? "Connected" : "Connecting…" + readonly property string barValue: Helpers.metric(sample, options) + readonly property var selectedNetwork: Helpers.network(sample, options.interface) + + function acquire() { watchers++; } + function release() { watchers = Math.max(0, watchers - 1); } + function configure() { + Settings.openWidgetSettings("remote"); + Settings.showPanel("bar", Popouts.hostScreenName); + } + function start() { + if (!wanted || worker.running) return; + retry.stop(); + worker.issuedHost = host; + worker.command = ["python3", "-B", Quickshell.shellDir + "/scripts/remote-server.py", host]; + worker.running = true; + } + function refresh() { + if (worker.running) worker.write(JSON.stringify({ interval: cadence, refresh: true }) + "\n"); + else { failures = 0; start(); } + } + function receive(line) { + if (!wanted || worker.issuedHost !== host) return; + try { + if (line.length > 262144) throw new Error("Telemetry response too large"); + const value = JSON.parse(line); + if (!Helpers.validSample(value)) throw new Error("Invalid telemetry response"); + now = Date.now(); + history = Helpers.historyAppend(history, value, now); + sample = value; + updatedAt = now; + heartbeat = now; + error = ""; + failures = 0; + } catch (e) { + error = "Could not read server statistics. Check that the server supports Python 3.9+ and Linux /proc."; + worker.running = false; + } + } + onHostChanged: { + sample = null; history = []; updatedAt = 0; error = ""; failures = 0; + retry.stop(); + if (worker.running) worker.running = false; + else Qt.callLater(start); + } + onWantedChanged: { + if (wanted) Qt.callLater(start); + else { retry.stop(); worker.running = false; } + } + onCadenceChanged: { + if (worker.running) worker.write(JSON.stringify({ interval: cadence }) + "\n"); + } + Component.onCompleted: Qt.callLater(start) + + IpcHandler { + target: "remoteServer" + function status(): string { + return JSON.stringify({ host: root.host, label: root.label, status: root.status, + updatedAt: root.updatedAt, stale: root.stale, error: root.error, + cadence: root.cadence, watchers: root.watchers, running: worker.running, + metric: root.options.metric, value: root.barValue, sample: root.sample }); + } + function refresh(): void { root.refresh(); } + function configure(): void { root.configure(); } + } + + Timer { + id: retry + onTriggered: root.start() + } + Timer { + interval: 1000 + running: root.wanted + repeat: true + onTriggered: { + root.now = Date.now(); + if (worker.running && root.now - root.heartbeat > Math.max(20000, root.cadence * 3000)) { + root.error = "Server stopped responding; reconnecting…"; + worker.running = false; + } + } + } + Process { + id: worker + property string issuedHost: "" + property string diagnostic: "" + stdinEnabled: true + stdout: SplitParser { onRead: data => root.receive(data) } + stderr: SplitParser { + onRead: data => { if (data.trim()) worker.diagnostic = data.trim().slice(0, 240); } + } + onStarted: write(JSON.stringify({ interval: root.cadence }) + "\n") + onRunningChanged: { + if (running) { + diagnostic = ""; + root.heartbeat = Date.now(); + } else if (root.wanted) { + if (issuedHost !== root.host) { + retry.interval = 1; + } else { + if (!root.error) root.error = diagnostic || "SSH connection ended. Check the host, trusted host key and SSH key access."; + root.failures++; + retry.interval = Math.min(60000, 5000 * Math.pow(2, Math.min(root.failures - 1, 4))); + } + retry.restart(); + } + } + } +} diff --git a/roles/desktop/files/quickshell/Common/RemoteServerHelpers.js b/roles/desktop/files/quickshell/Common/RemoteServerHelpers.js new file mode 100644 index 00000000..50581f68 --- /dev/null +++ b/roles/desktop/files/quickshell/Common/RemoteServerHelpers.js @@ -0,0 +1,100 @@ +// Pure presentation/validation helpers, also exercised by Node fixtures. +var METRICS = [ + { value: "cpu", label: "CPU utilization" }, + { value: "load", label: "Load average" }, + { value: "memory", label: "Memory used (%)" }, + { value: "memoryUsed", label: "Memory used" }, + { value: "memoryFree", label: "Memory available" }, + { value: "disk", label: "Storage used (%)" }, + { value: "diskFree", label: "Storage free" }, + { value: "rx", label: "Network download" }, + { value: "tx", label: "Network upload" }, + { value: "temperature", label: "Temperature (hottest sensor)" } +]; + +function known(value) { return typeof value === "number" && isFinite(value); } +function percent(value) { return known(value) ? Math.round(value) + "%" : "—"; } +function bytes(value) { + if (!known(value)) return "—"; + var units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + var index = 0; + while (value >= 1024 && index < units.length - 1) { value /= 1024; index++; } + return value.toFixed(index > 0 && value < 100 ? 1 : 0) + " " + units[index]; +} +function rate(value) { return known(value) ? bytes(value) + "/s" : "—"; } +function uptime(seconds) { + if (!known(seconds)) return "—"; + var hours = Math.floor(seconds / 3600); + return hours >= 24 ? Math.floor(hours / 24) + "d " + hours % 24 + "h" : hours + "h " + Math.floor(seconds / 60) % 60 + "m"; +} +function memoryPercent(sample) { + var m = sample && sample.memory; + return m && m.total > 0 && known(m.used) ? 100 * m.used / m.total : null; +} +function disk(sample, mount) { + return sample ? sample.storage.find(function(d) { return d.mount === mount; }) || null : null; +} +function network(sample, name) { + if (!sample) return null; + // Prefer the default route, avoiding double counting bridges/bonds and + // their members. Fall back to an addressed interface if no route exists. + if (name) return sample.network.find(function(n) { return n.name === name; }) || null; + var primary = sample.network.find(function(n) { return n.name === sample.meta.defaultInterface; }); + if (primary) return primary; + return sample.network.slice().sort(function(a, b) { + var address = Number(b.addresses.length > 0) - Number(a.addresses.length > 0); + return address || (b.rx || 0) + (b.tx || 0) - (a.rx || 0) - (a.tx || 0); + })[0] || null; +} +function temperature(sample) { + var values = sample ? sample.temperatures.map(function(t) { return t.celsius; }).filter(known) : []; + return values.length ? Math.max.apply(null, values) : null; +} +function metric(sample, options) { + if (!sample) return "—"; + var storage = disk(sample, options.mount); + var net = network(sample, options.interface); + switch (options.metric) { + case "load": return sample.load[0].toFixed(2); + case "memory": return percent(memoryPercent(sample)); + case "memoryUsed": return bytes(sample.memory.used); + case "memoryFree": return bytes(sample.memory.available); + case "disk": return percent(storage ? storage.percent : null); + case "diskFree": return bytes(storage ? storage.free : null); + case "rx": return "↓ " + rate(net ? net.rx : null); + case "tx": return "↑ " + rate(net ? net.tx : null); + case "temperature": return known(temperature(sample)) ? Math.round(temperature(sample)) + "°C" : "—"; + default: return percent(sample.cpu); + } +} +function validSample(s) { + return !!s && s.version === 1 && typeof s.boot === "string" + && known(s.uptime) && (s.cpu === null || known(s.cpu)) + && s.meta && typeof s.meta.hostname === "string" && typeof s.meta.os === "string" + && Array.isArray(s.load) && s.load.length === 3 && s.load.every(known) + && s.memory && known(s.memory.total) && (s.memory.used === null || known(s.memory.used)) + && (s.memory.available === null || known(s.memory.available)) + && Array.isArray(s.perCore) && s.perCore.every(function(c) { return c === null || known(c); }) + && Array.isArray(s.storage) && s.storage.every(function(d) { + return typeof d.mount === "string" && known(d.total) && known(d.free) && known(d.percent); + }) + && Array.isArray(s.network) && s.network.every(function(n) { + return typeof n.name === "string" && Array.isArray(n.addresses) + && (n.rx === null || known(n.rx)) && (n.tx === null || known(n.tx)); + }) + && Array.isArray(s.temperatures) && s.temperatures.every(function(t) { + return typeof t.name === "string" && known(t.celsius); + }); +} +function historyAppend(history, sample, now) { + var previous = history.length ? history[history.length - 1] : null; + var next = previous && (previous.boot !== sample.boot || now - previous.at > 90000) ? [] : history; + // Bound both time and memory. Each point keeps interface rates separately, + // so changing the selected interface doesn't relabel somebody else's graph. + return next.filter(function(p) { return now - p.at < 600000; }).concat([{ + at: now, boot: sample.boot, cpu: sample.cpu, memory: memoryPercent(sample), network: sample.network + }]).slice(-300); +} +if (typeof module !== "undefined" && module.exports) + module.exports = { METRICS, known, percent, bytes, rate, uptime, memoryPercent, disk, network, + temperature, metric, validSample, historyAppend }; diff --git a/roles/desktop/files/quickshell/Common/SettingsHelpers.js b/roles/desktop/files/quickshell/Common/SettingsHelpers.js index 91850bfa..54017ba6 100644 --- a/roles/desktop/files/quickshell/Common/SettingsHelpers.js +++ b/roles/desktop/files/quickshell/Common/SettingsHelpers.js @@ -39,7 +39,7 @@ var IDLE_SUSPEND_MINS = [0, 15, 30, 60, 120]; // the digitalpals.model-usage Omarchy plugin (see ModelUsage/README.md). var MODULE_IDS = [ "ws", "media", "indicators", "clock", "weather", "notes", "modelusage", "updates", "gh", - "t3", "hermes", "tray", + "t3", "hermes", "remote", "tray", "notifications", "vol", "wifi", "bt", "batt", "control" ]; @@ -50,7 +50,7 @@ var RETIRED_MODULE_IDS = ["bell", "idle", "usage"]; var MODEL_USAGE_PROVIDERS = ["claude", "codex", "kimi"]; var MODEL_USAGE_COST_PROVIDERS = ["claude", "codex"]; -var DETAIL_IDS = ["media", "weather", "clock", "t3", "hermes", "gh", "updates", +var DETAIL_IDS = ["remote", "media", "weather", "clock", "t3", "hermes", "gh", "updates", "notifications", "vol", "batt"]; var DETAIL_POLICIES = ["auto", "prefer", "compact"]; @@ -217,7 +217,7 @@ function defaultMods() { mod("notes", true)], right: [ mod("modelusage", false), mod("updates", true), mod("gh", false), mod("t3", false), - mod("hermes", false), + mod("hermes", false), mod("remote", false), mod("tray", false), mod("notifications", true), mod("vol", true), mod("wifi", true), mod("bt", true), mod("batt", true), mod("control", true) ] @@ -254,6 +254,7 @@ function defaultModOpts() { seconds: false, showDate: true, dateFormat: "ddd dd", showEvents: true, daysAhead: 14, pollMins: 15 }, + remote: { host: "", label: "", metric: "cpu", mount: "/", interface: "", showLabel: true, pollSecs: 5 }, weather: { place: "", lat: 0, lon: 0, pollMins: 20 }, notes: { titleProvider: "off", @@ -844,6 +845,15 @@ var MOD_OPT_CHECKS = { return enumIn(v, ["ddd dd", "ddd d MMM", "dd MMM", "dd-MM"], d); } }, + remote: { + host: function(v, d) { return textIn(v, 254, d).trim(); }, + label: function(v, d) { return textIn(v, 40, d).trim(); }, + metric: function(v, d) { return enumIn(v, ["cpu", "load", "memory", "memoryUsed", "memoryFree", "disk", "diskFree", "rx", "tx", "temperature"], d); }, + mount: function(v, d) { return textIn(v, 256, d); }, + interface: function(v, d) { return textIn(v, 64, d).trim(); }, + showLabel: boolIn, + pollSecs: function(v, d) { return intIn(v, 2, 60, 1, d); } + }, weather: { place: function(v, d) { return textIn(v, 40, d); }, lat: function(v, d) { return realIn(v, -90, 90, 0.0001, d); }, diff --git a/roles/desktop/files/quickshell/Common/WidgetCatalog.js b/roles/desktop/files/quickshell/Common/WidgetCatalog.js index a1a0edb0..99302835 100644 --- a/roles/desktop/files/quickshell/Common/WidgetCatalog.js +++ b/roles/desktop/files/quickshell/Common/WidgetCatalog.js @@ -21,6 +21,7 @@ var WIDGETS = { indicators: { name: "Indicators", short: "Indicators", tag: "clock-side", glyph: "tune", description: "Quick actions and recording indicators beside the clock." }, clock: { name: "Clock", short: "Clock", detail: true, glyph: "schedule", description: "Time, date, and calendar." }, weather: { name: "Weather", short: "Weather", detail: true, glyph: "cloud", description: "Local conditions and forecast." }, + remote: { name: "Remote Server", short: "Server", detail: true, glyph: "dns", description: "Monitor CPU, memory, storage and network over SSH." }, notes: { name: "Notes", short: "Notes", glyph: "edit_note", description: "Capture and revisit your notes." }, modelusage: { name: "Model Usage", short: "Model Usage", glyph: "monitoring", description: "Claude, Codex and Kimi quota limits, resets and API cost estimates." }, t3: { name: "T3 Code", short: "T3 Code", detail: true, glyph: "code", description: "Follow T3 Code sessions." }, diff --git a/roles/desktop/files/quickshell/Common/qmldir b/roles/desktop/files/quickshell/Common/qmldir index be9f5a43..58a0aa22 100644 --- a/roles/desktop/files/quickshell/Common/qmldir +++ b/roles/desktop/files/quickshell/Common/qmldir @@ -96,3 +96,4 @@ OmarchyServiceApi OmarchyServiceApi.qml singleton SystemSettings SystemSettings.qml singleton DisplaySettings DisplaySettings.qml SystemSettingsBackend SystemSettingsBackend.qml +singleton RemoteServer RemoteServer.qml diff --git a/roles/desktop/files/quickshell/Popovers/RemoteServerPopover.qml b/roles/desktop/files/quickshell/Popovers/RemoteServerPopover.qml new file mode 100644 index 00000000..5dd2e6ce --- /dev/null +++ b/roles/desktop/files/quickshell/Popovers/RemoteServerPopover.qml @@ -0,0 +1,365 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls as Controls +import "../Common" +import "../Ui" as Ui +import "../Common/RemoteServerHelpers.js" as Helpers + +PopoutPanel { + id: root + property bool allDisks: false + property bool allInterfaces: false + property bool allTemperatures: false + readonly property var stats: RemoteServer.sample + readonly property var net: RemoteServer.selectedNetwork + readonly property int gutter: Theme.panelPadding + implicitWidth: Math.min(520, availableWidth > 0 ? availableWidth : 520) + implicitHeight: Math.min(body.implicitHeight + gutter * 2 + footer.height + 12, + 840, availableHeight > 0 ? availableHeight : 840) + + Claim { + active: root.visible + onClaimed: RemoteServer.acquire() + onReleased: RemoteServer.release() + } + Rectangle { + anchors.fill: parent + visible: root.drawBackground + radius: Theme.panelRadius + color: root.surfaceColor + border.width: 1 + border.color: root.surfaceBorderColor + } + component Label: Text { + textFormat: Text.PlainText + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + color: Theme.textDim + wrapMode: Text.Wrap + } + component Heading: Label { + font.pixelSize: Theme.typography.section + font.weight: Theme.weightSemibold + font.letterSpacing: 1 + color: Theme.textMid + } + component Card: Rectangle { + radius: Theme.rowRadius + color: Theme.cardFill + border.width: 1 + border.color: Theme.hairlineSoft + } + component Meter: Rectangle { + id: meter + property real value: 0 + property color tint: value >= 90 ? Theme.amber : Theme.accentText + height: 5 + radius: 2.5 + color: Theme.hoverFill + Rectangle { + width: parent.width * Math.max(0, Math.min(100, meter.value)) / 100 + height: parent.height + radius: parent.radius + color: meter.tint + } + } + component StatCard: Card { + id: stat + property string title: "" + property string value: "" + property string detail: "" + property var points: [] + height: content.implicitHeight + 28 + Column { + id: content + x: 14; y: 14; width: parent.width - 28 + spacing: 8 + Heading { text: stat.title } + Label { + width: parent.width + text: stat.value + font.family: Theme.fontNumeric + font.pixelSize: Theme.typography.display + font.weight: Theme.weightSemibold + font.features: Theme.tabularNumberFeatures + color: Theme.textHi + } + Ui.TelemetryChart { + width: parent.width + points: stat.points + description: stat.title + " history" + } + Label { width: parent.width; text: stat.detail } + } + } + + Flickable { + id: scroll + anchors.fill: parent + anchors.margins: root.gutter + anchors.bottomMargin: root.gutter + footer.height + 12 + contentHeight: body.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + Controls.ScrollBar.vertical: Controls.ScrollBar {} + + Column { + id: body + width: scroll.width + spacing: 16 + Row { + width: parent.width + spacing: 12 + Rectangle { + width: 42; height: 42; radius: 12 + color: Qt.alpha(Theme.accentText, 0.10) + Sym { anchors.centerIn: parent; name: "dns"; size: 24; color: Theme.accentText } + } + Column { + width: parent.width - 54 + spacing: 4 + Label { + width: parent.width + text: RemoteServer.label + font.pixelSize: Theme.typography.heading + font.weight: Theme.weightSemibold + color: Theme.textHi + } + Label { + width: parent.width + text: root.stats ? root.stats.meta.os + " · Up " + Helpers.uptime(root.stats.uptime) + : "Your server, at a glance" + } + } + } + Row { + spacing: 7 + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 6; height: 6; radius: 3 + color: RemoteServer.stale || RemoteServer.error ? Theme.amber : Theme.accentText + } + Label { + text: RemoteServer.status + (root.stats ? " · Updated " + RemoteServer.age : "") + color: RemoteServer.stale || RemoteServer.error ? Theme.amber : Theme.textMid + } + } + Card { + width: parent.width + height: message.implicitHeight + 24 + visible: !root.stats || RemoteServer.error !== "" + Label { + id: message + x: 12; y: 12; width: parent.width - 24 + text: !RemoteServer.host + ? "Choose an SSH host in Settings to start monitoring. Your existing SSH keys and host aliases work here." + : RemoteServer.error || "Connecting securely and collecting the first readings…" + color: RemoteServer.error ? Theme.amber : Theme.textMid + } + } + Column { + width: parent.width + spacing: 16 + visible: root.stats !== null + opacity: RemoteServer.stale ? 0.6 : 1 + Row { + width: parent.width + spacing: 10 + StatCard { + width: (parent.width - 10) / 2 + title: "CPU" + value: root.stats ? Helpers.percent(root.stats.cpu) : "—" + detail: root.stats ? root.stats.meta.cores + " logical CPUs" : "" + points: RemoteServer.history.map(p => ({ at: p.at, value: p.cpu })) + } + StatCard { + width: (parent.width - 10) / 2 + title: "MEMORY" + value: Helpers.percent(Helpers.memoryPercent(root.stats)) + detail: root.stats ? Helpers.bytes(root.stats.memory.used) + " / " + Helpers.bytes(root.stats.memory.total) : "" + points: RemoteServer.history.map(p => ({ at: p.at, value: p.memory })) + } + } + Label { + width: parent.width + text: root.stats ? root.stats.meta.model : "" + } + // Per-core activity remains useful on large machines without + // turning 64 logical CPUs into 64 rows. + Flow { + width: parent.width + spacing: 3 + Repeater { + model: root.stats ? root.stats.perCore : [] + Rectangle { + required property var modelData + required property int index + width: 9; height: 12; radius: 2 + color: Helpers.known(modelData) + ? Qt.alpha(Theme.accentText, 0.15 + 0.85 * modelData / 100) : Theme.hoverFill + Accessible.name: "CPU " + index + ": " + Helpers.percent(modelData) + HoverHandler { id: coreHover } + Controls.ToolTip.visible: coreHover.hovered + Controls.ToolTip.text: "CPU " + index + " · " + Helpers.percent(modelData) + } + } + } + Heading { text: "LOAD AVERAGE" } + Row { + width: parent.width + Repeater { + model: ["1 minute", "5 minutes", "15 minutes"] + Column { + required property int index + required property string modelData + width: body.width / 3 + spacing: 4 + Label { + text: root.stats ? root.stats.load[parent.index].toFixed(2) : "—" + font.pixelSize: Theme.typography.heading + font.family: Theme.fontNumeric + color: Theme.textHi + } + Label { text: parent.modelData } + } + } + } + Label { + width: parent.width + text: root.stats ? Helpers.bytes(root.stats.memory.available) + " memory available · Swap " + + Helpers.bytes(root.stats.memory.swapUsed) + " / " + Helpers.bytes(root.stats.memory.swapTotal) : "" + } + HDivider { width: parent.width } + Heading { text: "STORAGE" } + Label { + width: parent.width + visible: root.stats !== null && (root.stats.storage.length === 0 || (!root.allDisks && !Helpers.disk(root.stats, RemoteServer.options.mount))) + text: root.stats ? root.stats.storageError || "Selected filesystem unavailable" : "" + } + Repeater { + model: root.stats ? (root.allDisks ? root.stats.storage : root.stats.storage.filter(d => d.mount === RemoteServer.options.mount).slice(0, 1)) : [] + Column { + id: diskRow + required property var modelData + width: body.width + spacing: 7 + Label { + width: parent.width + text: diskRow.modelData.mount + " · " + diskRow.modelData.percent + "% used" + color: Theme.textHi + } + Meter { width: parent.width; value: diskRow.modelData.percent } + Label { + width: parent.width + text: Helpers.bytes(diskRow.modelData.free) + " free of " + Helpers.bytes(diskRow.modelData.total) + + " · " + diskRow.modelData.type + } + } + } + ActionButton { + visible: root.stats !== null && root.stats.storage.length > 1 + label: root.allDisks ? "Show fewer filesystems" : "All " + (root.stats ? root.stats.storage.length : 0) + " filesystems" + onTriggered: root.allDisks = !root.allDisks + } + HDivider { width: parent.width } + Heading { text: "NETWORK" + (root.net ? " · " + root.net.name : "") } + Row { + width: parent.width + spacing: 12 + Repeater { + model: ["rx", "tx"] + Column { + id: networkRate + required property string modelData + width: (body.width - 12) / 2 + spacing: 8 + Label { + width: parent.width + text: (networkRate.modelData === "rx" ? "In " : "Out ") + + Helpers.rate(root.net ? root.net[networkRate.modelData] : null) + font.pixelSize: Theme.typography.title + font.family: Theme.fontNumeric + color: Theme.textHi + } + Ui.TelemetryChart { + width: parent.width + ceiling: 0 + tint: networkRate.modelData === "rx" ? Theme.accentText : Theme.textMid + description: networkRate.modelData === "rx" ? "Download history" : "Upload history" + points: RemoteServer.history.map(p => { + const n = root.net ? p.network.find(v => v.name === root.net.name) : null; + return { at: p.at, value: n ? n[networkRate.modelData] : null }; + }) + } + } + } + } + Label { + width: parent.width + text: root.net ? root.net.addresses.join(" · ") : "Selected interface unavailable" + } + Flow { + width: parent.width + spacing: 6 + Repeater { + model: root.stats ? root.stats.network.filter(n => root.allInterfaces || (root.net && n.name === root.net.name)).slice(0, 256) : [] + ActionButton { + required property var modelData + label: modelData.name + tint: root.net && root.net.name === modelData.name ? Theme.accentText : Theme.textDim + onTriggered: Settings.setModuleOption("remote", "interface", modelData.name) + } + } + ActionButton { + label: "Auto" + tint: RemoteServer.options.interface === "" ? Theme.accentText : Theme.textDim + onTriggered: Settings.setModuleOption("remote", "interface", "") + } + } + ActionButton { + visible: root.stats !== null && root.stats.network.length > 1 + label: root.allInterfaces ? "Fewer interfaces" : "All interfaces" + onTriggered: root.allInterfaces = !root.allInterfaces + } + HDivider { width: parent.width } + Heading { text: "TEMPERATURES" } + Label { + text: "No temperature sensors available" + visible: root.stats !== null && root.stats.temperatures.length === 0 + } + Flow { + width: parent.width + spacing: 8 + Repeater { + model: root.stats ? (root.allTemperatures ? root.stats.temperatures : root.stats.temperatures.slice().sort((a, b) => b.celsius - a.celsius).slice(0, 2)) : [] + Label { + required property var modelData + width: (body.width - 8) / 2 + text: modelData.name + " " + Math.round(modelData.celsius) + "°C" + color: modelData.celsius >= 85 ? Theme.amber : Theme.textMid + } + } + } + ActionButton { + visible: root.stats !== null && root.stats.temperatures.length > 2 + label: root.allTemperatures ? "Fewer sensors" : "All sensors" + onTriggered: root.allTemperatures = !root.allTemperatures + } + Label { + width: parent.width + text: root.stats ? root.stats.meta.hostname + " · " + root.stats.meta.kernel : "" + color: Theme.textFaint + } + } + } + } + Row { + id: footer + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: root.gutter + spacing: 8 + ActionButton { label: "Refresh"; enabled: RemoteServer.host !== ""; onTriggered: RemoteServer.refresh() } + ActionButton { label: "Settings"; onTriggered: RemoteServer.configure() } + } +} diff --git a/roles/desktop/files/quickshell/Popovers/qmldir b/roles/desktop/files/quickshell/Popovers/qmldir index b7e2d9eb..30750b7f 100644 --- a/roles/desktop/files/quickshell/Popovers/qmldir +++ b/roles/desktop/files/quickshell/Popovers/qmldir @@ -48,3 +48,4 @@ TailscalePopover TailscalePopover.qml UpdatesPopover UpdatesPopover.qml WeatherPopover WeatherPopover.qml WifiPopover WifiPopover.qml +RemoteServerPopover RemoteServerPopover.qml diff --git a/roles/desktop/files/quickshell/Settings/ModuleDetailView.qml b/roles/desktop/files/quickshell/Settings/ModuleDetailView.qml index 74aa0f7f..b14bd2ab 100644 --- a/roles/desktop/files/quickshell/Settings/ModuleDetailView.qml +++ b/roles/desktop/files/quickshell/Settings/ModuleDetailView.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import "../Common" import "../Common/SettingsHelpers.js" as SettingsHelpers +import "../Common/RemoteServerHelpers.js" as RemoteHelpers // Built-in options shared by the bar editor and standalone detail view. // The detail policy control lives here (storage stays in Settings.mods); @@ -244,6 +245,7 @@ SettingsPage { case "indicators": return indicatorsOptions; case "clock": return clockOptions; case "weather": return weatherOptions; + case "remote": return remoteOptions; case "notes": return notesOptions; case "t3": return t3Options; case "hermes": return hermesOptions; @@ -896,6 +898,91 @@ SettingsPage { } } + Component { + id: remoteOptions + Column { + id: remoteSettings + spacing: Theme.settingsRowSpacing + Claim { + active: remoteSettings.visible + onClaimed: RemoteServer.acquire() + onReleased: RemoteServer.release() + } + SettingsTextRow { + width: parent.width + label: "SSH host" + placeholder: "john@10.10.0.7 or SSH alias" + hint: "Uses your SSH config and keys. Connect once in a terminal to verify the host key. Requires Linux and Python 3.9+." + value: view.opts.host + dirty: view.optDirty("host") + onCommitted: text => view.setOpt("host", text) + onResetRequested: view.resetOpt("host") + } + SettingsTextRow { + width: parent.width + label: "Display name" + placeholder: "The Beast" + value: view.opts.label + dirty: view.optDirty("label") + onCommitted: text => view.setOpt("label", text) + onResetRequested: view.resetOpt("label") + } + SelectRow { + width: parent.width + label: "Menubar statistic" + model: RemoteHelpers.METRICS + current: view.opts.metric + dirty: view.optDirty("metric") + onPicked: value => view.setOpt("metric", value) + onResetRequested: view.resetOpt("metric") + } + SwitchRow { + width: parent.width + label: "Show server name" + checked: view.opts.showLabel + dirty: view.optDirty("showLabel") + onToggled: value => view.setOpt("showLabel", value) + onResetRequested: view.resetOpt("showLabel") + } + SettingsTextRow { + width: parent.width + label: "Filesystem" + hint: "Mount point used for the menubar storage statistic." + placeholder: "/" + value: view.opts.mount + dirty: view.optDirty("mount") + onCommitted: text => view.setOpt("mount", text) + onResetRequested: view.resetOpt("mount") + } + SettingsTextRow { + width: parent.width + label: "Network interface" + hint: "Leave empty to use the default route. Enter an interface name to pin it." + placeholder: "Automatic" + value: view.opts.interface + dirty: view.optDirty("interface") + onCommitted: text => view.setOpt("interface", text) + onResetRequested: view.resetOpt("interface") + } + SliderRow { + width: parent.width + label: "Update every" + hint: "The open dashboard updates every 2 seconds." + min: 2; max: 60; step: 1 + value: view.optValue("pollSecs") + unit: "s" + dirty: view.optDirty("pollSecs") + onMoved: value => view.settleOpt("pollSecs", value) + onResetRequested: view.resetOpt("pollSecs") + } + SettingsAction { + text: "Refresh connection" + enabled: RemoteServer.host !== "" + onTriggered: RemoteServer.refresh() + } + } + } + Component { id: weatherOptions diff --git a/roles/desktop/files/quickshell/Ui/TelemetryChart.qml b/roles/desktop/files/quickshell/Ui/TelemetryChart.qml new file mode 100644 index 00000000..d4380755 --- /dev/null +++ b/roles/desktop/files/quickshell/Ui/TelemetryChart.qml @@ -0,0 +1,43 @@ +import QtQuick +import "../Common" +import "../Common/Format.js" as Format + +Canvas { + id: root + property var points: [] + property real ceiling: 100 + property color tint: Theme.accentText + property string description: "Recent history" + implicitHeight: 48 + Accessible.role: Accessible.Chart + Accessible.name: description + onPointsChanged: requestPaint() + onCeilingChanged: requestPaint() + onTintChanged: requestPaint() + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + if (points.length < 2 || width <= 0 || height <= 0) return; + const start = points[0].at; + const span = Math.max(1, points[points.length - 1].at - start); + const max = ceiling > 0 ? ceiling : Math.max(1, ...points.map(p => p.value || 0)); + ctx.strokeStyle = Qt.alpha(tint, 0.15); + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, height - 1); ctx.lineTo(width, height - 1); ctx.stroke(); + ctx.strokeStyle = tint; + ctx.lineWidth = 2; + ctx.lineJoin = "round"; + ctx.beginPath(); + let joined = false; + for (const p of points) { + if (p.value === null || !isFinite(p.value)) { joined = false; continue; } + const x = 1 + (width - 2) * (p.at - start) / span; + const y = height - 2 - (height - 4) * Format.clamp01(p.value / max); + if (joined) ctx.lineTo(x, y); else ctx.moveTo(x, y); + joined = true; + } + ctx.stroke(); + } +} diff --git a/roles/desktop/files/quickshell/Ui/qmldir b/roles/desktop/files/quickshell/Ui/qmldir index 427a2088..f4004cf1 100644 --- a/roles/desktop/files/quickshell/Ui/qmldir +++ b/roles/desktop/files/quickshell/Ui/qmldir @@ -33,3 +33,4 @@ TextField TextField.qml Toggle Toggle.qml ToggleSwitch ToggleSwitch.qml WidgetButton WidgetButton.qml +TelemetryChart TelemetryChart.qml diff --git a/roles/desktop/files/quickshell/scripts/remote-server.py b/roles/desktop/files/quickshell/scripts/remote-server.py new file mode 100644 index 00000000..63dbe516 --- /dev/null +++ b/roles/desktop/files/quickshell/scripts/remote-server.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Launch one read-only Linux telemetry stream over the user's SSH connection. + +exec keeps the SSH lifetime identical to the shell-owned Process. The probe +is sent as program text, never installed remotely. stdin remains available for +cadence/refresh requests; EOF ends the remote probe. +""" +import argparse +import os +from pathlib import Path +import re +import shlex + + +def command(host): + # A destination is data, never SSH options, a URI or shell program. + if not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.@:%\[\]-]{0,253}", host): + raise ValueError("Enter an SSH alias or user@hostname; set ports in ~/.ssh/config") + source = Path(__file__).with_name("remote_server_probe.py").read_text() + return [ + "ssh", "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", + "-o", "ConnectTimeout=7", "-o", "ConnectionAttempts=1", + "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2", + # Own this connection, including its teardown, even if ssh config + # normally shares a persistent master with interactive terminals. + "-o", "ControlMaster=no", "-o", "ControlPath=none", + "-o", "ClearAllForwardings=yes", "-o", "ForwardAgent=no", + "-o", "PermitLocalCommand=no", "-o", "RequestTTY=no", + "--", host, "python3 -B -u -c " + shlex.quote(source), + ] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("host") + args = parser.parse_args() + try: + argv = command(args.host) + except ValueError as error: + parser.error(str(error)) + os.execvp(argv[0], argv) + + +if __name__ == "__main__": + main() diff --git a/roles/desktop/files/quickshell/scripts/remote_server_probe.py b/roles/desktop/files/quickshell/scripts/remote_server_probe.py new file mode 100644 index 00000000..4c3a7694 --- /dev/null +++ b/roles/desktop/files/quickshell/scripts/remote_server_probe.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Linux-only, unprivileged SSH probe. Standard library and GNU df/ip only. + +Newline JSON on stdout. stdin accepts {"interval": 2|5, "refresh": true}. +No files, credentials, services or persistent state are written on the host. +""" +import json +import os +from pathlib import Path +import re +import select +import socket +import subprocess +import sys +import time + + +def read(path, fallback=""): + try: + return Path(path).read_text(errors="replace").strip() + except OSError: + return fallback + + +def cpu_ticks(text): + result = {} + for line in text.splitlines(): + parts = line.split() + if parts and re.fullmatch(r"cpu\d*", parts[0]): + # guest/guest_nice are already included in user/nice. + ticks = [int(v) for v in parts[1:9]] + result[parts[0]] = (sum(ticks), ticks[3] + ticks[4]) + return result + + +def cpu_percent(current, previous): + if previous is None: + return None + total = current[0] - previous[0] + idle = current[1] - previous[1] + if total <= 0 or idle < 0 or idle > total: + return None + return round(100 * (total - idle) / total, 1) + + +def memory_info(text): + fields = {} + for line in text.splitlines(): + key, _, value = line.partition(":") + if value.strip(): + fields[key] = int(value.split()[0]) * 1024 + total = fields.get("MemTotal", 0) + available = fields.get("MemAvailable") + # Do not confuse MemFree with memory available to applications. + return {"total": total, "available": available, + "used": total - available if available is not None else None, + "swapTotal": fields.get("SwapTotal", 0), + "swapUsed": fields.get("SwapTotal", 0) - fields.get("SwapFree", 0)} + + +def network_counters(text): + result = {} + for line in text.splitlines(): + name, sep, values = line.partition(":") + fields = values.split() + if sep and len(fields) >= 16 and name.strip() != "lo": + result[name.strip()] = (int(fields[0]), int(fields[8])) + return result + + +def rate(value, old, elapsed): + return (value - old) / elapsed if old is not None and elapsed > 0 and value >= old else None + + +def temperatures(): + result = [] + for path in sorted(Path("/sys/class/hwmon").glob("hwmon*/temp*_input")): + try: + value = int(read(path)) / 1000 + except ValueError: + continue + if not -40 <= value <= 150: + continue + prefix = path.name.removesuffix("_input") + label = read(path.with_name(prefix + "_label"), prefix) + chip = read(path.parent / "name", path.parent.name) + result.append({"name": chip + " · " + label, "celsius": value}) + if not result: + for path in sorted(Path("/sys/class/thermal").glob("thermal_zone*/temp")): + try: + value = int(read(path)) / 1000 + except ValueError: + continue + if -40 <= value <= 150: + result.append({"name": read(path.parent / "type", path.parent.name), "celsius": value}) + return result[:128] + + +def run(args): + return subprocess.run(args, capture_output=True, text=True, timeout=3, + check=True, env={**os.environ, "LC_ALL": "C"}).stdout + + +def storage(): + rows = [] + try: + text = run(["df", "-l", "-B1", "--output=source,fstype,size,used,avail,pcent,target", + "-x", "tmpfs", "-x", "devtmpfs", "-x", "squashfs", "-x", "overlay"]) + for line in text.splitlines()[1:]: + parts = line.split(None, 6) + if len(parts) != 7: + continue + device, fs, total, used, free, percent, mount = parts + rows.append({"device": device, "type": fs, "total": int(total), + "used": int(used), "free": int(free), + "percent": int(percent.rstrip("%")), "mount": mount}) + return sorted(rows, key=lambda row: row["mount"]), "" + except (OSError, ValueError, subprocess.SubprocessError): + return [], "Filesystem statistics unavailable" + + +def metadata(): + os_release = dict(line.split("=", 1) for line in read("/etc/os-release").splitlines() if "=" in line) + model = next((line.split(":", 1)[1].strip() for line in read("/proc/cpuinfo").splitlines() + if line.startswith(("model name", "Hardware"))), "") + addresses = {} + default_interface = "" + try: + for link in json.loads(run(["ip", "-j", "address", "show"])): + addresses[link["ifname"]] = [a["local"] for a in link.get("addr_info", []) + if a.get("scope") == "global"] + except (OSError, ValueError, KeyError, subprocess.SubprocessError): + pass + try: + routes = json.loads(run(["ip", "-j", "route", "show", "default"])) + if routes: + default_interface = min(routes, key=lambda r: r.get("metric", 0)).get("dev", "") + except (OSError, ValueError, AttributeError, subprocess.SubprocessError): + pass + return {"hostname": socket.gethostname(), "os": os_release.get("PRETTY_NAME", "Linux").strip('"'), + "kernel": os.uname().release, "model": model, "cores": os.cpu_count(), + "addresses": addresses, "defaultInterface": default_interface} + + +class Probe: + def __init__(self): + self.previous_cpu = {} + self.previous_net = {} + self.previous_time = None + self.boot = "" + self.slow_at = -60.0 + self.meta = {} + self.disks = [] + self.storage_error = "" + + def sample(self): + now = time.monotonic() + boot = read("/proc/sys/kernel/random/boot_id") + if boot != self.boot: + self.previous_cpu, self.previous_net, self.previous_time = {}, {}, None + self.boot = boot + cpu = cpu_ticks(read("/proc/stat")) + if "cpu" not in cpu: + raise RuntimeError("This widget requires a Linux server with readable /proc") + net = network_counters(read("/proc/net/dev")) + elapsed = now - self.previous_time if self.previous_time is not None else 0 + if now - self.slow_at >= 60: + self.meta = metadata() + self.disks, self.storage_error = storage() + self.slow_at = now + interfaces = [] + for name, counters in net.items(): + previous = self.previous_net.get(name) + interfaces.append({"name": name, "received": counters[0], "sent": counters[1], + "rx": rate(counters[0], previous[0] if previous else None, elapsed), + "tx": rate(counters[1], previous[1] if previous else None, elapsed), + "addresses": self.meta["addresses"].get(name, [])}) + result = {"version": 1, "boot": boot, "uptime": float(read("/proc/uptime").split()[0]), + "meta": self.meta, "cpu": cpu_percent(cpu["cpu"], self.previous_cpu.get("cpu")), + "perCore": [cpu_percent(v, self.previous_cpu.get(k)) for k, v in cpu.items() if k != "cpu"], + "load": list(os.getloadavg()), "memory": memory_info(read("/proc/meminfo")), + "temperatures": temperatures(), "storage": self.disks, "storageError": self.storage_error, + "network": sorted(interfaces, key=lambda row: row["name"])} + self.previous_cpu, self.previous_net, self.previous_time = cpu, net, now + return result + + +def main(): + probe = Probe() + interval = 5 + pending = b"" + deadline = 0.0 + while True: + if time.monotonic() >= deadline: + print(json.dumps(probe.sample(), allow_nan=False, separators=(",", ":")), flush=True) + deadline = time.monotonic() + interval + ready, _, _ = select.select([sys.stdin], [], [], max(0, deadline - time.monotonic())) + if not ready: + continue + chunk = os.read(sys.stdin.fileno(), 4096) + if not chunk: + return + pending += chunk + if len(pending) > 8192: + raise ValueError("Control request too large") + while b"\n" in pending: + line, pending = pending.split(b"\n", 1) + try: + request = json.loads(line) + interval = max(2, min(60, int(request.get("interval", interval)))) + if request.get("refresh"): + probe.slow_at = -60.0 + deadline = min(deadline, time.monotonic() + (0 if request.get("refresh") else interval)) + except (ValueError, TypeError, AttributeError): + continue + + +if __name__ == "__main__": + try: + main() + except BrokenPipeError: + pass diff --git a/tests/quickshell/remote-server.test.cjs b/tests/quickshell/remote-server.test.cjs new file mode 100644 index 00000000..10a1cd11 --- /dev/null +++ b/tests/quickshell/remote-server.test.cjs @@ -0,0 +1,71 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const H = require("../../roles/desktop/files/quickshell/Common/RemoteServerHelpers.js"); +const S = require("../../roles/desktop/files/quickshell/Common/SettingsHelpers.js"); + +function sample() { + return { version: 1, boot: "a", uptime: 1200, cpu: 25, perCore: [20, 30], load: [1, 2, 3], + meta: { hostname: "beast", os: "Linux", defaultInterface: "eth0" }, + memory: { total: 16 * 1024 ** 3, available: 12 * 1024 ** 3, used: 4 * 1024 ** 3 }, + storage: [{ mount: "/", total: 1000, free: 400, percent: 60 }], + temperatures: [{ name: "CPU", celsius: 54 }], + network: [{ name: "eth0", addresses: ["10.10.0.7"], rx: 1024, tx: 2048 }, + { name: "docker0", addresses: ["172.17.0.1"], rx: 999999, tx: 999999 }] }; +} +test("remote widget migrates as disabled and preserves user options", () => { + const old = S.defaults(); + old.mods.right = old.mods.right.filter(m => m.id !== "remote"); + delete old.modOpts.remote; + const migrated = S.merge(old); + assert.equal(migrated.mods.right.find(m => m.id === "remote").on, false); + assert.equal(migrated.modOpts.remote.host, ""); + assert.equal(migrated.modOpts.remote.metric, "cpu"); + const normalized = S.normalizeModOpts({ remote: { host: " john@10.10.0.7 ", label: "The Beast", + metric: "diskFree", mount: "/data", interface: "eth0", pollSecs: 999 } }).remote; + assert.equal(normalized.host, "john@10.10.0.7"); + assert.equal(normalized.metric, "diskFree"); + assert.equal(normalized.mount, "/data"); + assert.equal(normalized.pollSecs, 60); + assert.equal(S.normalizeModOpts({ remote: { metric: "bogus" } }).remote.metric, "cpu"); +}); +test("every menu metric produces the intended unit and missing readings stay unknown", () => { + const s = sample(); + const options = { mount: "/", interface: "eth0" }; + const expected = { cpu: "25%", load: "1.00", memory: "25%", memoryUsed: "4.0 GiB", + memoryFree: "12.0 GiB", disk: "60%", diskFree: "400 B", rx: "↓ 1.0 KiB/s", + tx: "↑ 2.0 KiB/s", temperature: "54°C" }; + for (const { value } of H.METRICS) { + assert.equal(H.metric(s, { ...options, metric: value }), expected[value]); + assert.equal(H.metric(null, { ...options, metric: value }), "—"); + } + assert.equal(H.metric(s, { metric: "disk", mount: "/missing" }), "—"); + assert.equal(H.metric({ ...s, cpu: null }, { metric: "cpu" }), "—"); + assert.equal(H.temperature({ ...s, temperatures: [] }), null); +}); +test("network automatic uses the default route and explicit missing interfaces stay missing", () => { + const s = sample(); + assert.equal(H.network(s, "").name, "eth0"); + assert.equal(H.network(s, "docker0").name, "docker0"); + assert.equal(H.network(s, "missing"), null); + delete s.meta.defaultInterface; + assert.equal(H.network(s, "").name, "docker0"); +}); +test("malformed data is rejected while unavailable first-sample counters are valid", () => { + const s = sample(); + assert.equal(H.validSample(s), true); + s.cpu = null; s.perCore = [null]; s.network[0].rx = null; + assert.equal(H.validSample(s), true); + for (const value of [null, {}, { ...s, version: 2 }, { ...s, cpu: Infinity }, + { ...s, network: [{}] }, { ...s, storage: [{}] }, { ...s, load: [] }]) + assert.equal(H.validSample(value), false); +}); +test("history is bounded and reboot or long disconnection starts a new series", () => { + const s = sample(); + let history = []; + for (let at = 0; at <= 700000; at += 2000) history = H.historyAppend(history, s, at); + assert.equal(history.length, 300); + assert.ok(history.every(p => 700000 - p.at < 600000)); + assert.equal(H.historyAppend(history, { ...s, boot: "b" }, 702000).length, 1); + assert.equal(H.historyAppend(history, s, 900000).length, 1); + assert.equal(history[0].network[0].name, "eth0"); +}); diff --git a/tests/quickshell/settings-helpers.test.cjs b/tests/quickshell/settings-helpers.test.cjs index 803608e5..9f577ed4 100644 --- a/tests/quickshell/settings-helpers.test.cjs +++ b/tests/quickshell/settings-helpers.test.cjs @@ -61,7 +61,7 @@ test("defaults carry the design values", () => { ["indicators", "clock", "weather", "notes"]); assert.equal(d.mods.center.find(m => m.id === "notes").on, true); assert.deepEqual(d.mods.right.map(m => m.id), - ["modelusage", "updates", "gh", "t3", "hermes", "tray", "notifications", + ["modelusage", "updates", "gh", "t3", "hermes", "remote", "tray", "notifications", "vol", "wifi", "bt", "batt", "control"]); assert.equal(d.mods.left[1].on, true, "the media chip hides itself when nothing plays"); assert.equal(d.mods.right.find(m => m.id === "bt").on, true, @@ -81,7 +81,7 @@ test("defaults carry the design values", () => { assert.ok([...d.mods.left, ...d.mods.center, ...d.mods.right] .every(module => module.detail === "auto")); assert.deepEqual(Object.keys(d.modOpts), - ["ws", "media", "indicators", "clock", "weather", "notes", "t3", "hermes", "modelusage", + ["ws", "media", "indicators", "clock", "remote", "weather", "notes", "t3", "hermes", "modelusage", "gh", "updates", "tray", "notifications", "vol", "batt"]); assert.equal(d.modOpts.ws.minSlots, 5); assert.equal(d.modOpts.ws.style, "numbers"); @@ -608,7 +608,7 @@ test("normalizeMods appends ids missing from the file at their default column", assert.deepEqual(next.center.map(m => m.id), ["indicators", "clock", "weather", "notes"]); assert.deepEqual(next.right.map(m => m.id), - ["modelusage", "updates", "gh", "t3", "hermes", "tray", "notifications", + ["modelusage", "updates", "gh", "t3", "hermes", "remote", "tray", "notifications", "wifi", "bt", "batt", "control"]); assert.ok(next.right.some(m => m.id === "bt" && m.on === true), "appended module keeps its default enable flag"); @@ -790,7 +790,7 @@ test("schema-11 inserts notifications before the first right-side status widget" const migrated = H.merge({ v: 10, mods: raw }).mods.right; assert.deepEqual(migrated.map(mod => mod.id), ["modelusage", "updates", "tray", "gh", "notifications", "wifi", "t3", - "hermes", "vol", "bt", "batt", "control"]); + "hermes", "vol", "bt", "batt", "remote", "control"]); }); test("schema-11 appends notifications on the right when its status widgets moved", () => { diff --git a/tests/quickshell/settings.test.cjs b/tests/quickshell/settings.test.cjs index ad0d69b1..2335dd70 100644 --- a/tests/quickshell/settings.test.cjs +++ b/tests/quickshell/settings.test.cjs @@ -259,7 +259,7 @@ test("the tray and the updates chip use the reorderable widget pipeline", () => const bar = read("Bar/Bar.qml"); assert.match(helpers, /"updates", "gh"/); - assert.match(helpers, /"hermes",\s*"tray"/); + assert.match(helpers, /"hermes",\s*"remote",\s*"tray"/); assert.match(catalog, /updates:\s*\{ name: "Updates"/); assert.match(catalog, /tray:\s*\{ name: "System tray"/); assert.doesNotMatch(modules, /pinnedTail|text:\s*"pinned"/); @@ -396,7 +396,7 @@ test("schema twenty-three keeps safe defaults and exposes accessibility preferen assert.match(helpers, /nightLight:\s*false/); assert.match(helpers, /idleInhibitMode:\s*"off"/); assert.match(helpers, /idleInhibitUntilMs:\s*0/); - assert.match(helpers, /"modelusage", "updates", "gh",\s*"t3", "hermes", "tray"/); + assert.match(helpers, /"modelusage", "updates", "gh",\s*"t3", "hermes", "remote", "tray"/); assert.match(helpers, /hermes:\s*\{ showLabel: true, activityDetail: "verb" \}/); assert.match(helpers, /notes:\s*\{[\s\S]*?titleProvider:\s*"off"[\s\S]*?codexModel:\s*"gpt-5\.6-luna"[\s\S]*?codexEffort:\s*"none"[\s\S]*?claudeModel:\s*"fable"[\s\S]*?claudeEffort:\s*"low"/); diff --git a/tests/remote-server.py b/tests/remote-server.py new file mode 100644 index 00000000..306552d7 --- /dev/null +++ b/tests/remote-server.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Unprivileged telemetry, counter semantics and SSH stream lifecycle.""" +import importlib.util +import json +import os +from pathlib import Path +import select +import signal +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.dont_write_bytecode = True +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "roles/desktop/files/quickshell/scripts" + + +def load(name, filename): + spec = importlib.util.spec_from_file_location(name, SCRIPTS / filename) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +probe = load("probe", "remote_server_probe.py") +launcher = load("launcher", "remote-server.py") + + +class RemoteServerTests(unittest.TestCase): + def test_cpu_ignores_double_counted_guest_and_handles_reset(self): + ticks = probe.cpu_ticks("cpu 100 20 30 400 50 5 10 0 25 10\ncpu0 1 2 3 4 5 6 7 8 9 10") + self.assertEqual(ticks["cpu"], (615, 450)) + self.assertIsNone(probe.cpu_percent(ticks["cpu"], None)) + self.assertEqual(probe.cpu_percent((200, 100), (100, 50)), 50) + self.assertIsNone(probe.cpu_percent((100, 50), (200, 100))) + self.assertIsNone(probe.cpu_percent((100, 50), (100, 50))) + + def test_memory_uses_available_instead_of_free(self): + memory = probe.memory_info("MemTotal: 1000 kB\nMemFree: 100 kB\nMemAvailable: 400 kB\nSwapTotal: 500 kB\nSwapFree: 300 kB") + self.assertEqual(memory["used"], 600 * 1024) + self.assertEqual(memory["available"], 400 * 1024) + self.assertEqual(memory["swapUsed"], 200 * 1024) + self.assertIsNone(probe.memory_info("MemTotal: 1000 kB")["used"]) + + def test_rates_use_elapsed_time_and_do_not_spike_after_reset(self): + text = "lo: 99 0 0 0 0 0 0 0 88 0 0 0 0 0 0 0\n eth0: 2000 0 0 0 0 0 0 0 900 0 0 0 0 0 0 0" + self.assertEqual(probe.network_counters(text), {"eth0": (2000, 900)}) + self.assertEqual(probe.rate(2000, 1000, 2.5), 400) + self.assertIsNone(probe.rate(2, 1000, 2.5)) + self.assertIsNone(probe.rate(2000, None, 2.5)) + self.assertIsNone(probe.rate(2000, 1000, 0)) + + def test_storage_handles_spaces_and_failure(self): + with patch.object(probe, "run", return_value="header\n/dev/sda ext4 1000 600 350 64% /a mount\n"): + disks, error = probe.storage() + self.assertFalse(error) + self.assertEqual(disks[0]["mount"], "/a mount") + self.assertEqual(disks[0]["free"], 350) + with patch.object(probe, "run", side_effect=subprocess.TimeoutExpired("df", 3)): + self.assertEqual(probe.storage(), ([], "Filesystem statistics unavailable")) + + def test_probe_caches_slow_data_and_resets_on_new_boot(self): + files = {"/proc/stat": "cpu 100 0 0 100 0 0 0 0", "/proc/net/dev": "", + "/proc/meminfo": "MemTotal: 1000 kB\nMemAvailable: 600 kB", + "/proc/uptime": "100 100", "/proc/sys/kernel/random/boot_id": "a"} + with patch.object(probe, "read", side_effect=lambda p, *_: files[str(p)]), \ + patch.object(probe, "metadata", return_value={"addresses": {}}) as meta, \ + patch.object(probe, "storage", return_value=([], "")) as disks, \ + patch.object(probe, "temperatures", return_value=[]), \ + patch.object(probe.time, "monotonic", side_effect=[100, 102, 104, 165]): + collector = probe.Probe() + self.assertIsNone(collector.sample()["cpu"]) + files["/proc/stat"] = "cpu 130 0 0 110 0 0 0 0" + self.assertEqual(collector.sample()["cpu"], 75) + files["/proc/sys/kernel/random/boot_id"] = "b" + self.assertIsNone(collector.sample()["cpu"]) + collector.sample() + self.assertEqual(meta.call_count, 2) + self.assertEqual(disks.call_count, 2) + + def test_ssh_destination_is_data_and_host_verification_is_required(self): + for host in ["john@10.10.0.7", "beast", "john@[2001:db8::1]"]: + command = launcher.command(host) + self.assertEqual(command[-2], host) + for flag in ["BatchMode=yes", "StrictHostKeyChecking=yes", "ControlPath=none", "ClearAllForwardings=yes", "ForwardAgent=no"]: + self.assertIn(flag, command) + for host in ["-oProxyCommand=bad", "host;touch /tmp/no", "$(id)", "a b", "a\nb", ""]: + with self.assertRaises(ValueError): + launcher.command(host) + + def test_stream_controls_and_eof_stop_the_transport(self): + # A fake SSH executable runs the exact transmitted, quoted program + # locally. This covers delivery, framing and lifetime without a host. + with tempfile.TemporaryDirectory(prefix="cybexos-remote-test-") as directory: + fake = Path(directory) / "ssh" + fake.write_text("#!/usr/bin/env python3\nimport os,sys\nos.execl('/bin/sh','sh','-c',sys.argv[-1])\n") + fake.chmod(0o700) + proc = subprocess.Popen([sys.executable, "-B", str(SCRIPTS / "remote-server.py"), "fixture"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, start_new_session=True, + env={**os.environ, "PATH": directory + ":" + os.environ["PATH"]}) + try: + proc.stdin.write('not json\n{"interval":2}\n') + proc.stdin.flush() + samples = [] + for _ in range(2): + self.assertTrue(select.select([proc.stdout], [], [], 15)[0], "sample timed out") + samples.append(json.loads(proc.stdout.readline())) + self.assertEqual(samples[0]["version"], 1) + self.assertIsNone(samples[0]["cpu"]) + self.assertIsInstance(samples[1]["cpu"], (int, float)) + proc.stdin.close() + self.assertEqual(proc.wait(timeout=5), 0, proc.stderr.read()) + finally: + if proc.poll() is None: + os.killpg(proc.pid, signal.SIGTERM) + proc.wait(timeout=5) + for stream in (proc.stdin, proc.stdout, proc.stderr): + stream.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/run b/tests/run index 1c243bf0..e780b408 100755 --- a/tests/run +++ b/tests/run @@ -481,6 +481,7 @@ python_fixtures=( hermes-bridge hermes-webui-auth hermes-remote-runtime + remote-server ) python_fixture_limit=300 From 3b7fbab73d50e06547646998e46d75e5f9c7c9be Mon Sep 17 00:00:00 2001 From: John Pals <7024725+DigitalPals@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:00:40 +0200 Subject: [PATCH 5/5] Redesign the remote server widget and dashboard Replace the scrolling dashboard with a header and status pill, four reading tiles that select a detail view (CPU, memory, storage, temperature), an always-visible network section with an interface picker, and a fixed footer. Charts use a rolling window that grows from two to ten minutes, filled areas and avg/peak summaries. The menubar chip drops its trailing dot for an attention badge on the server mark and tints readings past warning thresholds. Collapse bind mounts of one device, hide firmware variable stores and number repeated sensor names. Expose the connection state over IPC and cover the new helpers with tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/remote-server-widget.md | 49 +- .../files/quickshell/Bar/Modules/Remote.qml | 75 +- .../files/quickshell/Common/RemoteServer.qml | 15 +- .../quickshell/Common/RemoteServerHelpers.js | 135 +- .../Popovers/RemoteServerPopover.qml | 1381 ++++++++++++++--- .../files/quickshell/Ui/TelemetryChart.qml | 119 +- tests/quickshell/remote-server.test.cjs | 79 + 7 files changed, 1535 insertions(+), 318 deletions(-) diff --git a/docs/remote-server-widget.md b/docs/remote-server-widget.md index fc9c80ad..fa216ab2 100644 --- a/docs/remote-server-widget.md +++ b/docs/remote-server-widget.md @@ -4,22 +4,44 @@ Add **Remote Server** from Settings → Menubar → Widgets, then open its optio Set **SSH host** to an existing SSH alias or `user@hostname`, and optionally set a display name. For example: `john@10.10.0.7`, **The Beast**. These are personal settings, not distribution defaults; new installations leave the widget disabled -and its host empty. +and its host empty, and its dashboard offers **Choose SSH host** until one is set. The default menubar statistic is CPU utilization. Options include load average, memory used percent/bytes or available bytes, filesystem used percent/free bytes, network receive/transmit rate, and the hottest reported temperature. The server -label can be hidden or compacted away on a crowded bar. Select a mount point or -interface in the options; interface buttons in the dashboard also pin that choice. -Automatic networking prefers the default route, then the busiest interface with -an address. It never sums bridges, bonds and members together. - -Click the widget for CPU/memory histories, per-core activity, 1/5/15-minute load, -memory/swap capacities, local filesystems, network traffic and temperatures. -Large filesystem, interface and sensor lists expand on demand. The dashboard -scrolls within the output's available height. Missing sensors and first-sample -rates are unavailable, never zero. Disconnections retain explicitly stale values -and the time since the last successful sample. +label can be hidden or compacted away on a crowded bar. A healthy server shows +only its reading; the reading turns amber or red past its warning threshold +(85%/95% for percentages and per-CPU load, 75/90 °C for temperature), and the +server mark gains an amber badge while readings are stale or a red one once the +connection is lost. + +Click the widget for its dashboard. The header names the server with its SSH +destination, uptime and a Live/Stale/Offline/Connecting status. Four tiles show +CPU, memory, the selected filesystem and the hottest sensor, each with a meter; +the tiles are also tabs for the detail beneath them, which opens on whatever the +menubar shows: + +- **CPU**: usage history, one bar per logical CPU (hover for its reading), the + CPU model and 1/5/15-minute load, including load per thread. +- **Memory**: usage history with used, available, total and swap use. +- **Storage**: local filesystems, fullest first. Bind mounts of one device + collapse into its shortest path and firmware variable stores are hidden. + Selecting a row makes it the filesystem the tile and menubar report. +- **Temp**: history of the hottest sensor and the hottest sensors by name; + repeated chip names (one per NVMe drive) are numbered. + +Network download/upload rates and their history stay in view below. **Change** +lists the default route, then addressed or active interfaces (idle container +links on request); choosing one pins it, and Automatic follows the default +route. Automatic networking prefers the default route, then the busiest +interface with an address. It never sums bridges, bonds and members together. + +Charts cover the history collected so far, from two up to ten minutes, and +scroll with time between samples; network charts scale to the next binary unit +above their peak. Missing sensors and first-sample rates are unavailable, never +zero. Disconnections keep the last readings, dimmed, with the reason, a Retry +action and the time since the last reading. Long lists scroll inside the +dashboard while its header and footer stay fixed. ## Connection requirements @@ -72,5 +94,6 @@ cybexos-runtime ipc popouts open remote Tests cover counter resets, memory semantics, cache lifetime, transport quoting, SSH restrictions, stream cadence/EOF, malformed samples, interface selection, -metric formatting, history bounds and settings migration. Run `./tests/run`. +metric formatting, warning levels, chart windows and scales, filesystem and +sensor presentation, history bounds and settings migration. Run `./tests/run`. For live checks, use `tests/lib/quickshell-live` begin/end and the managed service. diff --git a/roles/desktop/files/quickshell/Bar/Modules/Remote.qml b/roles/desktop/files/quickshell/Bar/Modules/Remote.qml index 3680f262..2825bb8e 100644 --- a/roles/desktop/files/quickshell/Bar/Modules/Remote.qml +++ b/roles/desktop/files/quickshell/Bar/Modules/Remote.qml @@ -3,8 +3,17 @@ import ".." import "../../Common" import "../../Common/RemoteServerHelpers.js" as Helpers +// The chosen reading beside a server mark. The mark carries a badge only when +// something needs attention — amber while readings are old, red once the +// connection is lost — and the reading turns amber or red past its warning +// threshold, so a healthy server is just a number. BarModule { id: root + + readonly property string connection: RemoteServer.connection + readonly property bool troubled: connection === "stale" || connection === "offline" + readonly property string level: Helpers.metricLevel(RemoteServer.sample, RemoteServer.options) + moduleId: "remote" detailSaving: RemoteServer.options.showLabel ? serverName.width + chip.spacing : 0 @@ -15,19 +24,46 @@ BarModule { isle: root.isle anchorItem: root.groupAnchor ?? chip spacing: 6 - tooltip: RemoteServer.label + " · " + RemoteServer.status - + "\n" + (Helpers.METRICS.find(m => m.value === RemoteServer.options.metric)?.label || "CPU") - + ": " + RemoteServer.barValue - + (RemoteServer.sample ? "\nCPU " + Helpers.percent(RemoteServer.sample.cpu) - + " · RAM " + Helpers.percent(Helpers.memoryPercent(RemoteServer.sample)) - + " · Updated " + RemoteServer.age : "") - + (RemoteServer.error ? "\n" + RemoteServer.error : "") - - Sym { + tooltip: [RemoteServer.label + " · " + RemoteServer.status, + RemoteServer.sample ? "CPU " + Helpers.percent(RemoteServer.sample.cpu) + + " · Memory " + Helpers.percent(Helpers.memoryPercent(RemoteServer.sample)) + + " · Up " + Helpers.uptime(RemoteServer.sample.uptime) : "", + root.troubled && RemoteServer.sample ? "Last reading " + RemoteServer.age : "", + RemoteServer.error].filter(line => line !== "").join("\n") + + Item { anchors.verticalCenter: parent.verticalCenter - name: "dns" - size: Theme.barIconSize - color: RemoteServer.error ? Theme.barAmber : chip.fg + width: Theme.barIconSize + height: Theme.barIconSize + + Sym { + anchors.fill: parent + name: "dns" + size: Theme.barIconSize + color: chip.fg + } + + // Ringed in the bar surface so it reads as sitting on the mark, + // as the notification bell's unread mark does. + Rectangle { + visible: root.troubled + anchors.right: parent.right + anchors.top: parent.top + anchors.rightMargin: -3 + anchors.topMargin: -3 + width: 8 + height: 8 + radius: 4 + color: Theme.barSurface + + Rectangle { + anchors.centerIn: parent + width: 5 + height: 5 + radius: 3 + color: root.connection === "offline" ? Theme.barRedText : Theme.barAmber + } + } } Text { id: serverName @@ -49,13 +85,14 @@ BarModule { font.pixelSize: Theme.typography.bar font.weight: Theme.weightSemibold font.features: Theme.tabularNumberFeatures - color: RemoteServer.stale ? Theme.barTextFaint : Theme.barTextHi - } - Rectangle { - anchors.verticalCenter: parent.verticalCenter - width: 4; height: 4; radius: 2 - color: RemoteServer.error || RemoteServer.stale ? Theme.barAmber - : RemoteServer.sample ? Theme.barAccent : Theme.barTextFaint + color: !RemoteServer.host ? Theme.barTextLow + : root.troubled || !RemoteServer.sample ? Theme.barTextFaint + : root.level === "critical" ? Theme.barRedText + : root.level === "warn" ? Theme.barAmber : Theme.barTextHi + + Behavior on color { + ColorAnimation { duration: Theme.chipFadeDuration } + } } } } diff --git a/roles/desktop/files/quickshell/Common/RemoteServer.qml b/roles/desktop/files/quickshell/Common/RemoteServer.qml index b3b8b294..aa9d575a 100644 --- a/roles/desktop/files/quickshell/Common/RemoteServer.qml +++ b/roles/desktop/files/quickshell/Common/RemoteServer.qml @@ -24,10 +24,13 @@ Singleton { readonly property bool busy: worker.running && updatedAt === 0 readonly property bool stale: sample !== null && (error !== "" || !worker.running || now - updatedAt > Math.max(15000, cadence * 3000)) - readonly property string age: updatedAt > 0 - ? Math.max(0, Math.floor((now - updatedAt) / 1000)) + "s ago" : "No readings yet" - readonly property string status: !host ? "Set up SSH" : error ? "Disconnected" - : stale ? "Stale" : sample ? "Connected" : "Connecting…" + readonly property string age: updatedAt > 0 ? Helpers.ago(now - updatedAt) : "No readings yet" + // One connection state for the chip, the dashboard and IPC. An error wins + // over retained readings: they stay on screen, marked as old. + readonly property string connection: !host ? "setup" : error ? "offline" + : stale ? "stale" : sample ? "live" : "connecting" + readonly property string status: ({ setup: "Set up SSH", offline: "Disconnected", + stale: "Stale", live: "Connected", connecting: "Connecting…" })[connection] readonly property string barValue: Helpers.metric(sample, options) readonly property var selectedNetwork: Helpers.network(sample, options.interface) @@ -84,8 +87,8 @@ Singleton { IpcHandler { target: "remoteServer" function status(): string { - return JSON.stringify({ host: root.host, label: root.label, status: root.status, - updatedAt: root.updatedAt, stale: root.stale, error: root.error, + return JSON.stringify({ host: root.host, label: root.label, connection: root.connection, + status: root.status, updatedAt: root.updatedAt, stale: root.stale, error: root.error, cadence: root.cadence, watchers: root.watchers, running: worker.running, metric: root.options.metric, value: root.barValue, sample: root.sample }); } diff --git a/roles/desktop/files/quickshell/Common/RemoteServerHelpers.js b/roles/desktop/files/quickshell/Common/RemoteServerHelpers.js index 50581f68..219312a7 100644 --- a/roles/desktop/files/quickshell/Common/RemoteServerHelpers.js +++ b/roles/desktop/files/quickshell/Common/RemoteServerHelpers.js @@ -12,6 +12,17 @@ var METRICS = [ { value: "temperature", label: "Temperature (hottest sensor)" } ]; +// The dashboard's reading tiles, in order. Each also selects the history +// shown beneath them; storage shows its filesystems instead of a chart. +var VIEWS = [ + { value: "cpu", label: "CPU" }, + { value: "memory", label: "Memory" }, + { value: "storage", label: "Storage" }, + { value: "temperature", label: "Temp" } +]; +// Warning and critical thresholds. Load is judged per logical CPU. +var LIMITS = { percent: [85, 95], celsius: [75, 90] }; + function known(value) { return typeof value === "number" && isFinite(value); } function percent(value) { return known(value) ? Math.round(value) + "%" : "—"; } function bytes(value) { @@ -22,6 +33,23 @@ function bytes(value) { return value.toFixed(index > 0 && value < 100 ? 1 : 0) + " " + units[index]; } function rate(value) { return known(value) ? bytes(value) + "/s" : "—"; } +function compactRate(value) { + if (!known(value)) return "—"; + var units = ["B", "K", "M", "G", "T"]; + var index = 0; + while (value >= 1000 && index < units.length - 1) { value /= 1024; index++; } + return (index > 0 && value < 10 ? value.toFixed(1) : Math.round(value)) + " " + units[index] + "/s"; +} +function ago(ms) { + if (!known(ms) || ms < 0) return ""; + var seconds = Math.floor(ms / 1000); + if (seconds < 5) return "just now"; + if (seconds < 60) return seconds + "s ago"; + var minutes = Math.floor(seconds / 60); + if (minutes < 60) return minutes + " min ago"; + var hours = Math.floor(minutes / 60); + return hours < 24 ? hours + " h ago" : Math.floor(hours / 24) + " d ago"; +} function uptime(seconds) { if (!known(seconds)) return "—"; var hours = Math.floor(seconds / 3600); @@ -50,6 +78,105 @@ function temperature(sample) { var values = sample ? sample.temperatures.map(function(t) { return t.celsius; }).filter(known) : []; return values.length ? Math.max.apply(null, values) : null; } +function level(value, kind) { + if (!known(value)) return "unknown"; + var limits = LIMITS[kind] || LIMITS.percent; + return value >= limits[1] ? "critical" : value >= limits[0] ? "warn" : "ok"; +} +// The level of whatever the menubar shows, so the chip can warn in place. +function metricLevel(sample, options) { + if (!sample) return "unknown"; + var storage = disk(sample, options.mount); + switch (options.metric) { + case "load": return sample.meta.cores > 0 ? level(100 * sample.load[0] / sample.meta.cores) : "unknown"; + case "memory": case "memoryUsed": case "memoryFree": return level(memoryPercent(sample)); + case "disk": case "diskFree": return level(storage ? storage.percent : null); + case "temperature": return level(temperature(sample), "celsius"); + case "rx": case "tx": return "ok"; + default: return level(sample.cpu); + } +} +// Open the dashboard on the reading the menubar summarises. +function viewFor(metric) { + switch (metric) { + case "memory": case "memoryUsed": case "memoryFree": return "memory"; + case "disk": case "diskFree": return "storage"; + case "temperature": return "temperature"; + default: return "cpu"; + } +} +function summary(points) { + var values = points.map(function(p) { return p.value; }).filter(known); + if (!values.length) return null; + var total = values.reduce(function(sum, v) { return sum + v; }, 0); + return { average: total / values.length, peak: Math.max.apply(null, values), + low: Math.min.apply(null, values) }; +} +// The window a history chart shows: the whole minutes collected so far, +// between two and ten, so a young history is not a sliver at the right edge. +function chartSpan(elapsed) { + var minutes = Math.ceil((known(elapsed) && elapsed > 0 ? elapsed : 0) / 60000); + return Math.max(2, Math.min(10, minutes)) * 60000; +} +// A rate chart's top edge: the next power of two above the peak, so the +// scale reads in whole binary units and only moves when traffic really does. +function chartCeiling(peak) { + if (!known(peak) || peak <= 1024) return 1024; + return Math.pow(2, Math.ceil(Math.log(peak) / Math.LN2 - 1e-9)); +} +// Firmware variable stores are not storage anyone manages. +var PSEUDO_FILESYSTEMS = ["efivarfs"]; +// One row per filesystem: bind mounts of one device (container config mounts, +// for example) collapse into its shortest mount path unless one of them is +// the selected mount. The selected filesystem leads, then the fullest. +function storageRows(sample, mount) { + if (!sample) return []; + var rows = []; + var byDevice = {}; + sample.storage.forEach(function(d) { + if (PSEUDO_FILESYSTEMS.indexOf(d.type) >= 0 && d.mount !== mount) return; + var key = d.device ? d.type + ":" + d.device + ":" + d.total : null; + var index = key !== null && Object.prototype.hasOwnProperty.call(byDevice, key) ? byDevice[key] : -1; + if (index < 0) { + if (key !== null) byDevice[key] = rows.length; + rows.push(d); + } else if (rows[index].mount !== mount + && (d.mount === mount || d.mount.length < rows[index].mount.length)) { + rows[index] = d; + } + }); + return rows.sort(function(a, b) { + return Number(b.mount === mount) - Number(a.mount === mount) || b.percent - a.percent + || (a.mount < b.mount ? -1 : a.mount > b.mount ? 1 : 0); + }); +} +// Hottest first. Identical chip/label pairs (one per NVMe drive) number their +// chip in reported order, so each row names one sensor: "nvme 2 · Composite". +function sensorRows(sample) { + if (!sample) return []; + var seen = {}; + var totals = {}; + sample.temperatures.forEach(function(t) { totals[t.name] = (totals[t.name] || 0) + 1; }); + return sample.temperatures.map(function(t) { + seen[t.name] = (seen[t.name] || 0) + 1; + var split = t.name.indexOf(" · "); + var name = totals[t.name] < 2 ? t.name : split < 0 ? t.name + " " + seen[t.name] + : t.name.slice(0, split) + " " + seen[t.name] + t.name.slice(split); + return { name: name, celsius: t.celsius }; + }).sort(function(a, b) { return b.celsius - a.celsius; }); +} +// Interfaces worth choosing from: the default route, then addressed ones, then +// the busiest. Unaddressed idle links (veth/tap members) stay at the end. +function interfaceRows(sample) { + if (!sample) return []; + var primary = sample.meta.defaultInterface; + return sample.network.slice().sort(function(a, b) { + return Number(b.name === primary) - Number(a.name === primary) + || Number(b.addresses.length > 0) - Number(a.addresses.length > 0) + || (b.rx || 0) + (b.tx || 0) - (a.rx || 0) - (a.tx || 0) + || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + }); +} function metric(sample, options) { if (!sample) return "—"; var storage = disk(sample, options.mount); @@ -92,9 +219,11 @@ function historyAppend(history, sample, now) { // Bound both time and memory. Each point keeps interface rates separately, // so changing the selected interface doesn't relabel somebody else's graph. return next.filter(function(p) { return now - p.at < 600000; }).concat([{ - at: now, boot: sample.boot, cpu: sample.cpu, memory: memoryPercent(sample), network: sample.network + at: now, boot: sample.boot, cpu: sample.cpu, memory: memoryPercent(sample), + temperature: temperature(sample), network: sample.network }]).slice(-300); } if (typeof module !== "undefined" && module.exports) - module.exports = { METRICS, known, percent, bytes, rate, uptime, memoryPercent, disk, network, - temperature, metric, validSample, historyAppend }; + module.exports = { METRICS, VIEWS, LIMITS, known, percent, bytes, rate, compactRate, ago, uptime, + memoryPercent, disk, network, temperature, level, metricLevel, viewFor, summary, chartSpan, chartCeiling, + storageRows, sensorRows, interfaceRows, metric, validSample, historyAppend }; diff --git a/roles/desktop/files/quickshell/Popovers/RemoteServerPopover.qml b/roles/desktop/files/quickshell/Popovers/RemoteServerPopover.qml index 5dd2e6ce..653688fb 100644 --- a/roles/desktop/files/quickshell/Popovers/RemoteServerPopover.qml +++ b/roles/desktop/files/quickshell/Popovers/RemoteServerPopover.qml @@ -1,365 +1,1236 @@ pragma ComponentBehavior: Bound import QtQuick -import QtQuick.Controls as Controls import "../Common" import "../Ui" as Ui +import "../Common/Format.js" as Format import "../Common/RemoteServerHelpers.js" as Helpers +// The server dashboard. A header says which machine and whether it is live; +// four reading tiles give the state at a glance and double as the tabs for +// the one detailed view beneath them; the network stays in view below that; +// and a one-line footer carries freshness and the two actions. The common +// case fits without scrolling. Expanded lists scroll inside the body while +// the header and footer stay put. PopoutPanel { id: root - property bool allDisks: false - property bool allInterfaces: false - property bool allTemperatures: false + readonly property var stats: RemoteServer.sample readonly property var net: RemoteServer.selectedNetwork + readonly property var disk: Helpers.disk(stats, RemoteServer.options.mount) + readonly property var hottest: Helpers.temperature(stats) + readonly property bool configured: RemoteServer.host !== "" + readonly property bool dimmed: RemoteServer.stale || RemoteServer.error !== "" readonly property int gutter: Theme.panelPadding - implicitWidth: Math.min(520, availableWidth > 0 ? availableWidth : 520) - implicitHeight: Math.min(body.implicitHeight + gutter * 2 + footer.height + 12, - 840, availableHeight > 0 ? availableHeight : 840) + readonly property int gap: Theme.scaled(12) + readonly property int preferredWidth: Theme.scaled(472) + + // An explicit pick wins for as long as the panel exists; otherwise the + // dashboard opens on whatever the menubar is summarising. + property string picked: "" + readonly property string view: picked || Helpers.viewFor(RemoteServer.options.metric) + property bool allDisks: false + property bool allSensors: false + property bool pickingInterface: false + property bool allInterfaces: false + + readonly property double span: Helpers.chartSpan(RemoteServer.history.length + ? RemoteServer.now - RemoteServer.history[0].at : 0) + readonly property string spanLabel: "last " + Math.round(span / 60000) + " min" + readonly property var cpuPoints: RemoteServer.history.map(p => ({ at: p.at, value: p.cpu })) + readonly property var memoryPoints: RemoteServer.history.map(p => ({ at: p.at, value: p.memory })) + readonly property var temperaturePoints: RemoteServer.history.map( + p => ({ at: p.at, value: p.temperature ?? null })) + function ratePoints(key) { + const name = root.net ? root.net.name : ""; + return RemoteServer.history.map(p => { + const n = name ? p.network.find(v => v.name === name) : null; + return { at: p.at, value: n ? n[key] : null }; + }); + } + readonly property var rxPoints: ratePoints("rx") + readonly property var txPoints: ratePoints("tx") + + implicitWidth: Math.min(preferredWidth, availableWidth > 0 ? availableWidth : preferredWidth) + implicitHeight: Math.min(gutter * 2 + header.height + gap + body.implicitHeight + gap + footer.height, + availableHeight > 0 ? availableHeight : 860) Claim { active: root.visible onClaimed: RemoteServer.acquire() onReleased: RemoteServer.release() } + + function tone(level) { + return level === "critical" ? Theme.redText : level === "warn" ? Theme.amber : Theme.textHi; + } + function meterTone(level) { + return level === "critical" ? Theme.red : level === "warn" ? Theme.amber : Theme.accent; + } + function reading(view) { + const s = root.stats; + let value = null; + let unit = "%"; + let kind = "percent"; + if (view === "memory") + value = Helpers.memoryPercent(s); + else if (view === "storage") + value = root.disk ? root.disk.percent : null; + else if (view === "temperature") { + value = root.hottest; + unit = "°C"; + kind = "celsius"; + } else + value = s ? s.cpu : null; + const known = Helpers.known(value); + return { number: known ? String(Math.round(value)) : "—", unit: known ? unit : "", + fraction: known ? Format.clamp01(value / 100) : 0, level: Helpers.level(value, kind), + text: known ? Math.round(value) + unit : "unavailable" }; + } + function spread(points, unit) { + const s = Helpers.summary(points); + return s ? "avg " + Math.round(s.average) + unit + " · peak " + Math.round(s.peak) + unit : ""; + } + Rectangle { anchors.fill: parent visible: root.drawBackground radius: Theme.panelRadius color: root.surfaceColor - border.width: 1 + border.width: Theme.surfaceBorderWidth border.color: root.surfaceBorderColor } - component Label: Text { + + component Caption: Text { textFormat: Text.PlainText + elide: Text.ElideRight font.family: Theme.fontMenu - font.pixelSize: Theme.typography.secondary + font.pixelSize: Theme.typography.metadata color: Theme.textDim - wrapMode: Text.Wrap } - component Heading: Label { - font.pixelSize: Theme.typography.section + component Figure: Text { + textFormat: Text.PlainText + font.family: Theme.fontNumeric + font.pixelSize: Theme.typography.secondary font.weight: Theme.weightSemibold - font.letterSpacing: 1 - color: Theme.textMid + font.features: Theme.tabularNumberFeatures + color: Theme.textHi } - component Card: Rectangle { - radius: Theme.rowRadius - color: Theme.cardFill - border.width: 1 - border.color: Theme.hairlineSoft + + // A reading, and the tab for its detail. + component ReadingTile: Rectangle { + id: tile + + required property var modelData + readonly property string view: modelData.value + readonly property var reading: root.reading(view) + readonly property bool selected: root.view === view + + function pick() { + root.picked = tile.view; + } + + height: Theme.scaled(66) + radius: Theme.chipRadius + color: tile.selected || tileMouse.containsMouse ? Theme.chipHover : Theme.chip + border.width: tile.selected || tile.activeFocus ? 1 : 0 + border.color: Qt.alpha(Theme.accentText, tile.activeFocus ? 1 : 0.5) + activeFocusOnTab: true + Accessible.role: Accessible.PageTab + Accessible.name: tile.modelData.label + Accessible.description: tile.reading.text + (tile.selected ? ", shown below" : "") + Accessible.onPressAction: tile.pick() + Keys.onPressed: event => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter + || event.key === Qt.Key_Space) { + tile.pick(); + event.accepted = true; + } + } + + Behavior on color { + ColorAnimation { duration: Theme.chipFadeDuration } + } + + Text { + id: tileLabel + x: Theme.scaled(10) + y: Theme.scaled(9) + width: parent.width - x * 2 + elide: Text.ElideRight + text: tile.modelData.label.toUpperCase() + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.section + font.weight: Theme.weightSemibold + font.letterSpacing: 1 + color: tile.selected ? Theme.accentText : Theme.textFaint + } + Text { + id: tileNumber + anchors.left: tileLabel.left + anchors.top: tileLabel.bottom + anchors.topMargin: Theme.scaled(2) + text: tile.reading.number + font.family: Theme.fontNumeric + font.pixelSize: Theme.typography.title + font.weight: Theme.weightSemibold + font.features: Theme.tabularNumberFeatures + color: root.tone(tile.reading.level) + } + Text { + anchors.left: tileNumber.right + anchors.leftMargin: 1 + anchors.baseline: tileNumber.baseline + text: tile.reading.unit + font.family: Theme.fontNumeric + font.pixelSize: Theme.typography.metadata + font.weight: Theme.weightMedium + color: Theme.textLow + } + BlockMeter { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Theme.scaled(10) + height: 5 + blockWidth: 3 + gap: 2 + value: tile.reading.fraction + fillColor: root.meterTone(tile.reading.level) + } + MouseArea { + id: tileMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: tile.pick() + } } - component Meter: Rectangle { - id: meter - property real value: 0 - property color tint: value >= 90 ? Theme.amber : Theme.accentText - height: 5 - radius: 2.5 - color: Theme.hoverFill - Rectangle { - width: parent.width * Math.max(0, Math.min(100, meter.value)) / 100 - height: parent.height - radius: parent.radius - color: meter.tint + + // A run of labelled figures on one recessed strip. + component FactStrip: Rectangle { + id: strip + + property var facts: [] + + width: parent ? parent.width : 0 + height: Theme.scaled(46) + radius: Theme.chipRadius + color: Theme.chip + + Repeater { + model: strip.facts + + Item { + id: fact + + required property var modelData + required property int index + readonly property real cell: strip.width / Math.max(1, strip.facts.length) + + x: fact.index * fact.cell + width: fact.cell + height: strip.height + Accessible.role: Accessible.StaticText + Accessible.name: fact.modelData.label + Accessible.description: fact.modelData.value + + (fact.modelData.detail ? ", " + fact.modelData.detail : "") + + Rectangle { + visible: fact.index > 0 + anchors.verticalCenter: parent.verticalCenter + width: 1 + height: parent.height - Theme.scaled(18) + color: Theme.hairlineSoft + } + Caption { + id: factLabel + x: Theme.scaled(10) + y: Theme.scaled(7) + width: parent.width - x * 2 + text: fact.modelData.label + } + Figure { + anchors.left: factLabel.left + anchors.top: factLabel.bottom + anchors.topMargin: 1 + width: factLabel.width + elide: Text.ElideRight + text: fact.modelData.value + color: root.tone(fact.modelData.level || "ok") + } + } } } - component StatCard: Card { - id: stat + + // A chart with its title and summary on one line above it. + component History: Column { + id: history + property string title: "" - property string value: "" - property string detail: "" - property var points: [] - height: content.implicitHeight + 28 + property string summary: "" + property var series: [] + property real ceiling: 100 + property string ceilingLabel: "" + property real chartHeight: Theme.scaled(84) + + width: parent ? parent.width : 0 + spacing: Theme.scaled(6) + + Item { + width: parent.width + height: historyTitle.implicitHeight + + Text { + id: historyTitle + width: Math.min(implicitWidth, parent.width - historySummary.implicitWidth + - historySpan.implicitWidth - 20) + elide: Text.ElideRight + text: history.title + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + font.weight: Theme.weightSemibold + color: Theme.textMid + } + Caption { + id: historySpan + anchors.left: historyTitle.right + anchors.leftMargin: Theme.scaled(8) + anchors.baseline: historyTitle.baseline + text: root.spanLabel + color: Theme.textFaint + } + Caption { + id: historySummary + anchors.right: parent.right + anchors.baseline: historyTitle.baseline + text: history.summary + font.features: Theme.tabularNumberFeatures + } + } + Item { + width: parent.width + height: history.chartHeight + + Ui.TelemetryChart { + anchors.fill: parent + series: history.series + ceiling: history.ceiling + span: root.span + now: RemoteServer.now + description: history.title + ", " + root.spanLabel + } + Caption { + visible: history.ceilingLabel !== "" + x: 4 + y: 3 + text: history.ceilingLabel + color: Theme.textFaint + } + Caption { + visible: !history.series.some(s => s.points.some(p => Helpers.known(p.value))) + anchors.centerIn: parent + text: RemoteServer.connection === "connecting" ? "Collecting readings…" : "No history yet" + color: Theme.textFaint + } + } + } + + // A pressable row inside an expanded list. + component ListRow: Rectangle { + id: row + + property string accessibleName: "" + signal activated() + + width: parent ? parent.width : 0 + radius: Theme.chipRadius + color: rowMouse.containsMouse || row.activeFocus ? Theme.hoverFill : "transparent" + border.width: row.activeFocus ? 1 : 0 + border.color: Theme.accentText + activeFocusOnTab: true + Accessible.role: Accessible.Button + Accessible.name: row.accessibleName + Accessible.onPressAction: row.activated() + Keys.onPressed: event => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter + || event.key === Qt.Key_Space) { + row.activated(); + event.accepted = true; + } + } + + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: row.activated() + } + } + + // ---- header ---------------------------------------------------------- + Item { + id: header + + x: root.gutter + y: root.gutter + width: root.width - root.gutter * 2 + height: Theme.scaled(42) + + Rectangle { + id: mark + anchors.verticalCenter: parent.verticalCenter + width: Theme.scaled(38) + height: width + radius: Theme.chipRadius + color: Theme.chip + + Sym { + anchors.centerIn: parent + name: "dns" + size: Theme.iconLarge + color: root.configured ? Theme.accentText : Theme.textDim + } + } Column { - id: content - x: 14; y: 14; width: parent.width - 28 - spacing: 8 - Heading { text: stat.title } - Label { + anchors.left: mark.right + anchors.leftMargin: Theme.scaled(12) + anchors.right: pill.left + anchors.rightMargin: Theme.scaled(10) + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.scaled(2) + + Text { width: parent.width - text: stat.value - font.family: Theme.fontNumeric - font.pixelSize: Theme.typography.display + elide: Text.ElideRight + textFormat: Text.PlainText + text: root.configured ? RemoteServer.label : "Remote Server" + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.title font.weight: Theme.weightSemibold - font.features: Theme.tabularNumberFeatures color: Theme.textHi } - Ui.TelemetryChart { + Text { width: parent.width - points: stat.points - description: stat.title + " history" + elide: Text.ElideRight + textFormat: Text.PlainText + text: { + if (!root.configured) + return "Monitor a Linux machine over SSH"; + const who = RemoteServer.label !== RemoteServer.host ? RemoteServer.host + : root.stats ? root.stats.meta.hostname : ""; + return [who, root.stats ? "up " + Helpers.uptime(root.stats.uptime) : ""] + .filter(part => part !== "").join(" · "); + } + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + color: Theme.textLow + } + } + Rectangle { + id: pill + + readonly property string connection: RemoteServer.connection + readonly property color tint: connection === "live" ? Theme.ok + : connection === "offline" ? Theme.redText + : connection === "stale" ? Theme.amber : Theme.textDim + + visible: root.configured + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: pillRow.implicitWidth + Theme.scaled(18) + height: Theme.scaled(24) + radius: height / 2 + color: connection === "live" ? Theme.okBgSoft + : connection === "offline" ? Theme.redBgSoft + : connection === "stale" ? Theme.amberBgSoft : Theme.chip + Accessible.role: Accessible.StaticText + Accessible.name: "Connection" + Accessible.description: RemoteServer.status + + Row { + id: pillRow + anchors.centerIn: parent + spacing: Theme.scaled(6) + + Rectangle { + id: pillDot + anchors.verticalCenter: parent.verticalCenter + width: 6 + height: 6 + radius: 3 + color: pill.tint + + SequentialAnimation on opacity { + running: pill.connection === "connecting" && !Theme.reducedMotion + loops: Animation.Infinite + onRunningChanged: if (!running) pillDot.opacity = 1 + NumberAnimation { from: 1; to: 0.3; duration: 700; easing.type: Easing.InOutSine } + NumberAnimation { from: 0.3; to: 1; duration: 700; easing.type: Easing.InOutSine } + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: ({ live: "Live", offline: "Offline", stale: "Stale", + connecting: "Connecting" })[pill.connection] || "" + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + font.weight: Theme.weightSemibold + color: pill.tint + } } - Label { width: parent.width; text: stat.detail } } } + // ---- body ------------------------------------------------------------ Flickable { id: scroll - anchors.fill: parent - anchors.margins: root.gutter - anchors.bottomMargin: root.gutter + footer.height + 12 + + x: root.gutter + y: header.y + header.height + root.gap + width: header.width + height: footer.y - root.gap - y + contentWidth: width contentHeight: body.implicitHeight - clip: true boundsBehavior: Flickable.StopAtBounds - Controls.ScrollBar.vertical: Controls.ScrollBar {} + clip: true Column { id: body + width: scroll.width - spacing: 16 - Row { + spacing: Theme.panelSectionSpacing + + // Nothing to monitor yet. + Column { + visible: !root.configured width: parent.width - spacing: 12 - Rectangle { - width: 42; height: 42; radius: 12 - color: Qt.alpha(Theme.accentText, 0.10) - Sym { anchors.centerIn: parent; name: "dns"; size: 24; color: Theme.accentText } + topPadding: Theme.scaled(10) + bottomPadding: Theme.scaled(6) + spacing: Theme.scaled(10) + + Text { + width: parent.width + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + textFormat: Text.PlainText + text: "Choose a server you can already reach with an SSH key. Nothing is installed or written on it; it needs Linux and Python 3.9 or newer." + lineHeight: Theme.proseLineHeight + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + color: Theme.textLow + } + ActionButton { + anchors.horizontalCenter: parent.horizontalCenter + label: "Choose SSH host" + tint: Theme.accentText + onTriggered: RemoteServer.configure() + } + } + + // Why the readings are missing or old. Offline is red wherever it + // shows — here, the status pill and the menubar badge. + Rectangle { + visible: RemoteServer.error !== "" + width: parent.width + height: problem.implicitHeight + Theme.scaled(22) + radius: Theme.chipRadius + color: Theme.redBgSoft + border.width: 1 + border.color: Theme.redBorder + + Sym { + x: Theme.scaled(12) + y: Theme.scaled(12) + name: "error" + size: Theme.iconMedium + color: Theme.redText } Column { - width: parent.width - 54 - spacing: 4 - Label { + id: problem + x: Theme.scaled(38) + y: Theme.scaled(11) + width: parent.width - x - retry.width - Theme.scaled(22) + spacing: Theme.scaled(3) + + Text { width: parent.width - text: RemoteServer.label - font.pixelSize: Theme.typography.heading + wrapMode: Text.Wrap + textFormat: Text.PlainText + text: root.stats ? "Connection lost · showing the last readings" + : "Can't reach " + RemoteServer.label + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary font.weight: Theme.weightSemibold - color: Theme.textHi + color: Theme.redText } - Label { + Text { width: parent.width - text: root.stats ? root.stats.meta.os + " · Up " + Helpers.uptime(root.stats.uptime) - : "Your server, at a glance" + wrapMode: Text.Wrap + textFormat: Text.PlainText + text: RemoteServer.error + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + color: Theme.textMid } } - } - Row { - spacing: 7 - Rectangle { + ActionButton { + id: retry + anchors.right: parent.right + anchors.rightMargin: Theme.scaled(10) anchors.verticalCenter: parent.verticalCenter - width: 6; height: 6; radius: 3 - color: RemoteServer.stale || RemoteServer.error ? Theme.amber : Theme.accentText - } - Label { - text: RemoteServer.status + (root.stats ? " · Updated " + RemoteServer.age : "") - color: RemoteServer.stale || RemoteServer.error ? Theme.amber : Theme.textMid + label: "Retry" + tint: Theme.redText + onTriggered: RemoteServer.refresh() } } - Card { + + Rectangle { + visible: RemoteServer.connection === "connecting" width: parent.width - height: message.implicitHeight + 24 - visible: !root.stats || RemoteServer.error !== "" - Label { - id: message - x: 12; y: 12; width: parent.width - 24 - text: !RemoteServer.host - ? "Choose an SSH host in Settings to start monitoring. Your existing SSH keys and host aliases work here." - : RemoteServer.error || "Connecting securely and collecting the first readings…" - color: RemoteServer.error ? Theme.amber : Theme.textMid + height: Theme.scaled(64) + radius: Theme.chipRadius + color: Theme.chip + + Caption { + anchors.centerIn: parent + width: Math.min(implicitWidth, parent.width - Theme.scaled(24)) + text: "Connecting securely and collecting the first readings…" + color: Theme.textLow } } + Column { - width: parent.width - spacing: 16 visible: root.stats !== null - opacity: RemoteServer.stale ? 0.6 : 1 - Row { - width: parent.width - spacing: 10 - StatCard { - width: (parent.width - 10) / 2 - title: "CPU" - value: root.stats ? Helpers.percent(root.stats.cpu) : "—" - detail: root.stats ? root.stats.meta.cores + " logical CPUs" : "" - points: RemoteServer.history.map(p => ({ at: p.at, value: p.cpu })) - } - StatCard { - width: (parent.width - 10) / 2 - title: "MEMORY" - value: Helpers.percent(Helpers.memoryPercent(root.stats)) - detail: root.stats ? Helpers.bytes(root.stats.memory.used) + " / " + Helpers.bytes(root.stats.memory.total) : "" - points: RemoteServer.history.map(p => ({ at: p.at, value: p.memory })) - } - } - Label { - width: parent.width - text: root.stats ? root.stats.meta.model : "" + width: parent.width + spacing: Theme.panelSectionSpacing + opacity: root.dimmed ? 0.6 : 1 + + Behavior on opacity { + NumberAnimation { duration: Theme.chipFadeDuration } } - // Per-core activity remains useful on large machines without - // turning 64 logical CPUs into 64 rows. - Flow { + + Row { + id: tiles width: parent.width - spacing: 3 + spacing: Theme.scaled(8) + Repeater { - model: root.stats ? root.stats.perCore : [] - Rectangle { - required property var modelData - required property int index - width: 9; height: 12; radius: 2 - color: Helpers.known(modelData) - ? Qt.alpha(Theme.accentText, 0.15 + 0.85 * modelData / 100) : Theme.hoverFill - Accessible.name: "CPU " + index + ": " + Helpers.percent(modelData) - HoverHandler { id: coreHover } - Controls.ToolTip.visible: coreHover.hovered - Controls.ToolTip.text: "CPU " + index + " · " + Helpers.percent(modelData) + model: Helpers.VIEWS + ReadingTile { + width: (tiles.width - tiles.spacing * (Helpers.VIEWS.length - 1)) + / Helpers.VIEWS.length } } } - Heading { text: "LOAD AVERAGE" } - Row { + + // ---- CPU ---- + Column { + visible: root.view === "cpu" width: parent.width - Repeater { - model: ["1 minute", "5 minutes", "15 minutes"] - Column { - required property int index - required property string modelData - width: body.width / 3 - spacing: 4 - Label { - text: root.stats ? root.stats.load[parent.index].toFixed(2) : "—" - font.pixelSize: Theme.typography.heading - font.family: Theme.fontNumeric - color: Theme.textHi + spacing: Theme.scaled(10) + + History { + title: "CPU usage" + summary: root.spread(root.cpuPoints, "%") + series: [{ points: root.cpuPoints, tint: Theme.accentText }] + } + // Every logical CPU as one bar, wrapping only on very + // large machines, so 64 threads read as one texture. + Item { + id: cores + + readonly property var values: root.stats ? root.stats.perCore : [] + readonly property int count: values.length + readonly property real pitchGap: count > 96 ? 1 : 2 + readonly property int perRow: Math.max(1, Math.min(count, + Math.floor((width + pitchGap) / (3 + pitchGap)))) + readonly property int rows: Math.ceil(count / perRow) + readonly property real cell: (width - pitchGap * (perRow - 1)) / perRow + readonly property real rowHeight: rows > 1 ? Theme.scaled(14) : Theme.scaled(22) + property int hovered: -1 + + visible: count > 0 + width: parent.width + height: count > 0 ? rows * rowHeight + (rows - 1) * 3 : 0 + Accessible.role: Accessible.Chart + Accessible.name: "Per-CPU activity" + Accessible.description: count + " logical CPUs" + + Repeater { + model: cores.values + + Rectangle { + id: core + + required property var modelData + required property int index + readonly property string level: Helpers.level(core.modelData) + + x: (core.index % cores.perRow) * (cores.cell + cores.pitchGap) + y: Math.floor(core.index / cores.perRow) * (cores.rowHeight + 3) + width: cores.cell + height: cores.rowHeight + radius: Math.min(1.5, width / 2) + color: cores.hovered === core.index ? Theme.chip : Theme.hairlineSoft + + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: Helpers.known(core.modelData) + ? Math.max(1.5, parent.height * Format.clamp01(core.modelData / 100)) : 0 + radius: parent.radius + color: root.meterTone(core.level) + } } - Label { text: parent.modelData } + } + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + onPositionChanged: mouse => { + const column = Math.floor(mouse.x / (cores.cell + cores.pitchGap)); + const line = Math.floor(mouse.y / (cores.rowHeight + 3)); + const index = line * cores.perRow + Math.min(cores.perRow - 1, column); + cores.hovered = index >= 0 && index < cores.count ? index : -1; + } + onExited: cores.hovered = -1 } } + Item { + width: parent.width + height: coreModel.implicitHeight + + Caption { + id: coreModel + width: parent.width - coreReading.implicitWidth - 12 + text: root.stats ? [root.stats.meta.cores + " threads", root.stats.meta.model] + .filter(part => part).join(" · ") : "" + } + Caption { + id: coreReading + anchors.right: parent.right + text: cores.hovered >= 0 + ? "CPU " + cores.hovered + " · " + Helpers.percent(cores.values[cores.hovered]) : "" + color: Theme.textMid + font.features: Theme.tabularNumberFeatures + } + } + FactStrip { + readonly property var perThread: root.stats && root.stats.meta.cores > 0 + ? 100 * root.stats.load[0] / root.stats.meta.cores : null + facts: root.stats ? [ + { label: "Load 1 min", value: root.stats.load[0].toFixed(2), + level: Helpers.level(perThread) }, + { label: "5 min", value: root.stats.load[1].toFixed(2) }, + { label: "15 min", value: root.stats.load[2].toFixed(2) }, + { label: "Per thread", value: Helpers.known(perThread) + ? (perThread / 100).toFixed(2) : "—" } + ] : [] + } } - Label { + + // ---- memory ---- + Column { + visible: root.view === "memory" width: parent.width - text: root.stats ? Helpers.bytes(root.stats.memory.available) + " memory available · Swap " - + Helpers.bytes(root.stats.memory.swapUsed) + " / " + Helpers.bytes(root.stats.memory.swapTotal) : "" + spacing: Theme.scaled(10) + + History { + title: "Memory in use" + summary: root.spread(root.memoryPoints, "%") + series: [{ points: root.memoryPoints, tint: Theme.accentText }] + } + FactStrip { + readonly property var memory: root.stats ? root.stats.memory : null + readonly property var swapPercent: memory && memory.swapTotal > 0 + ? 100 * memory.swapUsed / memory.swapTotal : null + facts: memory ? [ + { label: "Used", value: Helpers.bytes(memory.used) }, + { label: "Available", value: Helpers.bytes(memory.available) }, + { label: "Total", value: Helpers.bytes(memory.total) }, + { label: "Swap used", + value: memory.swapTotal > 0 ? Helpers.percent(swapPercent) : "Off", + level: Helpers.level(swapPercent), + detail: Helpers.bytes(memory.swapUsed) + " of " + Helpers.bytes(memory.swapTotal) } + ] : [] + } } - HDivider { width: parent.width } - Heading { text: "STORAGE" } - Label { + + // ---- storage ---- + Column { + id: storage + + readonly property var rows: Helpers.storageRows(root.stats, RemoteServer.options.mount) + + visible: root.view === "storage" width: parent.width - visible: root.stats !== null && (root.stats.storage.length === 0 || (!root.allDisks && !Helpers.disk(root.stats, RemoteServer.options.mount))) - text: root.stats ? root.stats.storageError || "Selected filesystem unavailable" : "" - } - Repeater { - model: root.stats ? (root.allDisks ? root.stats.storage : root.stats.storage.filter(d => d.mount === RemoteServer.options.mount).slice(0, 1)) : [] - Column { - id: diskRow - required property var modelData - width: body.width - spacing: 7 - Label { - width: parent.width - text: diskRow.modelData.mount + " · " + diskRow.modelData.percent + "% used" - color: Theme.textHi + spacing: Theme.scaled(4) + + Item { + width: parent.width + height: storageTitle.implicitHeight + Theme.scaled(4) + + Text { + id: storageTitle + text: "Filesystems" + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + font.weight: Theme.weightSemibold + color: Theme.textMid } - Meter { width: parent.width; value: diskRow.modelData.percent } - Label { - width: parent.width - text: Helpers.bytes(diskRow.modelData.free) + " free of " + Helpers.bytes(diskRow.modelData.total) - + " · " + diskRow.modelData.type + Caption { + anchors.right: parent.right + anchors.baseline: storageTitle.baseline + text: "Select one for the Storage tile" + color: Theme.textFaint } } - } - ActionButton { - visible: root.stats !== null && root.stats.storage.length > 1 - label: root.allDisks ? "Show fewer filesystems" : "All " + (root.stats ? root.stats.storage.length : 0) + " filesystems" - onTriggered: root.allDisks = !root.allDisks - } - HDivider { width: parent.width } - Heading { text: "NETWORK" + (root.net ? " · " + root.net.name : "") } - Row { - width: parent.width - spacing: 12 + Caption { + visible: root.stats !== null && storage.rows.length === 0 + width: parent.width + text: root.stats ? root.stats.storageError || "No local filesystems reported" : "" + } Repeater { - model: ["rx", "tx"] - Column { - id: networkRate - required property string modelData - width: (body.width - 12) / 2 - spacing: 8 - Label { - width: parent.width - text: (networkRate.modelData === "rx" ? "In " : "Out ") - + Helpers.rate(root.net ? root.net[networkRate.modelData] : null) - font.pixelSize: Theme.typography.title - font.family: Theme.fontNumeric - color: Theme.textHi + model: root.allDisks ? storage.rows : storage.rows.slice(0, 4) + + ListRow { + id: diskRow + + required property var modelData + readonly property bool chosen: modelData.mount === RemoteServer.options.mount + readonly property string level: Helpers.level(modelData.percent) + + height: Theme.scaled(48) + accessibleName: modelData.mount + ", " + modelData.percent + "% used, " + + Helpers.bytes(modelData.free) + " free" + + (chosen ? ", shown in the Storage tile" : "") + onActivated: Settings.setModuleOption("remote", "mount", diskRow.modelData.mount) + + Text { + id: mountName + x: Theme.scaled(8) + y: Theme.scaled(7) + width: Math.min(implicitWidth, parent.width * 0.45) + elide: Text.ElideMiddle + textFormat: Text.PlainText + text: diskRow.modelData.mount + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.primary + font.weight: Theme.weightMedium + color: diskRow.chosen ? Theme.accentText : Theme.textHi + } + Caption { + anchors.left: mountName.right + anchors.leftMargin: Theme.scaled(8) + anchors.right: diskFree.left + anchors.rightMargin: Theme.scaled(8) + anchors.baseline: mountName.baseline + text: diskRow.modelData.type + color: Theme.textFaint } - Ui.TelemetryChart { - width: parent.width - ceiling: 0 - tint: networkRate.modelData === "rx" ? Theme.accentText : Theme.textMid - description: networkRate.modelData === "rx" ? "Download history" : "Upload history" - points: RemoteServer.history.map(p => { - const n = root.net ? p.network.find(v => v.name === root.net.name) : null; - return { at: p.at, value: n ? n[networkRate.modelData] : null }; - }) + Caption { + id: diskFree + anchors.right: diskPercent.left + anchors.rightMargin: Theme.scaled(8) + anchors.baseline: mountName.baseline + text: Helpers.bytes(diskRow.modelData.free) + " free of " + + Helpers.bytes(diskRow.modelData.total) + font.features: Theme.tabularNumberFeatures + } + Figure { + id: diskPercent + anchors.right: parent.right + anchors.rightMargin: Theme.scaled(8) + anchors.baseline: mountName.baseline + text: diskRow.modelData.percent + "%" + color: root.tone(diskRow.level) + } + BlockMeter { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: Theme.scaled(8) + anchors.rightMargin: Theme.scaled(8) + anchors.bottomMargin: Theme.scaled(9) + height: 5 + blockWidth: 3 + gap: 2 + value: diskRow.modelData.percent / 100 + fillColor: root.meterTone(diskRow.level) } } } + LinkText { + visible: storage.rows.length > 4 + x: Theme.scaled(8) + text: root.allDisks ? "Show fewer" : "Show all " + storage.rows.length + " filesystems" + onClicked: root.allDisks = !root.allDisks + } } - Label { - width: parent.width - text: root.net ? root.net.addresses.join(" · ") : "Selected interface unavailable" - } - Flow { + + // ---- temperature ---- + Column { + id: thermal + + readonly property var sensors: Helpers.sensorRows(root.stats) + + visible: root.view === "temperature" width: parent.width - spacing: 6 - Repeater { - model: root.stats ? root.stats.network.filter(n => root.allInterfaces || (root.net && n.name === root.net.name)).slice(0, 256) : [] - ActionButton { - required property var modelData - label: modelData.name - tint: root.net && root.net.name === modelData.name ? Theme.accentText : Theme.textDim - onTriggered: Settings.setModuleOption("remote", "interface", modelData.name) + spacing: Theme.scaled(10) + + History { + title: "Hottest sensor" + summary: root.spread(root.temperaturePoints, "°C") + series: [{ points: root.temperaturePoints, tint: Theme.accentText }] + } + Caption { + visible: root.stats !== null && thermal.sensors.length === 0 + width: parent.width + text: "This server reports no temperature sensors." + } + Grid { + width: parent.width + columns: 2 + columnSpacing: Theme.scaled(16) + + Repeater { + model: root.allSensors ? thermal.sensors : thermal.sensors.slice(0, 6) + + Item { + id: sensor + + required property var modelData + + width: (thermal.width - Theme.scaled(16)) / 2 + height: Theme.scaled(26) + Accessible.role: Accessible.StaticText + Accessible.name: sensor.modelData.name + Accessible.description: Math.round(sensor.modelData.celsius) + " °C" + + Text { + anchors.left: parent.left + anchors.right: sensorValue.left + anchors.rightMargin: Theme.scaled(8) + anchors.verticalCenter: parent.verticalCenter + elide: Text.ElideRight + textFormat: Text.PlainText + text: sensor.modelData.name + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + color: Theme.textMid + } + Figure { + id: sensorValue + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: Math.round(sensor.modelData.celsius) + "°C" + color: root.tone(Helpers.level(sensor.modelData.celsius, "celsius")) + } + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: Theme.hairlineSoft + } + } } } - ActionButton { - label: "Auto" - tint: RemoteServer.options.interface === "" ? Theme.accentText : Theme.textDim - onTriggered: Settings.setModuleOption("remote", "interface", "") + LinkText { + visible: thermal.sensors.length > 6 + text: root.allSensors ? "Show fewer" : "Show all " + thermal.sensors.length + " sensors" + onClicked: root.allSensors = !root.allSensors } } - ActionButton { - visible: root.stats !== null && root.stats.network.length > 1 - label: root.allInterfaces ? "Fewer interfaces" : "All interfaces" - onTriggered: root.allInterfaces = !root.allInterfaces - } - HDivider { width: parent.width } - Heading { text: "TEMPERATURES" } - Label { - text: "No temperature sensors available" - visible: root.stats !== null && root.stats.temperatures.length === 0 - } - Flow { + + // ---- network ---- + Column { + id: network + + readonly property var choices: { + const rows = Helpers.interfaceRows(root.stats); + return root.allInterfaces ? rows + : rows.filter(n => n.addresses.length > 0 || (n.rx || 0) + (n.tx || 0) > 0 + || n.name === RemoteServer.options.interface); + } + readonly property real ceiling: { + const rx = Helpers.summary(root.rxPoints); + const tx = Helpers.summary(root.txPoints); + return Helpers.chartCeiling(Math.max(rx ? rx.peak : 0, tx ? tx.peak : 0)); + } + width: parent.width - spacing: 8 - Repeater { - model: root.stats ? (root.allTemperatures ? root.stats.temperatures : root.stats.temperatures.slice().sort((a, b) => b.celsius - a.celsius).slice(0, 2)) : [] - Label { - required property var modelData - width: (body.width - 8) / 2 - text: modelData.name + " " + Math.round(modelData.celsius) + "°C" - color: modelData.celsius >= 85 ? Theme.amber : Theme.textMid + spacing: Theme.scaled(8) + + Item { + width: parent.width + height: networkLabel.height + + SectionLabel { + id: networkLabel + width: parent.width - (pickLink.visible ? pickLink.width + Theme.scaled(10) : 0) + text: "NETWORK" + detail: root.net ? root.net.name + + (RemoteServer.options.interface === "" ? " · automatic" : "") : "" + } + LinkText { + id: pickLink + visible: root.stats !== null && root.stats.network.length > 1 + anchors.right: parent.right + anchors.verticalCenter: networkLabel.verticalCenter + font.pixelSize: Theme.typography.secondary + text: root.pickingInterface ? "Done" : "Change" + accessibleName: root.pickingInterface ? "Close interface list" : "Choose network interface" + onClicked: root.pickingInterface = !root.pickingInterface + } + } + + Row { + width: parent.width + + Repeater { + model: [ + { key: "rx", label: "in", tint: Theme.accentText, glyph: "arrow_downward" }, + { key: "tx", label: "out", tint: Theme.textMid, glyph: "arrow_upward" } + ] + + Row { + id: rate + + required property var modelData + + width: network.width / 2 + spacing: Theme.scaled(6) + Accessible.role: Accessible.StaticText + Accessible.name: rate.modelData.key === "rx" ? "Download" : "Upload" + Accessible.description: rateValue.text + + Sym { + anchors.verticalCenter: parent.verticalCenter + name: rate.modelData.glyph + size: Theme.iconMedium + color: rate.modelData.tint + } + Text { + id: rateValue + anchors.verticalCenter: parent.verticalCenter + text: Helpers.rate(root.net ? root.net[rate.modelData.key] : null) + font.family: Theme.fontNumeric + font.pixelSize: Theme.typography.title + font.weight: Theme.weightSemibold + font.features: Theme.tabularNumberFeatures + color: Theme.textHi + } + Caption { + anchors.baseline: rateValue.baseline + text: rate.modelData.label + } + } + } + } + + Item { + width: parent.width + height: Theme.scaled(54) + + Ui.TelemetryChart { + anchors.fill: parent + ceiling: network.ceiling + span: root.span + now: RemoteServer.now + description: "Network traffic, " + root.spanLabel + series: [ + { points: root.txPoints, tint: Theme.textMid }, + { points: root.rxPoints, tint: Theme.accentText } + ] + } + Caption { + x: 4 + y: 3 + text: Helpers.bytes(network.ceiling) + "/s" + color: Theme.textFaint + } + } + + Caption { + visible: text !== "" + width: parent.width + text: root.net ? root.net.addresses.join(" · ") + : root.stats ? "Interface " + RemoteServer.options.interface + " is not present" : "" + } + + // The interface list, opened from the section's link. + Column { + visible: root.pickingInterface + width: parent.width + spacing: 0 + + Repeater { + model: [{ name: "", addresses: [], rx: null, tx: null }].concat(network.choices) + + ListRow { + id: choice + + required property var modelData + readonly property bool automatic: modelData.name === "" + readonly property bool chosen: modelData.name === RemoteServer.options.interface + + height: Theme.listRowHeight + accessibleName: choice.automatic ? "Automatic interface" : choice.modelData.name + onActivated: { + Settings.setModuleOption("remote", "interface", choice.modelData.name); + root.pickingInterface = false; + } + + Sym { + id: choiceCheck + x: Theme.scaled(8) + anchors.verticalCenter: parent.verticalCenter + name: "check" + size: Theme.iconSmall + color: Theme.accentText + opacity: choice.chosen ? 1 : 0 + } + Text { + id: choiceName + anchors.left: choiceCheck.right + anchors.leftMargin: Theme.scaled(8) + anchors.verticalCenter: parent.verticalCenter + width: Math.min(implicitWidth, parent.width * 0.4) + elide: Text.ElideRight + textFormat: Text.PlainText + text: choice.automatic ? "Automatic" : choice.modelData.name + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.primary + font.weight: choice.chosen ? Theme.weightSemibold : Theme.weightMedium + color: choice.chosen ? Theme.accentText : Theme.textHi + } + Caption { + anchors.left: choiceName.right + anchors.leftMargin: Theme.scaled(8) + anchors.right: choiceRate.left + anchors.rightMargin: Theme.scaled(8) + anchors.verticalCenter: parent.verticalCenter + text: choice.automatic + ? "default route" + (root.stats && root.stats.meta.defaultInterface + ? " · " + root.stats.meta.defaultInterface : "") + : choice.modelData.addresses[0] || "" + } + Row { + id: choiceRate + anchors.right: parent.right + anchors.rightMargin: Theme.scaled(8) + anchors.verticalCenter: parent.verticalCenter + visible: !choice.automatic + spacing: Theme.scaled(3) + + Sym { + anchors.verticalCenter: parent.verticalCenter + name: "arrow_downward" + size: Theme.iconTiny + color: Theme.textDim + } + Caption { + anchors.verticalCenter: parent.verticalCenter + width: implicitWidth + Theme.scaled(8) + text: Helpers.compactRate(choice.modelData.rx) + font.features: Theme.tabularNumberFeatures + } + Sym { + anchors.verticalCenter: parent.verticalCenter + name: "arrow_upward" + size: Theme.iconTiny + color: Theme.textDim + } + Caption { + anchors.verticalCenter: parent.verticalCenter + text: Helpers.compactRate(choice.modelData.tx) + font.features: Theme.tabularNumberFeatures + } + } + } + } + LinkText { + visible: root.stats !== null && root.stats.network.length > network.choices.length + || root.allInterfaces + x: Theme.scaled(8) + topPadding: Theme.scaled(6) + text: root.allInterfaces ? "Hide idle interfaces" + : "Show all " + (root.stats ? root.stats.network.length : 0) + " interfaces" + onClicked: root.allInterfaces = !root.allInterfaces } } } - ActionButton { - visible: root.stats !== null && root.stats.temperatures.length > 2 - label: root.allTemperatures ? "Fewer sensors" : "All sensors" - onTriggered: root.allTemperatures = !root.allTemperatures - } - Label { + + Caption { + visible: root.stats !== null width: parent.width - text: root.stats ? root.stats.meta.hostname + " · " + root.stats.meta.kernel : "" + text: root.stats ? [root.stats.meta.os, "Linux " + root.stats.meta.kernel, + root.stats.meta.hostname].join(" · ") : "" color: Theme.textFaint } } } } - Row { + ScrollChrome { + x: scroll.x + y: scroll.y + width: scroll.width + height: scroll.height + target: scroll + edgeColor: root.surfaceColor + } + + // ---- footer ---------------------------------------------------------- + Item { id: footer - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.margins: root.gutter - spacing: 8 - ActionButton { label: "Refresh"; enabled: RemoteServer.host !== ""; onTriggered: RemoteServer.refresh() } - ActionButton { label: "Settings"; onTriggered: RemoteServer.configure() } + + x: root.gutter + y: root.height - root.gutter - height + width: header.width + height: Theme.panelFooterHeight + + Rectangle { + width: parent.width + height: 1 + color: Theme.hairlineSoft + } + Caption { + anchors.left: parent.left + anchors.leftMargin: 2 + anchors.right: actions.left + anchors.rightMargin: Theme.scaled(10) + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: 1 + text: { + switch (RemoteServer.connection) { + case "setup": return "Nothing to monitor yet"; + case "connecting": return "Connecting to " + RemoteServer.host + "…"; + case "live": return "Updated " + RemoteServer.age + " · every " + RemoteServer.cadence + " s"; + default: return root.stats ? "Last reading " + RemoteServer.age : "Retrying automatically"; + } + } + color: Theme.textFaint + } + Row { + id: actions + anchors.right: parent.right + anchors.rightMargin: 2 + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: 1 + spacing: Theme.scaled(16) + + LinkText { + visible: root.configured + text: "Refresh" + accessibleName: "Refresh server readings" + onClicked: RemoteServer.refresh() + } + LinkText { + text: "Settings" + accessibleName: "Remote Server settings" + onClicked: RemoteServer.configure() + } + } } } diff --git a/roles/desktop/files/quickshell/Ui/TelemetryChart.qml b/roles/desktop/files/quickshell/Ui/TelemetryChart.qml index d4380755..97850f43 100644 --- a/roles/desktop/files/quickshell/Ui/TelemetryChart.qml +++ b/roles/desktop/files/quickshell/Ui/TelemetryChart.qml @@ -2,42 +2,117 @@ import QtQuick import "../Common" import "../Common/Format.js" as Format +// A rolling history plot. The time axis is a fixed window ending at `now`, so +// a short history fills in from the right instead of being stretched across +// the whole width, and the plot keeps moving between samples. Each series is +// { points: [{ at, value }], tint }; a null value breaks the line rather than +// dropping it to zero. Colours reach the 2D context as rgba() strings, which +// is the one form it reads alpha from reliably. Canvas { id: root - property var points: [] + + property var series: [] + // The value at the top edge. Callers with an open-ended scale (rates) + // pass a rounded ceiling rather than the raw peak, so the plot does not + // rescale on every sample. property real ceiling: 100 - property color tint: Theme.accentText + property double span: 600000 + property double now: Date.now() + property int divisions: 4 + property color gridColor: Theme.hairlineSoft property string description: "Recent history" + + // Keep the latest-point mark whole at the right edge. + readonly property real inset: 3 + implicitHeight: 48 Accessible.role: Accessible.Chart Accessible.name: description - onPointsChanged: requestPaint() + + onSeriesChanged: requestPaint() onCeilingChanged: requestPaint() - onTintChanged: requestPaint() + onNowChanged: requestPaint() + onGridColorChanged: requestPaint() onWidthChanged: requestPaint() onHeightChanged: requestPaint() + + function rgba(c, alpha) { + return "rgba(" + Math.round(c.r * 255) + "," + Math.round(c.g * 255) + "," + + Math.round(c.b * 255) + "," + (c.a * alpha).toFixed(3) + ")"; + } + onPaint: { const ctx = getContext("2d"); ctx.reset(); - if (points.length < 2 || width <= 0 || height <= 0) return; - const start = points[0].at; - const span = Math.max(1, points[points.length - 1].at - start); - const max = ceiling > 0 ? ceiling : Math.max(1, ...points.map(p => p.value || 0)); - ctx.strokeStyle = Qt.alpha(tint, 0.15); + if (width <= root.inset * 2 || height <= 2) + return; + const top = 1; + const bottom = height - 1; + const plot = bottom - top; + const right = width - root.inset; + const start = root.now - root.span; + const max = root.ceiling > 0 ? root.ceiling : 1; + ctx.lineWidth = 1; - ctx.beginPath(); ctx.moveTo(0, height - 1); ctx.lineTo(width, height - 1); ctx.stroke(); - ctx.strokeStyle = tint; - ctx.lineWidth = 2; - ctx.lineJoin = "round"; - ctx.beginPath(); - let joined = false; - for (const p of points) { - if (p.value === null || !isFinite(p.value)) { joined = false; continue; } - const x = 1 + (width - 2) * (p.at - start) / span; - const y = height - 2 - (height - 4) * Format.clamp01(p.value / max); - if (joined) ctx.lineTo(x, y); else ctx.moveTo(x, y); - joined = true; + ctx.strokeStyle = rgba(root.gridColor, 1); + for (let i = 0; i <= root.divisions; i++) { + const y = Math.round(top + plot * i / root.divisions) + 0.5; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + } + + for (const line of root.series) { + const runs = []; + let run = []; + for (const p of line.points) { + if (p.value === null || p.value === undefined || !isFinite(p.value)) { + if (run.length) + runs.push(run); + run = []; + continue; + } + run.push([right * (p.at - start) / root.span, + bottom - plot * Format.clamp01(p.value / max)]); + } + if (run.length) + runs.push(run); + + const fill = ctx.createLinearGradient(0, top, 0, bottom); + fill.addColorStop(0, rgba(line.tint, 0.30)); + fill.addColorStop(1, rgba(line.tint, 0.02)); + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + for (const r of runs) { + if (r.length < 2) + continue; + ctx.beginPath(); + ctx.moveTo(r[0][0], bottom); + for (const point of r) + ctx.lineTo(point[0], point[1]); + ctx.lineTo(r[r.length - 1][0], bottom); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + + ctx.beginPath(); + ctx.moveTo(r[0][0], r[0][1]); + for (const point of r) + ctx.lineTo(point[0], point[1]); + ctx.lineWidth = 1.5; + ctx.strokeStyle = rgba(line.tint, 1); + ctx.stroke(); + } + + const last = runs.length ? runs[runs.length - 1] : null; + if (last) { + const point = last[last.length - 1]; + ctx.beginPath(); + ctx.arc(point[0], point[1], 2.5, 0, 2 * Math.PI); + ctx.fillStyle = rgba(line.tint, 1); + ctx.fill(); + } } - ctx.stroke(); } } diff --git a/tests/quickshell/remote-server.test.cjs b/tests/quickshell/remote-server.test.cjs index 10a1cd11..96807954 100644 --- a/tests/quickshell/remote-server.test.cjs +++ b/tests/quickshell/remote-server.test.cjs @@ -69,3 +69,82 @@ test("history is bounded and reboot or long disconnection starts a new series", assert.equal(H.historyAppend(history, s, 900000).length, 1); assert.equal(history[0].network[0].name, "eth0"); }); +test("readings warn at their thresholds and load is judged per logical CPU", () => { + assert.equal(H.level(null), "unknown"); + assert.equal(H.level(84), "ok"); + assert.equal(H.level(85), "warn"); + assert.equal(H.level(95), "critical"); + assert.equal(H.level(74, "celsius"), "ok"); + assert.equal(H.level(75, "celsius"), "warn"); + assert.equal(H.level(90, "celsius"), "critical"); + const s = sample(); + s.meta.cores = 1; + assert.equal(H.metricLevel(s, { metric: "load" }), "critical"); + s.meta.cores = 2; + assert.equal(H.metricLevel(s, { metric: "load" }), "ok"); + assert.equal(H.metricLevel(s, { metric: "cpu" }), "ok"); + assert.equal(H.metricLevel(s, { metric: "diskFree", mount: "/" }), "ok"); + assert.equal(H.metricLevel(s, { metric: "rx" }), "ok"); + assert.equal(H.metricLevel(null, { metric: "cpu" }), "unknown"); +}); +test("the dashboard opens on the reading the menubar shows", () => { + const views = H.VIEWS.map(v => v.value); + for (const { value } of H.METRICS) + assert.ok(views.includes(H.viewFor(value)), value); + assert.equal(H.viewFor("memoryFree"), "memory"); + assert.equal(H.viewFor("diskFree"), "storage"); + assert.equal(H.viewFor("temperature"), "temperature"); + assert.equal(H.viewFor("rx"), "cpu"); +}); +test("chart windows grow with history and rate scales use whole binary steps", () => { + assert.equal(H.chartSpan(0), 120000); + assert.equal(H.chartSpan(121000), 180000); + assert.equal(H.chartSpan(3600000), 600000); + assert.equal(H.chartCeiling(null), 1024); + assert.equal(H.chartCeiling(530000), 1048576); + assert.equal(H.chartCeiling(1048576), 1048576); + assert.deepEqual(H.summary([{ value: 10 }, { value: null }, { value: 30 }]), + { average: 20, peak: 30, low: 10 }); + assert.equal(H.summary([{ value: null }]), null); +}); +test("filesystems collapse bind mounts, drop firmware stores and lead with the selection", () => { + const s = sample(); + s.storage = [ + { device: "tank/ROOT", type: "zfs", mount: "/", total: 10, free: 5, percent: 50 }, + { device: "efivarfs", type: "efivarfs", mount: "/sys/firmware/efi/efivars", total: 1, free: 0, percent: 78 }, + { device: "/dev/loop0", type: "btrfs", mount: "/var/lib/incus/devices/a/config.mount", total: 4, free: 1, percent: 79 }, + { device: "/dev/loop0", type: "btrfs", mount: "/var/lib/incus/pool", total: 4, free: 1, percent: 79 } + ]; + assert.deepEqual(H.storageRows(s, "/").map(d => d.mount), ["/", "/var/lib/incus/pool"]); + assert.deepEqual(H.storageRows(s, "/var/lib/incus/devices/a/config.mount").map(d => d.mount), + ["/var/lib/incus/devices/a/config.mount", "/"]); + assert.deepEqual(H.storageRows(null, "/"), []); +}); +test("repeated sensor names identify their chip and sort hottest first", () => { + const s = sample(); + s.temperatures = [{ name: "nvme · Composite", celsius: 30 }, { name: "k10temp · Tctl", celsius: 58 }, + { name: "nvme · Composite", celsius: 27 }]; + assert.deepEqual(H.sensorRows(s).map(t => t.name), + ["k10temp · Tctl", "nvme 1 · Composite", "nvme 2 · Composite"]); +}); +test("interfaces list the default route, then addressed, then busy links", () => { + const s = sample(); + s.network.push({ name: "veth0", addresses: [], rx: 5000000, tx: 0 }); + assert.deepEqual(H.interfaceRows(s).map(n => n.name), ["eth0", "docker0", "veth0"]); +}); +test("relative ages and compact rates stay short", () => { + assert.equal(H.ago(2000), "just now"); + assert.equal(H.ago(42000), "42s ago"); + assert.equal(H.ago(125000), "2 min ago"); + assert.equal(H.ago(7200000), "2 h ago"); + assert.equal(H.ago(null), ""); + assert.equal(H.compactRate(900), "900 B/s"); + assert.equal(H.compactRate(425984), "416 K/s"); + assert.equal(H.compactRate(1300000), "1.2 M/s"); + assert.equal(H.compactRate(null), "—"); +}); +test("history keeps the hottest reading for the temperature chart", () => { + const [point] = H.historyAppend([], sample(), 1000); + assert.equal(point.temperature, 54); + assert.equal(point.cpu, 25); +});