Skip to content

feat: Added healthcheck functionality to plugins, along with a GUI component for displaying plugin health#321

Open
truesec-jhagg wants to merge 4 commits into
CybercentreCanada:developfrom
truesec-jhagg:develop
Open

feat: Added healthcheck functionality to plugins, along with a GUI component for displaying plugin health#321
truesec-jhagg wants to merge 4 commits into
CybercentreCanada:developfrom
truesec-jhagg:develop

Conversation

@truesec-jhagg

@truesec-jhagg truesec-jhagg commented Apr 21, 2026

Copy link
Copy Markdown

Purpose

The purpose of these changes are to allow the user to see the status of plugins that may or may not be critical to the system health so they can be aware if the system has some issues. Plugins can be marked as "critical" to be flagged with red color, otherwise they will be flagged in orange color when they for any reason start reporting unhealthy.

The GUI component will be hidden if there are no plugins reporting any health status.

How to add your plugin to healthcheck GUI monitor:

In your plugin manifest add health_check: true under modules

then create a file health.py implementing a function like this.

def check_health() -> bool:
    # return True if healthy, False otherwise
    return False

And make that function perform whatever health check action you need.

To mark a plugin as critical (will display red in the GUI if it stops functioning), in the pyproject.toml of your plugin add

[tool.poetry]
...
importance = "critical"
...

Also in the front-end add a translation entry for:
healthcheck.plugin.name-of-your-plugin

…ve as the plugins' internal health check, to be displayed under /healthz/plugins endpoint
… check to allow setting of importance of plugin for ui purposes
Copilot AI review requested due to automatic review settings April 21, 2026 07:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds end-to-end plugin health monitoring: backend exposes plugin health status (including “importance”), and the UI polls and renders a top-nav indicator with a hover popover listing plugin health.

Changes:

  • UI: introduce useFetchHealth polling hook and SystemHealthStatus indicator component (with tests) and add i18n strings.
  • API: extend plugin config to support modules.health_check and importance, and add /api/healthz/plugins endpoint to report plugin health.
  • Minor UI spacing tweak in Classification chip.

Reviewed changes

Copilot reviewed 8 out of 10 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
ui/src/locales/fr/translation.json Adds French i18n strings for healthcheck status (and one route key entry in the edited region).
ui/src/locales/en/translation.json Adds English i18n strings for healthcheck status.
ui/src/components/hooks/useFetchHealth.tsx New hook to poll plugin health endpoint and expose loading/status state.
ui/src/components/hooks/useFetchHealth.test.tsx Unit tests for the new polling hook.
ui/src/components/elements/display/Classification.tsx Adjusts chip spacing (adds left margin).
ui/src/components/elements/SystemHealthStatus.tsx New top-nav UI element showing overall health + popover with per-plugin health.
ui/src/components/elements/SystemHealthStatus.test.tsx Unit tests for the new UI element.
ui/src/commons/components/topnav/AppBar.tsx Adds SystemHealthStatus to the app top bar.
api/howler/plugins/config.py Adds plugin importance enum + modules.health_check support and parsing.
api/howler/healthz.py Adds /api/healthz/plugins endpoint that runs plugin health checks and returns results.

Comment thread ui/src/locales/fr/translation.json Outdated
Comment on lines +23 to +26
.catch(() => {
const currentHealthStatus = healthStatus;
result = currentHealthStatus.map(element => ({ ...element, healthy: false }));
});
};

getSystemHealthStatus();
// Initial fetch and set up polling every pollingRateMS milliseconds

