@rxtx4816/cockpit-plugin-base-react 1.0.4 → 1.0.6

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.4",
3
+ "version": "1.0.6",
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",
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[] }
@@ -1,6 +1,7 @@
1
- import { useState } from "react";
1
+ import { useState, useEffect, useRef, useCallback, type CSSProperties, type ReactNode } from "react";
2
2
  import {
3
3
  Alert,
4
+ AlertActionCloseButton,
4
5
  Button,
5
6
  SearchInput,
6
7
  Spinner,
@@ -9,51 +10,188 @@ import {
9
10
  Toolbar,
10
11
  ToolbarContent,
11
12
  ToolbarItem,
13
+ ToolbarGroup,
12
14
  } from "@patternfly/react-core";
15
+ import { highlightWithSearch } from "../lib/logParser";
13
16
 
14
17
  interface Props {
15
- /** Log lines to display. Each string becomes one line in the pre block. */
18
+ /** Log lines to display. Each string becomes one highlighted line. */
16
19
  lines: string[];
17
- /** When `true`, shows a spinner instead of the log content. */
20
+ /** When `true`, shows a spinner instead of log content on first load. */
18
21
  loading?: boolean;
19
- /** If set, renders a danger alert at the top of the component. */
22
+ /** If set, renders a danger alert at the top. */
20
23
  error?: string | null;
21
- /** When provided, adds a refresh button to the toolbar. */
24
+ /** When provided, adds a refresh button. */
22
25
  onRefresh?: () => void;
23
- /** Placeholder text for the search input. Defaults to `"Search logs…"`. */
26
+ /** When `true`, log output is frozen (auto-scroll stops). */
27
+ paused?: boolean;
28
+ /** Called when the user clicks the Pause button. */
29
+ onPause?: () => void;
30
+ /** Called when the user clicks the Resume button. */
31
+ onResume?: () => void;
32
+ /**
33
+ * Suggested download file name (without extension). When provided a download
34
+ * button is shown. First tries the File System Access API; falls back to
35
+ * saving via cockpit to ~/Downloads/ (Firefox/Cockpit-iframe fallback).
36
+ */
37
+ downloadFileName?: string;
38
+ /** Pre-fills and drives the search box externally. */
39
+ filterValue?: string;
40
+ /** Called whenever the search input changes. */
41
+ onFilterChange?: (value: string) => void;
42
+ /** Placeholder text for the search input. */
24
43
  searchPlaceholder?: string;
25
- /** Message shown when `lines` is empty. Defaults to `"No log entries."`. */
44
+ /** Message shown when `lines` is empty and not loading. */
26
45
  emptyMessage?: string;
27
- /** Message shown when the search filter matches nothing. Defaults to `"No matching entries."`. */
46
+ /** Message shown when the filter matches nothing. */
28
47
  noMatchesMessage?: string;
29
- /** Title of the danger alert when `error` is set. Defaults to `"Failed to load logs"`. */
48
+ /** Title of the danger alert when `error` is set. */
30
49
  errorTitle?: string;
31
- /** `aria-label` for the refresh button. Defaults to `"Refresh"`. */
50
+ /** `aria-label` for the refresh button. */
32
51
  refreshAriaLabel?: string;
33
52
  }
34
53
 
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
- */
54
+ const VIEWER_STYLE: CSSProperties = {
55
+ overflowY: "auto",
56
+ maxHeight: "60vh",
57
+ background: "#0d1117",
58
+ borderRadius: "var(--pf-v6-global--BorderRadius--sm, 4px)",
59
+ padding: "0.4rem 0",
60
+ fontFamily: "var(--pf-t--global--font--family--mono, monospace)",
61
+ fontSize: "0.78rem",
62
+ lineHeight: 1.6,
63
+ };
64
+
65
+ const LINE_BASE: CSSProperties = {
66
+ padding: "0.05rem 0.75rem",
67
+ whiteSpace: "pre-wrap",
68
+ wordBreak: "break-all",
69
+ color: "#e6edf3",
70
+ };
71
+
72
+ function levelBg(line: string): string {
73
+ if (/\b(FATAL|CRITICAL|ERROR|ERR)\b/i.test(line)) return "rgba(248,81,73,0.10)";
74
+ if (/\bWARN(ING)?\b/i.test(line)) return "rgba(227,179,65,0.08)";
75
+ return "";
76
+ }
77
+
78
+ function LogLine({ line, search, isRegex, index }: {
79
+ line: string; search: string; isRegex: boolean; index: number;
80
+ }): ReactNode {
81
+ const bg = levelBg(line) || (index % 2 !== 0 ? "rgba(255,255,255,0.015)" : "transparent");
82
+ return (
83
+ <div style={{ ...LINE_BASE, background: bg }}>
84
+ {highlightWithSearch(line, search, isRegex)}
85
+ </div>
86
+ );
87
+ }
88
+
41
89
  export function LogViewer({
42
90
  lines,
43
91
  loading = false,
44
92
  error,
45
93
  onRefresh,
94
+ paused = false,
95
+ onPause,
96
+ onResume,
97
+ downloadFileName,
98
+ filterValue,
99
+ onFilterChange,
46
100
  searchPlaceholder = "Search logs…",
47
101
  emptyMessage = "No log entries.",
48
102
  noMatchesMessage = "No matching entries.",
49
103
  errorTitle = "Failed to load logs",
50
104
  refreshAriaLabel = "Refresh",
51
105
  }: Props) {
52
- const [search, setSearch] = useState("");
106
+ const [internalSearch, setInternalSearch] = useState(filterValue ?? "");
107
+ const [isRegex, setIsRegex] = useState(false);
108
+ const [downloadNote, setDownloadNote] = useState<string | null>(null);
109
+ const [downloadError, setDownloadError] = useState<string | null>(null);
110
+ const logRef = useRef<HTMLDivElement>(null);
111
+
112
+ useEffect(() => {
113
+ setInternalSearch(filterValue ?? "");
114
+ }, [filterValue]);
115
+
116
+ const search = filterValue !== undefined ? filterValue : internalSearch;
117
+
118
+ const setSearch = useCallback((v: string) => {
119
+ if (onFilterChange) onFilterChange(v);
120
+ else setInternalSearch(v);
121
+ }, [onFilterChange]);
122
+
123
+ useEffect(() => {
124
+ if (!paused && logRef.current) {
125
+ logRef.current.scrollTop = logRef.current.scrollHeight;
126
+ }
127
+ }, [lines, paused]);
128
+
129
+ const scrollToTop = () => { if (logRef.current) logRef.current.scrollTop = 0; };
130
+ const scrollToBottom = () => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; };
131
+
132
+ const filtered = lines.filter(l => {
133
+ if (!search) return true;
134
+ try {
135
+ return isRegex ? new RegExp(search, "i").test(l) : l.toLowerCase().includes(search.toLowerCase());
136
+ } catch { return true; }
137
+ });
53
138
 
54
- const filtered = search
55
- ? lines.filter(l => l.toLowerCase().includes(search.toLowerCase()))
56
- : lines;
139
+ const handleDownload = useCallback(async () => {
140
+ if (!downloadFileName) return;
141
+ const text = filtered.join("\n");
142
+ if ("showSaveFilePicker" in window) {
143
+ try {
144
+ const handle = await (window as Window & {
145
+ showSaveFilePicker(o: object): Promise<{
146
+ createWritable(): Promise<{ write(s: string): Promise<void>; close(): Promise<void> }>;
147
+ }>;
148
+ }).showSaveFilePicker({
149
+ suggestedName: `${downloadFileName}.txt`,
150
+ types: [{ description: "Text files", accept: { "text/plain": [".txt"] } }],
151
+ });
152
+ const w = await handle.createWritable();
153
+ await w.write(text);
154
+ await w.close();
155
+ return;
156
+ } catch (e) {
157
+ if ((e as { name?: string }).name === "AbortError") return;
158
+ // SecurityError in Cockpit's iframe — fall through
159
+ }
160
+ }
161
+ try {
162
+ const user = await cockpit.user();
163
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
164
+ const savePath = `${user.home}/Downloads/${downloadFileName}-${ts}.txt`;
165
+ await cockpit.spawn(["mkdir", "-p", "--", `${user.home}/Downloads`], { err: "message" });
166
+ await cockpit.file(savePath).replace(text);
167
+ setDownloadNote(savePath);
168
+ } catch (err) {
169
+ setDownloadError((err as { message?: string })?.message ?? String(err));
170
+ }
171
+ }, [filtered, downloadFileName]);
172
+
173
+ const lineCount = filtered.length !== lines.length
174
+ ? `${filtered.length} / ${lines.length}`
175
+ : String(lines.length);
176
+
177
+ const hasPauseControl = onPause !== undefined || onResume !== undefined;
178
+
179
+ const regexBtnStyle: CSSProperties = {
180
+ height: "var(--pf-t--global--control--size--base, 36px)",
181
+ padding: "0 0.5rem",
182
+ fontFamily: "var(--pf-t--global--font--family--mono, monospace)",
183
+ fontSize: "0.8rem",
184
+ fontWeight: 600,
185
+ border: "1px solid var(--pf-t--global--border--color--default)",
186
+ borderRadius: "var(--pf-t--global--border--radius--100, 4px)",
187
+ cursor: "pointer",
188
+ transition: "background 120ms ease, color 120ms ease",
189
+ background: isRegex
190
+ ? "var(--pf-t--global--color--brand--default, #06c)"
191
+ : "var(--pf-t--global--background--color--primary--default)",
192
+ color: isRegex ? "#fff" : "var(--pf-t--global--text--color--subtle)",
193
+ borderColor: isRegex ? "var(--pf-t--global--color--brand--default, #06c)" : undefined,
194
+ };
57
195
 
58
196
  return (
59
197
  <Stack hasGutter>
@@ -62,31 +200,79 @@ export function LogViewer({
62
200
  <Alert
63
201
  variant="danger"
64
202
  title={errorTitle}
65
- actionLinks={onRefresh && (
66
- <Button variant="link" onClick={onRefresh}>Retry</Button>
67
- )}
203
+ actionLinks={onRefresh && <Button variant="link" onClick={onRefresh}>Retry</Button>}
68
204
  >
69
205
  {error}
70
206
  </Alert>
71
207
  </StackItem>
72
208
  )}
209
+ {downloadNote && (
210
+ <StackItem>
211
+ <Alert variant="info" isInline title={`Saved to ${downloadNote}`}
212
+ actionClose={<AlertActionCloseButton onClose={() => setDownloadNote(null)} />}
213
+ />
214
+ </StackItem>
215
+ )}
216
+ {downloadError && (
217
+ <StackItem>
218
+ <Alert variant="danger" isInline title={downloadError}
219
+ actionClose={<AlertActionCloseButton onClose={() => setDownloadError(null)} />}
220
+ />
221
+ </StackItem>
222
+ )}
73
223
 
74
224
  <StackItem>
75
- <Toolbar>
225
+ <Toolbar style={{ paddingInline: 0 }}>
76
226
  <ToolbarContent>
77
- <ToolbarItem>
78
- <SearchInput
79
- placeholder={searchPlaceholder}
80
- value={search}
81
- onChange={(_e, v) => setSearch(v)}
82
- onClear={() => setSearch("")}
83
- />
84
- </ToolbarItem>
85
- {onRefresh && (
86
- <ToolbarItem align={{ default: "alignEnd" }}>
87
- <Button variant="plain" onClick={onRefresh} aria-label={refreshAriaLabel}>↺</Button>
227
+ <ToolbarGroup variant="filter-group">
228
+ <ToolbarItem>
229
+ <div style={{ display: "flex", alignItems: "center", gap: "0.25rem" }}>
230
+ <SearchInput
231
+ placeholder={searchPlaceholder}
232
+ value={search}
233
+ onChange={(_e, v) => setSearch(v)}
234
+ onClear={() => setSearch("")}
235
+ style={{ width: 220 }}
236
+ />
237
+ <button type="button" aria-label="Toggle regex" aria-pressed={isRegex}
238
+ title="Toggle regex filter" onClick={() => setIsRegex(r => !r)}
239
+ style={regexBtnStyle}
240
+ >.*</button>
241
+ </div>
88
242
  </ToolbarItem>
89
- )}
243
+ <ToolbarItem>
244
+ <span style={{ fontSize: "0.75rem", color: "var(--pf-v6-global--Color--200)" }}>
245
+ {lineCount} lines
246
+ </span>
247
+ </ToolbarItem>
248
+ </ToolbarGroup>
249
+
250
+ <ToolbarGroup variant="action-group-plain" align={{ default: "alignEnd" }}>
251
+ <ToolbarItem>
252
+ <Button variant="plain" size="sm" onClick={scrollToTop} aria-label="Jump to top" title="Jump to top">⇑</Button>
253
+ </ToolbarItem>
254
+ <ToolbarItem>
255
+ <Button variant="plain" size="sm" onClick={scrollToBottom} aria-label="Jump to bottom" title="Jump to bottom">⇓</Button>
256
+ </ToolbarItem>
257
+ {hasPauseControl && (
258
+ <ToolbarItem>
259
+ {paused
260
+ ? <Button variant="primary" size="sm" onClick={onResume}>▶ Resume</Button>
261
+ : <Button variant="secondary" size="sm" onClick={onPause}>⏸ Pause</Button>
262
+ }
263
+ </ToolbarItem>
264
+ )}
265
+ {downloadFileName && filtered.length > 0 && (
266
+ <ToolbarItem>
267
+ <Button variant="plain" size="sm" onClick={() => void handleDownload()} aria-label="Download logs" title="Download logs">⬇</Button>
268
+ </ToolbarItem>
269
+ )}
270
+ {onRefresh && (
271
+ <ToolbarItem>
272
+ <Button variant="plain" size="sm" onClick={onRefresh} aria-label={refreshAriaLabel} title={refreshAriaLabel}>↺</Button>
273
+ </ToolbarItem>
274
+ )}
275
+ </ToolbarGroup>
90
276
  </ToolbarContent>
91
277
  </Toolbar>
92
278
  </StackItem>
@@ -99,23 +285,11 @@ export function LogViewer({
99
285
  {lines.length === 0 ? emptyMessage : noMatchesMessage}
100
286
  </p>
101
287
  ) : (
102
- <pre
103
- style={{
104
- fontFamily: "monospace",
105
- fontSize: "0.8rem",
106
- overflowX: "auto",
107
- maxHeight: "60vh",
108
- overflowY: "auto",
109
- background: "var(--pf-v6-global--BackgroundColor--dark-100, #1b1d21)",
110
- color: "var(--pf-v6-global--Color--light-100, #e8e8e8)",
111
- padding: "1rem",
112
- borderRadius: "var(--pf-v6-global--BorderRadius--sm, 4px)",
113
- whiteSpace: "pre-wrap",
114
- wordBreak: "break-all",
115
- }}
116
- >
117
- {filtered.join("\n")}
118
- </pre>
288
+ <div ref={logRef} style={VIEWER_STYLE}>
289
+ {filtered.map((line, i) => (
290
+ <LogLine key={i} line={line} search={search} isRegex={isRegex} index={i} />
291
+ ))}
292
+ </div>
119
293
  )}
120
294
  </StackItem>
121
295
  </Stack>
@@ -0,0 +1,85 @@
1
+ import { type ReactNode } from "react";
2
+
3
+ // Token types in priority order; first match wins at each position.
4
+ export const TOKEN_RE = new RegExp(
5
+ [
6
+ String.raw`\b(FATAL|CRITICAL|ERROR|ERR|WARN(?:ING)?|INFO|DEBUG|TRACE)\b`,
7
+ String.raw`\b5\d{2}\b`,
8
+ String.raw`\b4\d{2}\b`,
9
+ String.raw`\b[23]\d{2}\b`,
10
+ String.raw`"[^"]*"|'[^']*'`,
11
+ String.raw`(?:\/[\w.\-]+){2,}`,
12
+ String.raw`\b\d{1,3}(?:\.\d{1,3}){3}\b`,
13
+ ].join("|"),
14
+ "gi",
15
+ );
16
+
17
+ export function tokenColor(token: string): string {
18
+ const u = token.toUpperCase();
19
+ if (/^(FATAL|CRITICAL|ERROR|ERR)$/.test(u)) return "#f85149";
20
+ if (/^WARN/.test(u)) return "#e3b341";
21
+ if (u === "INFO") return "#79c0ff";
22
+ if (u === "DEBUG") return "#8b949e";
23
+ if (u === "TRACE") return "#6e7681";
24
+ if (/^5\d{2}$/.test(token)) return "#f85149";
25
+ if (/^4\d{2}$/.test(token)) return "#e3b341";
26
+ if (/^[23]\d{2}$/.test(token)) return "#56d364";
27
+ if (token.startsWith('"') || token.startsWith("'")) return "#a5d6ff";
28
+ if (token.startsWith("/")) return "#d2a8ff";
29
+ return "#ffa657";
30
+ }
31
+
32
+ export function tokenWeight(token: string): string | number {
33
+ const u = token.toUpperCase();
34
+ if (/^(FATAL|CRITICAL|ERROR|ERR|WARN)/.test(u)) return 700;
35
+ return "inherit";
36
+ }
37
+
38
+ /** Renders a log message string with syntax-highlighted tokens as React nodes. */
39
+ export function highlightMessage(msg: string): ReactNode {
40
+ const parts: ReactNode[] = [];
41
+ let last = 0;
42
+ let k = 0;
43
+ TOKEN_RE.lastIndex = 0;
44
+ let m: RegExpExecArray | null;
45
+ while ((m = TOKEN_RE.exec(msg)) !== null) {
46
+ if (m.index > last) parts.push(<span key={k++}>{msg.slice(last, m.index)}</span>);
47
+ parts.push(
48
+ <span key={k++} style={{ color: tokenColor(m[0]), fontWeight: tokenWeight(m[0]) }}>
49
+ {m[0]}
50
+ </span>,
51
+ );
52
+ last = m.index + m[0].length;
53
+ }
54
+ if (last < msg.length) parts.push(<span key={k++}>{msg.slice(last)}</span>);
55
+ return parts.length ? <>{parts}</> : msg;
56
+ }
57
+
58
+ /** Renders a log message with search-term highlighting layered on top of syntax coloring. */
59
+ export function highlightWithSearch(msg: string, term: string, isRegex: boolean): ReactNode {
60
+ if (!term) return highlightMessage(msg);
61
+ try {
62
+ const re = isRegex
63
+ ? new RegExp(term, "gi")
64
+ : new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
65
+ const parts: ReactNode[] = [];
66
+ let last = 0;
67
+ let m: RegExpExecArray | null;
68
+ while ((m = re.exec(msg)) !== null) {
69
+ if (m.index > last) {
70
+ parts.push(<span key={`pre-${last}`}>{highlightMessage(msg.slice(last, m.index))}</span>);
71
+ }
72
+ parts.push(
73
+ <mark key={`match-${m.index}`} style={{ background: "rgba(255,213,0,0.35)", color: "inherit", borderRadius: 2 }}>
74
+ {m[0]}
75
+ </mark>,
76
+ );
77
+ last = m.index + m[0].length;
78
+ if (m[0].length === 0) re.lastIndex++;
79
+ }
80
+ if (last < msg.length) parts.push(<span key={`tail-${last}`}>{highlightMessage(msg.slice(last))}</span>);
81
+ return parts.length > 0 ? parts : highlightMessage(msg);
82
+ } catch {
83
+ return highlightMessage(msg);
84
+ }
85
+ }
@@ -32,6 +32,10 @@ export interface ServiceControlLabels {
32
32
  confirmRestartBody?: string;
33
33
  confirmReloadTitle?: string;
34
34
  confirmReloadBody?: string;
35
+ successStart?: string;
36
+ successStop?: string;
37
+ successRestart?: string;
38
+ successReload?: string;
35
39
  }
36
40
 
37
41
  const DEFAULTS: Required<ServiceControlLabels> = {
@@ -49,6 +53,10 @@ const DEFAULTS: Required<ServiceControlLabels> = {
49
53
  confirmRestartBody: "The service will be restarted.",
50
54
  confirmReloadTitle: "Reload service?",
51
55
  confirmReloadBody: "The service configuration will be reloaded.",
56
+ successStart: "Service started",
57
+ successStop: "Service stopped",
58
+ successRestart: "Service restarted",
59
+ successReload: "Configuration reloaded",
52
60
  };
53
61
 
54
62
  interface Props {
@@ -88,12 +96,20 @@ export function ServiceControl({ unit, status, loading = false, onRefresh, statu
88
96
  reload: () => reloadService(unit),
89
97
  };
90
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
+
91
106
  async function runAction() {
92
107
  if (!pendingAction) return;
93
108
  setBusy(true);
94
109
  setActionError(null);
95
110
  try {
96
111
  await ACTION_FN[pendingAction]();
112
+ toast.success(successLabel[pendingAction]);
97
113
  setPendingAction(null);
98
114
  onRefresh?.();
99
115
  } catch (e) {
@@ -112,6 +128,7 @@ export function ServiceControl({ unit, status, loading = false, onRefresh, statu
112
128
 
113
129
  const isRunning = status === "active";
114
130
  const notInstalled = status === "not-installed";
131
+ const isDisabledBase = busy || loading || notInstalled;
115
132
 
116
133
  const confirmTitle: Record<PendingAction, string> = {
117
134
  start: l.confirmStartTitle,
@@ -141,7 +158,7 @@ export function ServiceControl({ unit, status, loading = false, onRefresh, statu
141
158
  <Button
142
159
  variant="primary"
143
160
  size="sm"
144
- isDisabled={busy || notInstalled || isRunning}
161
+ isDisabled={isDisabledBase || isRunning}
145
162
  onClick={() => openAction("start")}
146
163
  >
147
164
  {l.start}
@@ -151,7 +168,7 @@ export function ServiceControl({ unit, status, loading = false, onRefresh, statu
151
168
  <Button
152
169
  variant="secondary"
153
170
  size="sm"
154
- isDisabled={busy || notInstalled || !isRunning}
171
+ isDisabled={isDisabledBase || !isRunning}
155
172
  onClick={() => openAction("stop")}
156
173
  >
157
174
  {l.stop}
@@ -161,7 +178,7 @@ export function ServiceControl({ unit, status, loading = false, onRefresh, statu
161
178
  <Button
162
179
  variant="secondary"
163
180
  size="sm"
164
- isDisabled={busy || notInstalled || !isRunning}
181
+ isDisabled={isDisabledBase || !isRunning}
165
182
  onClick={() => openAction("restart")}
166
183
  >
167
184
  {l.restart}
@@ -171,7 +188,7 @@ export function ServiceControl({ unit, status, loading = false, onRefresh, statu
171
188
  <Button
172
189
  variant="plain"
173
190
  size="sm"
174
- isDisabled={busy || notInstalled || !isRunning}
191
+ isDisabled={isDisabledBase || !isRunning}
175
192
  onClick={() => openAction("reload")}
176
193
  >
177
194
  {l.reload}