@rxtx4816/cockpit-plugin-base-react 1.0.3 → 1.0.5

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/README.md CHANGED
@@ -73,6 +73,8 @@ For full setup guidance, config sharing, and workflow integration see the [wiki]
73
73
 
74
74
  ## Documentation
75
75
 
76
+ **[API Reference](https://rxtx4816.github.io/cockpit-plugin-base-react/)** — auto-generated from source, updated on every release.
77
+
76
78
  - [Getting Started](docs/wiki/Getting-Started.md)
77
79
  - [Hooks](docs/wiki/Hooks.md)
78
80
  - [Components](docs/wiki/Components.md)
package/docs/wiki/Home.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  This wiki covers everything you need to build, test, and ship Cockpit plugins using `@rxtx4816/cockpit-plugin-base-react`.
4
4
 
5
+ **[API Reference](https://rxtx4816.github.io/cockpit-plugin-base-react/)** — auto-generated TypeDoc, updated on every release.
6
+
5
7
  ## Contents
6
8
 
7
9
  - [Getting Started](Getting-Started.md) — install, bootstrap, and first plugin setup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rxtx4816/cockpit-plugin-base-react",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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",
@@ -80,7 +80,9 @@
80
80
  "lint": "eslint src/",
81
81
  "typecheck": "tsc --noEmit",
82
82
  "test": "vitest run",
83
- "test:watch": "vitest"
83
+ "test:watch": "vitest",
84
+ "docs": "typedoc",
85
+ "docs:watch": "typedoc --watch"
84
86
  },
85
87
  "peerDependencies": {
86
88
  "i18next": ">=26",
@@ -104,6 +106,7 @@
104
106
  "react": "^19.2.7",
105
107
  "react-dom": "^19.2.7",
106
108
  "react-i18next": "^17.0.8",
109
+ "typedoc": "^0.28.19",
107
110
  "typescript": "^6.0.3",
108
111
  "vitest": "^4.1.9"
109
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
@@ -39,8 +39,16 @@ declare interface CockpitUser {
39
39
  groups: string[];
40
40
  }
41
41
 
42
+ declare interface CockpitPermission {
43
+ allowed: boolean | null;
44
+ addEventListener(event: "changed", callback: () => void): void;
45
+ removeEventListener(event: "changed", callback: () => void): void;
46
+ close(): void;
47
+ }
48
+
42
49
  declare const cockpit: {
43
50
  user(): Promise<CockpitUser>;
51
+ permission(options: { admin: boolean }): CockpitPermission;
44
52
  spawn(
45
53
  args: string[],
46
54
  options?: { superuser?: "try" | "require"; err?: string; environ?: 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;
@@ -22,6 +32,10 @@ export interface ServiceControlLabels {
22
32
  confirmRestartBody?: string;
23
33
  confirmReloadTitle?: string;
24
34
  confirmReloadBody?: string;
35
+ successStart?: string;
36
+ successStop?: string;
37
+ successRestart?: string;
38
+ successReload?: string;
25
39
  }
26
40
 
27
41
  const DEFAULTS: Required<ServiceControlLabels> = {
@@ -39,17 +53,35 @@ const DEFAULTS: Required<ServiceControlLabels> = {
39
53
  confirmRestartBody: "The service will be restarted.",
40
54
  confirmReloadTitle: "Reload service?",
41
55
  confirmReloadBody: "The service configuration will be reloaded.",
56
+ successStart: "Service started",
57
+ successStop: "Service stopped",
58
+ successRestart: "Service restarted",
59
+ successReload: "Configuration reloaded",
42
60
  };
43
61
 
44
62
  interface Props {
63
+ /** The systemd unit name (e.g. `"nginx.service"`). */
45
64
  unit: string;
65
+ /** Current unit status — drives which buttons are enabled. */
46
66
  status: ServiceStatus;
67
+ /** When `true`, shows a spinner in place of the status badge. */
47
68
  loading?: boolean;
69
+ /** Called after a successful action so the parent can re-poll status. */
48
70
  onRefresh?: () => void;
71
+ /** Optional status badge rendered to the left of the action buttons. */
49
72
  statusBadge?: ReactNode;
73
+ /** Override any user-visible string. See {@link ServiceControlLabels}. */
50
74
  labels?: ServiceControlLabels;
51
75
  }
52
76
 
77
+ /**
78
+ * A row of Start / Stop / Restart / Reload buttons for a systemd unit.
79
+ *
80
+ * Each action opens a `ConfirmDialog` before executing. Errors are shown
81
+ * both inline in the dialog and via the nearest `ToastProvider`.
82
+ *
83
+ * Pair with `useServiceStatus` for reactive status updates.
84
+ */
53
85
  export function ServiceControl({ unit, status, loading = false, onRefresh, statusBadge, labels }: Props) {
54
86
  const toast = useToast();
55
87
  const l = { ...DEFAULTS, ...labels };
@@ -64,12 +96,20 @@ export function ServiceControl({ unit, status, loading = false, onRefresh, statu
64
96
  reload: () => reloadService(unit),
65
97
  };
66
98
 
99
+ const successLabel: Record<PendingAction, string> = {
100
+ start: l.successStart,
101
+ stop: l.successStop,
102
+ restart: l.successRestart,
103
+ reload: l.successReload,
104
+ };
105
+
67
106
  async function runAction() {
68
107
  if (!pendingAction) return;
69
108
  setBusy(true);
70
109
  setActionError(null);
71
110
  try {
72
111
  await ACTION_FN[pendingAction]();
112
+ toast.success(successLabel[pendingAction]);
73
113
  setPendingAction(null);
74
114
  onRefresh?.();
75
115
  } catch (e) {
@@ -88,6 +128,7 @@ export function ServiceControl({ unit, status, loading = false, onRefresh, statu
88
128
 
89
129
  const isRunning = status === "active";
90
130
  const notInstalled = status === "not-installed";
131
+ const isDisabledBase = busy || loading || notInstalled;
91
132
 
92
133
  const confirmTitle: Record<PendingAction, string> = {
93
134
  start: l.confirmStartTitle,
@@ -117,7 +158,7 @@ export function ServiceControl({ unit, status, loading = false, onRefresh, statu
117
158
  <Button
118
159
  variant="primary"
119
160
  size="sm"
120
- isDisabled={busy || notInstalled || isRunning}
161
+ isDisabled={isDisabledBase || isRunning}
121
162
  onClick={() => openAction("start")}
122
163
  >
123
164
  {l.start}
@@ -127,7 +168,7 @@ export function ServiceControl({ unit, status, loading = false, onRefresh, statu
127
168
  <Button
128
169
  variant="secondary"
129
170
  size="sm"
130
- isDisabled={busy || notInstalled || !isRunning}
171
+ isDisabled={isDisabledBase || !isRunning}
131
172
  onClick={() => openAction("stop")}
132
173
  >
133
174
  {l.stop}
@@ -137,7 +178,7 @@ export function ServiceControl({ unit, status, loading = false, onRefresh, statu
137
178
  <Button
138
179
  variant="secondary"
139
180
  size="sm"
140
- isDisabled={busy || notInstalled || !isRunning}
181
+ isDisabled={isDisabledBase || !isRunning}
141
182
  onClick={() => openAction("restart")}
142
183
  >
143
184
  {l.restart}
@@ -147,7 +188,7 @@ export function ServiceControl({ unit, status, loading = false, onRefresh, statu
147
188
  <Button
148
189
  variant="plain"
149
190
  size="sm"
150
- isDisabled={busy || notInstalled || !isRunning}
191
+ isDisabled={isDisabledBase || !isRunning}
151
192
  onClick={() => openAction("reload")}
152
193
  >
153
194
  {l.reload}
@@ -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";
@@ -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] ?? "{}")),