@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.
- package/eslint.config.base.js +3 -0
- package/package.json +2 -1
- package/src/components/CollapsibleSearch.tsx +95 -0
- package/src/components/LayoutSelector.css +34 -0
- package/src/components/LayoutSelector.tsx +70 -0
- package/src/components/LogViewer.tsx +227 -53
- package/src/components/PluginFooter.tsx +61 -0
- package/src/components/index.ts +4 -0
- package/src/hooks/useLayout.ts +22 -0
- package/src/index.ts +1 -0
- package/src/lib/logParser.tsx +85 -0
- package/src/systemd/ServiceControl.tsx +8 -1
package/eslint.config.base.js
CHANGED
|
@@ -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.
|
|
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
|
|
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,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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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
|