Merge branch 'master' into extensions-api

# Conflicts:
#	src/renderer/components/app.tsx
#	src/renderer/components/cluster-manager/clusters-menu.scss
#	src/renderer/components/cluster-manager/clusters-menu.tsx
#	src/renderer/components/layout/main-layout.tsx
#	src/renderer/components/layout/sidebar.tsx
This commit is contained in:
Roman committed 2020-09-23 11:49:16 +03:00
commit 91b4823ec6
54 files changed
+619 -333

No files matched your search

+2 -2
View File
@@ -77,7 +77,7 @@ export class BaseStore<T = any> extends Singleton {
);
if (ipcMain) {
const callback = (event: IpcMainEvent, model: T) => {
logger.debug(`[STORE]: SYNC ${this.name} from renderer`, { model });
logger.silly(`[STORE]: SYNC ${this.name} from renderer`, { model });
this.onSync(model);
};
ipcMain.on(this.syncChannel, callback);
@@ -85,7 +85,7 @@ export class BaseStore<T = any> extends Singleton {
}
if (ipcRenderer) {
const callback = (event: IpcRendererEvent, model: T) => {
logger.debug(`[STORE]: SYNC ${this.name} from main`, { model });
logger.silly(`[STORE]: SYNC ${this.name} from main`, { model });
this.onSync(model);
};
ipcRenderer.on(this.syncChannel, callback);
+8
View File
@@ -14,6 +14,14 @@ export const clusterIpc = {
},
}),
refresh: createIpcChannel({
channel: "cluster:refresh",
handle: (clusterId: ClusterId) => {
const cluster = clusterStore.getById(clusterId);
if (cluster) return cluster.refresh();
},
}),
disconnect: createIpcChannel({
channel: "cluster:disconnect",
handle: (clusterId: ClusterId) => {
+1 -1
View File
@@ -76,7 +76,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
if (ipcRenderer) {
ipcRenderer.on("cluster:state", (event, model: ClusterState) => {
this.applyWithoutSync(() => {
logger.debug(`[CLUSTER-STORE]: received push-state at ${location.host}`, model);
logger.silly(`[CLUSTER-STORE]: received push-state at ${location.host}`, model);
this.getById(model.id)?.updateModel(model);
})
})
+1 -1
View File
@@ -68,7 +68,7 @@ export function broadcastIpc({ channel, frameId, frameOnly, webContentId, filter
}
views.forEach(webContent => {
const type = webContent.getType();
logger.debug(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args });
logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args });
if (!frameOnly) {
webContent.send(channel, ...args);
}
+1 -1
View File
@@ -44,7 +44,7 @@ export class ClusterManager {
// lens-server is connecting to 127.0.0.1:<port>/<uid>
if (req.headers.host.startsWith("127.0.0.1")) {
const clusterId = req.url.split("/")[1]
const cluster = clusterStore.getById(clusterId)
cluster = clusterStore.getById(clusterId)
if (cluster) {
// we need to swap path prefix so that request is proxied to kube api
req.url = req.url.replace(`/${clusterId}`, apiKubePrefix)
+12 -9
View File
@@ -92,7 +92,7 @@ export class Cluster implements ClusterModel {
async init(port: number) {
try {
this.contextHandler = new ContextHandler(this);
this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler);
this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler, port);
this.kubeProxyUrl = `http://localhost:${port}${apiKubePrefix}`;
this.initialized = true;
logger.info(`[CLUSTER]: "${this.contextName}" init success`, {
@@ -111,12 +111,10 @@ export class Cluster implements ClusterModel {
protected bindEvents() {
logger.info(`[CLUSTER]: bind events`, this.getMeta());
const refreshTimer = setInterval(() => this.online && this.refresh(), 30000); // every 30s
const refreshEventsTimer = setInterval(() => this.online && this.refreshEvents(), 3000); // every 3s
this.eventDisposers.push(
reaction(this.getState, this.pushState),
() => clearInterval(refreshTimer),
() => clearInterval(refreshEventsTimer),
);
}
@@ -135,7 +133,13 @@ export class Cluster implements ClusterModel {
if (this.disconnected || (!init && !this.accessible)) {
await this.reconnect();
}
await this.refresh();
await this.refreshConnectionStatus()
if (this.accessible) {
await this.refreshAllowedResources()
this.ready = true
this.kubeCtl = new Kubectl(this.version)
this.kubeCtl.ensureKubectl() // download kubectl in background, so it's not blocking dashboard
}
return this.pushState();
}
@@ -161,15 +165,14 @@ export class Cluster implements ClusterModel {
@action
async refresh() {
logger.info(`[CLUSTER]: refresh`, this.getMeta());
await this.refreshConnectionStatus(); // refresh "version", "online", etc.
await this.whenInitialized;
await this.refreshConnectionStatus();
if (this.accessible) {
this.kubeCtl = new Kubectl(this.version)
this.distribution = this.detectKubernetesDistribution(this.version)
const [features, isAdmin, nodesCount] = await Promise.all([
getFeatures(this),
this.isClusterAdmin(),
this.getNodeCount(),
this.kubeCtl.ensureKubectl()
]);
this.features = features;
this.isAdmin = isAdmin;
@@ -178,8 +181,8 @@ export class Cluster implements ClusterModel {
this.refreshEvents(),
this.refreshAllowedResources(),
]);
this.ready = true
}
this.pushState();
}
@action
@@ -396,7 +399,7 @@ export class Cluster implements ClusterModel {
}
pushState = (state = this.getState()): ClusterState => {
logger.debug(`[CLUSTER]: push-state`, state);
logger.silly(`[CLUSTER]: push-state`, state);
broadcastIpc({
channel: "cluster:state",
frameId: this.frameId,
+3 -2
View File
@@ -70,7 +70,8 @@ export class ContextHandler {
async resolveAuthProxyUrl() {
const proxyPort = await this.ensurePort();
return `http://127.0.0.1:${proxyPort}`;
const path = this.clusterUrl.path !== "/" ? this.clusterUrl.path : ""
return `http://127.0.0.1:${proxyPort}${path}`;
}
async getApiTarget(isWatchRequest = false): Promise<httpProxy.ServerOptions> {
@@ -88,7 +89,7 @@ export class ContextHandler {
protected async newApiTarget(timeout: number): Promise<httpProxy.ServerOptions> {
const proxyUrl = await this.resolveAuthProxyUrl();
return {
target: proxyUrl + this.clusterUrl.path,
target: proxyUrl,
changeOrigin: true,
timeout: timeout,
headers: {
+7 -3
View File
@@ -11,7 +11,7 @@ export class KubeconfigManager {
protected configDir = app.getPath("temp")
protected tempFile: string;
constructor(protected cluster: Cluster, protected contextHandler: ContextHandler) {
constructor(protected cluster: Cluster, protected contextHandler: ContextHandler, protected port: number) {
this.init();
}
@@ -28,6 +28,10 @@ export class KubeconfigManager {
return this.tempFile;
}
protected resolveProxyUrl() {
return `http://127.0.0.1:${this.port}/${this.cluster.id}`
}
/**
* Creates new "temporary" kubeconfig that point to the kubectl-proxy.
* This way any user of the config does not need to know anything about the auth etc. details.
@@ -42,7 +46,7 @@ export class KubeconfigManager {
clusters: [
{
name: contextName,
server: await contextHandler.resolveAuthProxyUrl(),
server: this.resolveProxyUrl(),
skipTLSVerify: undefined,
}
],
@@ -62,7 +66,7 @@ export class KubeconfigManager {
// write
const configYaml = dumpConfigYaml(proxyConfig);
fs.ensureDir(path.dirname(tempFile));
fs.writeFileSync(tempFile, configYaml);
fs.writeFileSync(tempFile, configYaml, { mode: 0o600 });
this.tempFile = tempFile;
logger.debug(`Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`);
return tempFile;
+40 -27
View File
@@ -1,13 +1,14 @@
import net from "net";
import http from "http";
import spdy from "spdy";
import httpProxy from "http-proxy";
import url from "url";
import * as WebSocket from "ws"
import { apiPrefix, apiKubePrefix } from "../common/vars"
import { openShell } from "./node-shell-session";
import { Router } from "./router"
import { ClusterManager } from "./cluster-manager"
import { ContextHandler } from "./context-handler";
import { apiKubePrefix } from "../common/vars";
import logger from "./logger"
export class LensProxy {
@@ -40,37 +41,49 @@ export class LensProxy {
protected buildCustomProxy(): http.Server {
const proxy = this.createProxy();
const customProxy = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {
this.handleRequest(proxy, req, res);
});
customProxy.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => {
this.handleWsUpgrade(req, socket, head)
});
customProxy.on("error", (err) => {
const spdyProxy = spdy.createServer({
spdy: {
plain: true,
connection: {
autoSpdy31: true
}
}
}, (req: http.IncomingMessage, res: http.ServerResponse) => {
this.handleRequest(proxy, req, res)
})
spdyProxy.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => {
if (req.url.startsWith(`${apiPrefix}?`)) {
this.handleWsUpgrade(req, socket, head)
} else {
if (req.headers.upgrade?.startsWith("SPDY")) {
this.handleSpdyProxy(proxy, req, socket, head)
} else {
socket.end()
}
}
})
spdyProxy.on("error", (err) => {
logger.error("proxy error", err)
});
return customProxy;
})
return spdyProxy
}
protected async handleSpdyProxy(proxy: httpProxy, req: http.IncomingMessage, socket: net.Socket, head: Buffer) {
const cluster = this.clusterManager.getClusterForRequest(req)
if (cluster) {
const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, "")
const apiUrl = url.parse(cluster.apiUrl)
const res = new http.ServerResponse(req)
res.assignSocket(socket)
res.setHeader("Location", proxyUrl)
res.setHeader("Host", apiUrl.hostname)
res.statusCode = 302
res.end()
}
}
protected createProxy(): httpProxy {
const proxy = httpProxy.createProxyServer();
proxy.on("proxyRes", (proxyRes, req, res) => {
if (req.method !== "GET") {
return;
}
if (proxyRes.statusCode === 502) {
const cluster = this.clusterManager.getClusterForRequest(req)
const proxyError = cluster?.contextHandler.proxyLastError;
if (proxyError) {
return res.writeHead(502).end(proxyError);
}
}
const reqId = this.getRequestId(req);
if (this.retryCounters.has(reqId)) {
logger.debug(`Resetting proxy retry cache for url: ${reqId}`);
this.retryCounters.delete(reqId)
}
})
proxy.on("error", (error, req, res, target) => {
if (this.closed) {
return;
+4 -2
View File
@@ -2,14 +2,16 @@ import { app, remote } from "electron";
import winston from "winston"
import { isDebugging } from "../common/vars";
const logLevel = process.env.LOG_LEVEL ? process.env.LOG_LEVEL : isDebugging ? "debug" : "info"
const consoleOptions: winston.transports.ConsoleTransportOptions = {
handleExceptions: false,
level: isDebugging ? "debug" : "info",
level: logLevel,
}
const fileOptions: winston.transports.FileTransportOptions = {
handleExceptions: false,
level: isDebugging ? "debug" : "info",
level: logLevel,
filename: "lens.log",
dirname: (app ?? remote?.app)?.getPath("logs"),
maxsize: 16 * 1024,
+1 -1
View File
@@ -39,7 +39,7 @@ export class ShellSession extends EventEmitter {
public async open() {
this.kubectlBinDir = await this.kubectl.binDir()
const pathFromPreferences = userStore.preferences.kubectlBinariesPath || Kubectl.bundledKubectlPath
this.kubectlPathDir = userStore.preferences.downloadKubectlBinaries ? await this.kubectl.binDir() : path.dirname(pathFromPreferences)
this.kubectlPathDir = userStore.preferences.downloadKubectlBinaries ? this.kubectlBinDir : path.dirname(pathFromPreferences)
this.helmBinDir = helmCli.getBinaryDir()
const env = await this.getCachedShellEnv()
const shell = env.PTYSHELL
+2 -2
View File
@@ -23,8 +23,8 @@ export class WindowManager {
this.mainView = new BrowserWindow({
x, y, width, height,
show: false,
minWidth: 900,
minHeight: 760,
minWidth: 700, // accommodate 800 x 600 display minimum
minHeight: 500, // accommodate 800 x 600 display minimum
titleBarStyle: "hidden",
backgroundColor: "#1e2124",
webPreferences: {
+5 -5
View File
@@ -1,15 +1,15 @@
import React from "react";
import { Trans } from "@lingui/macro";
import { MainLayout } from "../layout/main-layout";
import { TabLayout } from "../layout/tab-layout";
export class NotFound extends React.Component {
render() {
return (
<MainLayout className="NotFound" contentClass="flex" footer={null}>
<TabLayout className="NotFound" contentClass="flex">
<p className="box center">
<Trans>Page not found</Trans>
</p>
</MainLayout>
)
</TabLayout>
);
}
}
}
@@ -80,7 +80,6 @@ export class AddCluster extends React.Component {
const contexts = this.getContexts(this.kubeConfigLocal);
this.kubeContexts.replace(contexts);
break;
case KubeConfigSourceTab.TEXT:
try {
this.error = ""
@@ -91,6 +90,10 @@ export class AddCluster extends React.Component {
}
break;
}
if (this.kubeContexts.size === 1) {
this.selectedContexts.push(this.kubeContexts.keys().next().value)
}
}
getContexts(config: KubeConfig): Map<string, KubeConfig> {
@@ -206,7 +209,7 @@ export class AddCluster extends React.Component {
<Tab
value={KubeConfigSourceTab.FILE}
label={<Trans>Select kubeconfig file</Trans>}
active={this.sourceTab == KubeConfigSourceTab.FILE}/>
active={this.sourceTab == KubeConfigSourceTab.FILE} />
<Tab
value={KubeConfigSourceTab.TEXT}
label={<Trans>Paste as text</Trans>}
@@ -320,13 +323,15 @@ export class AddCluster extends React.Component {
return (
<div className={cssNames("kube-context flex gaps align-center", context)}>
<span>{context}</span>
{isNew && <Icon small material="fiber_new"/>}
{isSelected && <Icon small material="check" className="box right"/>}
{isNew && <Icon small material="fiber_new" />}
{isSelected && <Icon small material="check" className="box right" />}
</div>
)
};
render() {
const addDisabled = this.selectedContexts.length === 0
return (
<WizardLayout
className="AddCluster"
@@ -374,9 +379,12 @@ export class AddCluster extends React.Component {
<div className="actions-panel">
<Button
primary
disabled={addDisabled}
label={<Trans>Add cluster(s)</Trans>}
onClick={this.addClusters}
waiting={this.isWaiting}
tooltip={addDisabled ? _i18n._("Select at least one cluster to add.") : undefined}
tooltipOverrideDisabled
/>
</div>
</WizardLayout>
+3 -3
View File
@@ -2,7 +2,7 @@ import React from "react";
import { observer } from "mobx-react";
import { Redirect, Route, Switch } from "react-router";
import { Trans } from "@lingui/macro";
import { MainLayout, TabRoute } from "../layout/main-layout";
import { TabLayout, TabRoute } from "../layout/tab-layout";
import { HelmCharts, helmChartsRoute, helmChartsURL } from "../+apps-helm-charts";
import { HelmReleases, releaseRoute, releaseURL } from "../+apps-releases";
import { namespaceStore } from "../+namespaces/namespace.store";
@@ -30,12 +30,12 @@ export class Apps extends React.Component {
render() {
const tabRoutes = Apps.tabRoutes;
return (
<MainLayout className="Apps" tabs={tabRoutes}>
<TabLayout className="Apps" tabs={tabRoutes}>
<Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={tabRoutes[0].url}/>
</Switch>
</MainLayout>
</TabLayout>
)
}
}
@@ -1,21 +1,38 @@
import "./cluster-settings.scss";
import React from "react";
import { observer } from "mobx-react";
import { observer, disposeOnUnmount } from "mobx-react";
import { Features } from "./features";
import { Removal } from "./removal";
import { Status } from "./status";
import { General } from "./general";
import { Cluster } from "../../../main/cluster";
import { WizardLayout } from "../layout/wizard-layout";
import { ClusterIcon } from "../cluster-icon";
import { Icon } from "../icon";
import { getMatchedCluster } from "../cluster-manager/cluster-view.route";
import { navigate } from "../../navigation";
import { IClusterSettingsRouteParams } from "./cluster-settings.route";
import { clusterStore } from "../../../common/cluster-store";
import { RouteComponentProps } from "react-router";
import { clusterIpc } from "../../../common/cluster-ipc";
import { autorun } from "mobx";
interface Props extends RouteComponentProps<IClusterSettingsRouteParams> {
}
@observer
export class ClusterSettings extends React.Component {
export class ClusterSettings extends React.Component<Props> {
get cluster(): Cluster {
return clusterStore.getById(this.props.match.params.clusterId);
}
async componentDidMount() {
window.addEventListener('keydown', this.onEscapeKey);
disposeOnUnmount(this,
autorun(() => {
this.refreshCluster();
})
)
}
componentWillUnmount() {
@@ -29,12 +46,18 @@ export class ClusterSettings extends React.Component {
}
}
refreshCluster = () => {
if(this.cluster) {
clusterIpc.refresh.invokeFromRenderer(this.cluster.id);
}
}
close() {
navigate("/");
}
render() {
const cluster = getMatchedCluster();
const cluster = this.cluster
if (!cluster) return null;
const header = (
<>
+3 -3
View File
@@ -3,7 +3,7 @@ import "./cluster.scss"
import React from "react";
import { computed, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { MainLayout } from "../layout/main-layout";
import { TabLayout } from "../layout/tab-layout";
import { ClusterIssues } from "./cluster-issues";
import { Spinner } from "../spinner";
import { cssNames, interval, isElectron } from "../../utils";
@@ -54,7 +54,7 @@ export class Cluster extends React.Component {
render() {
const { isLoaded } = this;
return (
<MainLayout>
<TabLayout>
<div className="Cluster">
{!isLoaded && <Spinner center/>}
{isLoaded && (
@@ -65,7 +65,7 @@ export class Cluster extends React.Component {
</>
)}
</div>
</MainLayout>
</TabLayout>
)
}
}
+3 -3
View File
@@ -2,7 +2,7 @@ import React from "react";
import { observer } from "mobx-react";
import { Redirect, Route, Switch } from "react-router";
import { Trans } from "@lingui/macro";
import { MainLayout, TabRoute } from "../layout/main-layout";
import { TabLayout, TabRoute } from "../layout/tab-layout";
import { ConfigMaps, configMapsRoute, configMapsURL } from "../+config-maps";
import { Secrets, secretsRoute, secretsURL } from "../+config-secrets";
import { namespaceStore } from "../+namespaces/namespace.store";
@@ -68,12 +68,12 @@ export class Config extends React.Component {
render() {
const tabRoutes = Config.tabRoutes;
return (
<MainLayout className="Config" tabs={tabRoutes}>
<TabLayout className="Config" tabs={tabRoutes}>
<Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={configURL({ query: namespaceStore.getContextParams() })}/>
</Switch>
</MainLayout>
</TabLayout>
)
}
}
@@ -2,7 +2,7 @@ import React from "react";
import { observer } from "mobx-react";
import { Redirect, Route, Switch } from "react-router";
import { Trans } from "@lingui/macro";
import { MainLayout, TabRoute } from "../layout/main-layout";
import { TabLayout, TabRoute } from "../layout/tab-layout";
import { crdResourcesRoute, crdRoute, crdURL, crdDefinitionsRoute } from "./crd.route";
import { CrdList } from "./crd-list";
import { CrdResources } from "./crd-resources";
@@ -25,13 +25,13 @@ export class CustomResources extends React.Component {
render() {
return (
<MainLayout>
<TabLayout>
<Switch>
<Route component={CrdList} {...crdDefinitionsRoute} exact/>
<Route component={CrdResources} {...crdResourcesRoute}/>
<Redirect to={crdURL()}/>
</Switch>
</MainLayout>
</TabLayout>
);
}
}
+3 -3
View File
@@ -2,7 +2,7 @@ import "./events.scss";
import React, { Fragment } from "react";
import { observer } from "mobx-react";
import { MainLayout } from "../layout/main-layout";
import { TabLayout } from "../layout/tab-layout";
import { eventStore } from "./event.store";
import { KubeObjectListLayout, KubeObjectListLayoutProps } from "../kube-object";
import { Trans } from "@lingui/macro";
@@ -118,9 +118,9 @@ export class Events extends React.Component<Props> {
return events;
}
return (
<MainLayout>
<TabLayout>
{events}
</MainLayout>
</TabLayout>
)
}
}
@@ -87,6 +87,11 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
super.reset();
this.contextNs.clear();
}
async remove(item: Namespace) {
await super.remove(item);
this.contextNs.remove(item.getName());
}
}
export const namespaceStore = new NamespaceStore();
@@ -4,7 +4,7 @@ import React from "react";
import { Trans } from "@lingui/macro";
import { Namespace, namespacesApi, NamespaceStatus } from "../../api/endpoints";
import { AddNamespaceDialog } from "./add-namespace-dialog";
import { MainLayout } from "../layout/main-layout";
import { TabLayout } from "../layout/tab-layout";
import { Badge } from "../badge";
import { RouteComponentProps } from "react-router";
import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu";
@@ -26,7 +26,7 @@ interface Props extends RouteComponentProps<INamespacesRouteParams> {
export class Namespaces extends React.Component<Props> {
render() {
return (
<MainLayout>
<TabLayout>
<KubeObjectListLayout
isClusterScoped
className="Namespaces" store={namespaceStore}
@@ -65,7 +65,7 @@ export class Namespaces extends React.Component<Props> {
})}
/>
<AddNamespaceDialog/>
</MainLayout>
</TabLayout>
)
}
}
@@ -67,7 +67,7 @@ export class IngressDetails extends React.Component<Props> {
}
renderIngressPoints(ingressPoints: ILoadBalancerIngress[]) {
if (ingressPoints.length === 0) return null
if (!ingressPoints || ingressPoints.length === 0) return null
return (
<div>
<Table className="ingress-points">
+3 -3
View File
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import { Redirect, Route, Switch } from "react-router";
import { RouteComponentProps } from "react-router-dom";
import { Trans } from "@lingui/macro";
import { MainLayout, TabRoute } from "../layout/main-layout";
import { TabLayout, TabRoute } from "../layout/tab-layout";
import { Services, servicesRoute, servicesURL } from "../+network-services";
import { Endpoints, endpointRoute, endpointURL } from "../+network-endpoints";
import { Ingresses, ingressRoute, ingressURL } from "../+network-ingresses";
@@ -60,12 +60,12 @@ export class Network extends React.Component<Props> {
render() {
const tabRoutes = Network.tabRoutes;
return (
<MainLayout className="Network" tabs={tabRoutes}>
<TabLayout className="Network" tabs={tabRoutes}>
<Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={networkURL({ query: namespaceStore.getContextParams() })}/>
</Switch>
</MainLayout>
</TabLayout>
)
}
}
+3 -3
View File
@@ -4,7 +4,7 @@ import { observer } from "mobx-react";
import { RouteComponentProps } from "react-router";
import { t, Trans } from "@lingui/macro";
import { cssNames, interval } from "../../utils";
import { MainLayout } from "../layout/main-layout";
import { TabLayout } from "../layout/tab-layout";
import { nodesStore } from "./nodes.store";
import { podsStore } from "../+workloads-pods/pods.store";
import { KubeObjectListLayout } from "../kube-object";
@@ -123,7 +123,7 @@ export class Nodes extends React.Component<Props> {
render() {
return (
<MainLayout>
<TabLayout>
<KubeObjectListLayout
className="Nodes"
store={nodesStore} isClusterScoped
@@ -182,7 +182,7 @@ export class Nodes extends React.Component<Props> {
return <NodeMenu object={item}/>
}}
/>
</MainLayout>
</TabLayout>
)
}
}
+3 -3
View File
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import { Redirect, Route, Switch } from "react-router";
import { RouteComponentProps } from "react-router-dom";
import { Trans } from "@lingui/macro";
import { MainLayout, TabRoute } from "../layout/main-layout";
import { TabLayout, TabRoute } from "../layout/tab-layout";
import { PersistentVolumes, volumesRoute, volumesURL } from "../+storage-volumes";
import { StorageClasses, storageClassesRoute, storageClassesURL } from "../+storage-classes";
import { PersistentVolumeClaims, volumeClaimsRoute, volumeClaimsURL } from "../+storage-volume-claims";
@@ -52,12 +52,12 @@ export class Storage extends React.Component<Props> {
render() {
const tabRoutes = Storage.tabRoutes;
return (
<MainLayout className="Storage" tabs={tabRoutes}>
<TabLayout className="Storage" tabs={tabRoutes}>
<Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={storageURL({ query: namespaceStore.getContextParams() })}/>
</Switch>
</MainLayout>
</TabLayout>
)
}
}
@@ -15,6 +15,7 @@ import { getDetailsUrl } from "../../navigation";
import { KubeObjectDetailsProps } from "../kube-object";
import { apiManager } from "../../api/api-manager";
import { KubeObjectMeta } from "../kube-object/kube-object-meta";
import { Icon } from "../icon";
interface Props extends KubeObjectDetailsProps<ServiceAccount> {
}
@@ -22,21 +23,25 @@ interface Props extends KubeObjectDetailsProps<ServiceAccount> {
@observer
export class ServiceAccountsDetails extends React.Component<Props> {
@observable secrets: Secret[];
@observable imagePullSecrets: Secret[];
@disposeOnUnmount
loadSecrets = autorun(async () => {
this.secrets = null;
this.imagePullSecrets = null;
const { object: serviceAccount } = this.props;
if (!serviceAccount) {
return;
}
const namespace = serviceAccount.getNs();
const secrets = serviceAccount.getSecrets().map(({ name }) => {
const secret = secretsStore.getByName(name, namespace);
if (!secret) return secretsStore.load({ name, namespace });
return secret;
return secretsStore.load({ name, namespace });
});
this.secrets = await Promise.all(secrets);
const imagePullSecrets = serviceAccount.getImagePullSecrets().map(async({ name }) => {
return secretsStore.load({ name, namespace }).catch(_err => { return this.generateDummySecretObject(name) });
});
this.imagePullSecrets = await Promise.all(imagePullSecrets)
})
renderSecrets() {
@@ -49,15 +54,46 @@ export class ServiceAccountsDetails extends React.Component<Props> {
)
}
renderImagePullSecrets() {
const { imagePullSecrets } = this;
if (!imagePullSecrets) {
return <Spinner center/>
}
return this.renderSecretLinks(imagePullSecrets)
}
renderSecretLinks(secrets: Secret[]) {
return secrets.map(secret => {
return secrets.map((secret) => {
if (secret.getId() === null) {
return (
<div key={secret.getName()}>
{secret.getName()}
<Icon
small material="warning"
tooltip={<Trans>Secret is not found</Trans>}
/>
</div>
)
}
return (
<Link key={secret.getId()} to={getDetailsUrl(secret.selfLink)}>
{secret.getName()}
</Link>
)
}
)
})
}
generateDummySecretObject(name: string) {
return new Secret({
apiVersion: "v1",
kind: "Secret",
metadata: {
name: name,
uid: null,
selfLink: null,
resourceVersion: null
}
})
}
render() {
@@ -69,9 +105,7 @@ export class ServiceAccountsDetails extends React.Component<Props> {
secret.getNs() == serviceAccount.getNs() &&
secret.getAnnotations().some(annot => annot == `kubernetes.io/service-account.name: ${serviceAccount.getName()}`)
)
const imagePullSecrets = serviceAccount.getImagePullSecrets().map(({ name }) =>
secretsStore.getByName(name, serviceAccount.getNs())
)
const imagePullSecrets = serviceAccount.getImagePullSecrets()
return (
<div className="ServiceAccountsDetails">
<KubeObjectMeta object={serviceAccount}/>
@@ -83,7 +117,7 @@ export class ServiceAccountsDetails extends React.Component<Props> {
}
{imagePullSecrets.length > 0 &&
<DrawerItem name={<Trans>ImagePullSecrets</Trans>} className="links">
{this.renderSecretLinks(imagePullSecrets)}
{this.renderImagePullSecrets()}
</DrawerItem>
}
@@ -4,7 +4,7 @@ import { observer } from "mobx-react";
import { Redirect, Route, Switch } from "react-router";
import { RouteComponentProps } from "react-router-dom";
import { Trans } from "@lingui/macro";
import { MainLayout, TabRoute } from "../layout/main-layout";
import { TabLayout, TabRoute } from "../layout/tab-layout";
import { Roles } from "../+user-management-roles";
import { RoleBindings } from "../+user-management-roles-bindings";
import { ServiceAccounts } from "../+user-management-service-accounts";
@@ -55,12 +55,12 @@ export class UserManagement extends React.Component<Props> {
render() {
const tabRoutes = UserManagement.tabRoutes;
return (
<MainLayout className="UserManagement" tabs={tabRoutes}>
<TabLayout className="UserManagement" tabs={tabRoutes}>
<Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={usersManagementURL({ query: namespaceStore.getContextParams() })}/>
</Switch>
</MainLayout>
</TabLayout>
)
}
}
@@ -1,10 +1,24 @@
.WhatsNew {
$spacing: $padding * 2;
background: $mainBackground url(../../components/icon/crane.svg) no-repeat;
background-position: 0 35%;
background-size: 85%;
background-clip: content-box;
&::after {
content: "";
background: url(../../components/icon/crane.svg) no-repeat;
background-position: 0 35%;
background-size: 85%;
background-clip: content-box;
opacity: .75;
top: 0;
left: 0;
bottom: 0;
right: 0;
position: absolute;
z-index: -1;
.theme-light & {
opacity: 0.2;
}
}
.logo {
width: 200px;
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import { Redirect, Route, Switch } from "react-router";
import { RouteComponentProps } from "react-router-dom";
import { Trans } from "@lingui/macro";
import { MainLayout, TabRoute } from "../layout/main-layout";
import { TabLayout, TabRoute } from "../layout/tab-layout";
import { WorkloadsOverview } from "../+workloads-overview/overview";
import { cronJobsRoute, cronJobsURL, daemonSetsRoute, daemonSetsURL, deploymentsRoute, deploymentsURL, jobsRoute, jobsURL, overviewRoute, overviewURL, podsRoute, podsURL, statefulSetsRoute, statefulSetsURL, workloadsURL } from "./workloads.route";
import { namespaceStore } from "../+namespaces/namespace.store";
@@ -86,12 +86,12 @@ export class Workloads extends React.Component<Props> {
render() {
const tabRoutes = Workloads.tabRoutes;
return (
<MainLayout className="Workloads" tabs={tabRoutes}>
<TabLayout className="Workloads" tabs={tabRoutes}>
<Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={workloadsURL({ query: namespaceStore.getContextParams() })}/>
</Switch>
</MainLayout>
</TabLayout>
)
}
}
+20 -18
View File
@@ -29,6 +29,7 @@ import { CronJobTriggerDialog } from "./+workloads-cronjobs/cronjob-trigger-dial
import { CustomResources } from "./+custom-resources/custom-resources";
import { crdRoute } from "./+custom-resources";
import { isAllowedResource } from "../../common/rbac";
import { MainLayout } from "./layout/main-layout";
import { ErrorBoundary } from "./error-boundary";
import { Terminal } from "./dock/terminal";
import { getHostedCluster, getHostedClusterId } from "../../common/cluster-store";
@@ -60,24 +61,25 @@ export class App extends React.Component {
<I18nProvider i18n={_i18n}>
<Router history={history}>
<ErrorBoundary>
<Switch>
<Route component={Cluster} {...clusterRoute}/>
<Route component={Nodes} {...nodesRoute}/>
<Route component={Workloads} {...workloadsRoute}/>
<Route component={Config} {...configRoute}/>
<Route component={Network} {...networkRoute}/>
<Route component={Storage} {...storageRoute}/>
<Route component={Namespaces} {...namespacesRoute}/>
<Route component={Events} {...eventRoute}/>
<Route component={CustomResources} {...crdRoute}/>
<Route component={UserManagement} {...usersManagementRoute}/>
<Route component={Apps} {...appsRoute}/>
{dynamicPages.clusterPages.map(({ path, components: { Page } }) => {
return <Route key={path} path={path} component={Page}/>
})}
<Redirect exact from="/" to={this.startURL}/>
<Route component={NotFound}/>
</Switch>
<MainLayout>
<Switch>
<Route component={Cluster} {...clusterRoute}/>
<Route component={Nodes} {...nodesRoute}/>
<Route component={Workloads} {...workloadsRoute}/>
<Route component={Config} {...configRoute}/>
<Route component={Network} {...networkRoute}/>
<Route component={Storage} {...storageRoute}/>
<Route component={Namespaces} {...namespacesRoute}/>
<Route component={Events} {...eventRoute}/>
<Route component={CustomResources} {...crdRoute}/>
<Route component={UserManagement} {...usersManagementRoute}/>
<Route component={Apps} {...appsRoute}/>
{dynamicPages.clusterPages.map(({ path, components: { Page } }) => {
return <Route key={path} path={path} component={Page}/>
})}
<Redirect exact from="/" to={this.startURL}/>
<Route component={NotFound}/>
</Switch></MainLayout>
<Notifications/>
<ConfirmDialog/>
<KubeObjectDetails/>
+9 -2
View File
@@ -13,11 +13,18 @@ interface Props extends React.HTMLAttributes<any>, TooltipDecoratorProps {
export class Badge extends React.Component<Props> {
render() {
const { className, label, small, children, ...elemProps } = this.props;
return (
return <>
<span className={cssNames("Badge", { small }, className)} {...elemProps}>
{label}
{children}
</span>
);
{ /**
* This is a zero-width-space. It makes there be a word seperation
* between adjacent Badge's because <span>'s are ignored for browers'
* word detection algorithmns use for determining the extent of the
* text to highlight on multi-click sequences.
*/}
&#8203;
</>
}
}
+2 -3
View File
@@ -54,8 +54,7 @@
form:not([novalidate]):invalid &[type=submit]:not(.active),
&:disabled {
color: silver;
background: $buttonDisabledBackground;
opacity: 50%;
pointer-events: none;
}
@@ -112,4 +111,4 @@
left: 100%;
width: 0;
}
}
}
@@ -39,7 +39,7 @@ export class ClusterStatus extends React.Component<Props> {
});
})
if (this.cluster.disconnected) {
await this.refreshCluster();
await this.activateCluster();
}
}
@@ -47,13 +47,13 @@ export class ClusterStatus extends React.Component<Props> {
ipcRenderer.removeAllListeners(`kube-auth:${this.props.clusterId}`);
}
refreshCluster = async () => {
activateCluster = async () => {
await clusterIpc.activate.invokeFromRenderer(this.props.clusterId);
}
reconnect = async () => {
this.isReconnecting = true;
await this.refreshCluster();
await this.activateCluster();
this.isReconnecting = false;
}
@@ -3,7 +3,6 @@ import { ipcRenderer } from "electron";
import { matchPath, RouteProps } from "react-router";
import { buildURL, navigation } from "../../navigation";
import { clusterStore } from "../../../common/cluster-store";
import { clusterSettingsRoute } from "../+cluster-settings/cluster-settings.route";
export interface IClusterViewRouteParams {
clusterId: string;
@@ -19,10 +18,7 @@ export const clusterViewURL = buildURL<IClusterViewRouteParams>(clusterViewRoute
export function getMatchedClusterId(): string {
const matched = matchPath<IClusterViewRouteParams>(navigation.location.pathname, {
exact: true,
path: [
clusterViewRoute.path,
clusterSettingsRoute.path,
].flat(),
path: clusterViewRoute.path
})
if (matched) {
return matched.params.clusterId;
@@ -49,12 +49,11 @@
.Badge {
$boxSize: 17px;
$offset: -7px;
position: absolute;
bottom: 0px;
transform: translateX(-50%) translateY(50%);
font-size: $font-size-small;
right: $offset;
bottom: $offset;
line-height: $boxSize;
min-width: $boxSize;
min-height: $boxSize;
@@ -77,4 +76,4 @@
--size: 40px;
}
}
}
}
@@ -1,16 +1,17 @@
import type { Cluster } from "../../../main/cluster";
import "./clusters-menu.scss"
import { remote } from "electron"
import React from "react";
import { observer } from "mobx-react";
import { _i18n } from "../../i18n";
import { t, Trans } from "@lingui/macro";
import type { Cluster } from "../../../main/cluster";
import { userStore } from "../../../common/user-store";
import { ClusterId, clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
import { ClusterIcon } from "../cluster-icon";
import { Icon } from "../icon";
import { autobind, cssNames, IClassName } from "../../utils";
import { cssNames, IClassName, autobind } from "../../utils";
import { Badge } from "../badge";
import { navigate } from "../../navigation";
import { addClusterURL } from "../+add-cluster";
@@ -19,8 +20,8 @@ import { landingURL } from "../+landing-page";
import { Tooltip } from "../tooltip";
import { ConfirmDialog } from "../confirm-dialog";
import { clusterIpc } from "../../../common/cluster-ipc";
import { clusterViewURL, getMatchedClusterId } from "./cluster-view.route";
import { DragDropContext, Draggable, DraggableProvided, Droppable, DroppableProvided, DropResult } from "react-beautiful-dnd";
import { clusterViewURL } from "./cluster-view.route";
import { DragDropContext, Droppable, Draggable, DropResult, DroppableProvided, DraggableProvided } from "react-beautiful-dnd";
import { dynamicPages } from "../../../extensions/register-page";
interface Props {
@@ -36,6 +37,7 @@ export class ClustersMenu extends React.Component<Props> {
addCluster = () => {
navigate(addClusterURL());
clusterStore.setActive(null);
}
showContextMenu = (cluster: Cluster) => {
@@ -45,6 +47,7 @@ export class ClustersMenu extends React.Component<Props> {
menu.append(new MenuItem({
label: _i18n._(t`Settings`),
click: () => {
clusterStore.setActive(cluster.id);
navigate(clusterSettingsURL({
params: {
clusterId: cluster.id
@@ -58,6 +61,7 @@ export class ClustersMenu extends React.Component<Props> {
click: async () => {
if (clusterStore.isActive(cluster.id)) {
navigate(landingURL());
clusterStore.setActive(null);
}
await clusterIpc.disconnect.invokeFromRenderer(cluster.id);
}
@@ -113,26 +117,29 @@ export class ClustersMenu extends React.Component<Props> {
ref={provided.innerRef}
{...provided.droppableProps}
>
{clusters.map((cluster, index) => (
<Draggable draggableId={cluster.id} index={index} key={cluster.id}>
{(provided: DraggableProvided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<ClusterIcon
key={cluster.id}
showErrors={true}
cluster={cluster}
isActive={cluster.id === getMatchedClusterId()}
onClick={() => this.showCluster(cluster.id)}
onContextMenu={() => this.showContextMenu(cluster)}
/>
</div>
)}
</Draggable>
))}
{clusters.map((cluster, index) => {
const isActive = cluster.id === clusterStore.activeClusterId;
return (
<Draggable draggableId={cluster.id} index={index} key={cluster.id}>
{(provided: DraggableProvided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<ClusterIcon
key={cluster.id}
showErrors={true}
cluster={cluster}
isActive={isActive}
onClick={() => this.showCluster(cluster.id)}
onContextMenu={() => this.showContextMenu(cluster)}
/>
</div>
)}
</Draggable>
)}
)}
{provided.placeholder}
</div>
)}
@@ -143,9 +150,9 @@ export class ClustersMenu extends React.Component<Props> {
<Tooltip targetId="add-cluster-icon">
<Trans>Add Cluster</Trans>
</Tooltip>
<Icon big material="add" id="add-cluster-icon"/>
<Icon big material="add" id="add-cluster-icon" />
{newContexts.size > 0 && (
<Badge className="counter" label={newContexts.size} tooltip={<Trans>new</Trans>}/>
<Badge className="counter" label={newContexts.size} tooltip={<Trans>new</Trans>} />
)}
</div>
<div className="dynamic-pages">
@@ -48,6 +48,7 @@ export class EditorPanel extends React.Component<Props> {
onResize = () => {
this.editor.resize();
this.editor.focus();
}
onCursorPosChange = (pos: Ace.Point) => {
+9 -9
View File
@@ -62,14 +62,13 @@ export class InfoPanel extends Component<Props> {
this.error = "";
this.waiting = true;
try {
this.result = await this.props.submit().finally(() => {
this.waiting = false;
});
this.result = await this.props.submit()
if (showNotifications) Notifications.ok(this.result);
} catch (error) {
this.error = error.toString();
if (showNotifications) Notifications.error(this.error);
throw error;
} finally {
this.waiting = false
}
}
@@ -91,12 +90,13 @@ export class InfoPanel extends Component<Props> {
<>
{result && (
<div className="success flex align-center">
<Icon material="done"/> <span>{result}</span>
<Icon material="done" />
<span>{result}</span>
</div>
)}
{errorInfo && (
<div className="error flex align-center">
<Icon material="error_outline"/>
<Icon material="error_outline" />
<span>{errorInfo}</span>
</div>
)}
@@ -114,9 +114,9 @@ export class InfoPanel extends Component<Props> {
{controls}
</div>
<div className="info flex gaps align-center">
{waiting ? <><Spinner/> {submittingMessage}</> : this.renderInfo()}
{waiting ? <><Spinner /> {submittingMessage}</> : this.renderInfo()}
</div>
<Button plain label={<Trans>Cancel</Trans>} onClick={close}/>
<Button plain label={<Trans>Cancel</Trans>} onClick={close} />
<Button
primary active
label={submitLabel}
@@ -134,4 +134,4 @@ export class InfoPanel extends Component<Props> {
</div>
);
}
}
}
+1
View File
@@ -152,6 +152,7 @@ export class Terminal {
onResize = () => {
if (!this.isActive) return;
this.fitLazy();
this.focus();
}
onActivate = () => {
@@ -44,7 +44,7 @@ export const isUrl: Validator = {
export const isPath: Validator = {
condition: ({ type }) => type === "text",
message: () => _i18n._(t`This field must be a path to an existing file`),
message: () => _i18n._(t`This field must be a valid path`),
validate: value => !value || fse.pathExistsSync(value),
}
@@ -73,4 +73,4 @@ export const accountId: Validator = {
export const conditionalValidators = [
isRequired, isEmail, isNumber, isUrl, minLength, maxLength
];
];
+12 -24
View File
@@ -1,19 +1,18 @@
.MainLayout {
--sidebar-max-size: 200px;
display: grid;
grid-template-areas: "aside header" "aside tabs" "aside main" "aside footer";
grid-template-areas:
"aside header"
"aside tabs"
"aside main"
"aside footer";
grid-template-rows: [header] var(--main-layout-header) [tabs] min-content [main] 1fr [footer] auto;
grid-template-columns: [sidebar] minmax(var(--main-layout-header), min-content) [main] 1fr;
height: 100%;
> .Tabs {
grid-area: tabs;
background: $layoutTabsBackground;
}
header {
> header {
grid-area: header;
background: $layoutBackground;
padding: $padding $padding * 2;
@@ -28,7 +27,7 @@
}
}
aside {
> aside {
grid-area: aside;
position: relative;
background: $sidebarBackground;
@@ -48,25 +47,14 @@
&.accessible:hover {
width: var(--sidebar-max-size);
transition-delay: 750ms;
box-shadow: 3px 3px 16px rgba(0, 0, 0, .35);
box-shadow: 3px 3px 16px rgba(0, 0, 0, 0.35);
z-index: $zIndex-sidebar-hover;
}
}
}
main {
@include custom-scrollbar;
$spacing: $margin * 2;
.theme-light & {
@include custom-scrollbar(dark);
}
grid-area: main;
overflow-y: scroll; // always reserve space for scrollbar (17px)
overflow-x: auto;
margin: $spacing;
margin-right: 0;
> main {
display: contents;
}
footer {
@@ -74,4 +62,4 @@
grid-area: footer;
min-width: 0; // restrict size when overflow content (e.g. <Dock> tabs scrolling)
}
}
}
+9 -39
View File
@@ -3,26 +3,16 @@ import "./main-layout.scss";
import React from "react";
import { observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { matchPath, RouteProps } from "react-router-dom";
import { createStorage, cssNames } from "../../utils";
import { Tab, Tabs } from "../tabs";
import { Sidebar } from "./sidebar";
import { ErrorBoundary } from "../error-boundary";
import { Dock } from "../dock";
import { navigate, navigation } from "../../navigation";
import { getHostedCluster } from "../../../common/cluster-store";
export interface TabRoute extends RouteProps {
title: React.ReactNode;
url: string;
}
export interface MainLayoutProps {
className?: any;
tabs?: TabRoute[];
footer?: React.ReactNode;
headerClass?: string;
contentClass?: string;
footerClass?: string;
}
@@ -35,18 +25,17 @@ export class MainLayout extends React.Component<MainLayoutProps> {
@disposeOnUnmount syncPinnedStateWithStorage = reaction(
() => this.isPinned,
isPinned => this.storage.merge({ pinnedSidebar: isPinned })
(isPinned) => this.storage.merge({ pinnedSidebar: isPinned })
);
toggleSidebar = () => {
this.isPinned = !this.isPinned;
this.isAccessible = false;
setTimeout(() => this.isAccessible = true, 250);
}
setTimeout(() => (this.isAccessible = true), 250);
};
render() {
const { className, contentClass, headerClass, tabs, footer, footerClass, children } = this.props;
const routePath = navigation.location.pathname;
const { className, headerClass, footer, footerClass, children } = this.props;
const cluster = getHostedCluster();
if (!cluster) {
return null; // fix: skip render when removing active (visible) cluster
@@ -54,37 +43,18 @@ export class MainLayout extends React.Component<MainLayoutProps> {
return (
<div className={cssNames("MainLayout", className)}>
<header className={cssNames("flex gaps align-center", headerClass)}>
<span className="cluster">
{cluster.preferences.clusterName || cluster.contextName}
</span>
<span className="cluster">{cluster.preferences.clusterName || cluster.contextName}</span>
</header>
<aside className={cssNames("flex column", { pinned: this.isPinned, accessible: this.isAccessible })}>
<Sidebar
className="box grow"
isPinned={this.isPinned}
toggle={this.toggleSidebar}
/>
<Sidebar className="box grow" isPinned={this.isPinned} toggle={this.toggleSidebar} />
</aside>
{tabs && (
<Tabs center onChange={url => navigate(url)}>
{tabs.map(({ title, path, url, ...routeProps }) => {
const isActive = !!matchPath(routePath, { path, ...routeProps });
return <Tab key={url} label={title} value={url} active={isActive}/>
})}
</Tabs>
)}
<main className={contentClass}>
<ErrorBoundary>
{children}
</ErrorBoundary>
<main>
<ErrorBoundary>{children}</ErrorBoundary>
</main>
<footer className={footerClass}>
{footer === undefined ? <Dock/> : footer}
</footer>
<footer className={footerClass}>{footer === undefined ? <Dock /> : footer}</footer>
</div>
);
}
+37 -37
View File
@@ -1,4 +1,4 @@
import type { TabRoute } from "./main-layout";
import type { TabRoute } from "./tab-layout";
import "./sidebar.scss";
import React from "react";
@@ -44,21 +44,21 @@ interface Props {
@observer
export class Sidebar extends React.Component<Props> {
async componentDidMount() {
if (!crdStore.isLoaded && isAllowedResource('customresourcedefinitions')) {
crdStore.loadAll()
if (!crdStore.isLoaded && isAllowedResource("customresourcedefinitions")) {
crdStore.loadAll();
}
}
renderCustomResources() {
return Object.entries(crdStore.groups).map(([group, crds]) => {
const submenus = crds.map(crd => {
const submenus = crds.map((crd) => {
return {
title: crd.getResourceKind(),
component: CrdList,
url: crd.getResourceUrl(),
path: crdResourcesRoute.path,
}
})
};
});
return (
<SidebarNavItem
key={group}
@@ -68,8 +68,8 @@ export class Sidebar extends React.Component<Props> {
subMenus={submenus}
text={group}
/>
)
})
);
});
}
render() {
@@ -80,7 +80,7 @@ export class Sidebar extends React.Component<Props> {
<div className={cssNames("Sidebar flex column", className, { pinned: isPinned })}>
<div className="header flex align-center">
<NavLink exact to="/" className="box grow">
<Icon svg="logo-full" className="logo-icon"/>
<Icon svg="logo-full" className="logo-icon" />
<div className="logo-text">Lens</div>
</NavLink>
<Icon
@@ -94,17 +94,17 @@ export class Sidebar extends React.Component<Props> {
<div className="sidebar-nav flex column box grow-fixed">
<SidebarNavItem
id="cluster"
isHidden={!isAllowedResource('nodes')}
isHidden={!isAllowedResource("nodes")}
url={clusterURL()}
text={<Trans>Cluster</Trans>}
icon={<Icon svg="kube"/>}
icon={<Icon svg="kube" />}
/>
<SidebarNavItem
id="nodes"
isHidden={!isAllowedResource('nodes')}
isHidden={!isAllowedResource("nodes")}
url={nodesURL()}
text={<Trans>Nodes</Trans>}
icon={<Icon svg="nodes"/>}
icon={<Icon svg="nodes" />}
/>
<SidebarNavItem
id="workloads"
@@ -113,7 +113,7 @@ export class Sidebar extends React.Component<Props> {
routePath={workloadsRoute.path}
subMenus={Workloads.tabRoutes}
text={<Trans>Workloads</Trans>}
icon={<Icon svg="workloads"/>}
icon={<Icon svg="workloads" />}
/>
<SidebarNavItem
id="config"
@@ -122,7 +122,7 @@ export class Sidebar extends React.Component<Props> {
routePath={configRoute.path}
subMenus={Config.tabRoutes}
text={<Trans>Configuration</Trans>}
icon={<Icon material="list"/>}
icon={<Icon material="list" />}
/>
<SidebarNavItem
id="networks"
@@ -131,7 +131,7 @@ export class Sidebar extends React.Component<Props> {
routePath={networkRoute.path}
subMenus={Network.tabRoutes}
text={<Trans>Network</Trans>}
icon={<Icon material="device_hub"/>}
icon={<Icon material="device_hub" />}
/>
<SidebarNavItem
id="storage"
@@ -139,22 +139,22 @@ export class Sidebar extends React.Component<Props> {
url={storageURL({ query })}
routePath={storageRoute.path}
subMenus={Storage.tabRoutes}
icon={<Icon svg="storage"/>}
icon={<Icon svg="storage" />}
text={<Trans>Storage</Trans>}
/>
<SidebarNavItem
id="namespaces"
isHidden={!isAllowedResource('namespaces')}
isHidden={!isAllowedResource("namespaces")}
url={namespacesURL()}
icon={<Icon material="layers"/>}
icon={<Icon material="layers" />}
text={<Trans>Namespaces</Trans>}
/>
<SidebarNavItem
id="events"
isHidden={!isAllowedResource('events')}
isHidden={!isAllowedResource("events")}
url={eventsURL({ query })}
routePath={eventRoute.path}
icon={<Icon material="access_time"/>}
icon={<Icon material="access_time" />}
text={<Trans>Events</Trans>}
/>
<SidebarNavItem
@@ -162,7 +162,7 @@ export class Sidebar extends React.Component<Props> {
url={appsURL({ query })}
subMenus={Apps.tabRoutes}
routePath={appsRoute.path}
icon={<Icon material="apps"/>}
icon={<Icon material="apps" />}
text={<Trans>Apps</Trans>}
/>
<SidebarNavItem
@@ -170,16 +170,16 @@ export class Sidebar extends React.Component<Props> {
url={usersManagementURL({ query })}
routePath={usersManagementRoute.path}
subMenus={UserManagement.tabRoutes}
icon={<Icon material="security"/>}
icon={<Icon material="security" />}
text={<Trans>Access Control</Trans>}
/>
<SidebarNavItem
id="custom-resources"
isHidden={!isAllowedResource('customresourcedefinitions')}
isHidden={!isAllowedResource("customresourcedefinitions")}
url={crdURL()}
subMenus={CustomResources.tabRoutes}
routePath={crdRoute.path}
icon={<Icon material="extension"/>}
icon={<Icon material="extension" />}
text={<Trans>Custom Resources</Trans>}
>
{this.renderCustomResources()}
@@ -199,7 +199,7 @@ export class Sidebar extends React.Component<Props> {
</div>
</div>
</SidebarContext.Provider>
)
);
}
}
@@ -216,7 +216,10 @@ interface SidebarNavItemProps {
const navItemStorage = createStorage<[string, boolean][]>("sidebar_menu_item", []);
const navItemState = observable.map<string, boolean>(navItemStorage.get());
reaction(() => [...navItemState], value => navItemStorage.set(value));
reaction(
() => [...navItemState],
(value) => navItemStorage.set(value)
);
@observer
class SidebarNavItem extends React.Component<SidebarNavItemProps> {
@@ -229,15 +232,15 @@ class SidebarNavItem extends React.Component<SidebarNavItemProps> {
toggleSubMenu = () => {
navItemState.set(this.props.id, !this.isExpanded);
}
};
isActive = () => {
const { routePath, url } = this.props;
const { pathname } = navigation.location;
return !!matchPath(pathname, {
path: routePath || url
path: routePath || url,
});
}
};
render() {
const { id, isHidden, subMenus = [], icon, text, url, children, className } = this.props;
@@ -252,10 +255,7 @@ class SidebarNavItem extends React.Component<SidebarNavItemProps> {
<div className={cssNames("nav-item", { active: isActive })} onClick={this.toggleSubMenu}>
{icon}
<span className="link-text">{text}</span>
<Icon
className="expand-icon"
material={this.isExpanded ? "keyboard_arrow_up" : "keyboard_arrow_down"}
/>
<Icon className="expand-icon" material={this.isExpanded ? "keyboard_arrow_up" : "keyboard_arrow_down"} />
</div>
<ul className={cssNames("sub-menu", { active: isActive })}>
{subMenus.map(({ title, url }) => (
@@ -265,18 +265,18 @@ class SidebarNavItem extends React.Component<SidebarNavItemProps> {
))}
{React.Children.toArray(children).map((child: React.ReactElement<any>) => {
return React.cloneElement(child, {
className: cssNames(child.props.className, { visible: this.isExpanded })
className: cssNames(child.props.className, { visible: this.isExpanded }),
});
})}
</ul>
</div>
)
);
}
return (
<NavLink className={cssNames("SidebarNavItem", className)} to={url} isActive={this.isActive}>
{icon}
<span className="link-text">{text}</span>
</NavLink>
)
);
}
}
+25
View File
@@ -0,0 +1,25 @@
.TabLayout {
display: contents;
> .Tabs {
grid-area: tabs;
background: $layoutTabsBackground;
}
main {
@include custom-scrollbar;
$spacing: $margin * 2;
.theme-light & {
@include custom-scrollbar(dark);
}
grid-area: main;
overflow-y: scroll; // always reserve space for scrollbar (17px)
overflow-x: auto;
margin: $spacing;
margin-right: 0;
}
}
@@ -0,0 +1,45 @@
import "./tab-layout.scss";
import React, { ReactNode } from "react";
import { matchPath, RouteProps } from "react-router-dom";
import { observer } from "mobx-react";
import { cssNames } from "../../utils";
import { Tab, Tabs } from "../tabs";
import { ErrorBoundary } from "../error-boundary";
import { navigate, navigation } from "../../navigation";
import { getHostedCluster } from "../../../common/cluster-store";
export interface TabRoute extends RouteProps {
title: React.ReactNode;
url: string;
}
interface Props {
children: ReactNode;
className?: any;
tabs?: TabRoute[];
contentClass?: string;
}
export const TabLayout = observer(({ className, contentClass, tabs, children }: Props) => {
const routePath = navigation.location.pathname;
const cluster = getHostedCluster();
if (!cluster) {
return null; // fix: skip render when removing active (visible) cluster
}
return (
<div className={cssNames("TabLayout", className)}>
{tabs && (
<Tabs center onChange={(url) => navigate(url)}>
{tabs.map(({ title, path, url, ...routeProps }) => {
const isActive = !!matchPath(routePath, { path, ...routeProps });
return <Tab key={url} label={title} value={url} active={isActive} />;
})}
</Tabs>
)}
<main className={contentClass}>
<ErrorBoundary>{children}</ErrorBoundary>
</main>
</div>
);
});
@@ -3,10 +3,11 @@
position: absolute;
right: 0;
bottom: 0;
top: 0;
padding: $padding * 2;
max-height: 100vh;
z-index: 100000;
height: min-content!important;
&:empty {
display: none;
@@ -42,4 +43,4 @@
}
}
}
}
}
+13 -4
View File
@@ -15,6 +15,7 @@ export enum TooltipPosition {
export interface TooltipProps {
targetId: string; // html-id of target element to bind for
tooltipOnParentHover?: boolean; // detect hover on parent of target
visible?: boolean; // initial visibility
offset?: number; // offset from target element in pixels (all sides)
usePortal?: boolean; // renders element outside of parent (in body), disable for "easy-styling", default: true
@@ -50,14 +51,22 @@ export class Tooltip extends React.Component<TooltipProps> {
return document.getElementById(this.props.targetId)
}
get hoverTarget(): HTMLElement {
if (this.props.tooltipOnParentHover) {
return this.targetElem.parentElement
}
return this.targetElem
}
componentDidMount() {
this.targetElem.addEventListener("mouseenter", this.onEnterTarget)
this.targetElem.addEventListener("mouseleave", this.onLeaveTarget)
this.hoverTarget.addEventListener("mouseenter", this.onEnterTarget)
this.hoverTarget.addEventListener("mouseleave", this.onLeaveTarget)
}
componentWillUnmount() {
this.targetElem.removeEventListener("mouseenter", this.onEnterTarget)
this.targetElem.removeEventListener("mouseleave", this.onLeaveTarget)
this.hoverTarget.removeEventListener("mouseenter", this.onEnterTarget)
this.hoverTarget.removeEventListener("mouseleave", this.onLeaveTarget)
}
@autobind()
@@ -8,6 +8,11 @@ import uniqueId from "lodash/uniqueId"
export interface TooltipDecoratorProps {
tooltip?: ReactNode | Omit<TooltipProps, "targetId">;
/**
* forces tooltip to detect the target's parent for mouse events. This is
* useful for displaying tooltips even when the target is "disabled"
*/
tooltipOverrideDisabled?: boolean;
}
export function withTooltip<T extends React.ComponentType<any>>(Target: T): T {
@@ -17,22 +22,25 @@ export function withTooltip<T extends React.ComponentType<any>>(Target: T): T {
protected tooltipId = uniqueId("tooltip_target_");
render() {
const { tooltip, ...targetProps } = this.props;
const { tooltip, tooltipOverrideDisabled, ...targetProps } = this.props;
if (tooltip) {
const tooltipId = targetProps.id || this.tooltipId;
const tooltipProps: TooltipProps = {
targetId: tooltipId,
tooltipOnParentHover: tooltipOverrideDisabled,
...(isReactNode(tooltip) ? { children: tooltip } : tooltip),
};
targetProps.id = tooltipId;
targetProps.children = (
<>
{targetProps.children}
<Tooltip {...tooltipProps}/>
<div>
{targetProps.children}
</div>
<Tooltip {...tooltipProps} />
</>
)
}
return <Target {...targetProps as any}/>;
return <Target {...targetProps as any} />;
}
}