diff --git a/docs/handoff/v0.8.6-q2-q6-dashboard.md b/docs/handoff/v0.8.6-q2-q6-dashboard.md new file mode 100644 index 00000000..b780230a --- /dev/null +++ b/docs/handoff/v0.8.6-q2-q6-dashboard.md @@ -0,0 +1,135 @@ +# deliverable.md — V0.8.6 Welle 1: Q2, Q3, Q5, Q6 (#58) + +> **Stand:** 2026-10-23 · **Basis:** `main` @ `08dad94` · **Branch:** `feature/58-q2-q6-dashboard` +> **Umfang:** die vier Dashboard-Punkte Q2, Q3, Q5 und Q6 aus Issue #58. + +## Was geändert wurde + +| Datei | Änderung | +|---|---| +| `lib/linux/linux_memory.dart` (neu) | `MemoryPressure` (PSI aus `/proc/pressure/memory`) und `ZramInfo` (zram aus `/sys/block/zram*/mm_stat` + `disksize`) mit reinen Parse-Funktionen | +| `lib/services/reboot_service.dart` (neu) | Liest `/var/run/reboot-required(.pkgs)`, parst die Paketliste | +| `lib/services/failed_units_service.dart` (neu) | Liest `systemctl list-units --failed -o json` in **beiden** Scopes (System und `--user`) | +| `lib/services/browser_status_service.dart` (neu) | Read-only-Status des Standardbrowsers aus `AppLauncher.defaultBrowserDesktopId()`, Abgleich mit `gio mime x-scheme-handler/https` | +| `lib/layouts/hub/dashboard_section.dart` | Neustart-Badge, Kachel „Fehlgeschlagene Dienste", Browser-Fakt, ehrliche Speicher-Kachel (PSI + getrennte zram-/Disk-Swap-Zeile) | +| `lib/layouts/hub/hub_shell.dart` | Die neuen Poller laufen nur, wenn ihre Kachel wirklich sichtbar ist; Fenster-Sichtbarkeit an alle drei Services | +| `lib/layouts/linux_health/overview.dart` | Zeile in der Health-Sektion mit den auslösenden Paketen | +| `lib/services/system_stats_service.dart` | hängt die neuen Werte an (zram, PSI) an | +| `lib/l10n/*.arb` + generierte Dateien | 15 neue Strings in **allen vier** Sprachen (en/de/it/fi), generiert mit `flutter gen-l10n` | +| `features.csv` | eine neue Zeile „Browser status" (Core) | +| `test/q_dashboard_kacheln_test.dart` (neu) | 24 Tests für die vier Parser | +| `test/hub_navigation_test.dart` | Hermetizität: die zwei neuen Poller schlafen genauso wie der Stats-Poller | + +## Die vier Abnahmekriterien + +### Q2 — Neustart-Badge + +`RebootService` liest `/var/run/reboot-required` (Marker) und `/var/run/reboot-required.pkgs` +(Paketliste). Das Dashboard zeigt ein Badge („Neustart nötig") und die Health-Sektion listet +die auslösenden Pakete. Fehlt die Paketliste, ist der Neustart trotzdem fällig — die Ursache +lässt sich dann nur nicht benennen. Das Badge verschwindet, sobald die Marker-Datei nach dem +Neustart weg ist. + +### Q3 — Kachel „Fehlgeschlagene Dienste" + +`FailedUnitsService` fragt **beide** Scopes ab: `systemctl list-units --failed -o json` und +`systemctl --user list-units --failed -o json`. Die Kachel zeigt die Zahl und die Liste mit +Unit, Beschreibung und Scope. Kaputtes JSON, ein leerer Wert oder ein Unit-Eintrag ohne Namen +werden übersprungen, statt die Kachel zu sprengen. Eigener Poller mit 30-Sekunden-Takt — der +Zustand ändert sich viel langsamer als die CPU-Zahlen. + +### Q5 — Browser-Status + +Read-only. Der Standardbrowser kommt aus `AppLauncher.defaultBrowserDesktopId()` (aus V0.8.2), +der Vergleich gegen `gio mime x-scheme-handler/https` liefert den Handler, den HTTPS-Links +tatsächlich öffnen. Weichen die beiden ab, steht eine Warnung daneben. Die App stellt nichts +um. + +**Das Ausgabeformat von `gio` wurde gemessen, nicht geraten** (gio 2.74): + +``` +Default application for ?x-scheme-handler/https?: brave-browser.desktop +No registered applications +No recommended applications +``` + +Der Parser liest die erste Zeile; die beiden anderen Abschnitte listen nur Kandidaten und +werden ignoriert. + +### Q6 — Speicher-Kachel ehrlich + +Die Warnstufe kommt jetzt aus **PSI** (`/proc/pressure/memory`, `avg60`) statt aus der +RAM-Quote. Die Swap-Zeile ist aufgeteilt: zram (`/sys/block/zram*/mm_stat`, komprimierter +RAM-Speicher) und Disk-Swap stehen in zwei eigenen Zeilen, weil sie verschiedene Dinge sind. +zram zeigt den Weg „7,2 → 2,1 GB". + +## TDD-Beleg + +`test/q_dashboard_kacheln_test.dart` — 24 Tests in vier Gruppen: + +| Gruppe | Testfälle | +|---|---| +| Q2 — der Reboot-Marker | Paketliste · leere Zeilen und Kommentare werden übersprungen · leere Datei · ohne Marker kein Reboot · Marker ohne Paketliste · Marker mit Paketliste | +| Q3 — die fehlgeschlagenen Units | Unit, Beschreibung und Scope · der Scope kommt vom gefragten Manager · Unit ohne Beschreibung · Eintrag ohne Namen · kaputtes JSON · leere Antwort | +| Q5 — der Standardbrowser | Handler wird gelesen · Kandidaten-Sektionen werden ignoriert · Ausgabe ohne Default-Zeile · leerer Wert | +| Q6 — die Speicher-Kachel | `avg60` aus der PSI-Datei · normal / elevated / high · unbrauchbare PSI-Datei · zram vor/nach der Kompression · kaputte zram-Statistik · zram-Kapazität | + +Die Tests laufen ohne Dateisystem und ohne Prozess: die Parser sind reine Funktionen, der +Dateisystem-Teil ist in `RebootService.readStatus` getrennt (dort mit Temp-Verzeichnis getestet). + +## Gate-Protokoll + +`bash /workspace/tools/run-mla-gates.sh` — echte Exitcodes, Ausgabe zeigt den geprüften Baum. + +| Gate | Ergebnis | +|---|---| +| `flutter pub get` | OK | +| `bash tool/check-versions.sh` | OK | +| `dart format --output=none --set-exit-if-changed lib test` | OK — 129 Dateien, 0 geändert | +| `flutter analyze` | OK — *No issues found!* | +| `flutter test` | OK — **254 Tests** (230 Baseline + 24 neue) | +| Python-Unit-Tests | OK — 62 Tests | +| GTK-Token-Gate | OK — 96 Tests (2 skipped) | +| `packages/la_core` | OK — 0 Findings, 25 Dateien, 68 Tests | + +**Summenzeile: `ALLE GATES GRUEN`.** + +**CSV-Check:** `{21} [25, 17, 4]` — 21 Felder, 46 Zeilen. Core steigt von 24 auf 25 durch die +neue Zeile „Browser status" (Core, gleiche Spaltenbelegung wie der Browser-Launcher). + +## Konventionen geprüft + +- Conventional Commit, deutschsprachiger Body, `Refs #58` ✓ +- `dart format` vor dem Commit ✓ +- Logging über `lib/services/logger.dart`, kein `print` ✓ +- **l10n-Ratchet:** alle vier ARB-Dateien (en/de/it/fi) mit denselben 15 neuen Keys; die + generierten Dateien mit `flutter gen-l10n` neu erzeugt ✓ +- features.csv: 21 Felder, Header unverändert ✓ +- Keine Secrets, kein Versions-Bump, kein Release ✓ +- **Kein Prozessstart beim Betreten des Dashboards ohne Nutzerwunsch:** die neuen Poller + starten `systemctl` bzw. `gio` erst, wenn ihre Kachel sichtbar ist — im Test nachgewiesen + (der Hub-Navigation-Test startet keinen Prozess) + +## Zwei Korrekturen an der Vorarbeit + +1. **Das `gio`-Format.** Die ursprüngliche Parser-Version erwartete die Zeile + `x-scheme-handler/https: `. Diese Zeile gibt `gio` **nie** aus — der Parser hätte in + Produktion immer `null` geliefert und die Kachel hätte nie den Browser gezeigt. Das + tatsächliche Format steht oben; Parser und Test sind darauf umgestellt. + +2. **Die Hermetizität des Hub-Tests.** Die beiden neuen Poller starten beim Mount des + Dashboards einen echten Prozess. Im Widget-Test hinterlässt das einen hängenden Timer und + der Test schlägt fehl. Die Poller werden jetzt im `setUp` genauso schlafen gelegt wie der + Stats-Poller — die Reihenfolge (erst `resetForTesting`, dann `setWindowVisible(false)`) + ist dokumentiert. + +## Offene Punkte + +- **Q7 bleibt offen** — die Empfehlung „Feedback an Upstream abklemmen" braucht Bastis + Entscheidung; das Issue ist nicht Teil dieses Pakets. +- **Q4 bleibt offen** — der Wechsel des Hotkeys auf gsettings ist ein M-Thema, greift in den + C++/GTK-Runner ein und braucht ein Desktop-System zum Abnehmen. +- **Die Kacheln zeigen Systemzustand, keine Aktion.** Weder Reboot noch Dienste noch Browser + werden von der App aus verändert — der Safety-Default des Projekts bleibt. +- **Die visuellen Details** (Farbe des Badges, Zeilenumbrüche in der Health-Sektion) sind + nicht per Screenshot geprüft; die Logik ist es. diff --git a/features.csv b/features.csv index 07fb2df4..cd62393d 100644 --- a/features.csv +++ b/features.csv @@ -41,6 +41,7 @@ Live system tiles (CPU/RAM/disk),QoL,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes Theme switching without restart,QoL,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes, Read-only security findings,Core,?,yes,yes,yes,yes,yes,yes,yes,yes,yes *,yes *,yes,yes,yes,-,-,-,-,*) checker script does not report an update count on these distributions Browser launcher,Core,?,?,?,?,?,?,?,yes,?,?,?,?,?,?,yes,?,?,?,fork hub tool; verified on the reference system only +Browser status,Core,?,?,?,?,?,?,?,yes,?,?,?,?,?,?,yes,?,?,?,fork hub tool; read-only browser status in the dashboard; verified on the reference system only Quick notes,Core,?,?,?,?,?,?,?,yes,?,?,?,?,?,?,yes,?,?,?,fork hub tool; verified on the reference system only File manager,Core,?,?,?,?,?,?,?,yes,?,?,?,?,?,?,yes,?,?,?,fork hub tool; verified on the reference system only System monitor,Core,?,?,?,?,?,?,?,yes,?,?,?,?,?,?,yes,?,?,?,fork hub tool; verified on the reference system only diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 1eb4b56a..4de53aa6 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1332,5 +1332,99 @@ "placeholders": {}, "description": "Explains what confirming the dialog above does", "type": "text" + }, + "browserDefaultIs": "Standardbrowser: {standard}", + "@browserDefaultIs": { + "placeholders": { + "standard": { + "type": "String" + } + }, + "type": "text" + }, + "browserDefaultUnknown": "Standardbrowser unbekannt", + "@browserDefaultUnknown": { + "placeholders": {}, + "type": "text" + }, + "browserHandlerMismatch": "HTTPS-Links oeffnen in {handler}, nicht in {standard}", + "@browserHandlerMismatch": { + "placeholders": { + "handler": { + "type": "String" + }, + "standard": { + "type": "String" + } + }, + "type": "text" + }, + "diskSwapLabel": "Swap auf der Festplatte", + "@diskSwapLabel": { + "placeholders": {}, + "type": "text" + }, + "failedServicesLabel": "Fehlgeschlagene Dienste", + "@failedServicesLabel": { + "placeholders": {}, + "type": "text" + }, + "failedServicesMore": "und {rest} weitere", + "@failedServicesMore": { + "placeholders": { + "rest": { + "type": "int" + } + }, + "type": "text" + }, + "failedServicesNone": "keine", + "@failedServicesNone": { + "placeholders": {}, + "type": "text" + }, + "failedServicesScopeSystem": "System", + "@failedServicesScopeSystem": { + "placeholders": {}, + "type": "text" + }, + "failedServicesScopeUser": "Benutzer", + "@failedServicesScopeUser": { + "placeholders": {}, + "type": "text" + }, + "memoryPressureLabel": "Speicherdruck", + "@memoryPressureLabel": { + "placeholders": {}, + "type": "text" + }, + "memoryPressureUnavailable": "nicht verfügbar", + "@memoryPressureUnavailable": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredBadge": "Neustart nötig", + "@rebootRequiredBadge": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredNoPackages": "Neustart nötig (kein Paket genannt)", + "@rebootRequiredNoPackages": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredPackages": "Neustart nötig nach dem Update von: {packages}", + "@rebootRequiredPackages": { + "placeholders": { + "packages": { + "type": "String" + } + }, + "type": "text" + }, + "zramLabel": "zram (komprimiert)", + "@zramLabel": { + "placeholders": {}, + "type": "text" } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 16174cae..e9a00ac2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1298,5 +1298,99 @@ "placeholders": {}, "description": "Explains what confirming the dialog above does", "type": "text" + }, + "browserDefaultIs": "Default browser: {standard}", + "@browserDefaultIs": { + "placeholders": { + "standard": { + "type": "String" + } + }, + "type": "text" + }, + "browserDefaultUnknown": "Default browser unknown", + "@browserDefaultUnknown": { + "placeholders": {}, + "type": "text" + }, + "browserHandlerMismatch": "HTTPS links open in {handler}, not in {standard}", + "@browserHandlerMismatch": { + "placeholders": { + "handler": { + "type": "String" + }, + "standard": { + "type": "String" + } + }, + "type": "text" + }, + "diskSwapLabel": "Disk swap", + "@diskSwapLabel": { + "placeholders": {}, + "type": "text" + }, + "failedServicesLabel": "Failed services", + "@failedServicesLabel": { + "placeholders": {}, + "type": "text" + }, + "failedServicesMore": "and {rest} more", + "@failedServicesMore": { + "placeholders": { + "rest": { + "type": "int" + } + }, + "type": "text" + }, + "failedServicesNone": "none", + "@failedServicesNone": { + "placeholders": {}, + "type": "text" + }, + "failedServicesScopeSystem": "System", + "@failedServicesScopeSystem": { + "placeholders": {}, + "type": "text" + }, + "failedServicesScopeUser": "User", + "@failedServicesScopeUser": { + "placeholders": {}, + "type": "text" + }, + "memoryPressureLabel": "Memory pressure", + "@memoryPressureLabel": { + "placeholders": {}, + "type": "text" + }, + "memoryPressureUnavailable": "not available", + "@memoryPressureUnavailable": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredBadge": "Reboot required", + "@rebootRequiredBadge": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredNoPackages": "Reboot required (no package named)", + "@rebootRequiredNoPackages": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredPackages": "Reboot required after updating: {packages}", + "@rebootRequiredPackages": { + "placeholders": { + "packages": { + "type": "String" + } + }, + "type": "text" + }, + "zramLabel": "zram (compressed)", + "@zramLabel": { + "placeholders": {}, + "type": "text" } } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 7ac3bc1d..897e180f 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1318,5 +1318,99 @@ "placeholders": {}, "description": "Explains what confirming the dialog above does", "type": "text" + }, + "browserDefaultIs": "Browser predefinito: {standard}", + "@browserDefaultIs": { + "placeholders": { + "standard": { + "type": "String" + } + }, + "type": "text" + }, + "browserDefaultUnknown": "Browser predefinito sconosciuto", + "@browserDefaultUnknown": { + "placeholders": {}, + "type": "text" + }, + "browserHandlerMismatch": "I link HTTPS si aprono in {handler}, non in {standard}", + "@browserHandlerMismatch": { + "placeholders": { + "handler": { + "type": "String" + }, + "standard": { + "type": "String" + } + }, + "type": "text" + }, + "diskSwapLabel": "Swap su disco", + "@diskSwapLabel": { + "placeholders": {}, + "type": "text" + }, + "failedServicesLabel": "Servizi falliti", + "@failedServicesLabel": { + "placeholders": {}, + "type": "text" + }, + "failedServicesMore": "e altri {rest}", + "@failedServicesMore": { + "placeholders": { + "rest": { + "type": "int" + } + }, + "type": "text" + }, + "failedServicesNone": "nessuno", + "@failedServicesNone": { + "placeholders": {}, + "type": "text" + }, + "failedServicesScopeSystem": "Sistema", + "@failedServicesScopeSystem": { + "placeholders": {}, + "type": "text" + }, + "failedServicesScopeUser": "Utente", + "@failedServicesScopeUser": { + "placeholders": {}, + "type": "text" + }, + "memoryPressureLabel": "Pressione sulla memoria", + "@memoryPressureLabel": { + "placeholders": {}, + "type": "text" + }, + "memoryPressureUnavailable": "non disponibile", + "@memoryPressureUnavailable": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredBadge": "Riavvio necessario", + "@rebootRequiredBadge": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredNoPackages": "Riavvio necessario (nessun pacchetto indicato)", + "@rebootRequiredNoPackages": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredPackages": "Riavvio necessario dopo l'aggiornamento di: {packages}", + "@rebootRequiredPackages": { + "placeholders": { + "packages": { + "type": "String" + } + }, + "type": "text" + }, + "zramLabel": "zram (compresso)", + "@zramLabel": { + "placeholders": {}, + "type": "text" } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 938bd118..c1ecabc4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2147,6 +2147,96 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'The file will be executed as a program with your user rights. Only continue if you know what it does.'** String get runFileWarning; + + /// No description provided for @browserDefaultIs. + /// + /// In en, this message translates to: + /// **'Default browser: {standard}'** + String browserDefaultIs(String standard); + + /// No description provided for @browserDefaultUnknown. + /// + /// In en, this message translates to: + /// **'Default browser unknown'** + String get browserDefaultUnknown; + + /// No description provided for @browserHandlerMismatch. + /// + /// In en, this message translates to: + /// **'HTTPS links open in {handler}, not in {standard}'** + String browserHandlerMismatch(String handler, String standard); + + /// No description provided for @diskSwapLabel. + /// + /// In en, this message translates to: + /// **'Disk swap'** + String get diskSwapLabel; + + /// No description provided for @failedServicesLabel. + /// + /// In en, this message translates to: + /// **'Failed services'** + String get failedServicesLabel; + + /// No description provided for @failedServicesMore. + /// + /// In en, this message translates to: + /// **'and {rest} more'** + String failedServicesMore(int rest); + + /// No description provided for @failedServicesNone. + /// + /// In en, this message translates to: + /// **'none'** + String get failedServicesNone; + + /// No description provided for @failedServicesScopeSystem. + /// + /// In en, this message translates to: + /// **'System'** + String get failedServicesScopeSystem; + + /// No description provided for @failedServicesScopeUser. + /// + /// In en, this message translates to: + /// **'User'** + String get failedServicesScopeUser; + + /// No description provided for @memoryPressureLabel. + /// + /// In en, this message translates to: + /// **'Memory pressure'** + String get memoryPressureLabel; + + /// No description provided for @memoryPressureUnavailable. + /// + /// In en, this message translates to: + /// **'not available'** + String get memoryPressureUnavailable; + + /// No description provided for @rebootRequiredBadge. + /// + /// In en, this message translates to: + /// **'Reboot required'** + String get rebootRequiredBadge; + + /// No description provided for @rebootRequiredNoPackages. + /// + /// In en, this message translates to: + /// **'Reboot required (no package named)'** + String get rebootRequiredNoPackages; + + /// No description provided for @rebootRequiredPackages. + /// + /// In en, this message translates to: + /// **'Reboot required after updating: {packages}'** + String rebootRequiredPackages(String packages); + + /// No description provided for @zramLabel. + /// + /// In en, this message translates to: + /// **'zram (compressed)'** + String get zramLabel; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 5baa7a6c..18a186cc 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1182,4 +1182,57 @@ class AppLocalizationsDe extends AppLocalizations { @override String get runFileWarning => 'Die Datei wird als Programm mit deinen Benutzerrechten ausgeführt. Fahre nur fort, wenn du weißt, was sie tut.'; + + @override + String browserDefaultIs(String standard) { + return 'Standardbrowser: $standard'; + } + + @override + String get browserDefaultUnknown => 'Standardbrowser unbekannt'; + + @override + String browserHandlerMismatch(String handler, String standard) { + return 'HTTPS-Links oeffnen in $handler, nicht in $standard'; + } + + @override + String get diskSwapLabel => 'Swap auf der Festplatte'; + + @override + String get failedServicesLabel => 'Fehlgeschlagene Dienste'; + + @override + String failedServicesMore(int rest) { + return 'und $rest weitere'; + } + + @override + String get failedServicesNone => 'keine'; + + @override + String get failedServicesScopeSystem => 'System'; + + @override + String get failedServicesScopeUser => 'Benutzer'; + + @override + String get memoryPressureLabel => 'Speicherdruck'; + + @override + String get memoryPressureUnavailable => 'nicht verfügbar'; + + @override + String get rebootRequiredBadge => 'Neustart nötig'; + + @override + String get rebootRequiredNoPackages => 'Neustart nötig (kein Paket genannt)'; + + @override + String rebootRequiredPackages(String packages) { + return 'Neustart nötig nach dem Update von: $packages'; + } + + @override + String get zramLabel => 'zram (komprimiert)'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0239d6eb..2e5e90ef 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1173,4 +1173,57 @@ class AppLocalizationsEn extends AppLocalizations { @override String get runFileWarning => 'The file will be executed as a program with your user rights. Only continue if you know what it does.'; + + @override + String browserDefaultIs(String standard) { + return 'Default browser: $standard'; + } + + @override + String get browserDefaultUnknown => 'Default browser unknown'; + + @override + String browserHandlerMismatch(String handler, String standard) { + return 'HTTPS links open in $handler, not in $standard'; + } + + @override + String get diskSwapLabel => 'Disk swap'; + + @override + String get failedServicesLabel => 'Failed services'; + + @override + String failedServicesMore(int rest) { + return 'and $rest more'; + } + + @override + String get failedServicesNone => 'none'; + + @override + String get failedServicesScopeSystem => 'System'; + + @override + String get failedServicesScopeUser => 'User'; + + @override + String get memoryPressureLabel => 'Memory pressure'; + + @override + String get memoryPressureUnavailable => 'not available'; + + @override + String get rebootRequiredBadge => 'Reboot required'; + + @override + String get rebootRequiredNoPackages => 'Reboot required (no package named)'; + + @override + String rebootRequiredPackages(String packages) { + return 'Reboot required after updating: $packages'; + } + + @override + String get zramLabel => 'zram (compressed)'; } diff --git a/lib/l10n/app_localizations_fi.dart b/lib/l10n/app_localizations_fi.dart index b355cb0b..d6285dbb 100644 --- a/lib/l10n/app_localizations_fi.dart +++ b/lib/l10n/app_localizations_fi.dart @@ -1183,4 +1183,58 @@ class AppLocalizationsFi extends AppLocalizations { @override String get runFileWarning => 'Tiedosto suoritetaan ohjelmana käyttöoikeuksillasi. Jatka vain, jos tiedät, mitä se tekee.'; + + @override + String browserDefaultIs(String standard) { + return 'Oletusselaime: $standard'; + } + + @override + String get browserDefaultUnknown => 'Oletusselaime tuntematon'; + + @override + String browserHandlerMismatch(String handler, String standard) { + return 'HTTPS-linkit avautuvat ohjelmassa $handler, ei $standard'; + } + + @override + String get diskSwapLabel => 'Levyn swap'; + + @override + String get failedServicesLabel => 'Epäonnistuneet palvelut'; + + @override + String failedServicesMore(int rest) { + return 'ja $rest muuta'; + } + + @override + String get failedServicesNone => 'ei mitään'; + + @override + String get failedServicesScopeSystem => 'Järjestelmä'; + + @override + String get failedServicesScopeUser => 'Käyttäjä'; + + @override + String get memoryPressureLabel => 'Muistipaine'; + + @override + String get memoryPressureUnavailable => 'ei saatavilla'; + + @override + String get rebootRequiredBadge => 'Uudelleenkäynnistys tarvitaan'; + + @override + String get rebootRequiredNoPackages => + 'Uudelleenkäynnistys tarvitaan (pakettia ei nimetty)'; + + @override + String rebootRequiredPackages(String packages) { + return 'Uudelleenkäynnistys tarvitaan päivityksen jälkeen: $packages'; + } + + @override + String get zramLabel => 'zram (pakattu)'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index edff0d0c..8d1fedda 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1191,4 +1191,58 @@ class AppLocalizationsIt extends AppLocalizations { @override String get runFileWarning => 'Il file verrà eseguito come programma con i tuoi privilegi utente. Continua solo se sai cosa fa.'; + + @override + String browserDefaultIs(String standard) { + return 'Browser predefinito: $standard'; + } + + @override + String get browserDefaultUnknown => 'Browser predefinito sconosciuto'; + + @override + String browserHandlerMismatch(String handler, String standard) { + return 'I link HTTPS si aprono in $handler, non in $standard'; + } + + @override + String get diskSwapLabel => 'Swap su disco'; + + @override + String get failedServicesLabel => 'Servizi falliti'; + + @override + String failedServicesMore(int rest) { + return 'e altri $rest'; + } + + @override + String get failedServicesNone => 'nessuno'; + + @override + String get failedServicesScopeSystem => 'Sistema'; + + @override + String get failedServicesScopeUser => 'Utente'; + + @override + String get memoryPressureLabel => 'Pressione sulla memoria'; + + @override + String get memoryPressureUnavailable => 'non disponibile'; + + @override + String get rebootRequiredBadge => 'Riavvio necessario'; + + @override + String get rebootRequiredNoPackages => + 'Riavvio necessario (nessun pacchetto indicato)'; + + @override + String rebootRequiredPackages(String packages) { + return 'Riavvio necessario dopo l\'aggiornamento di: $packages'; + } + + @override + String get zramLabel => 'zram (compresso)'; } diff --git a/lib/l10n/linuxassistant_fi.arb b/lib/l10n/linuxassistant_fi.arb index cc691305..ec79678d 100644 --- a/lib/l10n/linuxassistant_fi.arb +++ b/lib/l10n/linuxassistant_fi.arb @@ -903,5 +903,99 @@ "placeholders": {}, "description": "Explains what confirming the dialog above does", "type": "text" + }, + "browserDefaultIs": "Oletusselaime: {standard}", + "@browserDefaultIs": { + "placeholders": { + "standard": { + "type": "String" + } + }, + "type": "text" + }, + "browserDefaultUnknown": "Oletusselaime tuntematon", + "@browserDefaultUnknown": { + "placeholders": {}, + "type": "text" + }, + "browserHandlerMismatch": "HTTPS-linkit avautuvat ohjelmassa {handler}, ei {standard}", + "@browserHandlerMismatch": { + "placeholders": { + "handler": { + "type": "String" + }, + "standard": { + "type": "String" + } + }, + "type": "text" + }, + "diskSwapLabel": "Levyn swap", + "@diskSwapLabel": { + "placeholders": {}, + "type": "text" + }, + "failedServicesLabel": "Epäonnistuneet palvelut", + "@failedServicesLabel": { + "placeholders": {}, + "type": "text" + }, + "failedServicesMore": "ja {rest} muuta", + "@failedServicesMore": { + "placeholders": { + "rest": { + "type": "int" + } + }, + "type": "text" + }, + "failedServicesNone": "ei mitään", + "@failedServicesNone": { + "placeholders": {}, + "type": "text" + }, + "failedServicesScopeSystem": "Järjestelmä", + "@failedServicesScopeSystem": { + "placeholders": {}, + "type": "text" + }, + "failedServicesScopeUser": "Käyttäjä", + "@failedServicesScopeUser": { + "placeholders": {}, + "type": "text" + }, + "memoryPressureLabel": "Muistipaine", + "@memoryPressureLabel": { + "placeholders": {}, + "type": "text" + }, + "memoryPressureUnavailable": "ei saatavilla", + "@memoryPressureUnavailable": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredBadge": "Uudelleenkäynnistys tarvitaan", + "@rebootRequiredBadge": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredNoPackages": "Uudelleenkäynnistys tarvitaan (pakettia ei nimetty)", + "@rebootRequiredNoPackages": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredPackages": "Uudelleenkäynnistys tarvitaan päivityksen jälkeen: {packages}", + "@rebootRequiredPackages": { + "placeholders": { + "packages": { + "type": "String" + } + }, + "type": "text" + }, + "zramLabel": "zram (pakattu)", + "@zramLabel": { + "placeholders": {}, + "type": "text" } } diff --git a/lib/layouts/hub/dashboard_section.dart b/lib/layouts/hub/dashboard_section.dart index b72034eb..cb8af48f 100644 --- a/lib/layouts/hub/dashboard_section.dart +++ b/lib/layouts/hub/dashboard_section.dart @@ -3,9 +3,12 @@ import 'package:linux_assistant/l10n/app_localizations.dart'; import 'package:linux_assistant/layouts/hermes_tokens.dart'; import 'package:linux_assistant/layouts/hub/hub_grid.dart'; import 'package:linux_assistant/linux/linux_filesystem.dart'; +import 'package:linux_assistant/linux/linux_memory.dart'; import 'package:linux_assistant/models/action_entry.dart'; import 'package:linux_assistant/services/action_entry_list_service.dart'; import 'package:linux_assistant/services/action_handler.dart'; +import 'package:linux_assistant/services/browser_status_service.dart'; +import 'package:linux_assistant/services/failed_units_service.dart'; import 'package:linux_assistant/services/linux.dart'; import 'package:linux_assistant/services/system_stats_service.dart'; import 'package:linux_assistant/widgets/hermes/hermes_badge.dart'; @@ -39,19 +42,28 @@ class _DashboardSectionState extends State { "hard_info", ]; + /// How many failed units the tile lists before it summarizes the rest. + static const int _maxListedFailedUnits = 3; + final SystemStatsService _service = SystemStatsService(); + final FailedUnitsService _failedUnits = FailedUnitsService(); + final BrowserStatusService _browserStatus = BrowserStatusService(); List _quickActions = []; @override void initState() { super.initState(); _service.acquire(); + _failedUnits.acquire(); + _browserStatus.acquire(); _loadQuickActions(); } @override void dispose() { _service.release(); + _failedUnits.release(); + _browserStatus.release(); super.dispose(); } @@ -113,6 +125,7 @@ class _DashboardSectionState extends State { _memoryTile(context, stats), _processesTile(context, stats), _storageTile(context, stats), + _failedServicesTile(context), ], ), const SizedBox(height: HermesTokens.space4), @@ -129,6 +142,7 @@ class _DashboardSectionState extends State { final t = HermesTokens.of(context); final env = Linux.currentenvironment; final uptime = stats.uptime; + final rebootRequired = stats.reboot?.required ?? false; return HermesCard( spineColor: t.accent, @@ -155,6 +169,16 @@ class _DashboardSectionState extends State { text: "${l10n.uptimeLabel} " "${uptime.value}${_uptimeUnit(context, uptime.unit)}", ), + // The badge disappears with the marker file, i.e. after the + // reboot that was asked for. + if (rebootRequired) ...[ + const SizedBox(width: HermesTokens.space2), + HermesBadge( + icon: Icons.restart_alt, + tone: HermesTone.warning, + text: l10n.rebootRequiredBadge, + ), + ], ], ), const SizedBox(height: HermesTokens.space3), @@ -169,6 +193,7 @@ class _DashboardSectionState extends State { "${l10n.desktopLabel} ${env.desktop.name}"), _fact(context, Icons.memory, env.cpuModel), _fact(context, Icons.monitor, env.gpuModel), + _browserFact(context), ], ), ], @@ -195,6 +220,53 @@ class _DashboardSectionState extends State { ); } + /// Which browser the desktop calls the standard one, and a warning when + /// HTTPS links open in a different one. + /// + /// Read-only: the dashboard never changes the browser settings. + Widget _browserFact(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final t = HermesTokens.of(context); + + return ValueListenableBuilder( + valueListenable: _browserStatus.status, + builder: (context, status, _) { + if (status == null) { + // Not read yet — no claim about the browser until there is one. + return const SizedBox.shrink(); + } + + final standard = status.defaultDesktopId; + if (standard == null) { + return _fact(context, Icons.public, l10n.browserDefaultUnknown); + } + + if (status.mismatch) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.warning_amber, size: 14, color: t.warning), + const SizedBox(width: HermesTokens.space1), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Text( + l10n.browserHandlerMismatch( + status.httpsHandlerDesktopId ?? "", + standard, + ), + overflow: TextOverflow.ellipsis, + style: TextStyle(color: t.warning, fontSize: 12), + ), + ), + ], + ); + } + + return _fact(context, Icons.public, l10n.browserDefaultIs(standard)); + }, + ); + } + Widget _cpuTile(BuildContext context, SystemStats stats) { final l10n = AppLocalizations.of(context)!; final t = HermesTokens.of(context); @@ -228,13 +300,18 @@ class _DashboardSectionState extends State { final t = HermesTokens.of(context); final memory = stats.memory; final ratio = memory?.usedRatio ?? 0; + final pressure = stats.memoryPressure; + final zram = stats.zram; + final tone = _memoryTone(pressure, ratio); return HermesStatTile( label: l10n.memoryUsage, icon: Icons.developer_board, value: memory != null ? (ratio * 100).toStringAsFixed(0) : null, unit: "%", - tone: ratio >= 0.9 ? HermesTone.warning : HermesTone.accent, + // The pressure decides the tone, not the RAM quote: 40 % RAM with + // memory stalls is a problem, 95 % RAM without stalls is not. + tone: tone, badge: memory != null ? HermesBadge( dense: true, @@ -243,17 +320,74 @@ class _DashboardSectionState extends State { : null, visual: HermesSparkline( values: stats.memoryHistory, - color: ratio >= 0.9 ? t.warning : t.accent, + color: tone == HermesTone.error + ? t.error + : tone == HermesTone.warning + ? t.warning + : t.accent, ), - footer: memory != null && memory.hasSwap - ? HermesMetaRow( - label: l10n.swapLabel, + footer: Column( + mainAxisSize: MainAxisSize.min, + children: [ + HermesMetaRow( + label: l10n.memoryPressureLabel, + value: pressure == null + ? l10n.memoryPressureUnavailable + : "${pressure.someAvg60.toStringAsFixed(1)} %", + valueTone: _pressureTone(context, pressure), + ), + // zram and the disk swap are two different things and get two + // rows: the compressed RAM disk and the swap on the disk. + if (zram != null) + HermesMetaRow( + label: l10n.zramLabel, + value: "${_gbFromBytes(zram.originalBytes)}" + " → ${_gbFromBytes(zram.compressedBytes)}", + ), + if (memory != null && memory.hasSwap) + HermesMetaRow( + label: l10n.diskSwapLabel, value: "${_gb(memory.swapUsedMb)} / ${_gb(memory.swapTotalMb)}", - ) - : null, + ), + ], + ), ); } + /// The tone of the memory tile. + /// + /// PSI decides it. Only when the kernel does not report the pressure does + /// the RAM quote step in as a fallback. + static HermesTone _memoryTone(MemoryPressure? pressure, double ratio) { + switch (pressure?.level) { + case MemoryPressureLevel.high: + return HermesTone.error; + case MemoryPressureLevel.elevated: + return HermesTone.warning; + case MemoryPressureLevel.normal: + return HermesTone.accent; + case MemoryPressureLevel.unknown: + case null: + return ratio >= 0.9 ? HermesTone.warning : HermesTone.accent; + } + } + + Color? _pressureTone(BuildContext context, MemoryPressure? pressure) { + if (pressure == null) { + return null; + } + final t = HermesTokens.of(context); + switch (pressure.level) { + case MemoryPressureLevel.high: + return t.error; + case MemoryPressureLevel.elevated: + return t.warning; + case MemoryPressureLevel.normal: + case MemoryPressureLevel.unknown: + return null; + } + } + Widget _processesTile(BuildContext context, SystemStats stats) { final l10n = AppLocalizations.of(context)!; @@ -352,6 +486,94 @@ class _DashboardSectionState extends State { ); } + /// The failed systemd units of both scopes. + /// + /// The number is the headline; the list below it names the units, their + /// description and the scope they come from — a failed user unit is a + /// different problem than a failed system unit. + Widget _failedServicesTile(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return ValueListenableBuilder?>( + valueListenable: _failedUnits.units, + builder: (context, units, _) { + final count = units?.length ?? 0; + final listed = units?.take(_maxListedFailedUnits) ?? const []; + final rest = count - listed.length; + + return HermesStatTile( + label: l10n.failedServicesLabel, + icon: Icons.report_gmailerrorred_outlined, + // Null while the first read is still running — a dash is honest, + // a zero would be a claim nobody made yet. + value: units == null ? null : "$count", + tone: count > 0 ? HermesTone.error : HermesTone.accent, + footer: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (units == null) + const SizedBox.shrink() + else if (units.isEmpty) + HermesMetaRow( + label: l10n.failedServicesLabel, + value: l10n.failedServicesNone, + ) + else ...[ + for (final unit in listed) _failedUnitRow(context, unit), + if (rest > 0) + HermesMetaRow( + label: l10n.failedServicesLabel, + value: l10n.failedServicesMore(rest), + ), + ], + ], + ), + ); + }, + ); + } + + Widget _failedUnitRow(BuildContext context, FailedUnit unit) { + final l10n = AppLocalizations.of(context)!; + final t = HermesTokens.of(context); + final scope = unit.scope == FailedUnitScope.user + ? l10n.failedServicesScopeUser + : l10n.failedServicesScopeSystem; + + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + unit.unit, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: t.text, + fontSize: 12, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + const SizedBox(width: HermesTokens.space2), + HermesBadge(dense: true, text: scope), + ], + ), + if (unit.description.isNotEmpty) + Text( + unit.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: t.muted, fontSize: 11), + ), + ], + ), + ); + } + Widget _quickActionsGrid(BuildContext context) { final l10n = AppLocalizations.of(context)!; final t = HermesTokens.of(context); @@ -421,6 +643,10 @@ class _DashboardSectionState extends State { static String _gb(int megabytes) => "${(megabytes / 1024).toStringAsFixed(1)}G"; + /// A size in gigabytes, for the values that come in bytes (zram). + static String _gbFromBytes(int bytes) => + "${(bytes / 1024 / 1024 / 1024).toStringAsFixed(1)} GB"; + String _uptimeUnit(BuildContext context, String unit) { final l10n = AppLocalizations.of(context)!; switch (unit) { diff --git a/lib/layouts/hub/hub_shell.dart b/lib/layouts/hub/hub_shell.dart index 61a2b8f4..8ae1d7f8 100644 --- a/lib/layouts/hub/hub_shell.dart +++ b/lib/layouts/hub/hub_shell.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:linux_assistant/l10n/app_localizations.dart'; import 'package:linux_assistant/layouts/hermes_tokens.dart'; @@ -6,6 +8,8 @@ import 'package:linux_assistant/layouts/main_screen/main_search.dart'; import 'package:linux_assistant/layouts/settings/settings_start.dart'; import 'package:linux_assistant/main.dart'; import 'package:linux_assistant/services/app_launcher.dart'; +import 'package:linux_assistant/services/browser_status_service.dart'; +import 'package:linux_assistant/services/failed_units_service.dart'; import 'package:linux_assistant/services/linux.dart'; import 'package:linux_assistant/services/system_stats_service.dart'; import 'package:linux_assistant/services/theme_controller.dart'; @@ -71,7 +75,7 @@ class _HubShellState extends State // manager that reports the state change, and the Flutter lifecycle state // is not delivered by every desktop either. WidgetsBinding.instance.addObserver(this); - SystemStatsService().setSectionActive(hubModuleOf(_section).usesStats); + _syncPolling(); } @override @@ -87,20 +91,46 @@ class _HubShellState extends State // Leave the service in the state a non-hub screen expects. SystemStatsService().setSectionActive(true); SystemStatsService().setWindowVisible(true); + // The dashboard's own pollers stop with the hub. + FailedUnitsService().setSectionActive(false); + FailedUnitsService().setWindowVisible(true); + BrowserStatusService().setSectionActive(false); + BrowserStatusService().setWindowVisible(true); super.dispose(); } + /// Keeps the pollers in sync with what is on screen. + /// + /// The stats are shown by several sections, the failed units and the + /// browser status only by the dashboard. A poller for something nobody + /// sees is pure waste — and two `systemctl` calls every 30 seconds for a + /// hidden tile is exactly the kind of cost that only shows up as battery + /// drain. + void _syncPolling() { + SystemStatsService().setSectionActive(hubModuleOf(_section).usesStats); + final bool showsDashboard = _section == HubSection.dashboard; + FailedUnitsService().setSectionActive(showsDashboard); + BrowserStatusService().setSectionActive(showsDashboard); + } + + /// Whether the window is on screen, for every poller that depends on it. + void _setWindowVisible(bool visible) { + SystemStatsService().setWindowVisible(visible); + FailedUnitsService().setWindowVisible(visible); + BrowserStatusService().setWindowVisible(visible); + } + // Minimizing is the case that matters: the hub keeps every visited section // alive, so nothing else would ever stop the three-second poll. @override - void onWindowMinimize() => SystemStatsService().setWindowVisible(false); + void onWindowMinimize() => _setWindowVisible(false); @override - void onWindowRestore() => SystemStatsService().setWindowVisible(true); + void onWindowRestore() => _setWindowVisible(true); @override void didChangeAppLifecycleState(AppLifecycleState state) { - SystemStatsService().setWindowVisible( + _setWindowVisible( state == AppLifecycleState.resumed || state == AppLifecycleState.inactive, ); } @@ -117,7 +147,7 @@ class _HubShellState extends State _section = section; _screenTool = null; }); - SystemStatsService().setSectionActive(hubModuleOf(section).usesStats); + _syncPolling(); } /// What a screen may ask the shell to do ([HubNavigator]): the dashboard's @@ -434,7 +464,12 @@ class _HubShellState extends State tooltip: l10n.hubSearch, ), IconButton( - onPressed: () => SystemStatsService().refresh(), + onPressed: () { + // The manual reload covers every number the dashboard shows. + unawaited(SystemStatsService().refresh()); + unawaited(FailedUnitsService().refresh()); + unawaited(BrowserStatusService().refresh()); + }, icon: const Icon(Icons.refresh, size: 18), color: t.muted, tooltip: l10n.reload, diff --git a/lib/layouts/linux_health/overview.dart b/lib/layouts/linux_health/overview.dart index 96569f81..b6ee6676 100644 --- a/lib/layouts/linux_health/overview.dart +++ b/lib/layouts/linux_health/overview.dart @@ -106,6 +106,7 @@ class _LinuxHealthContentState extends State { final uptime = stats.uptime; final hasSwap = stats.memory?.hasSwap ?? false; + final reboot = stats.reboot; return ListView( shrinkWrap: true, @@ -116,6 +117,14 @@ class _LinuxHealthContentState extends State { ), if (uptime != null) _uptimeMessage(context, uptime.unit, uptime.value), + // The packages that asked for the reboot. The marker file is + // gone after the reboot, so this line disappears with it. + if (reboot != null && reboot.required) + WarningMessage( + text: reboot.packages.isEmpty + ? l10n.rebootRequiredNoPackages + : l10n.rebootRequiredPackages(reboot.packages.join(", ")), + ), stats.zombieCount == 0 ? SuccessMessage( text: l10n.processesWithZombiesMessage( diff --git a/lib/linux/linux_memory.dart b/lib/linux/linux_memory.dart new file mode 100644 index 00000000..c22ec183 --- /dev/null +++ b/lib/linux/linux_memory.dart @@ -0,0 +1,291 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:linux_assistant/services/logger.dart'; + +/// How hard the memory is under pressure right now. +enum MemoryPressureLevel { + /// The kernel does not report the pressure (no PSI support). + unknown, + + /// Some tasks waited for memory, but rarely and briefly. + normal, + + /// Waiting for memory is measurable. + elevated, + + /// Waiting for memory dominates — the machine is thrashing. + high, +} + +/// The memory pressure the kernel reports through PSI +/// (`/proc/pressure/memory`). +/// +/// PSI is the honest answer to "is the memory tight": the share of the last +/// 60 seconds in which at least one task had to wait for memory. The RAM +/// quote says nothing about that — a machine can be at 30 % RAM usage and +/// still be swapping badly, and it can be at 95 % RAM usage and completely +/// relaxed. +@immutable +class MemoryPressure { + const MemoryPressure({ + required this.someAvg60, + required this.fullAvg60, + required this.level, + }); + + /// `some avg60` — the share of the last minute in which at least one task + /// was stalled on memory. + final double someAvg60; + + /// `full avg60` — the share of the last minute in which *all* tasks were + /// stalled on memory. Much rarer, and much worse. + final double fullAvg60; + + final MemoryPressureLevel level; +} + +/// The zram device's accounting numbers. +/// +/// zram is a compressed RAM disk: the swap data is 7.2 GB in its +/// uncompressed form and occupies 2.1 GB of the real disk. Reporting it as +/// one number ("swap: 7.2 GB of 16 GB") would hide the compression, so the +/// two sizes are kept apart. +@immutable +class ZramInfo { + const ZramInfo({ + required this.devices, + required this.originalBytes, + required this.compressedBytes, + required this.capacityBytes, + }); + + /// The zram devices that were found, e.g. `["zram0"]`. + final List devices; + + /// `orig_data_size` — the size the stored data has in its uncompressed + /// form. + final int originalBytes; + + /// `compr_data_size` — the size the stored data occupies after + /// compression. + final int compressedBytes; + + /// `disksize` — the size the device would take on the disk. + final int capacityBytes; + + /// The data that is actually stored, in its uncompressed size. + int get usedBytes => originalBytes; +} + +/// Reads the memory pressure and the zram accounting from procfs and sysfs. +/// +/// The parsing functions are pure, so the thresholds and the number formats +/// can be tested without a machine that has PSI or zram. +abstract class LinuxMemory { + /// `/proc/pressure/memory` — the PSI lines for memory. + static const String pressurePath = "/proc/pressure/memory"; + + /// The block devices directory; the zram devices live in it. + static const String blockDevicePath = "/sys/block"; + + /// From this share of stalled time on, the pressure counts as elevated. + static const double elevatedThreshold = 5.0; + + /// From this share of stalled time on, the pressure counts as high. + static const double highThreshold = 15.0; + + /// `full avg60` is much rarer than `some avg60`; this share of it is + /// already "high", because at that point every task waits. + static const double fullHighThreshold = 5.0; + + /// Reads `/proc/pressure/memory`. + /// + /// Returns null when the kernel does not provide PSI (CONFIG PSI off) or + /// the file cannot be read. The caller then knows nothing about the + /// pressure instead of knowing "0 %". + static Future memoryPressure() async { + try { + final String raw = await File(pressurePath).readAsString(); + return parsePressure(raw); + } catch (e) { + logInfo("No memory pressure information available: $e"); + return null; + } + } + + /// Parses the contents of `/proc/pressure/memory`. + /// + /// The file has two lines: + /// + /// ``` + /// some avg10=0.00 avg60=0.00 avg300=0.00 total=0 + /// full avg10=0.00 avg60=0.00 avg300=0.00 total=0 + /// ``` + /// + /// `some` counts the time in which at least one task waited for memory, + /// `full` the time in which all of them waited. Only `avg60` is read — it + /// is the window a human can act on. + static MemoryPressure? parsePressure(String raw) { + double? some; + double? full; + + for (final line in raw.split('\n')) { + final trimmed = line.trim(); + if (trimmed.isEmpty) { + continue; + } + final List fields = trimmed.split(RegExp(r'\s+')); + final String kind = fields.first; + if (kind != "some" && kind != "full") { + continue; + } + final double? avg60 = _readAvg60(fields); + if (avg60 == null) { + continue; + } + if (kind == "some") { + some = avg60; + } else { + full = avg60; + } + } + + if (some == null && full == null) { + return null; + } + + final double someValue = some ?? 0; + final double fullValue = full ?? 0; + + return MemoryPressure( + someAvg60: someValue, + fullAvg60: fullValue, + level: levelFor(someAvg60: someValue, fullAvg60: fullValue), + ); + } + + /// Turns the two PSI figures into a level. + static MemoryPressureLevel levelFor({ + required double someAvg60, + required double fullAvg60, + }) { + if (someAvg60 >= highThreshold || fullAvg60 >= fullHighThreshold) { + return MemoryPressureLevel.high; + } + if (someAvg60 >= elevatedThreshold) { + return MemoryPressureLevel.elevated; + } + return MemoryPressureLevel.normal; + } + + static double? _readAvg60(List fields) { + for (final field in fields.skip(1)) { + if (!field.startsWith("avg60=")) { + continue; + } + return double.tryParse(field.substring("avg60=".length)); + } + return null; + } + + /// Reads the zram devices of this machine. + /// + /// Returns null when the machine has no zram device at all. + static Future zramInfo() async { + final List devices; + try { + devices = Directory(blockDevicePath) + .listSync() + .whereType() + .map((dir) => dir.path.split('/').last) + .where((name) => name.startsWith("zram")) + .toList() + ..sort(); + } catch (e) { + logInfo("Could not read $blockDevicePath: $e"); + return null; + } + + if (devices.isEmpty) { + return null; + } + + int original = 0; + int compressed = 0; + int capacity = 0; + var found = false; + + for (final device in devices) { + final String base = "$blockDevicePath/$device"; + final mmStat = await _readFile("$base/mm_stat"); + final diskSize = await _readFile("$base/disksize"); + if (mmStat == null && diskSize == null) { + continue; + } + found = true; + if (mmStat != null) { + final sizes = parseZramMmStat(mmStat); + original += sizes?.$1 ?? 0; + compressed += sizes?.$2 ?? 0; + } + // disksize is optional: a zram device that is not readable yet still + // counts towards the compressed sizes, it just has no capacity. + if (diskSize != null) { + capacity += parseZramDiskSize(diskSize) ?? 0; + } + } + + if (!found) { + return null; + } + + return ZramInfo( + devices: devices, + originalBytes: original, + compressedBytes: compressed, + capacityBytes: capacity, + ); + } + + /// Parses `/sys/block/zram*/mm_stat`. + /// + /// The first two numbers are the interesting ones: the size of the stored + /// data before compression and after it. The rest is the kernel's internal + /// bookkeeping and is ignored. + static (int, int)? parseZramMmStat(String raw) { + final List fields = raw + .trim() + .split(RegExp(r'\s+')) + .where((field) => field.isNotEmpty) + .toList(); + if (fields.length < 2) { + return null; + } + final int? original = int.tryParse(fields[0]); + final int? compressed = int.tryParse(fields[1]); + if (original == null || compressed == null) { + return null; + } + return (original, compressed); + } + + /// Parses `/sys/block/zram*/disksize` — the size the device reserves on + /// the disk, in bytes. + static int? parseZramDiskSize(String raw) { + final String trimmed = raw.trim(); + if (trimmed.isEmpty) { + return null; + } + return int.tryParse(trimmed); + } + + static Future _readFile(String path) async { + try { + return await File(path).readAsString(); + } catch (e) { + logInfo("Could not read $path: $e"); + return null; + } + } +} diff --git a/lib/services/browser_status_service.dart b/lib/services/browser_status_service.dart new file mode 100644 index 00000000..fd5913a3 --- /dev/null +++ b/lib/services/browser_status_service.dart @@ -0,0 +1,247 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:linux_assistant/helpers/command_helper.dart'; +import 'package:linux_assistant/services/app_launcher.dart'; +import 'package:linux_assistant/services/linux.dart'; +import 'package:linux_assistant/services/logger.dart'; + +/// What the system says about the default browser. +/// +/// Two sources that can disagree: `xdg-settings get default-web-browser` +/// (what the desktop settings call the default) and `gio mime +/// x-scheme-handler/https` (what actually opens an HTTPS link). When they +/// differ, the user clicks a link and gets a different browser than the one +/// the desktop advertises — that is worth a warning. +@immutable +class BrowserStatus { + const BrowserStatus({ + this.defaultDesktopId, + this.httpsHandlerDesktopId, + this.handlerKnown = false, + }); + + /// The desktop id of the standard browser, e.g. `firefox.desktop`. + /// Null when the desktop environment does not name one. + final String? defaultDesktopId; + + /// The desktop id that handles `x-scheme-handler/https`. Null when no + /// handler is registered. + final String? httpsHandlerDesktopId; + + /// Whether the HTTPS handler could be determined at all. + /// + /// `gio` is not installed on every minimal system and answers with an + /// error when no handler is registered. In that case there is nothing to + /// compare, so no warning is shown. + final bool handlerKnown; + + /// Whether the HTTPS handler differs from the standard browser. + bool get mismatch { + if (!handlerKnown) { + return false; + } + final standard = defaultDesktopId; + final handler = httpsHandlerDesktopId; + if (standard == null || handler == null) { + return false; + } + return !_sameDesktopId(standard, handler); + } + + /// Two ids are the same when they differ only in the `.desktop` suffix or + /// in case — `firefox` and `firefox.desktop` name the same application. + static bool _sameDesktopId(String a, String b) { + return _stripDesktopSuffix(a).toLowerCase() == + _stripDesktopSuffix(b).toLowerCase(); + } + + static String _stripDesktopSuffix(String id) { + return id.toLowerCase().endsWith(".desktop") + ? id.substring(0, id.length - ".desktop".length) + : id; + } +} + +/// Reads the default browser and the HTTPS handler. +/// +/// The reading is read-only: nothing is written, no settings are changed. +/// The desktop id comes from [AppLauncher.defaultBrowserDesktopId], the same +/// source the browser launcher uses, so the dashboard cannot disagree with +/// what the app does. +class BrowserStatusService { + static final BrowserStatusService _instance = BrowserStatusService._(); + + factory BrowserStatusService() => _instance; + + BrowserStatusService._(); + + /// The default browser changes rarely, so the timer is deliberately slow. + static const Duration defaultInterval = Duration(minutes: 5); + + /// `gio` lives in `/usr/bin` on every distribution that ships it. + static const String gioPath = "/usr/bin/gio"; + + /// The first line of `gio mime` names the default handler: + /// `Default application for ??: `. + static const String kGioDefaultApplicationPrefix = "Default application for"; + + final ValueNotifier status = + ValueNotifier(null); + + Duration interval = defaultInterval; + + Timer? _timer; + int _subscribers = 0; + + /// Both default to true so that a consumer outside the hub keeps working + /// without opting in. The hub sets them to false as soon as the dashboard + /// is not the section on screen. + bool _sectionActive = true; + bool _windowVisible = true; + + bool _reading = false; + + @visibleForTesting + int get subscriberCount => _subscribers; + + /// Read-only view of the section flag, so the coupling between the hub + /// and this service can be asserted without running `gio`. + @visibleForTesting + bool get sectionActive => _sectionActive; + + bool get _shouldPoll => _subscribers > 0 && _sectionActive && _windowVisible; + + /// Registers interest. The first subscriber triggers an immediate read. + void acquire() { + _subscribers++; + _sync(); + } + + /// Drops interest. The last release stops the timer. + void release() { + if (_subscribers == 0) { + return; + } + _subscribers--; + _sync(); + } + + /// Whether the view on screen shows the browser status at all. + void setSectionActive(bool active) { + if (_sectionActive == active) { + return; + } + _sectionActive = active; + _sync(); + } + + /// Whether the window is on screen. + void setWindowVisible(bool visible) { + if (_windowVisible == visible) { + return; + } + _windowVisible = visible; + _sync(); + } + + void _sync() { + if (_shouldPoll) { + if (_timer == null) { + _timer = Timer.periodic(interval, (_) => refresh()); + refresh(); + } + } else { + _timer?.cancel(); + _timer = null; + } + } + + /// Re-reads both sources and publishes the result. + Future refresh() async { + if (_reading) { + return; + } + _reading = true; + try { + final String? standard = await AppLauncher.defaultBrowserDesktopId(); + final String? handler = await _readHttpsHandler(); + status.value = BrowserStatus( + defaultDesktopId: standard, + httpsHandlerDesktopId: handler, + handlerKnown: handler != null, + ); + } catch (e) { + logError("Could not read the browser status", e); + } finally { + _reading = false; + } + } + + /// `gio mime x-scheme-handler/https` prints + /// `x-scheme-handler/https: firefox.desktop` — or several ids, separated + /// by semicolons, when more than one application can handle the scheme. + Future _readHttpsHandler() async { + final CommandResult result = await Linux.runProcess( + gioPath, + ["mime", "x-scheme-handler/https"], + environment: {"LC_ALL": "C"}, + runInShell: false, + ); + + if (!result.success) { + // `gio` exits with 1 when no handler is registered and prints the + // reason to stderr. That is an answer, not an error. + logInfo("gio did not name an HTTPS handler: ${result.error}"); + return null; + } + + return parseGioMimeHandler(result.output); + } + + /// Extracts the handler desktop id from the output of `gio mime`. + /// + /// The real output of `gio mime x-scheme-handler/https` looks like this + /// (measured on gio 2.74): + /// + /// Default application for ?x-scheme-handler/https?: brave-browser.desktop + /// No registered applications + /// No recommended applications + /// + /// Only the first line names the default handler. The other two sections + /// list applications that can open the type without being the default — + /// the dashboard wants the default, so they are ignored. + /// + /// Returns null when the output carries no handler for the scheme. + static String? parseGioMimeHandler(String raw) { + for (final line in raw.split('\n')) { + final String trimmed = line.trim(); + if (!trimmed.startsWith(kGioDefaultApplicationPrefix)) { + continue; + } + final int separator = trimmed.indexOf(':'); + if (separator < 0) { + continue; + } + final String value = trimmed.substring(separator + 1).trim(); + if (value.isEmpty) { + return null; + } + final String first = value.split(';').first.trim(); + return first.isEmpty ? null : first; + } + return null; + } + + /// Test seam: drops all state and stops the timer. + @visibleForTesting + void resetForTesting({BrowserStatus? seed}) { + _timer?.cancel(); + _timer = null; + _subscribers = 0; + _sectionActive = true; + _windowVisible = true; + _reading = false; + status.value = seed; + } +} diff --git a/lib/services/failed_units_service.dart b/lib/services/failed_units_service.dart new file mode 100644 index 00000000..fbe400cd --- /dev/null +++ b/lib/services/failed_units_service.dart @@ -0,0 +1,226 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:linux_assistant/helpers/command_helper.dart'; +import 'package:linux_assistant/services/linux.dart'; +import 'package:linux_assistant/services/logger.dart'; + +/// Which systemd manager a failed unit belongs to. +/// +/// A desktop has two: the system manager (root, `systemctl`) and the user +/// manager (`systemctl --user`). Both can hold failed units, and a service +/// that only lives in the user manager is invisible to the system one. +enum FailedUnitScope { system, user } + +/// One failed systemd unit. +@immutable +class FailedUnit { + const FailedUnit({ + required this.unit, + required this.description, + required this.scope, + }); + + /// The unit name, e.g. `restic-backup.service`. + final String unit; + + /// The unit's description, e.g. "Backup and restore service". May be empty + /// — systemd leaves the field out for units without a description. + final String description; + + /// Which manager reported the unit. + final FailedUnitScope scope; +} + +/// Collects the failed systemd units of both scopes. +/// +/// The list changes far more slowly than the CPU numbers, so this polls on +/// its own slow timer instead of riding on the three-second stats poll — +/// two `systemctl` calls per tick would be a lot of process spawns for a +/// number that usually stays at zero. +class FailedUnitsService { + static final FailedUnitsService _instance = FailedUnitsService._(); + + factory FailedUnitsService() => _instance; + + FailedUnitsService._(); + + /// How often the failed units are re-read while a consumer is listening. + static const Duration defaultInterval = Duration(seconds: 30); + + /// `systemctl` is always in `/usr/bin` on a systemd system. + static const String systemctlPath = "/usr/bin/systemctl"; + + final ValueNotifier?> units = + ValueNotifier?>(null); + + Duration interval = defaultInterval; + + Timer? _timer; + int _subscribers = 0; + + /// Both default to true so that a consumer outside the hub keeps working + /// without opting in. The hub sets them to false as soon as the dashboard + /// is not the section on screen. + bool _sectionActive = true; + bool _windowVisible = true; + + /// Guards against overlapping reads: `systemctl` on a busy system can take + /// longer than the interval, and stacked runs would pile up. + bool _reading = false; + + @visibleForTesting + int get subscriberCount => _subscribers; + + /// Read-only view of the section flag, so the coupling between the hub + /// and this service can be asserted without running `systemctl`. + @visibleForTesting + bool get sectionActive => _sectionActive; + + bool get _shouldPoll => _subscribers > 0 && _sectionActive && _windowVisible; + + /// Registers interest. The first subscriber triggers an immediate read. + void acquire() { + _subscribers++; + _sync(); + } + + /// Drops interest. The last release stops the timer. + void release() { + if (_subscribers == 0) { + return; + } + _subscribers--; + _sync(); + } + + /// Whether the view on screen shows the failed units at all. + void setSectionActive(bool active) { + if (_sectionActive == active) { + return; + } + _sectionActive = active; + _sync(); + } + + /// Whether the window is on screen. + void setWindowVisible(bool visible) { + if (_windowVisible == visible) { + return; + } + _windowVisible = visible; + _sync(); + } + + void _sync() { + if (_shouldPoll) { + if (_timer == null) { + _timer = Timer.periodic(interval, (_) => refresh()); + refresh(); + } + } else { + _timer?.cancel(); + _timer = null; + } + } + + /// Re-reads both scopes and publishes the result. + /// + /// A scope that cannot be read (no systemd, no user session) contributes an + /// empty list instead of failing the whole read: half an answer is better + /// than none, and the tile says what it knows. + Future refresh() async { + if (_reading) { + return; + } + _reading = true; + try { + final system = await _readScope(FailedUnitScope.system); + final user = await _readScope(FailedUnitScope.user); + units.value = [...system, ...user]; + } catch (e) { + logError("Could not read the failed systemd units", e); + } finally { + _reading = false; + } + } + + Future> _readScope(FailedUnitScope scope) async { + final List args = scope == FailedUnitScope.user + ? ["--user", "list-units", "--failed", "-o", "json"] + : ["list-units", "--failed", "-o", "json"]; + + final CommandResult result = await Linux.runProcess( + systemctlPath, + args, + // The C locale keeps the JSON keys and the unit names untranslated. + environment: {"LC_ALL": "C"}, + runInShell: false, + ); + + if (!result.success) { + logInfo("systemctl (${scope.name}) did not answer: ${result.error}"); + return const []; + } + + return parseUnitsJson(result.output, scope); + } + + /// Parses the JSON of `systemctl list-units --failed -o json`. + /// + /// systemd writes `{"units": [ … ]}`; the array alone is accepted as well, + /// because that is what the `--output=json` of some other tools produces + /// and a defensive parser costs nothing here. + static List parseUnitsJson(String raw, FailedUnitScope scope) { + if (raw.trim().isEmpty) { + return const []; + } + + final Object? decoded; + try { + decoded = jsonDecode(raw); + } on FormatException catch (e) { + logError("systemctl did not return valid JSON", e); + return const []; + } + + List entries = const []; + if (decoded is List) { + entries = decoded; + } else if (decoded is Map) { + final Object? units = decoded["units"]; + if (units is List) { + entries = units; + } + } + + final failed = []; + for (final entry in entries) { + if (entry is! Map) { + continue; + } + final String unit = (entry["unit"] ?? "").toString().trim(); + if (unit.isEmpty) { + continue; + } + final String description = (entry["description"] ?? "").toString().trim(); + failed.add( + FailedUnit(unit: unit, description: description, scope: scope), + ); + } + return failed; + } + + /// Test seam: drops all state and stops the timer. + @visibleForTesting + void resetForTesting({List? seed}) { + _timer?.cancel(); + _timer = null; + _subscribers = 0; + _sectionActive = true; + _windowVisible = true; + _reading = false; + units.value = seed; + } +} diff --git a/lib/services/reboot_service.dart b/lib/services/reboot_service.dart new file mode 100644 index 00000000..7cd22bb3 --- /dev/null +++ b/lib/services/reboot_service.dart @@ -0,0 +1,101 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:linux_assistant/services/logger.dart'; + +/// Whether the system asks for a reboot, and which packages caused it. +/// +/// Debian and Ubuntu write `/var/run/reboot-required` when a package upgrade +/// replaced something that is only replaceable with a restart, and +/// `/var/run/reboot-required.pkgs` with the list of packages that caused it. +/// Both files disappear after the reboot, which is exactly the behaviour the +/// dashboard badge needs: it is gone as soon as the machine was restarted. +@immutable +class RebootStatus { + const RebootStatus({required this.required, this.packages = const []}); + + /// The normal case: no reboot is pending. + static const RebootStatus notRequired = RebootStatus(required: false); + + /// Whether the marker file exists. + final bool required; + + /// The packages that triggered the reboot request. Empty when the marker + /// exists but the package list is missing or unreadable — the reboot is + /// still pending then, we just cannot name the cause. + final List packages; +} + +/// Reads the "reboot required" marker files. +/// +/// The parsing is a pure function ([parsePackages]) so it can be tested +/// without a file system; [readStatus] only does the I/O. +class RebootService { + /// The marker file. `/var/run` is a symlink to `/run` on every current + /// distribution, but not on every system, so both spellings are tried. + static const List markerPaths = [ + "/var/run/reboot-required", + "/run/reboot-required", + ]; + + /// The package list that belongs to the marker file. + static const List packagePaths = [ + "/var/run/reboot-required.pkgs", + "/run/reboot-required.pkgs", + ]; + + /// Parses the contents of `reboot-required.pkgs`. + /// + /// The file lists one package per line. Debian writes a header comment in + /// some releases, and trailing blank lines are common — both are dropped, + /// everything else is a package name. + static List parsePackages(String raw) { + final packages = []; + for (final line in raw.split('\n')) { + final trimmed = line.trim(); + if (trimmed.isEmpty || trimmed.startsWith('#')) { + continue; + } + packages.add(trimmed); + } + return packages; + } + + /// Reads the marker and the package list. + /// + /// Never throws: a missing marker file means "no reboot pending", and an + /// unreadable package list means "reboot pending, cause unknown". Both + /// are answers the dashboard can show. + static Future readStatus({ + List markerPaths = RebootService.markerPaths, + List packagePaths = RebootService.packagePaths, + }) async { + final String? marker = _firstExisting(markerPaths); + if (marker == null) { + return RebootStatus.notRequired; + } + + final String? packageFile = _firstExisting(packagePaths); + if (packageFile == null) { + logInfo("Reboot marker found, but no package list next to it."); + return const RebootStatus(required: true); + } + + try { + final String raw = await File(packageFile).readAsString(); + return RebootStatus(required: true, packages: parsePackages(raw)); + } catch (e) { + logError("Could not read $packageFile", e); + return const RebootStatus(required: true); + } + } + + static String? _firstExisting(List paths) { + for (final path in paths) { + if (File(path).existsSync()) { + return path; + } + } + return null; + } +} diff --git a/lib/services/system_stats_service.dart b/lib/services/system_stats_service.dart index 9077105b..c8345580 100644 --- a/lib/services/system_stats_service.dart +++ b/lib/services/system_stats_service.dart @@ -6,9 +6,11 @@ import 'package:flutter/foundation.dart'; // working entry point for it (an export alone does not import). import 'package:la_core/la_core.dart' show MemoryInfo; import 'package:linux_assistant/linux/linux_filesystem.dart'; +import 'package:linux_assistant/linux/linux_memory.dart'; import 'package:linux_assistant/linux/linux_process.dart'; import 'package:linux_assistant/linux/linux_system.dart'; import 'package:linux_assistant/services/linux.dart'; +import 'package:linux_assistant/services/reboot_service.dart'; export 'package:la_core/la_core.dart' show MemoryInfo; @@ -19,6 +21,17 @@ class SystemStats { /// but can exceed 1.0 on an overloaded machine. final double cpuLoad; final MemoryInfo? memory; + + /// The memory pressure the kernel reports through PSI. Null when the + /// kernel does not provide it — which is not the same as "no pressure". + final MemoryPressure? memoryPressure; + + /// The zram device's accounting, if the machine has one. + final ZramInfo? zram; + + /// Whether the system asks for a reboot, and which packages caused it. + final RebootStatus? reboot; + final List disks; final int processCount; final int zombieCount; @@ -40,6 +53,9 @@ class SystemStats { const SystemStats({ this.cpuLoad = 0, this.memory, + this.memoryPressure, + this.zram, + this.reboot, this.disks = const [], this.processCount = 0, this.zombieCount = 0, @@ -192,6 +208,11 @@ class SystemStatsService { LinuxProcess.topProcessesByCpu(5), LinuxProcess.topProcessesByMemory(5), _uptimeOrNull(), + // File reads, no processes: the pressure, the zram accounting and + // the reboot marker are cheap enough for every tick. + LinuxMemory.memoryPressure(), + LinuxMemory.zramInfo(), + RebootService.readStatus(), ]); final cpuLoad = results[0] as double; @@ -200,6 +221,10 @@ class SystemStatsService { stats.value = SystemStats( cpuLoad: cpuLoad, memory: memory ?? previous.memory, + memoryPressure: + results[8] as MemoryPressure? ?? previous.memoryPressure, + zram: results[9] as ZramInfo? ?? previous.zram, + reboot: results[10] as RebootStatus? ?? previous.reboot, disks: results[2] as List, processCount: results[3] as int, zombieCount: results[4] as int, @@ -218,6 +243,9 @@ class SystemStatsService { stats.value = SystemStats( cpuLoad: previous.cpuLoad, memory: previous.memory, + memoryPressure: previous.memoryPressure, + zram: previous.zram, + reboot: previous.reboot, disks: previous.disks, processCount: previous.processCount, zombieCount: previous.zombieCount, diff --git a/test/hub_navigation_test.dart b/test/hub_navigation_test.dart index 3ef17b81..e859af56 100644 --- a/test/hub_navigation_test.dart +++ b/test/hub_navigation_test.dart @@ -21,6 +21,8 @@ import 'package:linux_assistant/layouts/hub/dashboard_section.dart'; import 'package:linux_assistant/layouts/hub/storage_section.dart'; import 'package:linux_assistant/layouts/tools/quick_notes.dart'; import 'package:linux_assistant/services/app_launcher.dart'; +import 'package:linux_assistant/services/failed_units_service.dart'; +import 'package:linux_assistant/services/browser_status_service.dart'; import 'package:linux_assistant/services/system_stats_service.dart'; import 'package:linux_assistant/widgets/hermes/hermes_nav_item.dart'; @@ -74,11 +76,20 @@ void main() { // false and no Timer.periodic / ps/df/free fork ever starts. SystemStatsService().resetForTesting(); SystemStatsService().setWindowVisible(false); + // The dashboard polls the failed units and the browser status as well. + // Both start a process (`systemctl`, `gio`) the moment they are polled, + // so they are put to sleep the same way the stats poller is. + FailedUnitsService().resetForTesting(); + FailedUnitsService().setWindowVisible(false); + BrowserStatusService().resetForTesting(); + BrowserStatusService().setWindowVisible(false); }); tearDown(() { AppLauncher.resetOverrides(); SystemStatsService().resetForTesting(); + FailedUnitsService().resetForTesting(); + BrowserStatusService().resetForTesting(); }); testWidgets('H1 sidebar shows the nine modules and settings in order', diff --git a/test/q_dashboard_kacheln_test.dart b/test/q_dashboard_kacheln_test.dart new file mode 100644 index 00000000..4b352f4e --- /dev/null +++ b/test/q_dashboard_kacheln_test.dart @@ -0,0 +1,251 @@ +// Q2, Q3, Q5 and Q6 of issue #58 — the four dashboard additions. +// +// The dashboard shows: whether a reboot is pending and which packages caused +// it, which systemd units failed, which browser the desktop calls the +// standard one, and what the memory pressure actually is. All four are read +// from the system, so the parsers are the part worth pinning: a wrong +// number on a dashboard is worse than no number. +// +// The parsers are pure functions, so they are tested without touching the +// file system or starting a process. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:linux_assistant/linux/linux_memory.dart'; +import 'package:linux_assistant/services/browser_status_service.dart'; +import 'package:linux_assistant/services/failed_units_service.dart'; +import 'package:linux_assistant/services/reboot_service.dart'; + +void main() { + group("Q2 — the reboot marker", () { + test("lists the packages that caused the reboot", () { + final List packages = RebootService.parsePackages( + "linux-firmware\nlinux-image-6.1\n", + ); + expect(packages, ["linux-firmware", "linux-image-6.1"]); + }); + + test("skips blank lines and comments", () { + final List packages = RebootService.parsePackages( + "\n# a comment\nlinux-firmware\n\n \n", + ); + expect(packages, ["linux-firmware"]); + }); + + test("an empty file yields no packages", () { + expect(RebootService.parsePackages(""), isEmpty); + }); + + test("no marker file means no reboot is pending", () async { + final RebootStatus status = await RebootService.readStatus( + markerPaths: ["/nonexistent/reboot-required"], + packagePaths: ["/nonexistent/reboot-required.pkgs"], + ); + expect(status.required, isFalse); + expect(status.packages, isEmpty); + }); + + test("a marker without a package list is a pending reboot of unknown cause", + () async { + final Directory directory = + Directory.systemTemp.createTempSync("mla-reboot-marker"); + addTearDown(() { + if (directory.existsSync()) directory.deleteSync(recursive: true); + }); + final File marker = File("${directory.path}/reboot-required"); + marker.writeAsStringSync(""); + + final RebootStatus status = await RebootService.readStatus( + markerPaths: [marker.path], + packagePaths: ["${directory.path}/missing.pkgs"], + ); + expect(status.required, isTrue); + expect(status.packages, isEmpty); + }); + + test("a marker with a package list names the cause", () async { + final Directory directory = + Directory.systemTemp.createTempSync("mla-reboot-packages"); + addTearDown(() { + if (directory.existsSync()) directory.deleteSync(recursive: true); + }); + final File marker = File("${directory.path}/reboot-required"); + marker.writeAsStringSync(""); + final File packages = File("${directory.path}/reboot-required.pkgs"); + packages.writeAsStringSync("linux-firmware\nlinux-image-6.1\n"); + + final RebootStatus status = await RebootService.readStatus( + markerPaths: [marker.path], + packagePaths: [packages.path], + ); + expect(status.required, isTrue); + expect(status.packages, ["linux-firmware", "linux-image-6.1"]); + }); + }); + + group("Q3 — the failed systemd units", () { + test("reads unit, description and scope from the systemctl JSON", () { + final List units = FailedUnitsService.parseUnitsJson( + '{"units":[{"unit":"restic-backup.service",' + '"description":"Backup and restore service"}]}', + FailedUnitScope.system, + ); + expect(units, hasLength(1)); + expect(units.single.unit, "restic-backup.service"); + expect(units.single.description, "Backup and restore service"); + expect(units.single.scope, FailedUnitScope.system); + }); + + test("the scope comes from the manager that was asked", () { + final List units = FailedUnitsService.parseUnitsJson( + '{"units":[{"unit":"user@1000.service","description":""}]}', + FailedUnitScope.user, + ); + expect(units.single.scope, FailedUnitScope.user); + }); + + test("a unit without a description is still listed", () { + final List units = FailedUnitsService.parseUnitsJson( + '{"units":[{"unit":"a.service"}]}', + FailedUnitScope.system, + ); + expect(units, hasLength(1)); + expect(units.single.description, isEmpty); + }); + + test("an entry without a unit name is skipped", () { + final List units = FailedUnitsService.parseUnitsJson( + '{"units":[{"description":"no name here"}]}', + FailedUnitScope.system, + ); + expect(units, isEmpty); + }); + + test("broken JSON does not crash the dashboard", () { + final List units = FailedUnitsService.parseUnitsJson( + "{not json at all", + FailedUnitScope.system, + ); + expect(units, isEmpty); + }); + + test("an empty answer is an empty list", () { + expect( + FailedUnitsService.parseUnitsJson("", FailedUnitScope.system), + isEmpty, + ); + expect( + FailedUnitsService.parseUnitsJson( + '{"units":[]}', FailedUnitScope.system), + isEmpty, + ); + }); + }); + + group("Q5 — the standard browser", () { + // The format below is what `gio mime x-scheme-handler/https` really prints + // (measured on gio 2.74). The trailing sections list applications that can + // open the type without being the default; the dashboard wants the + // default, so they are ignored. + const String gioOutput = "Default application for " + "?x-scheme-handler/https?: brave-browser.desktop\n" + "No registered applications\n" + "No recommended applications\n"; + + test("reads the desktop entry that handles HTTPS", () { + expect( + BrowserStatusService.parseGioMimeHandler(gioOutput), + "brave-browser.desktop", + ); + }); + + test("ignores the sections that only list candidates", () { + expect( + BrowserStatusService.parseGioMimeHandler( + "No default applications for ?x-scheme-handler/https?\n", + ), + isNull, + ); + }); + + test("an output without a default line means the handler is unknown", () { + expect(BrowserStatusService.parseGioMimeHandler(""), isNull); + expect( + BrowserStatusService.parseGioMimeHandler( + "No registered applications\n"), + isNull, + ); + }); + + test("an empty value means the handler is unknown", () { + expect( + BrowserStatusService.parseGioMimeHandler( + "Default application for ?x-scheme-handler/https?: \n", + ), + isNull, + ); + }); + }); + + group("Q6 — the memory tile", () { + test("reads the average over the last minute from the PSI file", () { + final MemoryPressure? pressure = LinuxMemory.parsePressure( + "some avg10=0.00 avg60=1.23 avg300=0.45 total=1234\n" + "full avg10=0.00 avg60=0.10 avg300=0.02 total=567\n", + ); + expect(pressure, isNotNull); + expect(pressure!.someAvg60, closeTo(1.23, 0.001)); + expect(pressure.fullAvg60, closeTo(0.10, 0.001)); + }); + + test("a machine under no pressure is normal", () { + expect( + LinuxMemory.levelFor(someAvg60: 0.5, fullAvg60: 0.0), + MemoryPressureLevel.normal, + ); + }); + + test("sustained pressure is elevated", () { + expect( + LinuxMemory.levelFor(someAvg60: 6.0, fullAvg60: 0.0), + MemoryPressureLevel.elevated, + ); + }); + + test("a machine that is out of memory is at the high level", () { + expect( + LinuxMemory.levelFor(someAvg60: 20.0, fullAvg60: 0.0), + MemoryPressureLevel.high, + ); + expect( + LinuxMemory.levelFor(someAvg60: 0.0, fullAvg60: 6.0), + MemoryPressureLevel.high, + ); + }); + + test("a PSI file without a usable figure yields nothing", () { + expect(LinuxMemory.parsePressure(""), isNull); + expect(LinuxMemory.parsePressure("some avg10=0.00 total=1\n"), isNull); + }); + + test("zram reports the size before and after compression", () { + final (int, int)? sizes = + LinuxMemory.parseZramMmStat("7516192768 2147483648 0 0 0 0 0"); + expect(sizes, isNotNull); + expect(sizes!.$1, 7516192768); + expect(sizes.$2, 2147483648); + }); + + test("a broken zram statistic yields nothing", () { + expect(LinuxMemory.parseZramMmStat(""), isNull); + expect(LinuxMemory.parseZramMmStat("7516192768"), isNull); + expect(LinuxMemory.parseZramMmStat("no numbers here"), isNull); + }); + + test("the zram capacity is the size of the device", () { + expect(LinuxMemory.parseZramDiskSize("2147483648\n"), 2147483648); + expect(LinuxMemory.parseZramDiskSize(""), isNull); + }); + }); +}