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

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.
@@ -39,6 +39,9 @@ const BASE_GLOBALS = {
39
39
  HTMLPreElement: "readonly",
40
40
  HTMLTableSectionElement: "readonly",
41
41
  requestAnimationFrame: "readonly",
42
+ Node: "readonly",
43
+ PointerEvent: "readonly",
44
+ MouseEvent: "readonly",
42
45
  cockpit: "readonly",
43
46
  CockpitProcess: "readonly",
44
47
  CockpitChannel: "readonly",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rxtx4816/cockpit-plugin-base-react",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
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",
@@ -81,6 +81,7 @@
81
81
  "typecheck": "tsc --noEmit",
82
82
  "test": "vitest run",
83
83
  "test:watch": "vitest",
84
+ "yalc": "yalc push",
84
85
  "docs": "typedoc",
85
86
  "docs:watch": "typedoc --watch"
86
87
  },
@@ -0,0 +1,95 @@
1
+ import { type CSSProperties, useState, useRef, useEffect, useCallback } from "react";
2
+ import { Button, SearchInput } from "@patternfly/react-core";
3
+ import { SearchIcon } from "@patternfly/react-icons";
4
+
5
+ interface Props {
6
+ value: string;
7
+ onChange: (value: string) => void;
8
+ onClear: () => void;
9
+ placeholder?: string;
10
+ "aria-label"?: string;
11
+ /** Width of the expanded input in pixels. Defaults to 220. */
12
+ expandedWidth?: number;
13
+ }
14
+
15
+ export function CollapsibleSearch({
16
+ value,
17
+ onChange,
18
+ onClear,
19
+ placeholder = "Search…",
20
+ "aria-label": ariaLabel = "Search",
21
+ expandedWidth = 220,
22
+ }: Props) {
23
+ const [expanded, setExpanded] = useState(value.length > 0);
24
+ const inputRef = useRef<HTMLDivElement>(null);
25
+
26
+ // Stay expanded when there's an active search value
27
+ useEffect(() => {
28
+ if (value.length > 0) setExpanded(true);
29
+ }, [value]);
30
+
31
+ const collapse = useCallback(() => {
32
+ if (value.length === 0) setExpanded(false);
33
+ }, [value]);
34
+
35
+ // Collapse on outside click when empty
36
+ useEffect(() => {
37
+ if (!expanded) return;
38
+ function onPointerDown(e: PointerEvent) {
39
+ if (inputRef.current && !inputRef.current.contains(e.target as Node)) {
40
+ collapse();
41
+ }
42
+ }
43
+ document.addEventListener("pointerdown", onPointerDown);
44
+ return () => document.removeEventListener("pointerdown", onPointerDown);
45
+ }, [expanded, collapse]);
46
+
47
+ function handleClear() {
48
+ onClear();
49
+ setExpanded(false);
50
+ }
51
+
52
+ function handleExpand() {
53
+ setExpanded(true);
54
+ // Focus the inner input after the animation frame
55
+ requestAnimationFrame(() => {
56
+ inputRef.current?.querySelector("input")?.focus();
57
+ });
58
+ }
59
+
60
+ const containerStyle: CSSProperties = {
61
+ display: "flex",
62
+ alignItems: "center",
63
+ overflow: "hidden",
64
+ transition: "width 200ms ease, opacity 200ms ease",
65
+ width: expanded ? expandedWidth : 32,
66
+ opacity: expanded ? 1 : 0.6,
67
+ };
68
+
69
+ if (!expanded) {
70
+ return (
71
+ <Button
72
+ variant="plain"
73
+ size="sm"
74
+ aria-label={ariaLabel}
75
+ title={ariaLabel}
76
+ onClick={handleExpand}
77
+ >
78
+ <SearchIcon />
79
+ </Button>
80
+ );
81
+ }
82
+
83
+ return (
84
+ <div ref={inputRef} style={containerStyle}>
85
+ <SearchInput
86
+ placeholder={placeholder}
87
+ value={value}
88
+ onChange={(_e, v) => onChange(v)}
89
+ onClear={handleClear}
90
+ style={{ width: expandedWidth }}
91
+ aria-label={ariaLabel}
92
+ />
93
+ </div>
94
+ );
95
+ }
@@ -0,0 +1,34 @@
1
+ .ls-wrap {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 0.25rem;
5
+ }
6
+
7
+ .ls-trigger.pf-v6-c-button {
8
+ padding: 0.25rem;
9
+ width: 1.875rem;
10
+ height: 1.875rem;
11
+ min-width: unset;
12
+ border-radius: var(--pf-t--global--border--radius--base, 4px);
13
+ color: var(--pf-t--global--text--color--subtle, #6a6e73);
14
+ transition: color 0.15s ease, background 0.15s ease;
15
+ }
16
+
17
+ .ls-trigger.pf-v6-c-button:hover {
18
+ color: var(--pf-t--global--text--color--regular, #151515);
19
+ background: rgba(0, 0, 0, 0.06);
20
+ }
21
+
22
+ .ls-trigger--active.pf-v6-c-button {
23
+ color: var(--pf-t--global--color--brand--default, #0066cc);
24
+ background: rgba(0, 102, 204, 0.08);
25
+ }
26
+
27
+ .ls-toggle {
28
+ animation: ls-expand 0.12s ease-out;
29
+ }
30
+
31
+ @keyframes ls-expand {
32
+ from { opacity: 0; transform: scale(0.92); }
33
+ to { opacity: 1; transform: scale(1); }
34
+ }
@@ -0,0 +1,70 @@
1
+ import { useState, useRef, useEffect, type ReactNode } from "react";
2
+ import { Button, Tooltip, ToggleGroup, ToggleGroupItem } from "@patternfly/react-core";
3
+ import { SlidersHIcon } from "@patternfly/react-icons";
4
+ import "./LayoutSelector.css";
5
+
6
+ export interface LayoutOption<T extends string = string> {
7
+ key: T;
8
+ icon: ReactNode;
9
+ label: string;
10
+ }
11
+
12
+ interface Props<T extends string = string> {
13
+ layout: T;
14
+ onLayoutChange: (layout: T) => void;
15
+ layouts: LayoutOption<T>[];
16
+ ariaLabel?: string;
17
+ }
18
+
19
+ export function LayoutSelector<T extends string>({
20
+ layout,
21
+ onLayoutChange,
22
+ layouts,
23
+ ariaLabel = "Change layout",
24
+ }: Props<T>) {
25
+ const [open, setOpen] = useState(false);
26
+ const containerRef = useRef<HTMLDivElement>(null);
27
+
28
+ useEffect(() => {
29
+ if (!open) return;
30
+ const handler = (e: MouseEvent) => {
31
+ if (containerRef.current && !containerRef.current.contains(e.target as HTMLElement)) {
32
+ setOpen(false);
33
+ }
34
+ };
35
+ document.addEventListener("mousedown", handler);
36
+ return () => document.removeEventListener("mousedown", handler);
37
+ }, [open]);
38
+
39
+ const current = layouts.find(l => l.key === layout);
40
+
41
+ return (
42
+ <div ref={containerRef} className={`ls-wrap${open ? " ls-wrap--open" : ""}`}>
43
+ <Tooltip content={`Layout: ${current?.label ?? layout}`}>
44
+ <Button
45
+ variant="plain"
46
+ size="sm"
47
+ onClick={() => setOpen(o => !o)}
48
+ aria-label={ariaLabel}
49
+ className={`ls-trigger${open ? " ls-trigger--active" : ""}`}
50
+ >
51
+ {current?.icon ?? <SlidersHIcon />}
52
+ </Button>
53
+ </Tooltip>
54
+ {open && (
55
+ <ToggleGroup aria-label="Layout" isCompact className="ls-toggle">
56
+ {layouts.map(({ key, icon, label }) => (
57
+ <Tooltip key={key} content={label}>
58
+ <ToggleGroupItem
59
+ icon={icon}
60
+ isSelected={layout === key}
61
+ onChange={() => { onLayoutChange(key); setOpen(false); }}
62
+ aria-label={label}
63
+ />
64
+ </Tooltip>
65
+ ))}
66
+ </ToggleGroup>
67
+ )}
68
+ </div>
69
+ );
70
+ }
@@ -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,61 @@
1
+ import { type ReactNode } from "react";
2
+ import { PageSection, Label } from "@patternfly/react-core";
3
+
4
+ interface FooterLink {
5
+ label: string;
6
+ href: string;
7
+ }
8
+
9
+ interface Props {
10
+ /** Plugin version string (e.g. from package.json). */
11
+ version?: string;
12
+ /** Links rendered in the bottom row. */
13
+ links?: FooterLink[];
14
+ /** Extra labels or badges rendered next to the version in the top row. */
15
+ children?: ReactNode;
16
+ className?: string;
17
+ }
18
+
19
+ export function PluginFooter({ version, links, children, className }: Props) {
20
+ return (
21
+ <PageSection
22
+ className={className}
23
+ style={{
24
+ position: "sticky",
25
+ bottom: 0,
26
+ zIndex: 1,
27
+ borderTop: "1px solid var(--pf-t--global--border--color--default)",
28
+ padding: "0.5rem 1.5rem",
29
+ fontSize: 13,
30
+ textAlign: "center",
31
+ backgroundColor: "var(--pf-t--global--background--color--primary--default)",
32
+ }}
33
+ >
34
+ <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 6 }}>
35
+ {(version !== undefined || children) && (
36
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 6, justifyContent: "center", alignItems: "center" }}>
37
+ {version !== undefined && (
38
+ <Label isCompact color="grey">v{version}</Label>
39
+ )}
40
+ {children}
41
+ </div>
42
+ )}
43
+ {links && links.length > 0 && (
44
+ <div style={{ display: "flex", flexDirection: "row", gap: 16, justifyContent: "center" }}>
45
+ {links.map(({ label, href }) => (
46
+ <a
47
+ key={href}
48
+ href={href}
49
+ target="_blank"
50
+ rel="noopener noreferrer"
51
+ style={{ color: "var(--pf-t--global--color--brand--default, #06c)", textDecoration: "none" }}
52
+ >
53
+ {label}
54
+ </a>
55
+ ))}
56
+ </div>
57
+ )}
58
+ </div>
59
+ </PageSection>
60
+ );
61
+ }
@@ -6,3 +6,7 @@ export type { StatusBadgeConfig } from "./StatusBadge";
6
6
  export { ConfirmDialog } from "./ConfirmDialog";
