mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
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:
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 = [{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user