@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 +2 -0
- package/docs/wiki/Home.md +2 -0
- package/package.json +1 -1
- package/src/cockpit.d.ts +8 -0
- package/src/components/LogViewer.tsx +227 -53
- package/src/lib/logParser.tsx +85 -0
- package/src/systemd/ServiceControl.tsx +21 -4
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
|
+
"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
|
|
18
|
+
/** Log lines to display. Each string becomes one highlighted line. */
|
|
16
19
|
lines: string[];
|
|
17
|
-
/** When `true`, shows a spinner instead of
|
|
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
|
|
22
|
+
/** If set, renders a danger alert at the top. */
|
|
20
23
|
error?: string | null;
|
|
21
|
-
/** When provided, adds a refresh button
|
|
24
|
+
/** When provided, adds a refresh button. */
|
|
22
25
|
onRefresh?: () => void;
|
|
23
|
-
/**
|
|
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
|
|
44
|
+
/** Message shown when `lines` is empty and not loading. */
|
|
26
45
|
emptyMessage?: string;
|
|
27
|
-
/** Message shown when the
|
|
46
|
+
/** Message shown when the filter matches nothing. */
|
|
28
47
|
noMatchesMessage?: string;
|
|
29
|
-
/** Title of the danger alert when `error` is set.
|
|
48
|
+
/** Title of the danger alert when `error` is set. */
|
|
30
49
|
errorTitle?: string;
|
|
31
|
-
/** `aria-label` for the refresh button.
|
|
50
|
+
/** `aria-label` for the refresh button. */
|
|
32
51
|
refreshAriaLabel?: string;
|
|
33
52
|
}
|
|
34
53
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 [
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
<
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
191
|
+
isDisabled={isDisabledBase || !isRunning}
|
|
175
192
|
onClick={() => openAction("reload")}
|
|
176
193
|
>
|
|
177
194
|
{l.reload}
|