@rxtx4816/cockpit-plugin-base-react 1.0.0

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.
Files changed (41) hide show
  1. package/eslint.config.base.js +85 -0
  2. package/package.json +116 -0
  3. package/scripts/test-vm.sh +608 -0
  4. package/src/bootstrap.tsx +7 -0
  5. package/src/cockpit.d.ts +63 -0
  6. package/src/components/ConfirmDialog.test.tsx +61 -0
  7. package/src/components/ConfirmDialog.tsx +65 -0
  8. package/src/components/ErrorBoundary.test.tsx +50 -0
  9. package/src/components/ErrorBoundary.tsx +30 -0
  10. package/src/components/HelpPopover.tsx +30 -0
  11. package/src/components/LogViewer.tsx +108 -0
  12. package/src/components/StatusBadge.test.tsx +32 -0
  13. package/src/components/StatusBadge.tsx +18 -0
  14. package/src/components/ToastProvider.css +20 -0
  15. package/src/components/ToastProvider.test.tsx +61 -0
  16. package/src/components/ToastProvider.tsx +76 -0
  17. package/src/components/index.ts +8 -0
  18. package/src/css.d.ts +4 -0
  19. package/src/dark-theme.ts +30 -0
  20. package/src/hooks/useAsyncAction.test.ts +59 -0
  21. package/src/hooks/useAsyncAction.ts +31 -0
  22. package/src/hooks/useAsyncStream.test.ts +122 -0
  23. package/src/hooks/useAsyncStream.ts +94 -0
  24. package/src/hooks/useAutoRefresh.test.ts +106 -0
  25. package/src/hooks/useAutoRefresh.ts +23 -0
  26. package/src/hooks/useConfirmAction.test.ts +68 -0
  27. package/src/hooks/useConfirmAction.ts +35 -0
  28. package/src/hooks/usePollingFetch.ts +41 -0
  29. package/src/i18n.ts +51 -0
  30. package/src/index.ts +11 -0
  31. package/src/systemd/ServiceControl.tsx +172 -0
  32. package/src/systemd/api.test.ts +83 -0
  33. package/src/systemd/api.ts +36 -0
  34. package/src/systemd/index.ts +5 -0
  35. package/src/systemd/types.ts +1 -0
  36. package/src/systemd/useServiceStatus.test.ts +96 -0
  37. package/src/systemd/useServiceStatus.ts +29 -0
  38. package/src/testing/helpers.ts +30 -0
  39. package/src/testing/setup.ts +57 -0
  40. package/tsconfig.base.json +17 -0
  41. package/vitest.config.base.ts +47 -0
