@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 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.1",
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.ts"
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.ts"
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
- * Generic hook for accumulating line-buffered output from a CockpitProcess.
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 .then().
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
- * Initial fetch shows loading=true; subsequent background polls update silently.
13
- * Calling refresh() manually also runs silently (no loading flash).
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 };
@@ -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
  }
@@ -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, afterEach } from "vitest";
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);
@@ -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
- type TestConfig = TestUserConfig;
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: {