From 375f650338344d50d517cf27e36c8a662887333b Mon Sep 17 00:00:00 2001 From: Josephine Pfeiffer Date: Mon, 13 Apr 2026 13:48:12 +0200 Subject: [PATCH] systemd: Dynamic Overview health card via manifest The hardcoded allowlist in PageStatusNotifications is replaced with iteration over a new top-level "overview-health" manifest field. Each package opts in explicitly, letting any plugin contribute entries without changes to the systemd package. Signed-off-by: Josephine Pfeiffer --- doc/modules/guide/pages/packages.adoc | 20 ++++++ files.js | 1 + pkg/base1/test-overview-health.ts | 99 +++++++++++++++++++++++++++ pkg/lib/overview-health.ts | 27 ++++++++ pkg/packagekit/manifest.json | 4 +- pkg/playground/manifest.json | 1 + pkg/shell/manifests.ts | 2 + pkg/systemd/manifest.json | 2 + pkg/systemd/page-status.jsx | 8 ++- test/verify/check-pages | 10 +++ 10 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 pkg/base1/test-overview-health.ts create mode 100644 pkg/lib/overview-health.ts diff --git a/doc/modules/guide/pages/packages.adoc b/doc/modules/guide/pages/packages.adoc index a75383fca1c3..3585f2adac5d 100644 --- a/doc/modules/guide/pages/packages.adoc +++ b/doc/modules/guide/pages/packages.adoc @@ -126,6 +126,26 @@ preload:: The value of this field is an array of strings, where each string is one of the keys used in the `+dashboard+`, `+menu+`, or `+tool+` fields that are explained below. +overview-health:: + A list of Cockpit page keys whose `+page_status+` notifications should + appear on the _Health_ card of the Overview page. Each page named here + is rendered on the Health card when it publishes a non-empty + `+page_status+` via `+cockpit.page_status.set_own()+`. Pages not named + in any manifest's `+overview-health+` field never appear on the Health + card, even if they publish a status. + + + The value of this field is an array of strings. Each string is a + Cockpit page key in the form `+/+` (for example, + `+"system/services"+`) or the bare package name for the package's + tool-index page (for example, `+"updates"+`). + + + For example, the `+system+` package declares: + + + ---- + "overview-health": [ "system/services" ] + ---- + + + Pages appear on the Health card in alphabetical order by page key. parent:: This option is used when module does not have its own menu item but is a part of a different module. This is described by JSON object with diff --git a/files.js b/files.js index 6ece32f2dd35..b65c977e7a74 100644 --- a/files.js +++ b/files.js @@ -83,6 +83,7 @@ const info = { "base1/test-user.js", "base1/test-websocket.js", "base1/test-import-json.ts", + "base1/test-overview-health.ts", "kdump/test-config-client.js", diff --git a/pkg/base1/test-overview-health.ts b/pkg/base1/test-overview-health.ts new file mode 100644 index 000000000000..c7d64ff47477 --- /dev/null +++ b/pkg/base1/test-overview-health.ts @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2026 Red Hat, Inc. + * SPDX-License-Identifier: LGPL-2.1-or-later + */ + +import { JsonObject } from 'cockpit'; + +import { collect_overview_health_pages } from 'overview-health'; + +import QUnit from 'qunit-tests'; + +type Manifests = { [pkg: string]: JsonObject | undefined }; + +QUnit.test("undefined manifests", function(assert) { + assert.deepEqual(collect_overview_health_pages(undefined), []); +}); + +QUnit.test("null manifests", function(assert) { + assert.deepEqual(collect_overview_health_pages(null), []); +}); + +QUnit.test("empty manifests", function(assert) { + assert.deepEqual(collect_overview_health_pages({}), []); +}); + +QUnit.test("single manifest with one page", function(assert) { + const m: Manifests = { + foo: { "overview-health": ["foo/bar"] } + }; + assert.deepEqual(collect_overview_health_pages(m), ["foo/bar"]); +}); + +QUnit.test("sorted output regardless of iteration order", function(assert) { + const m: Manifests = { + zebra: { "overview-health": ["zebra/z"] }, + apple: { "overview-health": ["apple/a"] } + }; + assert.deepEqual( + collect_overview_health_pages(m), + ["apple/a", "zebra/z"] + ); +}); + +QUnit.test("manifest without the field is skipped", function(assert) { + const m: Manifests = { + foo: { "overview-health": ["foo/bar"] }, + quiet: { menu: { index: { label: "Quiet" } } } + }; + assert.deepEqual(collect_overview_health_pages(m), ["foo/bar"]); +}); + +QUnit.test("non-array overview-health values are ignored", function(assert) { + const m: Manifests = { + a: { "overview-health": "not-an-array" }, + b: { "overview-health": 42 }, + c: { "overview-health": null }, + d: { "overview-health": { nope: true } }, + e: { "overview-health": ["kept"] } + }; + assert.deepEqual(collect_overview_health_pages(m), ["kept"]); +}); + +QUnit.test("non-string array elements are filtered out", function(assert) { + const m: Manifests = { + a: { "overview-health": [1, "kept1", null, { k: 1 }, "kept2", true] } + }; + assert.deepEqual(collect_overview_health_pages(m), ["kept1", "kept2"]); +}); + +QUnit.test("duplicate page keys across manifests are deduped", function(assert) { + const m: Manifests = { + one: { "overview-health": ["shared", "one-only"] }, + two: { "overview-health": ["shared", "two-only"] } + }; + assert.deepEqual( + collect_overview_health_pages(m), + ["one-only", "shared", "two-only"] + ); +}); + +QUnit.test("realistic cockpit fixture", function(assert) { + const m: Manifests = { + system: { + name: "system", + "overview-health": ["system/services"] + }, + updates: { + name: "updates", + "overview-health": ["updates"] + }, + shell: { name: "shell" } + }; + assert.deepEqual( + collect_overview_health_pages(m), + ["system/services", "updates"] + ); +}); + +QUnit.start(); diff --git a/pkg/lib/overview-health.ts b/pkg/lib/overview-health.ts new file mode 100644 index 000000000000..58e8b7a06e15 --- /dev/null +++ b/pkg/lib/overview-health.ts @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 Red Hat, Inc. + * SPDX-License-Identifier: LGPL-2.1-or-later + */ + +import type { JsonObject } from 'cockpit'; + +type Manifests = { [pkg: string]: JsonObject | undefined }; + +export function collect_overview_health_pages( + manifests: Manifests | null | undefined +): string[] { + const seen = new Set(); + if (!manifests) + return []; + + for (const pkg of Object.keys(manifests)) { + const list = manifests[pkg]?.["overview-health"]; + if (!Array.isArray(list)) + continue; + for (const page of list) { + if (typeof page === "string") + seen.add(page); + } + } + return [...seen].sort(); +} diff --git a/pkg/packagekit/manifest.json b/pkg/packagekit/manifest.json index 32fafd20a351..cbd88b0e9a53 100644 --- a/pkg/packagekit/manifest.json +++ b/pkg/packagekit/manifest.json @@ -23,5 +23,7 @@ } }, - "preload": [ "index" ] + "preload": [ "index" ], + + "overview-health": [ "updates" ] } diff --git a/pkg/playground/manifest.json b/pkg/playground/manifest.json index 2ae1ea85793d..3f9ba896d23d 100644 --- a/pkg/playground/manifest.json +++ b/pkg/playground/manifest.json @@ -56,5 +56,6 @@ } }, "preload": [ "preloaded" ], + "overview-health": [ "playground" ], "content-security-policy": "img-src 'self' data:" } diff --git a/pkg/shell/manifests.ts b/pkg/shell/manifests.ts index 0b7c38dc7ee1..3801a2779f66 100644 --- a/pkg/shell/manifests.ts +++ b/pkg/shell/manifests.ts @@ -88,6 +88,7 @@ export interface Manifest { tools: ManifestSection | undefined; preload: string[] | undefined; + "overview-health": string[] | undefined; parent: ManifestParentSection | undefined; ".checksum": string | undefined; } @@ -99,6 +100,7 @@ function import_Manifest(val: JsonValue): Manifest { menu: get_optional(obj, "menu", import_ManifestSection), tools: get_optional(obj, "tools", import_ManifestSection), preload: get_optional(obj, "preload", v => import_array(v, import_string)), + "overview-health": get_optional(obj, "overview-health", v => import_array(v, import_string)), parent: get_optional(obj, "parent", import_ManifestParentSection), ".checksum": get_optional(obj, ".checksum", import_string), }; diff --git a/pkg/systemd/manifest.json b/pkg/systemd/manifest.json index 4afcbd6efdef..09278cec17e0 100644 --- a/pkg/systemd/manifest.json +++ b/pkg/systemd/manifest.json @@ -85,5 +85,7 @@ "preload": [ "index", "services" ], + "overview-health": [ "system/services" ], + "content-security-policy": "img-src 'self' data:" } diff --git a/pkg/systemd/page-status.jsx b/pkg/systemd/page-status.jsx index 44c4f8af25ff..7addc975bae9 100644 --- a/pkg/systemd/page-status.jsx +++ b/pkg/systemd/page-status.jsx @@ -23,6 +23,7 @@ import { import "./page-status.scss"; import { page_status } from "notifications"; +import { collect_overview_health_pages } from "overview-health"; function icon_for_type(type) { if (type == "error") @@ -77,8 +78,9 @@ export class PageStatusNotifications extends React.Component { } render() { - // Explicit allowlist for now, until we can get a dynamic list - return ["system/services", "updates"].map(page => { + // Pages opt in via their manifest's "overview-health" field. + // See doc/modules/guide/pages/packages.adoc. + return collect_overview_health_pages(cockpit.manifests).map(page => { const status = page_status.get(page); if (status && (status.type || status.details) && status.title) { let action; @@ -101,7 +103,7 @@ export class PageStatusNotifications extends React.Component { else icon = icon_for_type(status.type); return ( -
  • +
  • {icon} {action} diff --git a/test/verify/check-pages b/test/verify/check-pages index 67749a6896f0..6b03f55f0565 100755 --- a/test/verify/check-pages +++ b/test/verify/check-pages @@ -758,6 +758,11 @@ OnCalendar=daily b.wait_text("#received-type", "info") b.wait_text("#received-title", "My Little Page Status") + b.switch_to_top() + b.go("/system") + b.enter_page("/system") + b.wait_in_text("#page_status_notification_playground", "My Little Page Status") + b.switch_to_top() b.go("/playground") b.enter_page("/playground") @@ -771,6 +776,11 @@ OnCalendar=daily b.wait_text("#received-type", "-") b.wait_text("#received-title", "-") + b.switch_to_top() + b.go("/system") + b.enter_page("/system") + b.wait_not_present("#page_status_notification_playground") + def testHistory(self): b = self.browser