Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions doc/modules/guide/pages/packages.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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 `+<package>/<component>+` (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
Expand Down
1 change: 1 addition & 0 deletions files.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Expand Down
99 changes: 99 additions & 0 deletions pkg/base1/test-overview-health.ts
Original file line number Diff line number Diff line change
@@ -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();
27 changes: 27 additions & 0 deletions pkg/lib/overview-health.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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();
}
4 changes: 3 additions & 1 deletion pkg/packagekit/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,7 @@
}
},

"preload": [ "index" ]
"preload": [ "index" ],

"overview-health": [ "updates" ]
}
1 change: 1 addition & 0 deletions pkg/playground/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,6 @@
}
},
"preload": [ "preloaded" ],
"overview-health": [ "playground" ],
"content-security-policy": "img-src 'self' data:"
}
2 changes: 2 additions & 0 deletions pkg/shell/manifests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export interface Manifest {
tools: ManifestSection | undefined;

preload: string[] | undefined;
"overview-health": string[] | undefined;
parent: ManifestParentSection | undefined;
".checksum": string | undefined;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this changes how manifest works we'll need to discuss this. My initial thought is that we should have something more generic than this, will raise with the team next week

}
Expand All @@ -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),
};
Expand Down
2 changes: 2 additions & 0 deletions pkg/systemd/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,7 @@

"preload": [ "index", "services" ],

"overview-health": [ "system/services" ],

"content-security-policy": "img-src 'self' data:"
}
8 changes: 5 additions & 3 deletions pkg/systemd/page-status.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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;
Expand All @@ -101,7 +103,7 @@ export class PageStatusNotifications extends React.Component {
else
icon = icon_for_type(status.type);
return (
<li id={ "page_status_notification_" + page.replace('/', '_') } key={page}>
<li id={ "page_status_notification_" + page.replaceAll('/', '_') } key={page}>
<Flex flexWrap={{ default: 'nowrap' }} spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsCenter' }}>
{icon}
{action}
Expand Down
10 changes: 10 additions & 0 deletions test/verify/check-pages
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down