7
7
  export { LogViewer } from "./LogViewer";
8
8
  export { HelpPopover } from "./HelpPopover";
9
+ export { PluginFooter } from "./PluginFooter";
10
+ export { CollapsibleSearch } from "./CollapsibleSearch";
11
+ export { LayoutSelector } from "./LayoutSelector";
12
+ export type { LayoutOption } from "./LayoutSelector";
@@ -0,0 +1,22 @@
1
+ import { useState, useCallback } from "react";
2
+
3
+ export function useLayout<T extends string>(
4
+ storageKey: string,
5
+ defaultLayout: T,
6
+ validLayouts: T[],
7
+ ): [T, (layout: T) => void] {
8
+ const [layout, setLayoutState] = useState<T>(() => {
9
+ try {
10
+ const stored = localStorage.getItem(storageKey);
11
+ if (stored && (validLayouts as string[]).includes(stored)) return stored as T;
12
+ } catch { /* ignore */ }
13
+ return defaultLayout;
14
+ });
15
+
16
+ const setLayout = useCallback((next: T) => {
17
+ try { localStorage.setItem(storageKey, next); } catch { /* ignore */ }
18
+ setLayoutState(next);
19
+ }, [storageKey]);
20
+
21
+ return [layout, setLayout];
22
+ }
package/src/index.ts CHANGED
@@ -9,3 +9,4 @@ export { useConfirmAction } from "./hooks/useConfirmAction";
9
9
  export type { ConfirmStep, ConfirmActionState } from "./hooks/useConfirmAction";
