Add the ability to sync kube config files (#2567)

* Add the ability to sync kube config files

- Will update when the files changes

- add KUBECONFIG_SYNC label

- fix rebase and change to addObservableSource

- move UI to user settings

- support shallow folder watching

- add some unit tests for the diff-er

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* responding to review comments

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix tests and add try/catch

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* always sync c&p folder, remove bad rebase

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix preferences

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix settings saving and catalog view

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix unit tests

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix synced clusters not connectable

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* change to non-complete shallow watching

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix sizing

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Catch readStream errors

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* don't clear UserStore on non-existant preference field, fix unlinking not removing items from source

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* change label to file

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton authored and GitHub committed 2021-04-30 16:48:20 +03:00
1 parent f06d330835
commit 998f7aa934
32 files changed
+1215 -236

No files matched your search

@@ -43,27 +43,6 @@ describe("CatalogEntityRegistry", () => {
expect(catalog.items.length).toEqual(2);
});
it("ignores unknown items", () => {
const catalog = new CatalogEntityRegistry(catalogCategoryRegistry);
const items = [{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "FooBar",
metadata: {
uid: "123",
name: "foobar",
source: "test",
labels: {}
},
status: {
phase: "disconnected"
},
spec: {}
}];
catalog.updateItems(items);
expect(catalog.items.length).toEqual(0);
});
it("updates existing items", () => {
const catalog = new CatalogEntityRegistry(catalogCategoryRegistry);
const items = [{
+1 -21
View File
@@ -17,27 +17,7 @@ export class CatalogEntityRegistry {
}
@action updateItems(items: (CatalogEntityData & CatalogEntityKindData)[]) {
this._items.forEach((item, index) => {
const foundIndex = items.findIndex((i) => i.apiVersion === item.apiVersion && i.kind === item.kind && i.metadata.uid === item.metadata.uid);
if (foundIndex === -1) {
this._items.splice(index, 1);
}
});
items.forEach((data) => {
const item = this.categoryRegistry.getEntityForData(data);
if (!item) return; // invalid data
const index = this._items.findIndex((i) => i.apiVersion === item.apiVersion && i.kind === item.kind && i.metadata.uid === item.metadata.uid);
if (index === -1) {
this._items.push(item);
} else {
this._items.splice(index, 1, item);
}
});
this._items = items.map(data => this.categoryRegistry.getEntityForData(data));
}
set activeEntity(entity: CatalogEntity) {
+2 -1
View File
@@ -21,6 +21,7 @@ import { LensApp } from "./lens-app";
import { ThemeStore } from "./theme.store";
import { HelmRepoManager } from "../main/helm/helm-repo-manager";
import { ExtensionInstallationStateStore } from "./components/+extensions/extension-install.store";
import { DefaultProps } from "./mui-base-theme";
/**
* If this is a development buid, wait a second to attach
@@ -92,7 +93,7 @@ export async function bootstrap(App: AppComponent) {
});
render(<>
{isMac && <div id="draggable-top" />}
<App />
{DefaultProps(App)}
</>, rootElem);
}
@@ -11,7 +11,7 @@ import { AceEditor } from "../ace-editor";
import { Button } from "../button";
import { Icon } from "../icon";
import { kubeConfigDefaultPath, loadConfig, splitConfig, validateConfig, validateKubeConfig } from "../../../common/kube-helpers";
import { ClusterModel, ClusterStore } from "../../../common/cluster-store";
import { ClusterStore } from "../../../common/cluster-store";
import { v4 as uuid } from "uuid";
import { navigate } from "../../navigation";
import { UserStore } from "../../../common/user-store";
@@ -132,36 +132,28 @@ export class AddCluster extends React.Component {
};
@action
addClusters = () => {
let newClusters: ClusterModel[] = [];
addClusters = (): void => {
try {
if (!this.selectedContexts.length) {
this.error = "Please select at least one cluster context";
return;
return void (this.error = "Please select at least one cluster context");
}
this.error = "";
this.isWaiting = true;
appEventBus.emit({ name: "cluster-add", action: "click" });
newClusters = this.selectedContexts.filter(context => {
try {
const kubeConfig = this.kubeContexts.get(context);
const newClusters = this.selectedContexts.filter(context => {
const kubeConfig = this.kubeContexts.get(context);
const error = validateKubeConfig(kubeConfig, context);
validateKubeConfig(kubeConfig, context);
if (error) {
this.error = error.toString();
return true;
} catch (err) {
this.error = String(err.message);
if (err instanceof ExecValidationNotFoundError) {
if (error instanceof ExecValidationNotFoundError) {
Notifications.error(<>Error while adding cluster(s): {this.error}</>);
return false;
} else {
throw new Error(err);
}
}
return Boolean(!error);
}).map(context => {
const clusterId = uuid();
const kubeConfig = this.kubeContexts.get(context);
@@ -57,4 +57,23 @@
color: var(--halfGray);
}
}
.TableCell.labels {
overflow-x: scroll;
text-overflow: unset;
&::-webkit-scrollbar {
display: none;
}
.Badge {
overflow: unset;
text-overflow: unset;
max-width: unset;
&:not(:first-child) {
margin-left: 0.5em;
}
}
}
}
@@ -0,0 +1,176 @@
import React from "react";
import { remote } from "electron";
import { Avatar, IconButton, List, ListItem, ListItemAvatar, ListItemSecondaryAction, ListItemText, Paper } from "@material-ui/core";
import { Description, Folder, Delete, HelpOutline } from "@material-ui/icons";
import { action, computed, observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import fse from "fs-extra";
import { KubeconfigSyncEntry, KubeconfigSyncValue, UserStore } from "../../../common/user-store";
import { Button } from "../button";
import { SubTitle } from "../layout/sub-title";
import { Spinner } from "../spinner";
import logger from "../../../main/logger";
import { iter } from "../../utils";
interface SyncInfo {
type: "file" | "folder" | "unknown";
}
interface Entry extends Value {
filePath: string;
}
interface Value {
data: KubeconfigSyncValue;
info: SyncInfo;
}
async function getMapEntry({ filePath, ...data}: KubeconfigSyncEntry): Promise<[string, Value]> {
try {
// stat follows the stat(2) linux syscall spec, namely it follows symlinks
const stats = await fse.stat(filePath);
if (stats.isFile()) {
return [filePath, { info: { type: "file" }, data }];
}
if (stats.isDirectory()) {
return [filePath, { info: { type: "folder" }, data }];
}
logger.warn("[KubeconfigSyncs]: unknown stat entry", { stats });
return [filePath, { info: { type: "unknown" }, data }];
} catch (error) {
logger.warn(`[KubeconfigSyncs]: failed to stat entry: ${error}`, { error });
return [filePath, { info: { type: "unknown" }, data }];
}
}
@observer
export class KubeconfigSyncs extends React.Component {
syncs = observable.map<string, Value>();
@observable loaded = false;
async componentDidMount() {
const mapEntries = await Promise.all(
iter.map(
UserStore.getInstance().syncKubeconfigEntries,
([filePath, ...value]) => getMapEntry({ filePath, ...value }),
),
);
this.syncs.replace(mapEntries);
this.loaded = true;
disposeOnUnmount(this, [
reaction(() => Array.from(this.syncs.entries(), ([filePath, { data }]) => [filePath, data]), syncs => {
UserStore.getInstance().syncKubeconfigEntries.replace(syncs);
})
]);
}
@computed get syncsList(): Entry[] | undefined {
if (!this.loaded) {
return undefined;
}
return Array.from(this.syncs.entries(), ([filePath, value]) => ({ filePath, ...value }));
}
@action
openFileDialog = async () => {
const { dialog, BrowserWindow } = remote;
const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
properties: ["openFile", "showHiddenFiles", "multiSelections", "openDirectory"],
message: "Select kubeconfig file(s) and folder(s)",
buttonLabel: "Sync",
});
if (canceled) {
return;
}
const newEntries = await Promise.all(filePaths.map(filePath => getMapEntry({ filePath })));
for (const [filePath, info] of newEntries) {
this.syncs.set(filePath, info);
}
};
renderEntryIcon(entry: Entry) {
switch (entry.info.type) {
case "file":
return <Description />;
case "folder":
return <Folder />;
case "unknown":
return <HelpOutline />;
}
}
renderEntry = (entry: Entry) => {
return (
<Paper className="entry" key={entry.filePath} elevation={3}>
<ListItem>
<ListItemAvatar>
<Avatar>
{this.renderEntryIcon(entry)}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={entry.filePath}
className="description"
/>
<ListItemSecondaryAction className="action">
<IconButton
edge="end"
aria-label="delete"
onClick={() => this.syncs.delete(entry.filePath)}
>
<Delete />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</Paper>
);
};
renderEntries() {
const entries = this.syncsList;
if (!entries) {
return (
<div className="loading-spinner">
<Spinner />
</div>
);
}
return (
<List className="kubeconfig-sync-list">
{entries.map(this.renderEntry)}
</List>
);
}
render() {
return (
<>
<section className="small">
<SubTitle title="Files and Folders to sync" />
<Button
primary
label="Sync file or folder"
onClick={() => void this.openFileDialog()}
/>
<div className="hint">
Sync an individual file or all files in a folder (non-recursive).
</div>
{this.renderEntries()}
</section>
</>
);
}
}
@@ -1,2 +1,32 @@
.Preferences {
}
.loading-spinner {
margin: auto;
}
.kubeconfig-sync-list {
.entry {
&.MuiPaper-root {
background-color: var(--inputControlBackground);
margin-bottom: var(--flex-gap, 1em);
color: inherit;
}
.MuiAvatar-root {
color: var(--buttonPrimaryBackground);
font-size: calc(2.5 * var(--unit));
}
.description {
font-family: monospace;
.MuiTypography-body1 {
font-size: var(--font-size);
}
}
.action .MuiIconButton-root {
font-size: calc(2.5 * var(--unit));
}
}
}
}
@@ -18,6 +18,7 @@ import { KubectlBinaries } from "./kubectl-binaries";
import { navigation } from "../../navigation";
import { Tab, Tabs } from "../tabs";
import { FormSwitch, Switcher } from "../switch";
import { KubeconfigSyncs } from "./kubeconfig-syncs";
enum Pages {
Application = "application",
@@ -209,7 +210,6 @@ export class Preferences extends React.Component {
</section>
</section>
)}
{this.activeTab == Pages.Kubernetes && (
<section id="kubernetes">
<section id="kubectl">
@@ -217,20 +217,23 @@ export class Preferences extends React.Component {
<KubectlBinaries />
</section>
<hr/>
<section id="kube-sync">
<h2 data-testid="kubernetes-sync-header">Kubeconfig Syncs</h2>
<KubeconfigSyncs />
</section>
<hr/>
<section id="helm">
<h2>Helm Charts</h2>
<HelmCharts/>
</section>
</section>
)}
{this.activeTab == Pages.Telemetry && (
<section id="telemetry">
<h2 data-testid="telemetry-header">Telemetry</h2>
{telemetryExtensions.map(this.renderExtension)}
</section>
)}
{this.activeTab == Pages.Extensions && (
<section id="extensions">
<h2>Extensions</h2>
+34
View File
@@ -0,0 +1,34 @@
import React from "react";
import { createMuiTheme, ThemeProvider } from "@material-ui/core";
const defaultTheme = createMuiTheme({
props: {
MuiIconButton: {
color: "inherit",
},
MuiSvgIcon: {
fontSize: "inherit",
},
MuiTooltip: {
placement: "top",
}
},
overrides: {
MuiIconButton: {
root: {
"&:hover": {
color: "var(--iconActiveColor)",
backgroundColor: "var(--iconActiveBackground)",
}
}
}
},
});
export function DefaultProps(App: React.ComponentType) {
return (
<ThemeProvider theme= { defaultTheme } >
<App />
</ThemeProvider>
);
}