@rxtx4816/cockpit-plugin-base-react 1.0.6 → 1.0.8
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 +14 -5
- package/src/components/CollapsibleSearch.tsx +95 -0
- package/src/components/LayoutSelector.css +34 -0
- package/src/components/LayoutSelector.tsx +71 -0
- package/src/components/PluginFooter.tsx +61 -0
- package/src/components/Tooltip.tsx +5 -0
- package/src/components/index.ts +5 -0
- package/src/hooks/useAdminMode.ts +24 -0
- package/src/hooks/useLayout.ts +22 -0
- package/src/index.ts +2 -0
- package/src/systemd/ServiceControl.tsx +8 -1
- package/src/systemd/api.ts +29 -0
- package/src/systemd/index.ts +1 -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.8",
|
|
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",
|
|
@@ -43,6 +43,9 @@
|
|
|
43
43
|
"./hooks/usePollingFetch": {
|
|
44
44
|
"default": "./src/hooks/usePollingFetch.ts"
|
|
45
45
|
},
|
|
46
|
+
"./hooks/useAdminMode": {
|
|
47
|
+
"default": "./src/hooks/useAdminMode.ts"
|
|
48
|
+
},
|
|
46
49
|
"./components": {
|
|
47
50
|
"default": "./src/components/index.ts"
|
|
48
51
|
},
|
|
@@ -61,6 +64,9 @@
|
|
|
61
64
|
},
|
|
62
65
|
"./vitest.config.base": {
|
|
63
66
|
"default": "./vitest.config.base.js"
|
|
67
|
+
},
|
|
68
|
+
"./lib/logParser": {
|
|
69
|
+
"default": "./src/lib/logParser.tsx"
|
|
64
70
|
}
|
|
65
71
|
},
|
|
66
72
|
"files": [
|
|
@@ -81,6 +87,7 @@
|
|
|
81
87
|
"typecheck": "tsc --noEmit",
|
|
82
88
|
"test": "vitest run",
|
|
83
89
|
"test:watch": "vitest",
|
|
90
|
+
"yalc": "yalc push",
|
|
84
91
|
"docs": "typedoc",
|
|
85
92
|
"docs:watch": "typedoc --watch"
|
|
86
93
|
},
|
|
@@ -90,17 +97,19 @@
|
|
|
90
97
|
"react-dom": ">=19",
|
|
91
98
|
"react-i18next": ">=17"
|
|
92
99
|
},
|
|
100
|
+
"dependencies": {
|
|
101
|
+
"@typescript-eslint/eslint-plugin": "^8.61.1",
|
|
102
|
+
"@typescript-eslint/parser": "^8.61.1",
|
|
103
|
+
"eslint-plugin-react": "^7.37.5",
|
|
104
|
+
"eslint-plugin-react-hooks": "^7.1.1"
|
|
105
|
+
},
|
|
93
106
|
"devDependencies": {
|
|
94
107
|
"@patternfly/react-core": "^6.5.1",
|
|
95
108
|
"@testing-library/jest-dom": "^6.9.1",
|
|
96
109
|
"@testing-library/react": "^16.3.2",
|
|
97
110
|
"@types/react": "^19.2.17",
|
|
98
111
|
"@types/react-dom": "^19.2.3",
|
|
99
|
-
"@typescript-eslint/eslint-plugin": "^8.61.1",
|
|
100
|
-
"@typescript-eslint/parser": "^8.61.1",
|
|
101
112
|
"eslint": "^9.39.4",
|
|
102
|
-
"eslint-plugin-react": "^7.37.5",
|
|
103
|
-
"eslint-plugin-react-hooks": "^7.1.1",
|
|
104
113
|
"i18next": "^26.3.1",
|
|
105
114
|
"jsdom": "^29.1.1",
|
|
106
115
|
"react": "^19.2.7",
|
|
@@ -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,71 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, type ReactNode } from "react";
|
|
2
|
+
import { Button, ToggleGroup, ToggleGroupItem } from "@patternfly/react-core";
|
|
3
|
+
import { Tooltip } from "./Tooltip";
|
|
4
|
+
import { SlidersHIcon } from "@patternfly/react-icons";
|
|
5
|
+
import "./LayoutSelector.css";
|
|
6
|
+
|
|
7
|
+
export interface LayoutOption<T extends string = string> {
|
|
8
|
+
key: T;
|
|
9
|
+
icon: ReactNode;
|
|
10
|
+
label: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Props<T extends string = string> {
|
|
14
|
+
layout: T;
|
|
15
|
+
onLayoutChange: (layout: T) => void;
|
|
16
|
+
layouts: LayoutOption<T>[];
|
|
17
|
+
ariaLabel?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function LayoutSelector<T extends string>({
|
|
21
|
+
layout,
|
|
22
|
+
onLayoutChange,
|
|
23
|
+
layouts,
|
|
24
|
+
ariaLabel = "Change layout",
|
|
25
|
+
}: Props<T>) {
|
|
26
|
+
const [open, setOpen] = useState(false);
|
|
27
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!open) return;
|
|
31
|
+
const handler = (e: MouseEvent) => {
|
|
32
|
+
if (containerRef.current && !containerRef.current.contains(e.target as HTMLElement)) {
|
|
33
|
+
setOpen(false);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
document.addEventListener("mousedown", handler);
|
|
37
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
38
|
+
}, [open]);
|
|
39
|
+
|
|
40
|
+
const current = layouts.find(l => l.key === layout);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div ref={containerRef} className={`ls-wrap${open ? " ls-wrap--open" : ""}`}>
|
|
44
|
+
<Tooltip content={`Layout: ${current?.label ?? layout}`}>
|
|
45
|
+
<Button
|
|
46
|
+
variant="plain"
|
|
47
|
+
size="sm"
|
|
48
|
+
onClick={() => setOpen(o => !o)}
|
|
49
|
+
aria-label={ariaLabel}
|
|
50
|
+
className={`ls-trigger${open ? " ls-trigger--active" : ""}`}
|
|
51
|
+
>
|
|
52
|
+
{current?.icon ?? <SlidersHIcon />}
|
|
53
|
+
</Button>
|
|
54
|
+
</Tooltip>
|
|
55
|
+
{open && (
|
|
56
|
+
<ToggleGroup aria-label="Layout" isCompact className="ls-toggle">
|
|
57
|
+
{layouts.map(({ key, icon, label }) => (
|
|
58
|
+
<Tooltip key={key} content={label}>
|
|
59
|
+
<ToggleGroupItem
|
|
60
|
+
icon={icon}
|
|
61
|
+
isSelected={layout === key}
|
|
62
|
+
onChange={() => { onLayoutChange(key); setOpen(false); }}
|
|
63
|
+
aria-label={label}
|
|
64
|
+
/>
|
|
65
|
+
</Tooltip>
|
|
66
|
+
))}
|
|
67
|
+
</ToggleGroup>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -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,8 @@ 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";
|
|
13
|
+
export { Tooltip } from "./Tooltip";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns whether the current Cockpit session has administrative (superuser) access.
|
|
5
|
+
* - `null` — still determining (show nothing or a neutral state)
|
|
6
|
+
* - `true` — admin access granted
|
|
7
|
+
* - `false` — limited mode; privileged operations will fail
|
|
8
|
+
*/
|
|
9
|
+
export function useAdminMode(): boolean | null {
|
|
10
|
+
const [allowed, setAllowed] = useState<boolean | null>(null);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const perm = cockpit.permission({ admin: true });
|
|
14
|
+
setAllowed(perm.allowed);
|
|
15
|
+
const onChange = () => setAllowed(perm.allowed);
|
|
16
|
+
perm.addEventListener("changed", onChange);
|
|
17
|
+
return () => {
|
|
18
|
+
perm.removeEventListener("changed", onChange);
|
|
19
|
+
perm.close();
|
|
20
|
+
};
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
return allowed;
|
|
24
|
+
}
|
|
@@ -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,5 @@ 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";
|
|
13
|
+
export { useAdminMode } from "./hooks/useAdminMode";
|
|
@@ -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
|
package/src/systemd/api.ts
CHANGED
|
@@ -59,3 +59,32 @@ export async function restartService(unit: string): Promise<void> {
|
|
|
59
59
|
export async function reloadService(unit: string): Promise<void> {
|
|
60
60
|
await cockpit.spawn(["systemctl", "reload", unit], { superuser: "try" });
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reads a file from the host filesystem via Cockpit, requesting superuser escalation.
|
|
65
|
+
* @param path - Absolute path to the file.
|
|
66
|
+
*/
|
|
67
|
+
export async function readFile(path: string): Promise<string> {
|
|
68
|
+
return cockpit.file(path, { superuser: "try" }).read();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Writes content to a file on the host filesystem via Cockpit, requesting superuser escalation.
|
|
73
|
+
* @param path - Absolute path to the file.
|
|
74
|
+
* @param content - UTF-8 string content to write.
|
|
75
|
+
*/
|
|
76
|
+
export async function writeFile(path: string, content: string): Promise<void> {
|
|
77
|
+
await cockpit.file(path, { superuser: "try" }).replace(content);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Fetches recent journal entries for a systemd unit via `journalctl`.
|
|
82
|
+
* @param unit - The systemd unit name (e.g. `"caddy.service"`).
|
|
83
|
+
* @param lines - Number of lines to return (default 300).
|
|
84
|
+
*/
|
|
85
|
+
export async function fetchServiceLogs(unit: string, lines = 300): Promise<string> {
|
|
86
|
+
return cockpit.spawn(
|
|
87
|
+
["journalctl", "-u", unit, "-n", String(lines), "--no-pager", "--output=short-iso"],
|
|
88
|
+
{ superuser: "try" },
|
|
89
|
+
);
|
|
90
|
+
}
|
package/src/systemd/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { ServiceStatus } from "./types";
|
|
2
|
-
export { getServiceStatus, startService, stopService, restartService, reloadService } from "./api";
|
|
2
|
+
export { getServiceStatus, startService, stopService, restartService, reloadService, readFile, writeFile, fetchServiceLogs } from "./api";
|
|
3
3
|
export { useServiceStatus } from "./useServiceStatus";
|
|
4
4
|
export { ServiceControl } from "./ServiceControl";
|
|
5
5
|
export type { ServiceControlLabels } from "./ServiceControl";
|