afterEach(() => {
vi.clearAllMocks();
vi.clearAllTimers();
Comment on lines +22 to +24
it('should return the initial values for data and loading on rejected promise', async () => {
const { result } = renderHook(() => useFetchHealth({ pollingRateMS: 0, pluginHealthUri: '/' }));
await waitFor(() => {
Comment thread api/howler/plugins/config.py Outdated
Comment thread ui/src/locales/fr/translation.json Outdated
Comment on lines +38 to +40
return () => clearInterval(interval);
}, [pollingRateMS]);

Comment on lines +109 to +110
waitFor(() => expect(screen.queryByTestId('mouse-over-popover')).not.toBeInTheDocument());
});
Comment thread api/howler/plugins/config.py Outdated
cccs-mdr and others added 2 commits April 22, 2026 08:40
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Comment on lines +103 to +106
<ListItem disablePadding key={componentHealth.name}>
<ListItemButton>
<ListItemIcon>
{componentHealth.healthy ? (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is accurate: https://mui.com/material-ui/react-list/

List Item Button: an action element to be used inside a list item.

If the button isn't doing anything, we shouldn't use it.

Comment on lines +40 to +72
const getIcon = () => {
if (isHealthy) {
return <Check sx={{ mr: 1 }} />;
}
if (isCritical) {
return <Error sx={{ mr: 1 }} />;
}
return <Warning sx={{ mr: 1 }} />;
};

const healthStatusElement = (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
px: 1.2,
py: 0.45,
borderRadius: 2,
bgcolor: isHealthy ? 'success.dark' : isCritical ? 'error.dark' : 'warning.dark',
color: 'white',
fontWeight: 700,
letterSpacing: 0.7,
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
transition: 'background-color 200ms ease, color 200ms ease'
}}
aria-owns={open ? 'mouse-over-popover' : undefined}
aria-haspopup="true"
onMouseEnter={handleRootMouseEnter}
onMouseLeave={handleRootMouseLeave}
id="healthy-status-root"
>
{getIcon()}
<Typography variant="body2" sx={{ fontSize: '0.9rem', cursor: 'default' }}>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
const getIcon = () => {
if (isHealthy) {
return <Check sx={{ mr: 1 }} />;
}
if (isCritical) {
return <Error sx={{ mr: 1 }} />;
}
return <Warning sx={{ mr: 1 }} />;
};
const healthStatusElement = (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
px: 1.2,
py: 0.45,
borderRadius: 2,
bgcolor: isHealthy ? 'success.dark' : isCritical ? 'error.dark' : 'warning.dark',
color: 'white',
fontWeight: 700,
letterSpacing: 0.7,
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
transition: 'background-color 200ms ease, color 200ms ease'
}}
aria-owns={open ? 'mouse-over-popover' : undefined}
aria-haspopup="true"
onMouseEnter={handleRootMouseEnter}
onMouseLeave={handleRootMouseLeave}
id="healthy-status-root"
>
{getIcon()}
<Typography variant="body2" sx={{ fontSize: '0.9rem', cursor: 'default' }}>
const Icon = useMemo(() => {
if (isHealthy) {
return Check;
}
if (isCritical) {
return Error;
}
return Warning;
}, [isHealthy, isCritical]);
const healthStatusElement = (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
px: 1.2,
py: 0.45,
borderRadius: 2,
bgcolor: isHealthy ? 'success.dark' : isCritical ? 'error.dark' : 'warning.dark',
color: 'white',
fontWeight: 700,
letterSpacing: 0.7,
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
transition: 'background-color 200ms ease, color 200ms ease'
}}
aria-owns={open ? 'mouse-over-popover' : undefined}
aria-haspopup="true"
onMouseEnter={handleRootMouseEnter}
onMouseLeave={handleRootMouseLeave}
id="healthy-status-root"
>
<Icon sx={{mr: 1}}/>
<Typography variant="body2" sx={{ fontSize: '0.9rem', cursor: 'default' }}>

<Popover
id="mouse-over-popover"
sx={{ pointerEvents: 'none' }}
open={open}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since open is only used in this one spot:

Suggested change
open={open}
open={!!anchorEl}

return <Warning sx={{ mr: 1 }} />;
};

const healthStatusElement = (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Any reason not to have these elements inline as opposed to declaring them ahead of time?

Comment on lines +23 to +29
const handleRootMouseEnter = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};

const handleRootMouseLeave = () => {
setAnchorEl(null);
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could inline these callbacks

>
{renderLeft()}
<div style={{ flex: 1 }} />
<SystemHealthStatus />

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

the commons directory is effectively readonly, as it's actually maintained separately from howler internally. Instead, you can leverage the existing addToAppBar function in AppBarProvider.tsx.

Comment thread api/howler/healthz.py
Comment on lines +68 to +74
is_healthy = False
try:
is_healthy = plugin.modules.health_check()
logger.debug("Plugin %s reported healthy", plugin.name)
except Exception:
logger.exception("Health check failed for plugin %s", plugin.name)
plugins_list.append({"name": plugin.name, "healthy": is_healthy, "importance": plugin.importance.value})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It's a tad more legible to include the is_healthy declaration in the except, as it makes it clear why it is set to False:

Suggested change
is_healthy = False
try:
is_healthy = plugin.modules.health_check()
logger.debug("Plugin %s reported healthy", plugin.name)
except Exception:
logger.exception("Health check failed for plugin %s", plugin.name)
plugins_list.append({"name": plugin.name, "healthy": is_healthy, "importance": plugin.importance.value})
try:
is_healthy = plugin.modules.health_check()
logger.debug("Plugin %s reported healthy", plugin.name)
except Exception:
is_healthy = False
logger.exception("Health check failed for plugin %s", plugin.name)
plugins_list.append({"name": plugin.name, "healthy": is_healthy, "importance": plugin.importance.value})

);

if (healthStatus.length <= 0 && !loading) {
return <></>;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
return <></>;
return null;

</CardContent>
</Card>
</Popover>
{loading && healthStatus.length === 0 ? loadingElement : healthStatusElement}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

There's no reason to render the popover when it's loading - you could instead return the loadingElement and skip rendering the popover entirely. This would also simplify away this ternary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants