openk8s 1.0.1 → 1.0.3

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 (48) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -2
  3. package/src/app/__tests__/app-state.test.ts +376 -0
  4. package/src/app/__tests__/components/inspector-tokens.test.ts +101 -0
  5. package/src/app/__tests__/utils.test.ts +358 -0
  6. package/src/app/app-actions.ts +262 -0
  7. package/src/app/app-state.ts +16 -263
  8. package/src/app/app.tsx +22 -170
  9. package/src/app/components/detail-sections.tsx +131 -0
  10. package/src/app/components/footer.tsx +52 -0
  11. package/src/app/components/header.tsx +37 -0
  12. package/src/app/components/inspector-tokens.ts +93 -0
  13. package/src/app/components/inspector.tsx +3 -239
  14. package/src/app/components/resource-rows.tsx +5 -1
  15. package/src/app/hooks/keyboard/filter-handlers.ts +40 -0
  16. package/src/app/hooks/keyboard/global-handlers.ts +134 -0
  17. package/src/app/hooks/keyboard/helm-handlers.ts +104 -0
  18. package/src/app/hooks/keyboard/logs-handlers.ts +80 -0
  19. package/src/app/hooks/keyboard/navigation-handlers.ts +103 -0
  20. package/src/app/hooks/keyboard/overlay-handlers.ts +138 -0
  21. package/src/app/hooks/keyboard/port-forward-handlers.ts +71 -0
  22. package/src/app/hooks/keyboard/shell-edit-handlers.ts +253 -0
  23. package/src/app/hooks/use-app-keyboard.ts +56 -621
  24. package/src/app/hooks/use-app-side-effects.ts +1 -1
  25. package/src/app/hooks/use-clipboard.ts +1 -1
  26. package/src/app/hooks/use-data-fetching.ts +2 -11
  27. package/src/app/hooks/use-log-stream.ts +1 -1
  28. package/src/app/hooks/use-port-forward.ts +1 -1
  29. package/src/app/use-footer-hints.ts +107 -0
  30. package/src/index.tsx +4 -0
  31. package/src/lib/k8s/__tests__/k8s-format.test.ts +42 -0
  32. package/src/lib/k8s/__tests__/resource-detail-builder.test.ts +215 -0
  33. package/src/lib/k8s/__tests__/resource-parser.test.ts +455 -0
  34. package/src/lib/k8s/detail-builders/event-builder.ts +21 -0
  35. package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +63 -0
  36. package/src/lib/k8s/detail-builders/node-builder.ts +41 -0
  37. package/src/lib/k8s/detail-builders/overview-builder.ts +103 -0
  38. package/src/lib/k8s/detail-builders/pod-builder.ts +140 -0
  39. package/src/lib/k8s/detail-builders/rbac-builder.ts +57 -0
  40. package/src/lib/k8s/resource-detail-builder.ts +22 -502
  41. package/src/lib/kubectl/__tests__/kubectl-helpers.test.ts +343 -0
  42. package/src/lib/kubectl/__tests__/metrics-utils.test.ts +84 -0
  43. package/src/lib/kubectl/__tests__/spawn-utils.test.ts +56 -0
  44. package/src/lib/kubectl/kubectl-helpers.ts +246 -0
  45. package/src/lib/kubectl/kubectl-service.ts +77 -565
  46. package/src/lib/kubectl/kubectl-types.ts +248 -0
  47. package/src/lib/kubectl/metrics-utils.ts +33 -0
  48. package/src/lib/kubectl/spawn-utils.ts +27 -0
