diff --git a/src/common/fs/watch/watch.global-override-for-injectable.ts b/src/common/fs/watch.global-override-for-injectable.ts
similarity index 82%
rename from src/common/fs/watch/watch.global-override-for-injectable.ts
rename to src/common/fs/watch.global-override-for-injectable.ts
index 689c7150cf..c99f2c3916 100644
--- a/src/common/fs/watch/watch.global-override-for-injectable.ts
+++ b/src/common/fs/watch.global-override-for-injectable.ts
@@ -2,7 +2,7 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
-import { getGlobalOverride } from "../../test-utils/get-global-override";
+import { getGlobalOverride } from "../test-utils/get-global-override";
import watchInjectable from "./watch.injectable";
export default getGlobalOverride(watchInjectable, () => () => {
diff --git a/src/common/fs/watch/watch.injectable.ts b/src/common/fs/watch.injectable.ts
similarity index 99%
rename from src/common/fs/watch/watch.injectable.ts
rename to src/common/fs/watch.injectable.ts
index 50f96cdf57..832af07b01 100644
--- a/src/common/fs/watch/watch.injectable.ts
+++ b/src/common/fs/watch.injectable.ts
@@ -6,7 +6,7 @@ import { getInjectable } from "@ogre-tools/injectable";
import { watch } from "chokidar";
import type { Stats } from "fs";
import type TypedEventEmitter from "typed-emitter";
-import type { SingleOrMany } from "../../utils";
+import type { SingleOrMany } from "../utils";
export interface AlwaysStatWatcherEvents {
add: (path: string, stats: Stats) => void;
diff --git a/src/extensions/extension-discovery/extension-discovery.injectable.ts b/src/extensions/extension-discovery/extension-discovery.injectable.ts
index 378f519bb7..62679d4100 100644
--- a/src/extensions/extension-discovery/extension-discovery.injectable.ts
+++ b/src/extensions/extension-discovery/extension-discovery.injectable.ts
@@ -13,7 +13,7 @@ import extensionPackageRootDirectoryInjectable from "../extension-installer/exte
import readJsonFileInjectable from "../../common/fs/read-json-file.injectable";
import loggerInjectable from "../../common/logger.injectable";
import pathExistsInjectable from "../../common/fs/path-exists.injectable";
-import watchInjectable from "../../common/fs/watch/watch.injectable";
+import watchInjectable from "../../common/fs/watch.injectable";
import accessPathInjectable from "../../common/fs/access-path.injectable";
import copyInjectable from "../../common/fs/copy.injectable";
import ensureDirInjectable from "../../common/fs/ensure-dir.injectable";
diff --git a/src/extensions/extension-discovery/extension-discovery.test.ts b/src/extensions/extension-discovery/extension-discovery.test.ts
index d71f8c5292..91f0be84e2 100644
--- a/src/extensions/extension-discovery/extension-discovery.test.ts
+++ b/src/extensions/extension-discovery/extension-discovery.test.ts
@@ -13,7 +13,7 @@ import { delay } from "../../renderer/utils";
import { observable, runInAction, when } from "mobx";
import readJsonFileInjectable from "../../common/fs/read-json-file.injectable";
import pathExistsInjectable from "../../common/fs/path-exists.injectable";
-import watchInjectable from "../../common/fs/watch/watch.injectable";
+import watchInjectable from "../../common/fs/watch.injectable";
import extensionApiVersionInjectable from "../../common/vars/extension-api-version.injectable";
import removePathInjectable from "../../common/fs/remove.injectable";
import type { JoinPaths } from "../../common/path/join-paths.injectable";
diff --git a/src/extensions/extension-discovery/extension-discovery.ts b/src/extensions/extension-discovery/extension-discovery.ts
index c9646a1c6c..760595fb1c 100644
--- a/src/extensions/extension-discovery/extension-discovery.ts
+++ b/src/extensions/extension-discovery/extension-discovery.ts
@@ -17,7 +17,7 @@ import { requestInitialExtensionDiscovery } from "../../renderer/ipc";
import type { ReadJson } from "../../common/fs/read-json-file.injectable";
import type { Logger } from "../../common/logger";
import type { PathExists } from "../../common/fs/path-exists.injectable";
-import type { Watch } from "../../common/fs/watch/watch.injectable";
+import type { Watch } from "../../common/fs/watch.injectable";
import type { Stats } from "fs";
import type { LStat } from "../../common/fs/lstat.injectable";
import type { ReadDirectory } from "../../common/fs/read-directory.injectable";
diff --git a/src/features/kubeconfig-sync/__snapshots__/reactive-catalog.test.tsx.snap b/src/features/kubeconfig-sync/__snapshots__/reactive-catalog.test.tsx.snap
new file mode 100644
index 0000000000..087bc77c28
--- /dev/null
+++ b/src/features/kubeconfig-sync/__snapshots__/reactive-catalog.test.tsx.snap
@@ -0,0 +1,882 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`kubeconfig sync showing reactive catalog renders 1`] = `
+
+
+
+
+
+
+
+
+ home
+
+
+
+
+
+
+
+ arrow_back
+
+
+
+
+
+
+
+ arrow_forward
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Welcome to some-product-name!
+
+
+ To get you started we have auto-detected your clusters in your
+
+ kubeconfig file and added them to the catalog, your centralized
+
+ view for managing all your cloud-native resources.
+
+
+ If you have any questions or feedback, please join our
+
+ Lens Community slack channel
+
+ .
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ arrow_left
+
+
+
+
+
+ arrow_right
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`kubeconfig sync showing reactive catalog when navigating to the catalog renders 1`] = `
+
+
+
+
+
+
+
+
+ home
+
+
+
+
+
+
+
+ arrow_back
+
+
+
+
+
+
+
+ arrow_forward
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ arrow_left
+
+
+
+
+
+ arrow_right
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/features/kubeconfig-sync/reactive-catalog.test.tsx b/src/features/kubeconfig-sync/reactive-catalog.test.tsx
new file mode 100644
index 0000000000..7a50e922c4
--- /dev/null
+++ b/src/features/kubeconfig-sync/reactive-catalog.test.tsx
@@ -0,0 +1,80 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+
+import type { DiContainer } from "@ogre-tools/injectable";
+import type { RenderResult } from "@testing-library/react";
+import navigateToCatalogInjectable from "../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
+import writeFileInjectable from "../../common/fs/write-file.injectable";
+import { dumpConfigYaml } from "../../common/kube-helpers";
+import homeDirectoryPathInjectable from "../../common/os/home-directory-path.injectable";
+import joinPathsInjectable from "../../common/path/join-paths.injectable";
+import { flushPromises } from "../../common/test-utils/flush-promises";
+import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
+import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
+
+describe("kubeconfig sync showing reactive catalog", () => {
+ let builder: ApplicationBuilder;
+ let rendered: RenderResult;
+ let windowDi: DiContainer;
+ let mainDi: DiContainer;
+
+ beforeEach(async () => {
+ builder = getApplicationBuilder();
+
+ // builder.mainDi.override(loggerInjectable, () => console as any);
+ rendered = await builder.render();
+ windowDi = builder.applicationWindow.only.di;
+ mainDi = builder.mainDi;
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ describe("when navigating to the catalog", () => {
+ beforeEach(() => {
+ const navigateToCatalog = windowDi.inject(navigateToCatalogInjectable);
+
+ navigateToCatalog();
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ describe("when a config file is written under ~/.kube", () => {
+ beforeEach(async () => {
+ const writeFile = mainDi.inject(writeFileInjectable);
+ const joinPaths = mainDi.inject(joinPathsInjectable);
+ const homeDirectoryPath = mainDi.inject(homeDirectoryPathInjectable);
+
+ const configContents = dumpConfigYaml({
+ clusters: [{
+ name: "some-cluster-name",
+ server: "https://1.2.3.4",
+ skipTLSVerify: false,
+ }],
+ users: [{
+ name: "some-user-name",
+ }],
+ contexts: [{
+ cluster: "some-cluster-name",
+ name: "some-context-name",
+ user: "some-user-name",
+ }],
+ });
+
+ await writeFile(joinPaths(homeDirectoryPath, ".kube", "config"), configContents);
+ await flushPromises();
+ });
+
+ it.only("eventually shows the cluster as a new entity", async () => {
+ await rendered.findByTestId("catalog-entity-row-for-some-cluster-name", undefined, {
+ timeout: 10_000,
+ });
+ }, 100_000);
+ });
+ });
+});
diff --git a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts
index 6f3a8e3e0b..82e6a0e576 100644
--- a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts
+++ b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts
@@ -25,8 +25,8 @@ import type { AsyncFnMock } from "@async-fn/jest";
import type { Stat } from "../../../common/fs/stat.injectable";
import asyncFn from "@async-fn/jest";
import statInjectable from "../../../common/fs/stat.injectable";
-import type { Watcher } from "../../../common/fs/watch/watch.injectable";
-import watchInjectable from "../../../common/fs/watch/watch.injectable";
+import type { Watcher } from "../../../common/fs/watch.injectable";
+import watchInjectable from "../../../common/fs/watch.injectable";
import EventEmitter from "events";
import type { ReadStream, Stats } from "fs";
import createReadFileStreamInjectable from "../../../common/fs/create-read-file-stream.injectable";
diff --git a/src/main/catalog-sources/kubeconfig-sync/compute-diff.injectable.ts b/src/main/catalog-sources/kubeconfig-sync/compute-diff.injectable.ts
index cb1e62e6e8..23ed253663 100644
--- a/src/main/catalog-sources/kubeconfig-sync/compute-diff.injectable.ts
+++ b/src/main/catalog-sources/kubeconfig-sync/compute-diff.injectable.ts
@@ -86,7 +86,7 @@ const computeKubeconfigDiffInjectable = getInjectable({
logger.debug(`Added new cluster from sync`, { filePath, contextName });
} catch (error) {
- logger.warn(`Failed to create cluster from model: ${error}`, { filePath, contextName });
+ logger.warn(`Failed to create cluster with context="${contextName}" from path="${filePath}"`, error);
}
}
} catch (error) {
diff --git a/src/main/catalog-sources/kubeconfig-sync/create-watcher.injectable.ts b/src/main/catalog-sources/kubeconfig-sync/create-watcher.injectable.ts
new file mode 100644
index 0000000000..3954e7f973
--- /dev/null
+++ b/src/main/catalog-sources/kubeconfig-sync/create-watcher.injectable.ts
@@ -0,0 +1,66 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+import { getInjectable } from "@ogre-tools/injectable";
+import type { Stats } from "fs";
+import watchInjectable from "../../../common/fs/watch.injectable";
+import loggerInjectable from "../../../common/logger.injectable";
+
+export interface CreateKubeSyncWatcherOptions {
+ isDirectorySync: boolean;
+ onChange: (filePath: string, stats: Stats) => void;
+ onAdd: (filePath: string, stats: Stats) => void;
+ onRemove: (filePath: string) => void;
+ onError: (error: Error) => void;
+}
+
+export interface KubeSyncWatcher {
+ stop: () => void;
+}
+
+export type CreateKubeSyncWatcher = (filePath: string, opts: CreateKubeSyncWatcherOptions) => KubeSyncWatcher;
+
+const createKubeSyncWatcherInjectable = getInjectable({
+ id: "create-kube-sync-watcher",
+ instantiate: (di): CreateKubeSyncWatcher => {
+ const watch = di.inject(watchInjectable);
+ const logger = di.inject(loggerInjectable);
+
+ return (filePath, { isDirectorySync, ...handlers }) => {
+ const watcher = watch(filePath, {
+ followSymlinks: true,
+ depth: isDirectorySync ? 0 : 1, // DIRs works with 0 but files need 1 (bug: https://github.com/paulmillr/chokidar/issues/1095)
+ disableGlobbing: true,
+ ignorePermissionErrors: true,
+ usePolling: false,
+ awaitWriteFinish: {
+ pollInterval: 100,
+ stabilityThreshold: 1000,
+ },
+ atomic: 150, // for "atomic writes"
+ alwaysStat: true,
+ });
+
+ watcher
+ .on("change", handlers.onChange)
+ .on("add", handlers.onAdd)
+ .on("unlink", handlers.onRemove)
+ .on("error", handlers.onError);
+
+ return {
+ stop: () => {
+ void (async () => {
+ try {
+ await watcher.close();
+ } catch (error) {
+ logger.warn(`[KUBE-SYNC-WATCHER]: failed to stop watching "${filePath}": ${error}`);
+ }
+ })();
+ },
+ };
+ };
+ },
+});
+
+export default createKubeSyncWatcherInjectable;
diff --git a/src/main/catalog-sources/kubeconfig-sync/watch-file-changes.injectable.ts b/src/main/catalog-sources/kubeconfig-sync/watch-file-changes.injectable.ts
index 244daeec69..6cdfa78805 100644
--- a/src/main/catalog-sources/kubeconfig-sync/watch-file-changes.injectable.ts
+++ b/src/main/catalog-sources/kubeconfig-sync/watch-file-changes.injectable.ts
@@ -11,8 +11,8 @@ import { inspect } from "util";
import type { CatalogEntity } from "../../../common/catalog";
import type { Cluster } from "../../../common/cluster/cluster";
import statInjectable from "../../../common/fs/stat.injectable";
-import type { Watcher } from "../../../common/fs/watch/watch.injectable";
-import watchInjectable from "../../../common/fs/watch/watch.injectable";
+import type { KubeSyncWatcher } from "./create-watcher.injectable";
+import createKubeSyncWatcherInjectable from "./create-watcher.injectable";
import type { Disposer } from "../../../common/utils";
import { getOrInsertWith, iter } from "../../../common/utils";
import diffChangedKubeconfigInjectable from "./diff-changed-kubeconfig.injectable";
@@ -38,8 +38,8 @@ const ignoreGlobs = [
* Even if you have a cert-file, key-file, and client-cert files that is only
* 12kb of extra data (at 4096 bytes each) which allows for around 150 entries.
*/
-const folderSyncMaxAllowedFileReadSize = 2 * 1024 * 1024; // 2 MiB
-const fileSyncMaxAllowedFileReadSize = 16 * folderSyncMaxAllowedFileReadSize; // 32 MiB
+const dirSyncMaxAllowedFileReadSize = 2 * 1024 * 1024; // 2 MiB
+const fileSyncMaxAllowedFileReadSize = 16 * dirSyncMaxAllowedFileReadSize; // 32 MiB
const watchKubeconfigFileChangesInjectable = getInjectable({
id: "watch-kubeconfig-file-changes",
@@ -47,39 +47,27 @@ const watchKubeconfigFileChangesInjectable = getInjectable({
const diffChangedKubeconfig = di.inject(diffChangedKubeconfigInjectable);
const logger = di.inject(kubeconfigSyncLoggerInjectable);
const stat = di.inject(statInjectable);
- const watch = di.inject(watchInjectable);
+ const createKubeSyncWatcher = di.inject(createKubeSyncWatcherInjectable);
return (filePath) => {
const rootSource = observable.map>();
const derivedSource = computed(() => Array.from(iter.flatMap(rootSource.values(), from => iter.map(from.values(), child => child[1]))));
- let watcher: Watcher;
+ let watcher: KubeSyncWatcher;
(async () => {
try {
const stats = await stat(filePath);
- const isFolderSync = stats.isDirectory();
+ const isDirectorySync = stats.isDirectory();
const cleanupFns = new Map();
- const maxAllowedFileReadSize = isFolderSync
- ? folderSyncMaxAllowedFileReadSize
+ const maxAllowedFileReadSize = isDirectorySync
+ ? dirSyncMaxAllowedFileReadSize
: fileSyncMaxAllowedFileReadSize;
- watcher = watch(filePath, {
- followSymlinks: true,
- depth: isFolderSync ? 0 : 1, // DIRs works with 0 but files need 1 (bug: https://github.com/paulmillr/chokidar/issues/1095)
- disableGlobbing: true,
- ignorePermissionErrors: true,
- usePolling: false,
- awaitWriteFinish: {
- pollInterval: 100,
- stabilityThreshold: 1000,
- },
- atomic: 150, // for "atomic writes"
- alwaysStat: true,
- });
-
- watcher
- .on("change", (childFilePath, stats): void => {
+ watcher = createKubeSyncWatcher(filePath, {
+ isDirectorySync,
+ onChange: (childFilePath, stats): void => {
+ console.log("change", childFilePath);
const cleanup = cleanupFns.get(childFilePath);
if (!cleanup) {
@@ -94,9 +82,11 @@ const watchKubeconfigFileChangesInjectable = getInjectable({
stats,
maxAllowedFileReadSize,
}));
- })
- .on("add", (childFilePath, stats): void => {
- if (isFolderSync) {
+ },
+ onAdd: (childFilePath, stats): void => {
+ console.log("add", childFilePath);
+
+ if (isDirectorySync) {
const fileName = path.basename(childFilePath);
for (const ignoreGlob of ignoreGlobs) {
@@ -112,20 +102,24 @@ const watchKubeconfigFileChangesInjectable = getInjectable({
stats,
maxAllowedFileReadSize,
}));
- })
- .on("unlink", (childFilePath) => {
+ },
+ onRemove: (childFilePath) => {
cleanupFns.get(childFilePath)?.();
cleanupFns.delete(childFilePath);
rootSource.delete(childFilePath);
- })
- .on("error", error => logger.error(`watching file/folder failed: ${error}`, { filePath }));
+ },
+ onError: (error) => {
+ console.log("error", error);
+ logger.error(`watching file/folder failed: ${error}`, { filePath });
+ },
+ });
} catch (error) {
logger.warn(`failed to start watching changes: ${error}`);
}
})();
return [derivedSource, () => {
- watcher?.close();
+ watcher?.stop();
}];
};
},
diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx
index acd5346b17..6811c4df40 100644
--- a/src/renderer/components/+catalog/catalog.tsx
+++ b/src/renderer/components/+catalog/catalog.tsx
@@ -307,6 +307,7 @@ class NonInjectedCatalog extends React.Component {
getItems={() => catalogEntityStore.entities.get()}
customizeTableRowProps={entity => ({
disabled: !entity.isEnabled(),
+ testId: `catalog-entity-row-for-${entity.getId()}`,
})}
{...getCategoryColumns({ activeCategory })}
onDetails={this.onDetails}
diff --git a/src/renderer/components/dock/create-resource/user-templates.injectable.ts b/src/renderer/components/dock/create-resource/user-templates.injectable.ts
index 65b896f95d..0cc36ea687 100644
--- a/src/renderer/components/dock/create-resource/user-templates.injectable.ts
+++ b/src/renderer/components/dock/create-resource/user-templates.injectable.ts
@@ -9,7 +9,7 @@ import { readFile } from "fs/promises";
import { hasCorrectExtension } from "./has-correct-extension";
import type { RawTemplates } from "./create-resource-templates.injectable";
import joinPathsInjectable from "../../../../common/path/join-paths.injectable";
-import watchInjectable from "../../../../common/fs/watch/watch.injectable";
+import watchInjectable from "../../../../common/fs/watch.injectable";
import getRelativePathInjectable from "../../../../common/path/get-relative-path.injectable";
import homeDirectoryPathInjectable from "../../../../common/os/home-directory-path.injectable";
import getDirnameOfPathInjectable from "../../../../common/path/get-dirname.injectable";
diff --git a/src/renderer/components/test-utils/get-application-builder.tsx b/src/renderer/components/test-utils/get-application-builder.tsx
index 43f0bdeea5..e7381cdc4e 100644
--- a/src/renderer/components/test-utils/get-application-builder.tsx
+++ b/src/renderer/components/test-utils/get-application-builder.tsx
@@ -192,7 +192,7 @@ export const getApplicationBuilder = ({ useFakeTime = true }: ApplicationBuilder
const overrideFsWithFakes = getOverrideFsWithFakes();
- overrideFsWithFakes(mainDi);
+ overrideFsWithFakes(mainDi, true);
// Set up ~/.kube as existing as a folder
{
diff --git a/src/test-utils/override-fs-with-fakes.ts b/src/test-utils/override-fs-with-fakes.ts
index 734170b0c7..0731ec6197 100644
--- a/src/test-utils/override-fs-with-fakes.ts
+++ b/src/test-utils/override-fs-with-fakes.ts
@@ -10,6 +10,9 @@ import type {
readJsonSync as readJsonSyncImpl,
writeJsonSync as writeJsonSyncImpl,
} from "fs-extra";
+import createKubeSyncWatcherInjectable from "../main/catalog-sources/kubeconfig-sync/create-watcher.injectable";
+import { isErrnoException } from "../common/utils";
+import joinPathsInjectable from "../common/path/join-paths.injectable";
export const getOverrideFsWithFakes = () => {
const root = createFsFromVolume(Volume.fromJSON({}));
@@ -41,7 +44,7 @@ export const getOverrideFsWithFakes = () => {
root.mkdirpSync(path, mode);
}) as typeof ensureDirSyncImpl;
- return (di: DiContainer) => {
+ return (di: DiContainer, overrideWatches = false) => {
di.override(fsInjectable, () => ({
pathExists: async (path) => root.existsSync(path),
pathExistsSync: root.existsSync,
@@ -63,5 +66,76 @@ export const getOverrideFsWithFakes = () => {
createReadStream: root.createReadStream as any,
stat: root.promises.stat as any,
}));
+
+ if (overrideWatches) {
+ di.override(createKubeSyncWatcherInjectable, (di) => {
+ const joinPaths = di.inject(joinPathsInjectable);
+
+ return ((path, options) => {
+ const watcher = root.watch( path, {
+ recursive: options.isDirectorySync,
+ });
+ const seenPaths = new Set();
+
+ console.log("watching", path);
+
+ watcher.addListener("rename", (eventType, filename: string) => {
+ try {
+ const stats = root.statSync(filename);
+
+ options.onAdd(filename, stats);
+ } catch (error) {
+ if (isErrnoException(error) && error.code === "ENOENT") {
+ options.onRemove(filename);
+ } else {
+ options.onError(error as Error);
+ }
+ }
+ });
+ watcher.addListener("change", (...args) => {
+ const [,filename] = args;
+
+ if (options.isDirectorySync) {
+ // For testing purposes just emit change events for all files
+ for (const entry of root.readdirSync(filename) as string[]) {
+ const path = joinPaths(filename, entry);
+
+ try {
+ const stats = root.statSync(path);
+
+ if (seenPaths.has(path)) {
+ options.onChange(path, stats);
+ } else {
+ seenPaths.add(path);
+ options.onAdd(path, stats);
+ }
+ } catch (error) {
+ options.onError(error as Error);
+ }
+ }
+ } else {
+ try {
+ const stats = root.statSync(filename);
+
+ if (seenPaths.has(filename)) {
+ options.onChange(filename, stats);
+ } else {
+ seenPaths.add(filename);
+ options.onAdd(filename, stats);
+ }
+ } catch (error) {
+ options.onError(error as Error);
+ }
+ }
+ });
+
+ return {
+ stop: () => {
+ watcher.close();
+ },
+ };
+ });
+ });
+ }
};
};