feat: Added healthcheck functionality to plugins, along with a GUI component for displaying plugin health#321
Conversation
…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
There was a problem hiding this comment.
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
useFetchHealthpolling hook andSystemHealthStatusindicator component (with tests) and add i18n strings. - API: extend plugin config to support
modules.health_checkandimportance, and add/api/healthz/pluginsendpoint to report plugin health. - Minor UI spacing tweak in
Classificationchip.
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. |
| .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(); |
| it('should return the initial values for data and loading on rejected promise', async () => { | ||
| const { result } = renderHook(() => useFetchHealth({ pollingRateMS: 0, pluginHealthUri: '/' })); | ||
| await waitFor(() => { |
| return () => clearInterval(interval); | ||
| }, [pollingRateMS]); | ||
|
|
| waitFor(() => expect(screen.queryByTestId('mouse-over-popover')).not.toBeInTheDocument()); | ||
| }); |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
| <ListItem disablePadding key={componentHealth.name}> | ||
| <ListItemButton> | ||
| <ListItemIcon> | ||
| {componentHealth.healthy ? ( |
There was a problem hiding this comment.
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.
| 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' }}> |
There was a problem hiding this comment.
| 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} |
There was a problem hiding this comment.
Since open is only used in this one spot:
| open={open} | |
| open={!!anchorEl} |
| return <Warning sx={{ mr: 1 }} />; | ||
| }; | ||
|
|
||
| const healthStatusElement = ( |
There was a problem hiding this comment.
Any reason not to have these elements inline as opposed to declaring them ahead of time?
| const handleRootMouseEnter = (event: React.MouseEvent<HTMLElement>) => { | ||
| setAnchorEl(event.currentTarget); | ||
| }; | ||
|
|
||
| const handleRootMouseLeave = () => { | ||
| setAnchorEl(null); | ||
| }; |
There was a problem hiding this comment.
Could inline these callbacks
| > | ||
| {renderLeft()} | ||
| <div style={{ flex: 1 }} /> | ||
| <SystemHealthStatus /> |
There was a problem hiding this comment.
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.
| 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}) |
There was a problem hiding this comment.
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:
| 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 <></>; |
There was a problem hiding this comment.
| return <></>; | |
| return null; |
| </CardContent> | ||
| </Card> | ||
| </Popover> | ||
| {loading && healthStatus.length === 0 ? loadingElement : healthStatusElement} |
There was a problem hiding this comment.
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.
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.
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
Also in the front-end add a translation entry for:
healthcheck.plugin.name-of-your-plugin