@@ -0,0 +1,104 @@
1
+ import type { AppAction } from "../../app-actions";
2
+ import type { AppState, ResourceRef, ResourceKind, ResourceDetail, NotificationTone } from "../../../lib/k8s/types";
3
+ import { KubectlService } from "../../../lib/kubectl/kubectl-service";
4
+
5
+ export function handleHelmRollbackKeys(
6
+ key: { name: string },
7
+ state: AppState,
8
+ dispatch: React.Dispatch<AppAction>,
9
+ activeResourceRef: ResourceRef | undefined,
10
+ kubectl: KubectlService,
11
+ toast: (tone: NotificationTone, message: string) => void,
12
+ toastError: (error: unknown) => void,
13
+ setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
14
+ ): boolean {
15
+ if (state.overlay !== "helm-rollback") return false;
16
+
17
+ if (key.name === "return" && activeResourceRef && state.activeContext) {
18
+ const context = state.activeContext;
19
+ const namespace = state.activeNamespace;
20
+ const name = activeResourceRef.name;
21
+ const revisionStr = state.helmRollbackRevision.trim();
22
+ const revision = revisionStr ? Number.parseInt(revisionStr, 10) : undefined;
23
+
24
+ dispatch({ type: "setOverlay", overlay: undefined });
25
+ dispatch({
26
+ type: "setStatusMessage",
27
+ message: `Rolling back ${name}${revision !== undefined ? ` to r${revision}` : " to previous"}`,
28
+ });
29
+
30
+ void kubectl
31
+ .helmRollback({ context, namespace, name, revision })
32
+ .then(() => {
33
+ toast("success", `${name} rolled back${revision !== undefined ? ` to r${revision}` : ""}`);
34
+ setManualRefreshNonce((current) => current + 1);
35
+ })
36
+ .catch(toastError);
37
+
38
+ return true;
39
+ }
40
+
41
+ if (key.name === "escape") {
42
+ dispatch({ type: "setOverlay", overlay: undefined });
43
+ return true;
44
+ }
45
+
46
+ return true;
47
+ }
48
+
49
+ export function handleHelmUpgradeKeys(
50
+ key: { name: string; shift?: boolean },
51
+ state: AppState,
52
+ dispatch: React.Dispatch<AppAction>,
53
+ renderer: { suspend: () => void; resume: () => void },
54
+ activeResourceRef: ResourceRef | undefined,
55
+ selectedKind: ResourceKind | undefined,
56
+ activeDetail: ResourceDetail | undefined,
57
+ kubectl: KubectlService,
58
+ toast: (tone: NotificationTone, message: string) => void,
59
+ toastError: (error: unknown) => void,
60
+ setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
61
+ ): boolean {
62
+ if (state.overlay) return false;
63
+
64
+ // B → Helm rollback overlay
65
+ if (key.name === "b" && activeResourceRef?.kind.toLowerCase() === "helmreleases" && state.activeContext) {
66
+ dispatch({ type: "setHelmRollbackRevision", value: "" });
67
+ dispatch({ type: "setOverlay", overlay: "helm-rollback" });
68
+ return true;
69
+ }
70
+
71
+ // U → Helm upgrade --reuse-values
72
+ if (key.name === "u" && activeResourceRef?.kind.toLowerCase() === "helmreleases" && selectedKind && state.activeContext) {
73
+ const chartName = activeDetail?.helmChart ?? "";
74
+ if (!chartName) {
75
+ dispatch({ type: "setStatusMessage", message: "Chart name unavailable — load the detail pane first" });
76
+ return true;
77
+ }
78
+ const context = state.activeContext;
79
+ const namespace = state.activeNamespace;
80
+ const target = activeResourceRef;
81
+
82
+ dispatch({ type: "setStatusMessage", message: `Upgrading ${target.name}` });
83
+ dispatch({ type: "setError", error: undefined });
84
+ renderer.suspend();
85
+
86
+ const upgradeChild = kubectl.helmUpgrade({ context, namespace, name: target.name, chart: chartName });
87
+ upgradeChild.on("close", (code) => {
88
+ renderer.resume();
89
+ if (code === 0) {
90
+ toast("success", `${target.name} upgraded`);
91
+ setManualRefreshNonce((current) => current + 1);
92
+ } else {
93
+ dispatch({ type: "setStatusMessage", message: `Upgrade failed for ${target.name}` });
94
+ }
95
+ });
96
+ upgradeChild.on("error", (err) => {
97
+ renderer.resume();
98
+ toastError(err);
99
+ });
100
+ return true;
101
+ }
102
+
103
+ return false;
104
+ }
@@ -0,0 +1,80 @@
1
+ import type { ScrollBoxRenderable } from "@opentui/core";
2
+
3
+ import type { AppAction } from "../../app-actions";
4
+ import type { AppState } from "../../../lib/k8s/types";
5
+ import type { LogsDialogActions } from "../../components/overlays";
6
+
7
+ export function handleLogsKeys(
8
+ key: { name: string; shift?: boolean; sequence?: string },
9
+ state: AppState,
10
+ dispatch: React.Dispatch<AppAction>,
11
+ logsInputMode: "tail" | "since" | undefined,
12
+ logsInputValue: string,
13
+ logsSearchMode: boolean,
14
+ setLogsInputMode: React.Dispatch<React.SetStateAction<"tail" | "since" | undefined>>,
15
+ setLogsInputValue: React.Dispatch<React.SetStateAction<string>>,
16
+ setLogsSearchMode: React.Dispatch<React.SetStateAction<boolean>>,
17
+ setLogsSearchText: React.Dispatch<React.SetStateAction<string>>,
18
+ setOverlayIndex: React.Dispatch<React.SetStateAction<number>>,
19
+ logsScrollRef: React.MutableRefObject<ScrollBoxRenderable | null>,
20
+ logsActionsRef: React.MutableRefObject<LogsDialogActions | null>,
21
+ containers: string[] | undefined,
22
+ ): boolean {
23
+ if (state.overlay !== "logs") return false;
24
+
25
+ if (key.name === "escape") {
26
+ if (logsInputMode !== undefined) {
27
+ setLogsInputMode(undefined);
28
+ setLogsInputValue("");
29
+ return true;
30
+ }
31
+ if (logsSearchMode) {
32
+ setLogsSearchMode(false);
33
+ setLogsSearchText("");
34
+ return true;
35
+ }
36
+ return false; // fall through to generic escape
37
+ }
38
+
39
+ if (!logsInputMode && !logsSearchMode) {
40
+ if (key.name === "up" || key.name === "k") {
41
+ logsScrollRef.current?.scrollBy(-3);
42
+ } else if (key.name === "down" || key.name === "j") {
43
+ logsScrollRef.current?.scrollBy(3);
44
+ } else if (key.name === "c") {
45
+ if ((containers ?? []).length > 1) {
46
+ setOverlayIndex(0);
47
+ dispatch({ type: "setOverlay", overlay: "container-picker" });
48
+ }
49
+ } else if (key.name === "p") {
50
+ dispatch({ type: "setLogsOptions", options: { ...state.logsOptions, previous: !state.logsOptions.previous } });
51
+ } else if (key.name === "t") {
52
+ setLogsInputMode("tail");
53
+ setLogsInputValue(String(state.logsOptions.tail));
54
+ } else if (key.name === "s") {
55
+ setLogsInputMode("since");
56
+ setLogsInputValue(state.logsOptions.since ?? "");
57
+ } else if (key.sequence === "/" || key.name === "slash") {
58
+ setLogsSearchMode(true);
59
+ } else if (key.name === "n" && !key.shift) {
60
+ logsActionsRef.current?.nextMatch();
61
+ } else if (key.shift && key.name === "n") {
62
+ logsActionsRef.current?.prevMatch();
63
+ }
64
+ } else if (logsInputMode && key.name === "return") {
65
+ if (logsInputMode === "tail") {
66
+ const tail = Number.parseInt(logsInputValue, 10);
67
+ if (!Number.isNaN(tail) && tail > 0) {
68
+ dispatch({ type: "setLogsOptions", options: { ...state.logsOptions, tail } });
69
+ }
70
+ } else if (logsInputMode === "since") {
71
+ dispatch({ type: "setLogsOptions", options: { ...state.logsOptions, since: logsInputValue || undefined } });
72
+ }
73
+ setLogsInputMode(undefined);
74
+ setLogsInputValue("");
75
+ } else if (logsSearchMode && key.name === "return") {
76
+ setLogsSearchMode(false);
77
+ }
78
+
79
+ return true;
80
+ }
@@ -0,0 +1,103 @@
1
+ import type { AppAction } from "../../app-actions";
2
+ import { INSPECTOR_TABS } from "../../components/inspector";
3
+ import type { AppState, ResourceKind, ResourceListItem } from "../../../lib/k8s/types";
4
+
5
+ export function handlePaneNavigation(
6
+ key: { name: string; shift?: boolean },
7
+ state: AppState,
8
+ dispatch: React.Dispatch<AppAction>,
9
+ filteredResources: ResourceListItem[],
10
+ visibleSelectedResourceName: string | undefined,
11
+ ): boolean {
12
+ if (key.name === "tab") {
13
+ dispatch({ type: "cyclePane", direction: key.shift ? -1 : 1 });
14
+ return true;
15
+ }
16
+
17
+ // Clusters pane navigation
18
+ if (state.activePane === "clusters") {
19
+ const currentIndex = Math.max(0, state.resourceKinds.findIndex((kind) => kind.name === state.selectedKind));
20
+
21
+ if ((key.name === "up" || key.name === "k") && currentIndex > 0) {
22
+ const nextKind = state.resourceKinds[currentIndex - 1];
23
+ if (nextKind) {
24
+ dispatch({ type: "setSelectedKind", kind: nextKind.name });
25
+ }
26
+ return true;
27
+ }
28
+
29
+ if ((key.name === "down" || key.name === "j") && currentIndex < state.resourceKinds.length - 1) {
30
+ const nextKind = state.resourceKinds[currentIndex + 1];
31
+ if (nextKind) {
32
+ dispatch({ type: "setSelectedKind", kind: nextKind.name });
33
+ }
34
+ return true;
35
+ }
36
+
37
+ if (key.name === "right") {
38
+ dispatch({ type: "setActivePane", pane: "resources" });
39
+ return true;
40
+ }
41
+
42
+ return true;
43
+ }
44
+
45
+ // Resources pane navigation
46
+ if (state.activePane === "resources") {
47
+ const currentIndex = Math.max(
48
+ 0,
49
+ filteredResources.findIndex((resource) => resource.ref.name === visibleSelectedResourceName),
50
+ );
51
+
52
+ if ((key.name === "up" || key.name === "k") && currentIndex > 0) {
53
+ const nextResource = filteredResources[currentIndex - 1];
54
+ if (nextResource) {
55
+ dispatch({ type: "setSelectedResourceName", name: nextResource.ref.name });
56
+ }
57
+ return true;
58
+ }
59
+
60
+ if ((key.name === "down" || key.name === "j") && currentIndex < filteredResources.length - 1) {
61
+ const nextResource = filteredResources[currentIndex + 1];
62
+ if (nextResource) {
63
+ dispatch({ type: "setSelectedResourceName", name: nextResource.ref.name });
64
+ }
65
+ return true;
66
+ }
67
+
68
+ if (key.name === "left" || key.name === "h") {
69
+ dispatch({ type: "setActivePane", pane: "clusters" });
70
+ return true;
71
+ }
72
+
73
+ if (key.name === "right") {
74
+ dispatch({ type: "setActivePane", pane: "inspector" });
75
+ return true;
76
+ }
77
+ }
78
+
79
+ // Inspector pane navigation
80
+ if (state.activePane === "inspector") {
81
+ if (key.name === "left" || key.name === "h") {
82
+ const currentIndex = INSPECTOR_TABS.findIndex((tab) => tab.id === state.inspectorTab);
83
+ if (currentIndex > 0) {
84
+ const prevTab = INSPECTOR_TABS[currentIndex - 1];
85
+ if (prevTab) dispatch({ type: "setInspectorTab", tab: prevTab.id });
86
+ } else {
87
+ dispatch({ type: "setActivePane", pane: "resources" });
88
+ }
89
+ return true;
90
+ }
91
+
92
+ if (key.name === "right") {
93
+ const currentIndex = INSPECTOR_TABS.findIndex((tab) => tab.id === state.inspectorTab);
94
+ const nextTab = INSPECTOR_TABS[currentIndex + 1];
95
+ if (nextTab) {
96
+ dispatch({ type: "setInspectorTab", tab: nextTab.id });
97
+ }
98
+ return true;
99
+ }
100
+ }
101
+
102
+ return false;
103
+ }
@@ -0,0 +1,138 @@
1
+ import type { AppAction } from "../../app-actions";
2
+ import type { AppState, ResourceListItem, ResourceKind, ResourceRef } from "../../../lib/k8s/types";
3
+ import { deleteStatusMessage, statusLine } from "../../utils";
4
+ import { KubectlService } from "../../../lib/kubectl/kubectl-service";
5
+
6
+ export function handleGenericEscape(
7
+ key: { name: string },
8
+ state: AppState,
9
+ dispatch: React.Dispatch<AppAction>,
10
+ ): boolean {
11
+ if (key.name === "escape") {
12
+ if (state.overlay) {
13
+ dispatch({ type: "setOverlay", overlay: undefined });
14
+ }
15
+ return true;
16
+ }
17
+ return false;
18
+ }
19
+
20
+ export function handleOverlayNavigation(
21
+ key: { name: string },
22
+ state: AppState,
23
+ dispatch: React.Dispatch<AppAction>,
24
+ overlayIndex: number,
25
+ setOverlayIndex: React.Dispatch<React.SetStateAction<number>>,
26
+ ): boolean {
27
+ if (!state.overlay) return false;
28
+
29
+ const items: Array<unknown> =
30
+ state.overlay === "cluster-switcher"
31
+ ? state.contexts
32
+ : state.overlay === "namespace-switcher"
33
+ ? state.namespaces
34
+ : state.overlay === "container-picker"
35
+ ? []
36
+ : [];
37
+
38
+ if (key.name === "up") {
39
+ setOverlayIndex((current) => Math.max(0, current - 1));
40
+ return true;
41
+ }
42
+
43
+ if (key.name === "down") {
44
+ setOverlayIndex((current) => Math.min(items.length - 1, current + 1));
45
+ return true;
46
+ }
47
+
48
+ // Cluster / namespace / container picker selection
49
+ if (key.name === "return") {
50
+ if (state.overlay === "container-picker") {
51
+ return false; // handled by the caller
52
+ }
53
+
54
+ if (state.overlay === "cluster-switcher") {
55
+ const selectedContext = state.contexts[overlayIndex];
56
+ if (selectedContext) {
57
+ dispatch({ type: "setActiveContext", activeContext: selectedContext.name });
58
+ }
59
+ }
60
+
61
+ if (state.overlay === "namespace-switcher") {
62
+ const selectedNamespace = state.namespaces[overlayIndex];
63
+ if (selectedNamespace) {
64
+ dispatch({ type: "setActiveNamespace", namespace: selectedNamespace.name });
65
+ }
66
+ }
67
+
68
+ dispatch({ type: "setOverlay", overlay: undefined });
69
+ return true;
70
+ }
71
+
72
+ return false;
73
+ }
74
+
75
+ export function handleDeleteConfirm(
76
+ key: { name: string },
77
+ state: AppState,
78
+ dispatch: React.Dispatch<AppAction>,
79
+ selectedKind: ResourceKind | undefined,
80
+ deleteTargets: ResourceListItem[],
81
+ kubectl: KubectlService,
82
+ toastError: (error: unknown) => void,
83
+ setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
84
+ ): boolean {
85
+ if (state.overlay !== "delete-confirm") return false;
86
+
87
+ if (key.name === "return" && selectedKind && state.activeContext && deleteTargets.length > 0) {
88
+ const context = state.activeContext;
89
+ const namespace = state.activeNamespace;
90
+ const namespaced = selectedKind.namespaced;
91
+ const targets = deleteTargets.map((resource) => resource.ref);
92
+
93
+ dispatch({ type: "setStatusMessage", message: deleteStatusMessage(targets, "Deleting") });
94
+
95
+ void Promise.all(
96
+ targets.map((target) => kubectl.deleteResource({ context, namespace, resourceRef: target, namespaced })),
97
+ )
98
+ .then(() => {
99
+ dispatch({ type: "clearTransientViews" });
100
+ dispatch({ type: "setSelectedResourceDetail", detail: undefined });
101
+ dispatch({ type: "setSelectedResourceName", name: undefined });
102
+ dispatch({ type: "clearSelectedResources" });
103
+ dispatch({ type: "setError", error: undefined });
104
+ dispatch({ type: "setStatusMessage", message: deleteStatusMessage(targets, "Deleted") });
105
+ setManualRefreshNonce((current) => current + 1);
106
+ })
107
+ .catch((error: unknown) => {
108
+ toastError(error);
109
+ dispatch({ type: "setStatusMessage", message: deleteStatusMessage(targets, "Delete failed for") });
110
+ });
111
+
112
+ dispatch({ type: "setOverlay", overlay: undefined });
113
+ return true;
114
+ }
115
+
116
+ return false;
117
+ }
118
+
119
+ export function handleContainerPicker(
120
+ key: { name: string },
121
+ state: AppState,
122
+ dispatch: React.Dispatch<AppAction>,
123
+ overlayIndex: number,
124
+ containers: string[] | undefined,
125
+ ): boolean {
126
+ if (state.overlay !== "container-picker") return false;
127
+
128
+ if (key.name === "return") {
129
+ const container = (containers ?? [])[overlayIndex];
130
+ if (container !== undefined) {
131
+ dispatch({ type: "setLogsContainer", container });
132
+ }
133
+ dispatch({ type: "setOverlay", overlay: "logs" });
134
+ return true;
135
+ }
136
+
137
+ return false;
138
+ }
@@ -0,0 +1,71 @@
1
+ import type { AppAction } from "../../app-actions";
2
+ import type { AppState, ActivePortForward, ResourceRef, ResourceKind, ResourceDetail } from "../../../lib/k8s/types";
3
+ import type { StartPortForwardLifecycleOptions } from "../../utils";
4
+ import { parseLocalPort } from "../../utils";
5
+ import { buildPortForwardEntries } from "../../components/overlays";
6
+
7
+ export function handlePortForwardKeys(
8
+ key: { name: string },
9
+ state: AppState,
10
+ dispatch: React.Dispatch<AppAction>,
11
+ overlayIndex: number,
12
+ portForwardLocalPort: string,
13
+ setOverlayIndex: React.Dispatch<React.SetStateAction<number>>,
14
+ setPortForwardLocalPort: React.Dispatch<React.SetStateAction<string>>,
15
+ activeResourceRef: ResourceRef | undefined,
16
+ selectedKind: ResourceKind | undefined,
17
+ activeDetail: ResourceDetail | undefined,
18
+ selectedResourcePortForwards: ActivePortForward[],
19
+ stopPortForward: (id: string) => void,
20
+ startPortForward: (options: StartPortForwardLifecycleOptions) => void,
21
+ ): boolean {
22
+ if (state.overlay !== "port-forward") return false;
23
+
24
+ if (key.name === "escape") {
25
+ dispatch({ type: "setOverlay", overlay: undefined });
26
+ return true;
27
+ }
28
+
29
+ const pfEntries = buildPortForwardEntries(activeDetail?.portForwards ?? [], selectedResourcePortForwards);
30
+
31
+ if (key.name === "up" || key.name === "k") {
32
+ const newIndex = Math.max(0, overlayIndex - 1);
33
+ setOverlayIndex(newIndex);
34
+ const entry = pfEntries[newIndex];
35
+ if (entry && !entry.activeForward) {
36
+ setPortForwardLocalPort(String(entry.suggestedLocalPort));
37
+ }
38
+ return true;
39
+ }
40
+
41
+ if (key.name === "down" || key.name === "j") {
42
+ const newIndex = Math.min(Math.max(0, pfEntries.length - 1), overlayIndex + 1);
43
+ setOverlayIndex(newIndex);
44
+ const entry = pfEntries[newIndex];
45
+ if (entry && !entry.activeForward) {
46
+ setPortForwardLocalPort(String(entry.suggestedLocalPort));
47
+ }
48
+ return true;
49
+ }
50
+
51
+ const selectedEntry = pfEntries[overlayIndex];
52
+
53
+ if (key.name === "x" && selectedEntry?.activeForward) {
54
+ stopPortForward(selectedEntry.activeForward.id);
55
+ return true;
56
+ }
57
+
58
+ if (key.name === "return" && activeResourceRef && selectedKind && state.activeContext && selectedEntry && !selectedEntry.activeForward) {
59
+ const localPort = parseLocalPort(portForwardLocalPort) ?? selectedEntry.suggestedLocalPort;
60
+ startPortForward({
61
+ context: state.activeContext,
62
+ namespace: state.activeNamespace,
63
+ target: activeResourceRef,
64
+ namespaced: selectedKind.namespaced,
65
+ localPort,
66
+ remotePort: selectedEntry.remotePort,
67
+ });
68
+ }
69
+
70
+ return true;
71
+ }