10
10
  export { usePollingFetch } from "./hooks/usePollingFetch";
11
11
  export type { PollingFetchResult } from "./hooks/usePollingFetch";
12
+ export { useLayout } from "./hooks/useLayout";
@@ -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
+ }
@@ -72,6 +72,8 @@ interface Props {
72
72
  statusBadge?: ReactNode;
73
73
  /** Override any user-visible string. See {@link ServiceControlLabels}. */
74
74
  labels?: ServiceControlLabels;
75
+ /** Extra content rendered at the far right of the button row (e.g. Backup / Restore buttons). */
76
+ extraActions?: ReactNode;
75
77
  }
76
78
 
77
79
  /**
@@ -82,7 +84,7 @@ interface Props {
82
84
  *
83
85
  * Pair with `useServiceStatus` for reactive status updates.
84
86
  */
85
- export function ServiceControl({ unit, status, loading = false, onRefresh, statusBadge, labels }: Props) {
87
+ export function ServiceControl({ unit, status, loading = false, onRefresh, statusBadge, labels, extraActions }: Props) {
86
88
  const toast = useToast();
87
89
  const l = { ...DEFAULTS, ...labels };
88
90
  const [busy, setBusy] = useState(false);
@@ -194,6 +196,11 @@ export function ServiceControl({ unit, status, loading = false, onRefresh, statu
194
196
  {l.reload}
195
197
  </Button>
196
198
  </FlexItem>
199
+ {extraActions && (
200
+ <FlexItem align={{ default: "alignRight" }}>
201
+ {extraActions}
202
+ </FlexItem>
203
+ )}
197
204
  </Flex>
198
205
 
199
206
  <ConfirmDialog