@rxtx4816/cockpit-plugin-base-react 1.0.1 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/docs/wiki/CI-CD.md +87 -0
- package/docs/wiki/Components.md +56 -0
- package/docs/wiki/Getting-Started.md +92 -0
- package/docs/wiki/Home.md +13 -0
- package/docs/wiki/Hooks.md +58 -0
- package/docs/wiki/Systemd.md +43 -0
- package/docs/wiki/Testing.md +51 -0
- package/docs/wiki/VM-Testing.md +99 -0
- package/package.json +11 -4
- package/src/bootstrap.tsx +7 -0
- package/src/cockpit.d.ts +1 -0
- package/src/components/ConfirmDialog.tsx +15 -0
- package/src/components/ErrorBoundary.tsx +5 -0
- package/src/components/HelpPopover.tsx +6 -0
- package/src/components/LogViewer.tsx +15 -0
- package/src/components/StatusBadge.tsx +21 -0
- package/src/components/ToastProvider.tsx +21 -0
- package/src/hooks/useAsyncAction.ts +8 -0
- package/src/hooks/useAsyncStream.ts +15 -4
- package/src/hooks/useAutoRefresh.ts +7 -0
- package/src/hooks/useConfirmAction.ts +18 -0
- package/src/hooks/usePollingFetch.ts +17 -2
- package/src/i18n.ts +12 -0
- package/src/systemd/ServiceControl.tsx +24 -0
- package/src/systemd/api.ts +25 -0
- package/src/systemd/types.ts +9 -0
- package/src/systemd/useServiceStatus.test.ts +1 -1
- package/src/systemd/useServiceStatus.ts +7 -0
- package/src/testing/helpers.ts +16 -0
- package/{vitest.config.base.ts → vitest.config.base.js} +1 -10
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RXTX4816
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @rxtx4816/cockpit-plugin-base-react
|
|
2
|
+
|
|
3
|
+
Shared foundation for building [Cockpit](https://cockpit-project.org/) plugins with React and PatternFly v6. Extracts the boilerplate that every plugin needs — bootstrapping, i18n, dark theme, async patterns, systemd integration, shared tooling config, and a full QEMU VM test harness — so each plugin only contains its own logic.
|
|
4
|
+
|
|
5
|
+
## What's included
|
|
6
|
+
|
|
7
|
+
**Plugin runtime**
|
|
8
|
+
- `bootstrapPlugin` — mounts your React app into the Cockpit frame with i18n and error boundary wired up
|
|
9
|
+
- `dark-theme` — side-effect module that automatically syncs the `pf-v6-theme-dark` class with the Cockpit shell, responding to user preference changes and system theme
|
|
10
|
+
- `initCockpitI18n` — sets up i18next with Cockpit's locale loading conventions
|
|
11
|
+
|
|
12
|
+
**Hooks**
|
|
13
|
+
- `useAsyncAction` — wraps an async operation with `loading`, `error`, and `execute` state; ideal for buttons that trigger backend calls
|
|
14
|
+
- `useAutoRefresh` — runs a callback on a configurable interval, with manual refresh support
|
|
15
|
+
- `useAsyncStream` — consumes a Cockpit channel as a line-buffered async stream
|
|
16
|
+
- `useConfirmAction` — multi-step confirmation flow with typed state transitions
|
|
17
|
+
- `usePollingFetch` — fetch with automatic polling, refresh, and loading state
|
|
18
|
+
|
|
19
|
+
**Components**
|
|
20
|
+
- `ConfirmDialog` — confirmation modal driven by `useConfirmAction`, supports multi-step flows
|
|
21
|
+
- `ErrorBoundary` — catches render errors and shows a PatternFly alert with details
|
|
22
|
+
- `HelpPopover` — PatternFly popover for contextual help text
|
|
23
|
+
- `LogViewer` — scrollable terminal-style log display backed by an async stream
|
|
24
|
+
- `StatusBadge` — color-coded badge for service or resource states
|
|
25
|
+
- `ToastProvider` + hook — global toast notification system
|
|
26
|
+
|
|
27
|
+
**Systemd layer**
|
|
28
|
+
- `useServiceStatus` — reactive hook for a systemd service state (active, failed, inactive…)
|
|
29
|
+
- `ServiceControl` — start/stop/restart/enable control component
|
|
30
|
+
- `api` — typed wrappers around `cockpit.spawn` for systemctl operations
|
|
31
|
+
|
|
32
|
+
**Shared tooling config**
|
|
33
|
+
- `tsconfig.base.json` — TypeScript base config tuned for Cockpit plugins
|
|
34
|
+
- `eslint.config.base` — `createEslintConfig()` factory with TS, React, and react-hooks rules
|
|
35
|
+
- `vitest.config.base` — Vitest base config with jsdom and PatternFly setup
|
|
36
|
+
|
|
37
|
+
**Testing utilities**
|
|
38
|
+
- Vitest setup file that installs jsdom and jest-dom matchers
|
|
39
|
+
- `mockCockpit` — in-memory Cockpit API mock for unit tests
|
|
40
|
+
- `mockHttpClient` — mock for Cockpit HTTP client used in tests
|
|
41
|
+
|
|
42
|
+
**QEMU VM test harness**
|
|
43
|
+
- `npm run vm` — spins up real cloud VMs (Arch, Debian, Fedora) with Cockpit installed and your plugin mounted via virtfs. Used for end-to-end testing against a live Cockpit instance. See [VM Testing](docs/wiki/VM-Testing.md).
|
|
44
|
+
|
|
45
|
+
**Reusable CI/CD workflows**
|
|
46
|
+
- Lint, typecheck, test, and build on every push
|
|
47
|
+
- RPM, DEB, and Arch package build verification
|
|
48
|
+
- Semantic version bumping from conventional commits
|
|
49
|
+
- Automated release asset upload and AUR publishing
|
|
50
|
+
- GitHub Wiki sync from `docs/wiki/`
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npm install @rxtx4816/cockpit-plugin-base-react
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Peer dependencies: `react >=19`, `react-dom >=19`, `i18next >=26`, `react-i18next >=17`
|
|
59
|
+
|
|
60
|
+
## Quick start
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
// src/index.tsx
|
|
64
|
+
import "./i18n";
|
|
65
|
+
import "@rxtx4816/cockpit-plugin-base-react/dark-theme";
|
|
66
|
+
import { bootstrapPlugin } from "@rxtx4816/cockpit-plugin-base-react/bootstrap";
|
|
67
|
+
import App from "./App";
|
|
68
|
+
|
|
69
|
+
bootstrapPlugin(App);
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
For full setup guidance, config sharing, and workflow integration see the [wiki](docs/wiki/Home.md).
|
|
73
|
+
|
|
74
|
+
## Documentation
|
|
75
|
+
|
|
76
|
+
- [Getting Started](docs/wiki/Getting-Started.md)
|
|
77
|
+
- [Hooks](docs/wiki/Hooks.md)
|
|
78
|
+
- [Components](docs/wiki/Components.md)
|
|
79
|
+
- [Systemd Layer](docs/wiki/Systemd.md)
|
|
80
|
+
- [Testing](docs/wiki/Testing.md)
|
|
81
|
+
- [VM Testing](docs/wiki/VM-Testing.md)
|
|
82
|
+
- [CI/CD Workflows](docs/wiki/CI-CD.md)
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT © 2026 RXTX4816
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# CI/CD Workflows
|
|
2
|
+
|
|
3
|
+
The package ships reusable GitHub Actions workflows that plugins call with `uses:`. This keeps CI logic in one place and lets all plugins inherit fixes and improvements automatically.
|
|
4
|
+
|
|
5
|
+
## Using the workflows
|
|
6
|
+
|
|
7
|
+
In your plugin's `.github/workflows/`:
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
# .github/workflows/ci.yml
|
|
11
|
+
name: CI
|
|
12
|
+
on:
|
|
13
|
+
push:
|
|
14
|
+
branches: [main]
|
|
15
|
+
pull_request:
|
|
16
|
+
branches: [main]
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
ci:
|
|
20
|
+
uses: RXTX4816/cockpit-plugin-base-react/.github/workflows/ci-plugin.yml@main
|
|
21
|
+
with:
|
|
22
|
+
plugin-name: cockpit-caddy
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Available workflows
|
|
28
|
+
|
|
29
|
+
### ci-plugin.yml
|
|
30
|
+
|
|
31
|
+
Runs on every push and pull request. Steps: lint → typecheck → test → build → verify build output.
|
|
32
|
+
|
|
33
|
+
Required inputs:
|
|
34
|
+
- `plugin-name` — used to verify `src/main.js` and `src/main.css` exist after build
|
|
35
|
+
|
|
36
|
+
### pkg-ci-plugin.yml
|
|
37
|
+
|
|
38
|
+
Builds and verifies RPM, DEB, and Arch packages using a placeholder `0.0.0` version. Confirms that the packaging definitions (`.spec`, `debian/`, `PKGBUILD`) are valid before a real release.
|
|
39
|
+
|
|
40
|
+
Required inputs:
|
|
41
|
+
- `plugin-name`
|
|
42
|
+
- `spec-file` — path to the RPM spec file (e.g. `cockpit-caddy.spec`)
|
|
43
|
+
- `aur-pkgname` — AUR package name
|
|
44
|
+
|
|
45
|
+
### semantic-release-plugin.yml
|
|
46
|
+
|
|
47
|
+
Determines the next version from conventional commits since the last git tag and creates the tag. Does not build or publish anything — it only tags. The tag then triggers whatever release workflow you have listening on `push.tags`.
|
|
48
|
+
|
|
49
|
+
Version bump rules:
|
|
50
|
+
- `BREAKING CHANGE` anywhere in a commit body → major
|
|
51
|
+
- `feat:` prefix → minor
|
|
52
|
+
- anything else → patch
|
|
53
|
+
- no commits since last tag → skip (exits 0, no tag created)
|
|
54
|
+
|
|
55
|
+
Required secrets:
|
|
56
|
+
- `RELEASE_TOKEN` — a GitHub token with `contents: write` permission
|
|
57
|
+
|
|
58
|
+
Optional inputs:
|
|
59
|
+
- `initial_version` — version to use when no prior tag exists (default: `v1.0.0`)
|
|
60
|
+
|
|
61
|
+
### release-plugin.yml
|
|
62
|
+
|
|
63
|
+
Builds release assets (tarball + sha256, RPM, DEB) and uploads them to a GitHub release. Also updates the `PKGBUILD` and pushes to AUR.
|
|
64
|
+
|
|
65
|
+
Required inputs:
|
|
66
|
+
- `plugin-name`, `spec-file`, `aur-pkgname`
|
|
67
|
+
- `maintainer-name`, `maintainer-email`
|
|
68
|
+
|
|
69
|
+
Required secrets:
|
|
70
|
+
- `AUR_SSH_KEY` — SSH private key registered with the AUR account
|
|
71
|
+
- `RELEASE_TOKEN` — GitHub token with `contents: write`
|
|
72
|
+
|
|
73
|
+
### sync-wiki-plugin.yml
|
|
74
|
+
|
|
75
|
+
Copies all `.md` files from `docs/wiki/` in your plugin repo to the GitHub Wiki. Triggered by push to main.
|
|
76
|
+
|
|
77
|
+
No inputs or secrets required beyond the default `GITHUB_TOKEN`.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Secrets reference
|
|
82
|
+
|
|
83
|
+
| Secret | Used by | Description |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `RELEASE_TOKEN` | semantic-release, release | GitHub PAT with `contents: write` |
|
|
86
|
+
| `AUR_SSH_KEY` | release | SSH private key for AUR pushes |
|
|
87
|
+
| `NPM_TOKEN` | (base package only) | npm automation token for publishing |
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Components
|
|
2
|
+
|
|
3
|
+
All components are exported from the components entrypoint:
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { ConfirmDialog, ErrorBoundary, HelpPopover, LogViewer, StatusBadge, ToastProvider } from "@rxtx4816/cockpit-plugin-base-react/components";
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
`ToastProvider` is mounted automatically by `bootstrapPlugin`. The others are available for use anywhere in your plugin.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## ConfirmDialog
|
|
14
|
+
|
|
15
|
+
A PatternFly modal dialog driven by `useConfirmAction` state. Supports multi-step confirmation flows — for example, showing a warning first and requiring the user to type a resource name before proceeding.
|
|
16
|
+
|
|
17
|
+
Receives the `state` and `cancel` from `useConfirmAction` and renders the appropriate step content.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## ErrorBoundary
|
|
22
|
+
|
|
23
|
+
A React error boundary that catches render errors anywhere in the component tree and displays a PatternFly alert with the error message and stack trace. Prevents the entire plugin from going blank on an unexpected error.
|
|
24
|
+
|
|
25
|
+
Mounted automatically by `bootstrapPlugin`, but can also be used to wrap specific subtrees.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## HelpPopover
|
|
30
|
+
|
|
31
|
+
A small PatternFly popover for contextual help. Renders a help icon button that opens a popover with a title and body text. Use it next to form fields or section headings to explain non-obvious behaviour.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## LogViewer
|
|
36
|
+
|
|
37
|
+
A scrollable, terminal-style log display that accepts an array of output lines (typically from `useAsyncStream`). Automatically scrolls to the bottom on new output. Used for displaying real-time command output or service journal entries.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## StatusBadge
|
|
42
|
+
|
|
43
|
+
A color-coded label for service or resource states. Maps state strings (e.g. `"active"`, `"failed"`, `"inactive"`, `"unknown"`) to PatternFly status colors. Used by `ServiceControl` and can be used standalone wherever you need to display a state.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## ToastProvider
|
|
48
|
+
|
|
49
|
+
Global toast notification context. Wrap your app with `ToastProvider` (done automatically by `bootstrapPlugin`) and use the `useToast` hook to fire notifications from anywhere:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const { addToast } = useToast();
|
|
53
|
+
addToast({ title: "Saved", variant: "success" });
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Toasts are displayed in the top-right corner and auto-dismiss after a configurable timeout.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Getting Started
|
|
2
|
+
|
|
3
|
+
## Installation
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @rxtx4816/cockpit-plugin-base-react
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Peer dependencies required in your plugin:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install react react-dom i18next react-i18next
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Bootstrapping your plugin
|
|
18
|
+
|
|
19
|
+
Every Cockpit plugin needs an entry point that initialises i18n, the dark theme, and mounts React. This package handles all of it:
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
// src/index.tsx
|
|
23
|
+
import "./i18n";
|
|
24
|
+
import "@rxtx4816/cockpit-plugin-base-react/dark-theme";
|
|
25
|
+
import { bootstrapPlugin } from "@rxtx4816/cockpit-plugin-base-react/bootstrap";
|
|
26
|
+
import App from "./App";
|
|
27
|
+
|
|
28
|
+
bootstrapPlugin(App);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`bootstrapPlugin` wraps your app in an `ErrorBoundary` and a `ToastProvider`, then mounts it into the `#app` element that Cockpit expects.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## i18n setup
|
|
36
|
+
|
|
37
|
+
Create `src/i18n.ts` in your plugin:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { initCockpitI18n } from "@rxtx4816/cockpit-plugin-base-react/i18n";
|
|
41
|
+
|
|
42
|
+
initCockpitI18n();
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
This sets up i18next with Cockpit's locale loading conventions so `useTranslation()` works throughout your plugin.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Shared tooling config
|
|
50
|
+
|
|
51
|
+
Extend from the base configs so all plugins stay consistent.
|
|
52
|
+
|
|
53
|
+
**tsconfig.json**
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"extends": "@rxtx4816/cockpit-plugin-base-react/tsconfig.base.json",
|
|
57
|
+
"compilerOptions": {
|
|
58
|
+
"paths": {}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**eslint.config.js**
|
|
64
|
+
```js
|
|
65
|
+
import { createEslintConfig } from "@rxtx4816/cockpit-plugin-base-react/eslint.config.base";
|
|
66
|
+
|
|
67
|
+
export default createEslintConfig();
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Pass extra globals if your plugin uses custom Cockpit types:
|
|
71
|
+
```js
|
|
72
|
+
export default createEslintConfig({ CockpitHttpClient: "readonly" });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**vitest.config.ts**
|
|
76
|
+
```ts
|
|
77
|
+
import { defineConfig } from "@rxtx4816/cockpit-plugin-base-react/vitest.config.base";
|
|
78
|
+
|
|
79
|
+
export default defineConfig();
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Dark theme
|
|
85
|
+
|
|
86
|
+
Importing the `dark-theme` side-effect module is all that's needed. It listens for three signals and keeps the `pf-v6-theme-dark` class on `<html>` in sync:
|
|
87
|
+
|
|
88
|
+
- `localStorage` key `shell:style` (values: `"light"`, `"dark"`, `"auto"`)
|
|
89
|
+
- The custom `cockpit-style` event dispatched by the Cockpit shell switcher
|
|
90
|
+
- The OS-level `prefers-color-scheme` media query
|
|
91
|
+
|
|
92
|
+
No configuration required.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# cockpit-plugin-base-react Wiki
|
|
2
|
+
|
|
3
|
+
This wiki covers everything you need to build, test, and ship Cockpit plugins using `@rxtx4816/cockpit-plugin-base-react`.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- [Getting Started](Getting-Started.md) — install, bootstrap, and first plugin setup
|
|
8
|
+
- [Hooks](Hooks.md) — async state, polling, streams, confirmation flows
|
|
9
|
+
- [Components](Components.md) — UI building blocks: toasts, dialogs, logs, badges
|
|
10
|
+
- [Systemd Layer](Systemd.md) — service status, control, and typed API wrappers
|
|
11
|
+
- [Testing](Testing.md) — unit testing with mocked Cockpit and HTTP client
|
|
12
|
+
- [VM Testing](VM-Testing.md) — end-to-end testing with real QEMU VMs
|
|
13
|
+
- [CI/CD Workflows](CI-CD.md) — reusable GitHub Actions for builds, packages, and releases
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Hooks
|
|
2
|
+
|
|
3
|
+
All hooks are exported from the main entrypoint:
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { useAsyncAction, useAutoRefresh, useAsyncStream, useConfirmAction, usePollingFetch } from "@rxtx4816/cockpit-plugin-base-react";
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## useAsyncAction
|
|
12
|
+
|
|
13
|
+
Wraps an async operation with `loading`, `error`, and `execute` state. Designed for buttons or forms that trigger backend calls.
|
|
14
|
+
|
|
15
|
+
The hook returns an object with:
|
|
16
|
+
- `execute(...args)` — triggers the operation
|
|
17
|
+
- `loading` — true while the operation is in progress
|
|
18
|
+
- `error` — the caught error if the operation failed, or null
|
|
19
|
+
- `reset()` — clears error state
|
|
20
|
+
|
|
21
|
+
Errors are caught automatically; unhandled promise rejections do not propagate to the component tree.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## useAutoRefresh
|
|
26
|
+
|
|
27
|
+
Runs a callback on a configurable interval. Returns a `refresh()` function for on-demand triggering and a `loading` flag.
|
|
28
|
+
|
|
29
|
+
Useful for periodically re-fetching data without managing `setInterval` lifecycle yourself. The interval is cleared on unmount.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## useAsyncStream
|
|
34
|
+
|
|
35
|
+
Consumes a Cockpit channel as a line-buffered async stream. Returns the accumulated output lines and an `error` state.
|
|
36
|
+
|
|
37
|
+
Used internally by `LogViewer` and useful anywhere you need to display or process real-time output from a spawned process.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## useConfirmAction
|
|
42
|
+
|
|
43
|
+
Manages a multi-step confirmation flow with typed state transitions. Returns:
|
|
44
|
+
|
|
45
|
+
- `state` — current step or `null` when idle
|
|
46
|
+
- `start(initialStep)` — opens the flow
|
|
47
|
+
- `next(step)` — advances to the next step
|
|
48
|
+
- `cancel()` — resets to idle
|
|
49
|
+
|
|
50
|
+
Pairs with `ConfirmDialog` to build destructive action flows (e.g. delete with a typed confirmation).
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## usePollingFetch
|
|
55
|
+
|
|
56
|
+
Fetches a resource with automatic polling and returns `{ data, loading, error, refresh }`. The poll interval is configurable.
|
|
57
|
+
|
|
58
|
+
Handles the full lifecycle: initial fetch, polling, cleanup on unmount, and error recovery. Calling `refresh()` triggers an immediate re-fetch and resets the poll timer.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Systemd Layer
|
|
2
|
+
|
|
3
|
+
The systemd layer provides typed hooks, a control component, and low-level API wrappers for interacting with systemd services through Cockpit.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { useServiceStatus, ServiceControl } from "@rxtx4816/cockpit-plugin-base-react/systemd";
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## useServiceStatus
|
|
12
|
+
|
|
13
|
+
Reactive hook that tracks the state of a systemd service. Polls via `systemctl is-active` and returns:
|
|
14
|
+
|
|
15
|
+
- `status` — one of `"active"`, `"inactive"`, `"failed"`, `"activating"`, `"deactivating"`, `"unknown"`
|
|
16
|
+
- `loading` — true on the initial fetch
|
|
17
|
+
- `error` — any error from the underlying `cockpit.spawn` call
|
|
18
|
+
- `refresh()` — manually re-check the service state
|
|
19
|
+
|
|
20
|
+
The hook automatically re-polls at a configurable interval, so your UI stays in sync without manual management.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## ServiceControl
|
|
25
|
+
|
|
26
|
+
A self-contained component that renders start/stop/restart/enable/disable buttons for a named service. Internally uses `useServiceStatus` for the current state and the `api` helpers to dispatch commands.
|
|
27
|
+
|
|
28
|
+
Buttons are disabled while an operation is in progress. Status changes are reflected immediately via an optimistic state update, then confirmed by the next poll.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## API helpers
|
|
33
|
+
|
|
34
|
+
Low-level typed wrappers around `cockpit.spawn` for common systemctl operations:
|
|
35
|
+
|
|
36
|
+
- `startService(name)` — `systemctl start`
|
|
37
|
+
- `stopService(name)` — `systemctl stop`
|
|
38
|
+
- `restartService(name)` — `systemctl restart`
|
|
39
|
+
- `enableService(name)` — `systemctl enable`
|
|
40
|
+
- `disableService(name)` — `systemctl disable`
|
|
41
|
+
- `getServiceStatus(name)` — returns the current active state string
|
|
42
|
+
|
|
43
|
+
All functions return Promises and throw on non-zero exit codes.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
## Setup
|
|
4
|
+
|
|
5
|
+
The package ships a Vitest setup file that configures jsdom and installs jest-dom matchers. Reference it from your plugin's `vitest.config.ts` (handled automatically when you extend the base config):
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { defineConfig } from "@rxtx4816/cockpit-plugin-base-react/vitest.config.base";
|
|
9
|
+
export default defineConfig();
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Test utilities
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { mockCockpit, mockHttpClient } from "@rxtx4816/cockpit-plugin-base-react/testing/helpers";
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### mockCockpit
|
|
21
|
+
|
|
22
|
+
Returns an in-memory mock of the `cockpit` browser global. Stubs out `cockpit.spawn`, `cockpit.file`, `cockpit.http`, and channel creation so tests never attempt real system calls.
|
|
23
|
+
|
|
24
|
+
Set it up in your test file:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.stubGlobal("cockpit", mockCockpit());
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Individual spawn calls can be configured to return specific output or to reject, letting you test both success and error paths.
|
|
33
|
+
|
|
34
|
+
### mockHttpClient
|
|
35
|
+
|
|
36
|
+
Returns a mock of the Cockpit HTTP client with configurable per-path responses. Useful for testing components that call `cockpit.http().get(path)`.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
const client = mockHttpClient({ "/api/status": '{"running": true}' });
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`get`, `post`, and `request` are all `vi.fn()` instances, so you can assert call counts and arguments with standard Vitest matchers.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Running tests
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm test # single run
|
|
50
|
+
npm run test:watch # watch mode
|
|
51
|
+
```
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# VM Testing
|
|
2
|
+
|
|
3
|
+
The package ships a QEMU-based VM harness for end-to-end testing against a real Cockpit instance running on a real OS. It spins up cloud VMs (Arch, Debian, Fedora by default), installs Cockpit, and mounts your built plugin via virtfs — no packaging or installation step required.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
Arch Linux:
|
|
8
|
+
```bash
|
|
9
|
+
sudo pacman -S qemu-full cloud-image-utils wget
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
KVM access is strongly recommended. Without it the VMs run without hardware acceleration and will be significantly slower.
|
|
13
|
+
|
|
14
|
+
## How it works
|
|
15
|
+
|
|
16
|
+
1. A base cloud image is downloaded once per distro and kept on disk
|
|
17
|
+
2. Each VM gets a thin overlay disk so the base image is never modified
|
|
18
|
+
3. cloud-init provisions the VM on first boot: creates a `test` user, installs Cockpit and any plugin-specific packages, and mounts your plugin's `src/` directory read-only into the Cockpit install path via 9p/virtfs
|
|
19
|
+
4. Changes to your built output (`src/main.js`, `src/main.css`) are immediately visible inside the VM without a restart
|
|
20
|
+
|
|
21
|
+
## Plugin configuration
|
|
22
|
+
|
|
23
|
+
Each plugin provides `scripts/test-vm.config.sh` to customise the harness:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
PLUGIN_NAME="cockpit-caddy"
|
|
27
|
+
MOUNT_TAG="cockpit_caddy"
|
|
28
|
+
INSTALL_PATH="/usr/share/cockpit/cockpit-caddy"
|
|
29
|
+
|
|
30
|
+
extra_packages() {
|
|
31
|
+
echo "caddy"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
extra_runcmd() {
|
|
35
|
+
echo " - systemctl enable --now caddy"
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
Add to your plugin's `package.json`:
|
|
42
|
+
```json
|
|
43
|
+
"vm": "node_modules/@rxtx4816/cockpit-plugin-base-react/scripts/test-vm.sh"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm run build # build the plugin first
|
|
50
|
+
npm run vm download arch # download base image (once)
|
|
51
|
+
npm run vm start arch # start the VM
|
|
52
|
+
npm run vm wait arch # block until cloud-init finishes (~2 min first boot)
|
|
53
|
+
# open https://localhost:9090 — login: test / test
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
After the initial boot, subsequent starts are fast (no re-provisioning unless you `clean`).
|
|
57
|
+
|
|
58
|
+
## All commands
|
|
59
|
+
|
|
60
|
+
| Command | Description |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `download [vm\|all]` | Download base cloud images |
|
|
63
|
+
| `build` | Run `npm run build` |
|
|
64
|
+
| `start [vm ...]` | Start VM(s) in background |
|
|
65
|
+
| `wait <vm>` | Block until cloud-init completes |
|
|
66
|
+
| `stop [vm ...]` | Stop VM(s) |
|
|
67
|
+
| `status` | Show all VMs with ports and running state |
|
|
68
|
+
| `ssh <vm>` | Open an SSH session into the VM |
|
|
69
|
+
| `logs <vm>` | Tail the VM serial console |
|
|
70
|
+
| `clean [vm ...]` | Wipe disk and cloud-init state (base image kept) |
|
|
71
|
+
| `rebuild [vm ...]` | `clean` + `start` in one step |
|
|
72
|
+
| `reset [vm ...]` | Remove all VM files including base image |
|
|
73
|
+
|
|
74
|
+
## Ports
|
|
75
|
+
|
|
76
|
+
By default the VMs are assigned sequential ports starting from:
|
|
77
|
+
- Cockpit: `9090`, `9091`, `9092` (arch, debian, fedora)
|
|
78
|
+
- SSH: `2220`, `2221`, `2222`
|
|
79
|
+
|
|
80
|
+
These can be changed in your `test-vm.config.sh` via `SSH_BASE` and `COCKPIT_BASE`.
|
|
81
|
+
|
|
82
|
+
## Live reload workflow
|
|
83
|
+
|
|
84
|
+
Because the plugin is mounted via virtfs, you can iterate quickly:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm run watch # rebuild on source changes
|
|
88
|
+
npm run vm start arch
|
|
89
|
+
npm run vm wait arch
|
|
90
|
+
# reload the browser tab after each rebuild — no VM restart needed
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Environment overrides
|
|
94
|
+
|
|
95
|
+
| Variable | Default | Description |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `VM_MEM` | `1024` | Memory per VM in MB |
|
|
98
|
+
| `VM_CPUS` | `2` | vCPU count |
|
|
99
|
+
| `VM_DISK_SIZE` | `12G` | Overlay disk size |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rxtx4816/cockpit-plugin-base-react",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Shared infrastructure for Cockpit plugins: i18n, dark theme, test setup, config presets, CI/CD workflows, and QEMU VM harness",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "RXTX4816",
|
|
@@ -60,23 +60,29 @@
|
|
|
60
60
|
"default": "./eslint.config.base.js"
|
|
61
61
|
},
|
|
62
62
|
"./vitest.config.base": {
|
|
63
|
-
"default": "./vitest.config.base.
|
|
63
|
+
"default": "./vitest.config.base.js"
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"files": [
|
|
67
|
+
"LICENSE",
|
|
68
|
+
"README.md",
|
|
69
|
+
"docs/",
|
|
67
70
|
"src/",
|
|
68
71
|
"scripts/",
|
|
69
72
|
"tsconfig.base.json",
|
|
70
73
|
"eslint.config.base.js",
|
|
71
|
-
"vitest.config.base.
|
|
74
|
+
"vitest.config.base.js"
|
|
72
75
|
],
|
|
73
76
|
"bin": {
|
|
74
77
|
"cockpit-test-vm": "./scripts/test-vm.sh"
|
|
75
78
|
},
|
|
76
79
|
"scripts": {
|
|
80
|
+
"lint": "eslint src/",
|
|
77
81
|
"typecheck": "tsc --noEmit",
|
|
78
82
|
"test": "vitest run",
|
|
79
|
-
"test:watch": "vitest"
|
|
83
|
+
"test:watch": "vitest",
|
|
84
|
+
"docs": "typedoc",
|
|
85
|
+
"docs:watch": "typedoc --watch"
|
|
80
86
|
},
|
|
81
87
|
"peerDependencies": {
|
|
82
88
|
"i18next": ">=26",
|
|
@@ -100,6 +106,7 @@
|
|
|
100
106
|
"react": "^19.2.7",
|
|
101
107
|
"react-dom": "^19.2.7",
|
|
102
108
|
"react-i18next": "^17.0.8",
|
|
109
|
+
"typedoc": "^0.28.19",
|
|
103
110
|
"typescript": "^6.0.3",
|
|
104
111
|
"vitest": "^4.1.9"
|
|
105
112
|
},
|
package/src/bootstrap.tsx
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { ComponentType } from "react";
|
|
2
2
|
import { createRoot } from "react-dom/client";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Mounts a React application into the `#root` DOM element.
|
|
6
|
+
*
|
|
7
|
+
* Call this once at the plugin entry point after {@link initCockpitI18n}.
|
|
8
|
+
*
|
|
9
|
+
* @param App - The root React component to render.
|
|
10
|
+
*/
|
|
4
11
|
export function bootstrapPlugin(App: ComponentType): void {
|
|
5
12
|
const root = createRoot(document.getElementById("root")!);
|
|
6
13
|
root.render(<App />);
|
package/src/cockpit.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Ambient type declarations for the Cockpit browser global.
|
|
2
2
|
// Superset covering cockpit-caddy and cockpit-compose usage patterns.
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
|
3
4
|
/// <reference path="./css.d.ts" />
|
|
4
5
|
|
|
5
6
|
declare interface CockpitProcess extends Promise<string> {
|
|
@@ -9,18 +9,33 @@ import {
|
|
|
9
9
|
import type { ReactNode } from "react";
|
|
10
10
|
|
|
11
11
|
interface Props {
|
|
12
|
+
/** Controls modal visibility. */
|
|
12
13
|
isOpen: boolean;
|
|
14
|
+
/** Modal heading text. */
|
|
13
15
|
title: string;
|
|
16
|
+
/** Optional body content rendered above the inline error alert. */
|
|
14
17
|
body?: ReactNode;
|
|
18
|
+
/** Label for the primary confirm button. */
|
|
15
19
|
confirmLabel: string;
|
|
20
|
+
/** Label for the cancel button. Defaults to `"Cancel"`. */
|
|
16
21
|
cancelLabel?: string;
|
|
22
|
+
/** Button variant — use `"danger"` for destructive actions. Defaults to `"primary"`. */
|
|
17
23
|
variant?: "primary" | "danger";
|
|
24
|
+
/** When `true`, the confirm button shows a spinner and both buttons are disabled. */
|
|
18
25
|
loading?: boolean;
|
|
26
|
+
/** If set, renders an inline danger alert above the footer buttons. */
|
|
19
27
|
error?: string | null;
|
|
28
|
+
/** Called when the user clicks the confirm button. */
|
|
20
29
|
onConfirm: () => void;
|
|
30
|
+
/** Called when the user clicks cancel or closes the modal. */
|
|
21
31
|
onClose: () => void;
|
|
22
32
|
}
|
|
23
33
|
|
|
34
|
+
/**
|
|
35
|
+
* A PatternFly `Modal` wired up for a single confirm/cancel action.
|
|
36
|
+
*
|
|
37
|
+
* Pair with `useConfirmAction` to manage the open/close and loading state.
|
|
38
|
+
*/
|
|
24
39
|
export function ConfirmDialog({
|
|
25
40
|
isOpen,
|
|
26
41
|
title,
|
|
@@ -3,6 +3,7 @@ import { EmptyState, EmptyStateBody } from "@patternfly/react-core";
|
|
|
3
3
|
|
|
4
4
|
interface Props {
|
|
5
5
|
children: ReactNode;
|
|
6
|
+
/** Heading shown in the PatternFly EmptyState fallback. Defaults to `"Something went wrong"`. */
|
|
6
7
|
fallbackTitle?: string;
|
|
7
8
|
}
|
|
8
9
|
|
|
@@ -10,6 +11,10 @@ interface State {
|
|
|
10
11
|
error: Error | null;
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* React error boundary that catches unhandled render errors and displays a
|
|
16
|
+
* PatternFly `EmptyState` fallback instead of a blank page.
|
|
17
|
+
*/
|
|
13
18
|
export class ErrorBoundary extends Component<Props, State> {
|
|
14
19
|
state: State = { error: null };
|
|
15
20
|
|
|
@@ -3,11 +3,17 @@ import { Popover, Button } from "@patternfly/react-core";
|
|
|
3
3
|
import { OutlinedQuestionCircleIcon } from "@patternfly/react-icons";
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
|
+
/** Popover heading text. */
|
|
6
7
|
header: string;
|
|
8
|
+
/** Popover body text. */
|
|
7
9
|
body: string;
|
|
10
|
+
/** `aria-label` for the trigger button. Defaults to `header`. */
|
|
8
11
|
"aria-label"?: string;
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* A question-mark icon button that opens a PatternFly `Popover` with help text.
|
|
16
|
+
*/
|
|
11
17
|
export function HelpPopover({ header, body, "aria-label": ariaLabel }: Props) {
|
|
12
18
|
const [visible, setVisible] = useState(false);
|
|
13
19
|
return (
|
|
@@ -12,17 +12,32 @@ import {
|
|
|
12
12
|
} from "@patternfly/react-core";
|
|
13
13
|
|
|
14
14
|
interface Props {
|
|
15
|
+
/** Log lines to display. Each string becomes one line in the pre block. */
|
|
15
16
|
lines: string[];
|
|
17
|
+
/** When `true`, shows a spinner instead of the log content. */
|
|
16
18
|
loading?: boolean;
|
|
19
|
+
/** If set, renders a danger alert at the top of the component. */
|
|
17
20
|
error?: string | null;
|
|
21
|
+
/** When provided, adds a refresh button to the toolbar. */
|
|
18
22
|
onRefresh?: () => void;
|
|
23
|
+
/** Placeholder text for the search input. Defaults to `"Search logs…"`. */
|
|
19
24
|
searchPlaceholder?: string;
|
|
25
|
+
/** Message shown when `lines` is empty. Defaults to `"No log entries."`. */
|
|
20
26
|
emptyMessage?: string;
|
|
27
|
+
/** Message shown when the search filter matches nothing. Defaults to `"No matching entries."`. */
|
|
21
28
|
noMatchesMessage?: string;
|
|
29
|
+
/** Title of the danger alert when `error` is set. Defaults to `"Failed to load logs"`. */
|
|
22
30
|
errorTitle?: string;
|
|
31
|
+
/** `aria-label` for the refresh button. Defaults to `"Refresh"`. */
|
|
23
32
|
refreshAriaLabel?: string;
|
|
24
33
|
}
|
|
25
34
|
|
|
35
|
+
/**
|
|
36
|
+
* A scrollable, searchable log viewer with a toolbar.
|
|
37
|
+
*
|
|
38
|
+
* Pass `lines` from `useAsyncStream` or any string array. The search
|
|
39
|
+
* input filters lines client-side; `onRefresh` adds a refresh button.
|
|
40
|
+
*/
|
|
26
41
|
export function LogViewer({
|
|
27
42
|
lines,
|
|
28
43
|
loading = false,
|
|
@@ -1,17 +1,38 @@
|
|
|
1
1
|
import { Label, type LabelProps } from "@patternfly/react-core";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Display configuration for a single status value.
|
|
5
|
+
*/
|
|
3
6
|
export interface StatusBadgeConfig {
|
|
7
|
+
/** PatternFly label color. */
|
|
4
8
|
color: LabelProps["color"];
|
|
9
|
+
/** Human-readable label text. */
|
|
5
10
|
label: string;
|
|
6
11
|
}
|
|
7
12
|
|
|
8
13
|
interface Props<T extends string> {
|
|
14
|
+
/** The current status value to look up in `config`. */
|
|
9
15
|
status: T;
|
|
16
|
+
/** Map of status values to their display configuration. */
|
|
10
17
|
config: Record<string, StatusBadgeConfig>;
|
|
18
|
+
/** Shown when `status` has no entry in `config`. Defaults to a grey label with the raw status string. */
|
|
11
19
|
fallback?: StatusBadgeConfig;
|
|
20
|
+
/** Renders a compact PatternFly `Label`. */
|
|
12
21
|
isCompact?: boolean;
|
|
13
22
|
}
|
|
14
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Renders a PatternFly `Label` whose color and text are driven by a `config` map.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```tsx
|
|
29
|
+
* const STATUS_CONFIG: Record<ServiceStatus, StatusBadgeConfig> = {
|
|
30
|
+
* active: { color: "green", label: "Active" },
|
|
31
|
+
* failed: { color: "red", label: "Failed" },
|
|
32
|
+
* };
|
|
33
|
+
* <StatusBadge status={serviceStatus} config={STATUS_CONFIG} />
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
15
36
|
export function StatusBadge<T extends string>({ status, config, fallback, isCompact }: Props<T>) {
|
|
16
37
|
const entry = config[status] ?? fallback ?? { color: "grey", label: status };
|
|
17
38
|
return <Label color={entry.color} isCompact={isCompact}>{entry.label}</Label>;
|
|
@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useRef, useState, type ReactNod
|
|
|
2
2
|
import { Alert, AlertGroup, AlertActionCloseButton } from "@patternfly/react-core";
|
|
3
3
|
import "./ToastProvider.css";
|
|
4
4
|
|
|
5
|
+
/** Severity level of a toast notification. */
|
|
5
6
|
export type ToastVariant = "success" | "danger" | "warning" | "info";
|
|
6
7
|
|
|
7
8
|
interface Toast {
|
|
@@ -11,11 +12,19 @@ interface Toast {
|
|
|
11
12
|
body?: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Context value exposed by {@link ToastProvider} and consumed by {@link useToast}.
|
|
17
|
+
*/
|
|
14
18
|
export interface ToastContextValue {
|
|
19
|
+
/** Adds a toast with an explicit variant. */
|
|
15
20
|
addToast: (variant: ToastVariant, title: string, body?: string) => void;
|
|
21
|
+
/** Shorthand for `addToast("success", ...)`. */
|
|
16
22
|
success: (title: string, body?: string) => void;
|
|
23
|
+
/** Shorthand for `addToast("danger", ...)`. */
|
|
17
24
|
error: (title: string, body?: string) => void;
|
|
25
|
+
/** Shorthand for `addToast("warning", ...)`. */
|
|
18
26
|
warn: (title: string, body?: string) => void;
|
|
27
|
+
/** Shorthand for `addToast("info", ...)`. */
|
|
19
28
|
info: (title: string, body?: string) => void;
|
|
20
29
|
}
|
|
21
30
|
|
|
@@ -23,6 +32,12 @@ const ToastContext = createContext<ToastContextValue | null>(null);
|
|
|
23
32
|
|
|
24
33
|
const AUTO_DISMISS_MS = 5000;
|
|
25
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Provides toast notification state to the component tree.
|
|
37
|
+
*
|
|
38
|
+
* Wrap your app root with `<ToastProvider>` and call {@link useToast} anywhere
|
|
39
|
+
* inside to fire notifications. Toasts auto-dismiss after 5 seconds.
|
|
40
|
+
*/
|
|
26
41
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
27
42
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
28
43
|
const counterRef = useRef(0);
|
|
@@ -71,6 +86,12 @@ const NOOP_TOAST: ToastContextValue = {
|
|
|
71
86
|
info: () => {},
|
|
72
87
|
};
|
|
73
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Returns the nearest {@link ToastProvider}'s context value.
|
|
91
|
+
*
|
|
92
|
+
* Falls back to a no-op implementation when called outside a `ToastProvider`,
|
|
93
|
+
* so it is safe to use in unit tests without a provider wrapper.
|
|
94
|
+
*/
|
|
74
95
|
export function useToast(): ToastContextValue {
|
|
75
96
|
return useContext(ToastContext) ?? NOOP_TOAST;
|
|
76
97
|
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { useState, useCallback } from "react";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Wraps an async function with `loading` and `error` state.
|
|
5
|
+
*
|
|
6
|
+
* @param action - The async function to execute. A new stable reference should be
|
|
7
|
+
* passed via `useCallback` to avoid unnecessary re-renders.
|
|
8
|
+
* @returns An object with `execute` (calls the action), `loading`, `error`, and
|
|
9
|
+
* `clearError` (resets the error state).
|
|
10
|
+
*/
|
|
3
11
|
export function useAsyncAction<T>(
|
|
4
12
|
action: () => Promise<T>,
|
|
5
13
|
): {
|
|
@@ -1,23 +1,34 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Accumulated result of a streaming Cockpit process.
|
|
5
|
+
*/
|
|
3
6
|
export interface AsyncStreamResult {
|
|
7
|
+
/** All output lines received so far, with blank lines and CR stripped. */
|
|
4
8
|
lines: string[];
|
|
9
|
+
/** `true` once the process exits (success or failure). */
|
|
5
10
|
done: boolean;
|
|
11
|
+
/** `true` when the process exited with an error. */
|
|
6
12
|
failed: boolean;
|
|
13
|
+
/** Error message when `failed` is `true`, otherwise empty string. */
|
|
7
14
|
errorMsg: string;
|
|
15
|
+
/** Closes the underlying process and stops accumulating output. */
|
|
8
16
|
cancel: () => void;
|
|
9
17
|
}
|
|
10
18
|
|
|
11
19
|
/**
|
|
12
|
-
*
|
|
20
|
+
* Accumulates line-buffered output from a Cockpit process into a `lines` array.
|
|
13
21
|
*
|
|
14
22
|
* The caller supplies a `startProcess` factory that receives a `launch` callback.
|
|
15
23
|
* Call `launch(proc)` synchronously once the process is ready — this avoids the
|
|
16
|
-
* JS Promise "following" behaviour that occurs when a CockpitProcess (which extends
|
|
17
|
-
* Promise) is returned from inside a
|
|
24
|
+
* JS Promise "following" behaviour that occurs when a `CockpitProcess` (which extends
|
|
25
|
+
* `Promise`) is returned from inside a `.then()`.
|
|
18
26
|
*
|
|
19
|
-
* The `deps` array works like useEffect deps — the hook tears down and restarts
|
|
27
|
+
* The `deps` array works like `useEffect` deps — the hook tears down and restarts
|
|
20
28
|
* the process whenever any dep changes.
|
|
29
|
+
*
|
|
30
|
+
* @param startProcess - Factory that receives a `launch` callback and must call it with the process.
|
|
31
|
+
* @param deps - Re-run dependencies (same semantics as `useEffect`).
|
|
21
32
|
*/
|
|
22
33
|
export function useAsyncStream(
|
|
23
34
|
startProcess: (launch: (proc: CockpitProcess) => void) => Promise<void>,
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Calls `fn` on a repeating interval, pausing automatically when the browser tab is hidden.
|
|
5
|
+
*
|
|
6
|
+
* @param fn - The callback to invoke on each tick. May be async — rejections are swallowed.
|
|
7
|
+
* @param intervalMs - Interval duration in milliseconds.
|
|
8
|
+
* @param paused - When `true`, suspends polling without tearing down the effect.
|
|
9
|
+
*/
|
|
3
10
|
export function useAutoRefresh(
|
|
4
11
|
fn: () => void | Promise<void>,
|
|
5
12
|
intervalMs: number,
|
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
import { useState, useCallback } from "react";
|
|
2
2
|
|
|
3
|
+
/** Current phase of the confirmation flow. */
|
|
3
4
|
export type ConfirmStep = "idle" | "confirming" | "submitting";
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* State and controls returned by {@link useConfirmAction}.
|
|
8
|
+
*/
|
|
5
9
|
export interface ConfirmActionState {
|
|
10
|
+
/** Current phase of the flow. */
|
|
6
11
|
step: ConfirmStep;
|
|
12
|
+
/** Error message from the last failed `submit`, or `null`. */
|
|
7
13
|
error: string | null;
|
|
14
|
+
/** Transitions from `idle` → `confirming`, opening the dialog. */
|
|
8
15
|
confirm: () => void;
|
|
16
|
+
/** Transitions back to `idle` and clears the error. */
|
|
9
17
|
cancel: () => void;
|
|
18
|
+
/**
|
|
19
|
+
* Runs `action` while in the `submitting` phase.
|
|
20
|
+
* On success transitions to `idle`; on failure stays in `confirming` with `error` set.
|
|
21
|
+
*/
|
|
10
22
|
submit: (action: () => Promise<void>) => Promise<void>;
|
|
23
|
+
/** Clears the error without changing the step. */
|
|
11
24
|
clearError: () => void;
|
|
12
25
|
}
|
|
13
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Manages state for a multi-step confirmation flow: idle → confirming → submitting.
|
|
29
|
+
*
|
|
30
|
+
* Pair with `ConfirmDialog` to wire up the confirmation modal.
|
|
31
|
+
*/
|
|
14
32
|
export function useConfirmAction(): ConfirmActionState {
|
|
15
33
|
const [step, setStep] = useState<ConfirmStep>("idle");
|
|
16
34
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect } from "react";
|
|
2
2
|
import { useAutoRefresh } from "./useAutoRefresh";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Result returned by {@link usePollingFetch}.
|
|
6
|
+
*/
|
|
4
7
|
export interface PollingFetchResult<T> {
|
|
8
|
+
/** Most recently fetched value, or `initial` before the first fetch completes. */
|
|
5
9
|
data: T;
|
|
10
|
+
/** `true` only during the initial fetch — background polls update silently. */
|
|
6
11
|
loading: boolean;
|
|
12
|
+
/** Error message from the most recent failed fetch, or `null`. */
|
|
7
13
|
error: string | null;
|
|
14
|
+
/** Manually triggers a fetch; runs silently (no `loading` flash). */
|
|
8
15
|
refresh: () => Promise<void>;
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
19
|
+
* Fetches data on mount and then polls at a fixed interval.
|
|
20
|
+
*
|
|
21
|
+
* The initial load sets `loading = true`; subsequent background polls and manual
|
|
22
|
+
* `refresh()` calls update `data` silently without a loading flash.
|
|
23
|
+
*
|
|
24
|
+
* @param fetcher - Async function that returns the data. Wrap in `useCallback` to
|
|
25
|
+
* avoid restarting the interval on every render.
|
|
26
|
+
* @param initial - Value used for `data` before the first fetch resolves.
|
|
27
|
+
* @param intervalMs - Polling interval in milliseconds.
|
|
28
|
+
* @param paused - When `true`, pauses background polling (does not cancel an in-flight request).
|
|
14
29
|
*/
|
|
15
30
|
export function usePollingFetch<T>(
|
|
16
31
|
fetcher: () => Promise<T>,
|
package/src/i18n.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import i18n from "i18next";
|
|
2
2
|
import { initReactI18next } from "react-i18next";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* i18next `resources` map keyed by locale (e.g. `"en"`, `"de"`), each with a
|
|
6
|
+
* `translation` namespace object. Pass this to {@link initCockpitI18n}.
|
|
7
|
+
*/
|
|
4
8
|
export type LocaleResources = Record<string, { translation: Record<string, unknown> }>;
|
|
5
9
|
|
|
6
10
|
// Reads Cockpit's language setting in priority order:
|
|
@@ -25,6 +29,14 @@ const cockpitDetector = {
|
|
|
25
29
|
},
|
|
26
30
|
};
|
|
27
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Initialises i18next with Cockpit's active locale and sets up a live observer
|
|
34
|
+
* so the UI re-translates when the user switches language in Cockpit settings.
|
|
35
|
+
*
|
|
36
|
+
* Call once at plugin startup, before {@link bootstrapPlugin}.
|
|
37
|
+
*
|
|
38
|
+
* @param resources - Translation resources keyed by locale. See {@link LocaleResources}.
|
|
39
|
+
*/
|
|
28
40
|
export function initCockpitI18n(resources: LocaleResources): void {
|
|
29
41
|
void i18n
|
|
30
42
|
.use({ type: "languageDetector", ...cockpitDetector } as Parameters<typeof i18n.use>[0])
|
|
@@ -7,12 +7,22 @@ import type { ServiceStatus } from "./types";
|
|
|
7
7
|
|
|
8
8
|
type PendingAction = "start" | "stop" | "restart" | "reload";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Overrides for all user-visible strings in {@link ServiceControl}.
|
|
12
|
+
* Every field is optional — unset fields fall back to English defaults.
|
|
13
|
+
*/
|
|
10
14
|
export interface ServiceControlLabels {
|
|
15
|
+
/** Start button label. */
|
|
11
16
|
start?: string;
|
|
17
|
+
/** Stop button label. */
|
|
12
18
|
stop?: string;
|
|
19
|
+
/** Restart button label. */
|
|
13
20
|
restart?: string;
|
|
21
|
+
/** Reload button label. */
|
|
14
22
|
reload?: string;
|
|
23
|
+
/** Cancel button label in the confirmation dialog. */
|
|
15
24
|
cancel?: string;
|
|
25
|
+
/** Confirm button label in the confirmation dialog. */
|
|
16
26
|
confirmAction?: string;
|
|
17
27
|
confirmStartTitle?: string;
|
|
18
28
|
confirmStartBody?: string;
|
|
@@ -42,14 +52,28 @@ const DEFAULTS: Required<ServiceControlLabels> = {
|
|
|
42
52
|
};
|
|
43
53
|
|
|
44
54
|
interface Props {
|
|
55
|
+
/** The systemd unit name (e.g. `"nginx.service"`). */
|
|
45
56
|
unit: string;
|
|
57
|
+
/** Current unit status — drives which buttons are enabled. */
|
|
46
58
|
status: ServiceStatus;
|
|
59
|
+
/** When `true`, shows a spinner in place of the status badge. */
|
|
47
60
|
loading?: boolean;
|
|
61
|
+
/** Called after a successful action so the parent can re-poll status. */
|
|
48
62
|
onRefresh?: () => void;
|
|
63
|
+
/** Optional status badge rendered to the left of the action buttons. */
|
|
49
64
|
statusBadge?: ReactNode;
|
|
65
|
+
/** Override any user-visible string. See {@link ServiceControlLabels}. */
|
|
50
66
|
labels?: ServiceControlLabels;
|
|
51
67
|
}
|
|
52
68
|
|
|
69
|
+
/**
|
|
70
|
+
* A row of Start / Stop / Restart / Reload buttons for a systemd unit.
|
|
71
|
+
*
|
|
72
|
+
* Each action opens a `ConfirmDialog` before executing. Errors are shown
|
|
73
|
+
* both inline in the dialog and via the nearest `ToastProvider`.
|
|
74
|
+
*
|
|
75
|
+
* Pair with `useServiceStatus` for reactive status updates.
|
|
76
|
+
*/
|
|
53
77
|
export function ServiceControl({ unit, status, loading = false, onRefresh, statusBadge, labels }: Props) {
|
|
54
78
|
const toast = useToast();
|
|
55
79
|
const l = { ...DEFAULTS, ...labels };
|
package/src/systemd/api.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { ServiceStatus } from "./types";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Returns the current {@link ServiceStatus} of a systemd unit.
|
|
5
|
+
*
|
|
6
|
+
* Checks with `which` first — returns `"not-installed"` when the unit binary is absent.
|
|
7
|
+
* Then calls `systemctl is-active` and maps the output to a {@link ServiceStatus} value.
|
|
8
|
+
*
|
|
9
|
+
* @param unit - The systemd unit name (e.g. `"nginx.service"`).
|
|
10
|
+
*/
|
|
3
11
|
export async function getServiceStatus(unit: string): Promise<ServiceStatus> {
|
|
4
12
|
try {
|
|
5
13
|
await cockpit.spawn(["which", unit]);
|
|
@@ -19,18 +27,35 @@ export async function getServiceStatus(unit: string): Promise<ServiceStatus> {
|
|
|
19
27
|
}
|
|
20
28
|
}
|
|
21
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Starts the given systemd unit via `systemctl start`. Requests superuser escalation.
|
|
32
|
+
* @param unit - The systemd unit name (e.g. `"nginx.service"`).
|
|
33
|
+
*/
|
|
22
34
|
export async function startService(unit: string): Promise<void> {
|
|
23
35
|
await cockpit.spawn(["systemctl", "start", unit], { superuser: "try" });
|
|
24
36
|
}
|
|
25
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Stops the given systemd unit via `systemctl stop`. Requests superuser escalation.
|
|
40
|
+
* @param unit - The systemd unit name (e.g. `"nginx.service"`).
|
|
41
|
+
*/
|
|
26
42
|
export async function stopService(unit: string): Promise<void> {
|
|
27
43
|
await cockpit.spawn(["systemctl", "stop", unit], { superuser: "try" });
|
|
28
44
|
}
|
|
29
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Restarts the given systemd unit via `systemctl restart`. Requests superuser escalation.
|
|
48
|
+
* @param unit - The systemd unit name (e.g. `"nginx.service"`).
|
|
49
|
+
*/
|
|
30
50
|
export async function restartService(unit: string): Promise<void> {
|
|
31
51
|
await cockpit.spawn(["systemctl", "restart", unit], { superuser: "try" });
|
|
32
52
|
}
|
|
33
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Reloads the configuration of the given systemd unit via `systemctl reload`.
|
|
56
|
+
* Requests superuser escalation.
|
|
57
|
+
* @param unit - The systemd unit name (e.g. `"nginx.service"`).
|
|
58
|
+
*/
|
|
34
59
|
export async function reloadService(unit: string): Promise<void> {
|
|
35
60
|
await cockpit.spawn(["systemctl", "reload", unit], { superuser: "try" });
|
|
36
61
|
}
|
package/src/systemd/types.ts
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Possible states of a systemd service unit.
|
|
3
|
+
*
|
|
4
|
+
* - `"active"` — unit is running
|
|
5
|
+
* - `"inactive"` — unit is stopped
|
|
6
|
+
* - `"failed"` — unit exited with an error
|
|
7
|
+
* - `"unknown"` — `systemctl is-active` returned an unrecognised value
|
|
8
|
+
* - `"not-installed"` — the unit binary was not found on the system
|
|
9
|
+
*/
|
|
1
10
|
export type ServiceStatus = "active" | "inactive" | "failed" | "unknown" | "not-installed";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { renderHook, act, waitFor } from "@testing-library/react";
|
|
2
|
-
import { vi, describe, it, expect, beforeEach
|
|
2
|
+
import { vi, describe, it, expect, beforeEach } from "vitest";
|
|
3
3
|
import { useServiceStatus } from "./useServiceStatus";
|
|
4
4
|
|
|
5
5
|
const mockSpawn = vi.fn();
|
|
@@ -5,6 +5,13 @@ import type { ServiceStatus } from "./types";
|
|
|
5
5
|
|
|
6
6
|
const DEFAULT_INTERVAL = 5000;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Polls the status of a systemd unit and returns reactive state.
|
|
10
|
+
*
|
|
11
|
+
* @param unit - The systemd unit name (e.g. `"nginx.service"`).
|
|
12
|
+
* @param intervalMs - How often to re-poll. Defaults to `5000` ms.
|
|
13
|
+
* @returns `{ status, loading, error, refresh }` — call `refresh()` to force an immediate re-poll.
|
|
14
|
+
*/
|
|
8
15
|
export function useServiceStatus(unit: string, intervalMs = DEFAULT_INTERVAL) {
|
|
9
16
|
const [status, setStatus] = useState<ServiceStatus>("unknown");
|
|
10
17
|
const [loading, setLoading] = useState(true);
|
package/src/testing/helpers.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { vi } from "vitest";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Creates a fake `CockpitProcess` that emits `data` chunks then resolves (or rejects).
|
|
5
|
+
*
|
|
6
|
+
* @param data - One or more output chunks delivered via the `stream` callback.
|
|
7
|
+
* @param error - When provided, the process rejects with this message instead of resolving.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* vi.spyOn(cockpit, "spawn").mockReturnValue(mockProcess("hello\nworld\n"));
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
3
14
|
export function mockProcess(data: string | string[], error?: string): CockpitProcess {
|
|
4
15
|
const chunks = Array.isArray(data) ? data : [data];
|
|
5
16
|
let streamCb: ((data: string) => void) | null = null;
|
|
@@ -20,6 +31,11 @@ export function mockProcess(data: string | string[], error?: string): CockpitPro
|
|
|
20
31
|
}) as CockpitProcess;
|
|
21
32
|
}
|
|
22
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Creates a fake `CockpitHttpClient` whose `get` method returns canned responses.
|
|
36
|
+
*
|
|
37
|
+
* @param responses - Map of URL paths to response body strings. Unmatched paths return `"{}"`.
|
|
38
|
+
*/
|
|
23
39
|
export function mockHttpClient(responses: Record<string, string> = {}): CockpitHttpClient {
|
|
24
40
|
return {
|
|
25
41
|
get: vi.fn((path: string) => Promise.resolve(responses[path] ?? "{}")),
|
|
@@ -1,22 +1,13 @@
|
|
|
1
1
|
import { defineConfig } from "vitest/config";
|
|
2
|
-
import type { TestUserConfig } from "vitest/config";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export function createVitestConfig(overrides: TestConfig = {}) {
|
|
3
|
+
export function createVitestConfig(overrides = {}) {
|
|
7
4
|
const { coverage: coverageOverrides, setupFiles: extraSetupFiles, ...rest } = overrides;
|
|
8
5
|
|
|
9
6
|
return defineConfig({
|
|
10
7
|
server: {
|
|
11
|
-
// Allow Vite's dev server to serve files from symlinked file: packages
|
|
12
|
-
// that live outside the consuming project's root directory.
|
|
13
8
|
fs: { allow: [".."] },
|
|
14
9
|
},
|
|
15
10
|
resolve: {
|
|
16
|
-
// Deduplicate packages that must be singletons when cockpit-plugin-base-react
|
|
17
|
-
// is installed as a file: link (symlink) — without this, the linked
|
|
18
|
-
// package resolves these from its own node_modules and React / i18next
|
|
19
|
-
// end up with two separate instances, breaking hooks and translations.
|
|
20
11
|
dedupe: ["react", "react-dom", "i18next", "react-i18next", "@patternfly/react-core", "@patternfly/react-icons"],
|
|
21
12
|
},
|
|
22
13
|
test: {
|