@@ -0,0 +1,31 @@
1
+ import { useState, useCallback } from "react";
2
+
3
+ export function useAsyncAction<T>(
4
+ action: () => Promise<T>,
5
+ ): {
6
+ execute: () => Promise<T | undefined>;
7
+ loading: boolean;
8
+ error: string | null;
9
+ clearError: () => void;
10
+ } {
11
+ const [loading, setLoading] = useState(false);
12
+ const [error, setError] = useState<string | null>(null);
13
+
14
+ const clearError = useCallback(() => setError(null), []);
15
+
16
+ const execute = useCallback(async (): Promise<T | undefined> => {
17
+ setLoading(true);
18
+ setError(null);
19
+ try {
20
+ const result = await action();
21
+ return result;
22
+ } catch (ex: unknown) {
23
+ setError(ex instanceof Error ? ex.message : String(ex));
24
+ return undefined;
25
+ } finally {
26
+ setLoading(false);
27
+ }
28
+ }, [action]);
29
+
30
+ return { execute, loading, error, clearError };
31
+ }
@@ -0,0 +1,122 @@
1
+ import { renderHook, act, waitFor } from "@testing-library/react";
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { useAsyncStream } from "./useAsyncStream";
4
+ import { mockProcess } from "../testing/helpers";
5
+
6
+ function mockRunningProcess(initialData: string): CockpitProcess {
7
+ let streamCb: ((data: string) => void) | null = null;
8
+ const p = new Promise<string>(() => {}); // never resolves — process stays "running"
9
+ queueMicrotask(() => { if (streamCb) streamCb(initialData); });
10
+ return Object.assign(p, {
11
+ stream: (cb: (data: string) => void) => { streamCb = cb; return p as CockpitProcess; },
12
+ close: vi.fn(),
13
+ input: vi.fn(),
14
+ wait: () => p,
15
+ }) as CockpitProcess;
16
+ }
17
+
18
+ describe("useAsyncStream", () => {
19
+ it("accumulates lines from streamed output", async () => {
20
+ const { result } = renderHook(() =>
21
+ useAsyncStream(async launch => {
22
+ launch(mockProcess("line1\nline2\nline3\n"));
23
+ }, []),
24
+ );
25
+
26
+ await waitFor(() => expect(result.current.done).toBe(true));
27
+ expect(result.current.lines).toEqual(["line1", "line2", "line3"]);
28
+ expect(result.current.failed).toBe(false);
29
+ });
30
+
31
+ it("handles multi-chunk output", async () => {
32
+ const { result } = renderHook(() =>
33
+ useAsyncStream(async launch => {
34
+ launch(mockProcess(["hello\n", "world\n"]));
35
+ }, []),
36
+ );
37
+
38
+ await waitFor(() => expect(result.current.done).toBe(true));
39
+ expect(result.current.lines).toEqual(["hello", "world"]);
40
+ });
41
+
42
+ it("handles \\r overwrite sequences by taking the last content on a line", async () => {
43
+ // \r without \n: "loading...\rprogress: 50%" on a single logical line
44
+ const { result } = renderHook(() =>
45
+ useAsyncStream(async launch => {
46
+ launch(mockProcess("loading...\rprogress: 50%\n"));
47
+ }, []),
48
+ );
49
+
50
+ await waitFor(() => expect(result.current.done).toBe(true));
51
+ expect(result.current.lines).toEqual(["progress: 50%"]);
52
+ });
53
+
54
+ it("sets failed and errorMsg on process error", async () => {
55
+ const { result } = renderHook(() =>
56
+ useAsyncStream(async launch => {
57
+ launch(mockProcess("partial\n", "process failed"));
58
+ }, []),
59
+ );
60
+
61
+ await waitFor(() => expect(result.current.done).toBe(true));
62
+ expect(result.current.failed).toBe(true);
63
+ expect(result.current.errorMsg).toBe("process failed");
64
+ });
65
+
66
+ it("sets failed when startProcess itself throws", async () => {
67
+ const { result } = renderHook(() =>
68
+ useAsyncStream(async () => {
69
+ throw new Error("launch error");
70
+ }, []),
71
+ );
72
+
73
+ await waitFor(() => expect(result.current.done).toBe(true));
74
+ expect(result.current.failed).toBe(true);
75
+ expect(result.current.errorMsg).toBe("launch error");
76
+ });
77
+
78
+ it("starts empty and not done", () => {
79
+ const { result } = renderHook(() =>
80
+ useAsyncStream(async launch => {
81
+ launch(mockRunningProcess(""));
82
+ }, []),
83
+ );
84
+ expect(result.current.lines).toHaveLength(0);
85
+ expect(result.current.done).toBe(false);
86
+ });
87
+
88
+ it("cancel() closes the running process", async () => {
89
+ let capturedProc!: CockpitProcess;
90
+ const { result } = renderHook(() =>
91
+ useAsyncStream(async launch => {
92
+ capturedProc = mockRunningProcess("line1\n");
93
+ launch(capturedProc);
94
+ }, []),
95
+ );
96
+
97
+ await waitFor(() => expect(result.current.lines).toHaveLength(1));
98
+ act(() => { result.current.cancel(); });
99
+ expect(capturedProc.close).toHaveBeenCalled();
100
+ });
101
+
102
+ it("resets state when deps change", async () => {
103
+ let dep = 1;
104
+ const { result, rerender } = renderHook(() =>
105
+ useAsyncStream(async launch => {
106
+ launch(mockProcess("line\n"));
107
+ }, [dep]),
108
+ );
109
+
110
+ await waitFor(() => expect(result.current.done).toBe(true));
111
+ expect(result.current.lines).toHaveLength(1);
112
+
113
+ dep = 2;
114
+ rerender();
115
+
116
+ expect(result.current.lines).toHaveLength(0);
117
+ expect(result.current.done).toBe(false);
118
+
119
+ await waitFor(() => expect(result.current.done).toBe(true));
120
+ expect(result.current.lines).toHaveLength(1);
121
+ });
122
+ });
@@ -0,0 +1,94 @@
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
+
3
+ export interface AsyncStreamResult {
4
+ lines: string[];
5
+ done: boolean;
6
+ failed: boolean;
7
+ errorMsg: string;
8
+ cancel: () => void;
9
+ }
10
+
11
+ /**
12
+ * Generic hook for accumulating line-buffered output from a CockpitProcess.
13
+ *
14
+ * The caller supplies a `startProcess` factory that receives a `launch` callback.
15
+ * Call `launch(proc)` synchronously once the process is ready — this avoids the
16
+ * JS Promise "following" behaviour that occurs when a CockpitProcess (which extends
17
+ * Promise) is returned from inside a .then().
18
+ *
19
+ * The `deps` array works like useEffect deps — the hook tears down and restarts
20
+ * the process whenever any dep changes.
21
+ */
22
+ export function useAsyncStream(
23
+ startProcess: (launch: (proc: CockpitProcess) => void) => Promise<void>,
24
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
+ deps: any[],
26
+ ): AsyncStreamResult {
27
+ const [lines, setLines] = useState<string[]>([]);
28
+ const [done, setDone] = useState(false);
29
+ const [failed, setFailed] = useState(false);
30
+ const [errorMsg, setErrorMsg] = useState("");
31
+ const bufRef = useRef("");
32
+ const procRef = useRef<CockpitProcess | null>(null);
33
+
34
+ useEffect(() => {
35
+ let cancelled = false;
36
+ bufRef.current = "";
37
+ setLines([]);
38
+ setDone(false);
39
+ setFailed(false);
40
+ setErrorMsg("");
41
+
42
+ const launch = (proc: CockpitProcess) => {
43
+ if (cancelled) { proc.close(); return; }
44
+ procRef.current = proc;
45
+
46
+ proc.stream(data => {
47
+ bufRef.current += data;
48
+ const parts = bufRef.current.split("\n");
49
+ bufRef.current = parts.pop() ?? "";
50
+ const newLines = parts
51
+ .map(line => line.split("\r").pop() ?? "")
52
+ .filter(line => line.trim() !== "");
53
+ if (newLines.length > 0) {
54
+ setLines(prev => [...prev, ...newLines]);
55
+ }
56
+ });
57
+
58
+ proc
59
+ .then(() => {
60
+ if (!cancelled) { setDone(true); setFailed(false); }
61
+ procRef.current = null;
62
+ })
63
+ .catch((ex: unknown) => {
64
+ if (!cancelled) {
65
+ setDone(true);
66
+ setFailed(true);
67
+ setErrorMsg(ex instanceof Error ? ex.message : String(ex));
68
+ }
69
+ procRef.current = null;
70
+ });
71
+ };
72
+
73
+ startProcess(launch).catch((ex: unknown) => {
74
+ if (!cancelled) {
75
+ setDone(true);
76
+ setFailed(true);
77
+ setErrorMsg(ex instanceof Error ? ex.message : String(ex));
78
+ }
79
+ });
80
+
81
+ return () => {
82
+ cancelled = true;
83
+ procRef.current?.close();
84
+ };
85
+ // eslint-disable-next-line react-hooks/exhaustive-deps
86
+ }, deps);
87
+
88
+ const cancel = useCallback(() => {
89
+ procRef.current?.close();
90
+ procRef.current = null;
91
+ }, []);
92
+
93
+ return { lines, done, failed, errorMsg, cancel };
94
+ }
@@ -0,0 +1,106 @@
1
+ import { renderHook, act } from "@testing-library/react";
2
+ import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
3
+ import { useAutoRefresh } from "./useAutoRefresh";
4
+
5
+ describe("useAutoRefresh", () => {
6
+ beforeEach(() => {
7
+ vi.useFakeTimers();
8
+ Object.defineProperty(document, "hidden", { value: false, configurable: true, writable: true });
9
+ });
10
+
11
+ afterEach(() => {
12
+ vi.useRealTimers();
13
+ });
14
+
15
+ it("calls fn at the given interval", () => {
16
+ const fn = vi.fn();
17
+ renderHook(() => useAutoRefresh(fn, 1000));
18
+
19
+ expect(fn).not.toHaveBeenCalled();
20
+ act(() => { vi.advanceTimersByTime(1000); });
21
+ expect(fn).toHaveBeenCalledTimes(1);
22
+ act(() => { vi.advanceTimersByTime(2000); });
23
+ expect(fn).toHaveBeenCalledTimes(3);
24
+ });
25
+
26
+ it("does not call fn when paused", () => {
27
+ const fn = vi.fn();
28
+ renderHook(() => useAutoRefresh(fn, 1000, true));
29
+
30
+ act(() => { vi.advanceTimersByTime(3000); });
31
+ expect(fn).not.toHaveBeenCalled();
32
+ });
33
+
34
+ it("does not call fn when tab is hidden", () => {
35
+ const fn = vi.fn();
36
+ Object.defineProperty(document, "hidden", { value: true, configurable: true, writable: true });
37
+ renderHook(() => useAutoRefresh(fn, 1000));
38
+
39
+ act(() => { vi.advanceTimersByTime(3000); });
40
+ expect(fn).not.toHaveBeenCalled();
41
+ });
42
+
43
+ it("resumes polling when tab becomes visible", () => {
44
+ const fn = vi.fn();
45
+ Object.defineProperty(document, "hidden", { value: true, configurable: true, writable: true });
46
+ renderHook(() => useAutoRefresh(fn, 1000));
47
+
48
+ act(() => { vi.advanceTimersByTime(2000); });
49
+ expect(fn).not.toHaveBeenCalled();
50
+
51
+ act(() => {
52
+ Object.defineProperty(document, "hidden", { value: false, configurable: true, writable: true });
53
+ document.dispatchEvent(new Event("visibilitychange"));
54
+ });
55
+
56
+ act(() => { vi.advanceTimersByTime(1000); });
57
+ expect(fn).toHaveBeenCalledTimes(1);
58
+ });
59
+
60
+ it("pauses polling when tab becomes hidden", () => {
61
+ const fn = vi.fn();
62
+ renderHook(() => useAutoRefresh(fn, 1000));
63
+
64
+ act(() => { vi.advanceTimersByTime(1000); });
65
+ expect(fn).toHaveBeenCalledTimes(1);
66
+
67
+ act(() => {
68
+ Object.defineProperty(document, "hidden", { value: true, configurable: true, writable: true });
69
+ document.dispatchEvent(new Event("visibilitychange"));
70
+ });
71
+
72
+ act(() => { vi.advanceTimersByTime(3000); });
73
+ expect(fn).toHaveBeenCalledTimes(1);
74
+ });
75
+
76
+ it("clears the interval on unmount", () => {
77
+ const fn = vi.fn();
78
+ const { unmount } = renderHook(() => useAutoRefresh(fn, 1000));
79
+
80
+ act(() => { vi.advanceTimersByTime(1000); });
81
+ expect(fn).toHaveBeenCalledTimes(1);
82
+
83
+ unmount();
84
+ act(() => { vi.advanceTimersByTime(5000); });
85
+ expect(fn).toHaveBeenCalledTimes(1);
86
+ });
87
+
88
+ it("picks up the latest fn ref without restarting the interval", () => {
89
+ let callCount = 0;
90
+ const fn1 = vi.fn(() => { callCount++; });
91
+ const fn2 = vi.fn(() => { callCount += 10; });
92
+
93
+ const { rerender } = renderHook(({ f }) => useAutoRefresh(f, 1000), {
94
+ initialProps: { f: fn1 },
95
+ });
96
+
97
+ act(() => { vi.advanceTimersByTime(1000); });
98
+ expect(callCount).toBe(1);
99
+
100
+ rerender({ f: fn2 });
101
+ act(() => { vi.advanceTimersByTime(1000); });
102
+ expect(callCount).toBe(11);
103
+ expect(fn1).toHaveBeenCalledTimes(1);
104
+ expect(fn2).toHaveBeenCalledTimes(1);
105
+ });
106
+ });
@@ -0,0 +1,23 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ export function useAutoRefresh(
4
+ fn: () => void | Promise<void>,
5
+ intervalMs: number,
6
+ paused = false,
7
+ ): void {
8
+ const fnRef = useRef(fn);
9
+ useEffect(() => { fnRef.current = fn; }, [fn]);
10
+
11
+ const [tabHidden, setTabHidden] = useState(() => document.hidden);
12
+ useEffect(() => {
13
+ const handler = () => setTabHidden(document.hidden);
14
+ document.addEventListener("visibilitychange", handler);
15
+ return () => document.removeEventListener("visibilitychange", handler);
16
+ }, []);
17
+
18
+ useEffect(() => {
19
+ if (paused || tabHidden) return;
20
+ const t = setInterval(() => void fnRef.current(), intervalMs);
21
+ return () => clearInterval(t);
22
+ }, [paused, tabHidden, intervalMs]);
23
+ }
@@ -0,0 +1,68 @@
1
+ import { renderHook, act } from "@testing-library/react";
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { useConfirmAction } from "./useConfirmAction";
4
+
5
+ describe("useConfirmAction", () => {
6
+ it("starts idle with no error", () => {
7
+ const { result } = renderHook(() => useConfirmAction());
8
+ expect(result.current.step).toBe("idle");
9
+ expect(result.current.error).toBeNull();
10
+ });
11
+
12
+ it("confirm() moves to confirming", () => {
13
+ const { result } = renderHook(() => useConfirmAction());
14
+ act(() => { result.current.confirm(); });
15
+ expect(result.current.step).toBe("confirming");
16
+ });
17
+
18
+ it("cancel() resets to idle and clears error", async () => {
19
+ const { result } = renderHook(() => useConfirmAction());
20
+ act(() => { result.current.confirm(); });
21
+
22
+ await act(async () => {
23
+ await result.current.submit(vi.fn().mockRejectedValue(new Error("fail")));
24
+ });
25
+ expect(result.current.error).toBe("fail");
26
+
27
+ act(() => { result.current.cancel(); });
28
+ expect(result.current.step).toBe("idle");
29
+ expect(result.current.error).toBeNull();
30
+ });
31
+
32
+ it("submit() transitions: confirming → submitting → idle on success", async () => {
33
+ const action = vi.fn().mockResolvedValue(undefined);
34
+ const { result } = renderHook(() => useConfirmAction());
35
+
36
+ act(() => { result.current.confirm(); });
37
+ expect(result.current.step).toBe("confirming");
38
+
39
+ await act(async () => { await result.current.submit(action); });
40
+ expect(result.current.step).toBe("idle");
41
+ expect(result.current.error).toBeNull();
42
+ expect(action).toHaveBeenCalledTimes(1);
43
+ });
44
+
45
+ it("submit() transitions: submitting → confirming on failure, with error set", async () => {
46
+ const action = vi.fn().mockRejectedValue(new Error("server error"));
47
+ const { result } = renderHook(() => useConfirmAction());
48
+
49
+ act(() => { result.current.confirm(); });
50
+
51
+ await act(async () => { await result.current.submit(action); });
52
+ expect(result.current.step).toBe("confirming");
53
+ expect(result.current.error).toBe("server error");
54
+ });
55
+
56
+ it("clearError() clears error without changing step", async () => {
57
+ const action = vi.fn().mockRejectedValue(new Error("oops"));
58
+ const { result } = renderHook(() => useConfirmAction());
59
+
60
+ act(() => { result.current.confirm(); });
61
+ await act(async () => { await result.current.submit(action); });
62
+ expect(result.current.error).toBe("oops");
63
+
64
+ act(() => { result.current.clearError(); });
65
+ expect(result.current.error).toBeNull();
66
+ expect(result.current.step).toBe("confirming");
67
+ });
68
+ });
@@ -0,0 +1,35 @@
1
+ import { useState, useCallback } from "react";
2
+
3
+ export type ConfirmStep = "idle" | "confirming" | "submitting";
4
+
5
+ export interface ConfirmActionState {
6
+ step: ConfirmStep;
7
+ error: string | null;
8
+ confirm: () => void;
9
+ cancel: () => void;
10
+ submit: (action: () => Promise<void>) => Promise<void>;
11
+ clearError: () => void;
12
+ }
13
+
14
+ export function useConfirmAction(): ConfirmActionState {
15
+ const [step, setStep] = useState<ConfirmStep>("idle");
16
+ const [error, setError] = useState<string | null>(null);
17
+
18
+ const confirm = useCallback(() => setStep("confirming"), []);
19
+ const cancel = useCallback(() => { setStep("idle"); setError(null); }, []);
20
+ const clearError = useCallback(() => setError(null), []);
21
+
22
+ const submit = useCallback(async (action: () => Promise<void>) => {
23
+ setStep("submitting");
24
+ setError(null);
25
+ try {
26
+ await action();
27
+ setStep("idle");
28
+ } catch (e) {
29
+ setError(e instanceof Error ? e.message : String(e));
30
+ setStep("confirming");
31
+ }
32
+ }, []);
33
+
34
+ return { step, error, confirm, cancel, submit, clearError };
35
+ }
@@ -0,0 +1,41 @@
1
+ import { useState, useCallback, useEffect } from "react";
2
+ import { useAutoRefresh } from "./useAutoRefresh";
3
+
4
+ export interface PollingFetchResult<T> {
5
+ data: T;
6
+ loading: boolean;
7
+ error: string | null;
8
+ refresh: () => Promise<void>;
9
+ }
10
+
11
+ /**
12
+ * Initial fetch shows loading=true; subsequent background polls update silently.
13
+ * Calling refresh() manually also runs silently (no loading flash).
14
+ */
15
+ export function usePollingFetch<T>(
16
+ fetcher: () => Promise<T>,
17
+ initial: T,
18
+ intervalMs: number,
19
+ paused = false,
20
+ ): PollingFetchResult<T> {
21
+ const [data, setData] = useState<T>(initial);
22
+ const [loading, setLoading] = useState(true);
23
+ const [error, setError] = useState<string | null>(null);
24
+
25
+ const refresh = useCallback(async () => {
26
+ try {
27
+ const result = await fetcher();
28
+ setData(result);
29
+ setError(null);
30
+ } catch (e) {
31
+ setError(e instanceof Error ? e.message : String(e));
32
+ } finally {
33
+ setLoading(false);
34
+ }
35
+ }, [fetcher]);
36
+
37
+ useEffect(() => { void refresh(); }, [refresh]);
38
+ useAutoRefresh(refresh, intervalMs, paused);
39
+
40
+ return { data, loading, error, refresh };
41
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,51 @@
1
+ import i18n from "i18next";
2
+ import { initReactI18next } from "react-i18next";
3
+
4
+ export type LocaleResources = Record<string, { translation: Record<string, unknown> }>;
5
+
6
+ // Reads Cockpit's language setting in priority order:
7
+ // 1. document.documentElement.lang — Cockpit sets this live when the user changes language
8
+ // 2. localStorage["cockpit:language"] — Cockpit mirrors the preference here
9
+ // 3. Falls back to "en" via fallbackLng
10
+ const cockpitDetector = {
11
+ name: "cockpit",
12
+ detect(): string | undefined {
13
+ const htmlLang = document.documentElement.lang;
14
+ if (htmlLang) return htmlLang;
15
+ try {
16
+ const stored = localStorage.getItem("cockpit:language");
17
+ if (stored) return stored;
18
+ } catch {
19
+ // localStorage may be unavailable in restricted contexts
20
+ }
21
+ return undefined;
22
+ },
23
+ cacheUserLanguage() {
24
+ // Language is owned by Cockpit settings — never write back
25
+ },
26
+ };
27
+
28
+ export function initCockpitI18n(resources: LocaleResources): void {
29
+ void i18n
30
+ .use({ type: "languageDetector", ...cockpitDetector } as Parameters<typeof i18n.use>[0])
31
+ .use(initReactI18next)
32
+ .init({
33
+ resources,
34
+ fallbackLng: "en",
35
+ load: "all",
36
+ interpolation: {
37
+ escapeValue: false,
38
+ },
39
+ });
40
+
41
+ // Cockpit updates document.documentElement.lang when the user switches language at runtime.
42
+ // i18next only detects on init, so we observe the attribute and sync the change.
43
+ new MutationObserver(() => {
44
+ const lang = document.documentElement.lang;
45
+ if (lang && lang !== i18n.language) {
46
+ void i18n.changeLanguage(lang);
47
+ }
48
+ }).observe(document.documentElement, { attributeFilter: ["lang"] });
49
+ }
50
+
51
+ export { i18n };
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export { initCockpitI18n } from "./i18n";
2
+ export type { LocaleResources } from "./i18n";
3
+ export { bootstrapPlugin } from "./bootstrap";
4
+ export { useAsyncAction } from "./hooks/useAsyncAction";
5
+ export { useAutoRefresh } from "./hooks/useAutoRefresh";
6
+ export { useAsyncStream } from "./hooks/useAsyncStream";
7
+ export type { AsyncStreamResult } from "./hooks/useAsyncStream";
8
+ export { useConfirmAction } from "./hooks/useConfirmAction";
9
+ export type { ConfirmStep, ConfirmActionState } from "./hooks/useConfirmAction";
10
+ export { usePollingFetch } from "./hooks/usePollingFetch";
11
+ export type { PollingFetchResult } from "./hooks/usePollingFetch";