From 35efd8254bf7d33c9b0c45a63ad35db1b25e97c1 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 17 Feb 2021 12:00:38 -0500 Subject: [PATCH] Add notification to user to add accessible namespaces - If cluster's refresh fails due to permissions - Broadcasting event and then receiving it in renderer Signed-off-by: Sebastian Malton --- src/common/ipc/cluster/index.ts | 11 ++++ src/common/ipc/index.ts | 1 + src/common/ipc/update-available/index.ts | 7 +-- src/common/utils/buildUrl.ts | 10 +++- src/main/cluster.ts | 11 ++-- .../+cluster-settings/cluster-settings.tsx | 5 ++ .../cluster-accessible-namespaces.tsx | 2 +- src/renderer/components/layout/sub-title.tsx | 9 ++-- src/renderer/ipc/index.tsx | 50 ++++++++++++++++++- 9 files changed, 87 insertions(+), 19 deletions(-) create mode 100644 src/common/ipc/cluster/index.ts diff --git a/src/common/ipc/cluster/index.ts b/src/common/ipc/cluster/index.ts new file mode 100644 index 0000000000..f252da9925 --- /dev/null +++ b/src/common/ipc/cluster/index.ts @@ -0,0 +1,11 @@ +/** + * This channel is broadcast on whenever the cluster fails to list namespaces + * during a refresh and no `accessibleNamespaces` have been set. + */ +export const ClusterListNamespaceForbiddenChannel = "cluster:list-namespace-forbidden"; + +export type ListNamespaceFordiddenArgs = [clusterId: string]; + +export function argArgsListNamespaceFordiddenArgs(args: unknown[]): args is ListNamespaceFordiddenArgs { + return args.length === 1 && typeof args[0] === "string"; +} diff --git a/src/common/ipc/index.ts b/src/common/ipc/index.ts index a34890472e..7a65dc2520 100644 --- a/src/common/ipc/index.ts +++ b/src/common/ipc/index.ts @@ -1,3 +1,4 @@ export * from "./ipc"; export * from "./update-available"; +export * from "./cluster"; export * from "./type-enforced-ipc"; diff --git a/src/common/ipc/update-available/index.ts b/src/common/ipc/update-available/index.ts index 1e3fcf1268..8571c08512 100644 --- a/src/common/ipc/update-available/index.ts +++ b/src/common/ipc/update-available/index.ts @@ -3,10 +3,7 @@ import { UpdateInfo } from "electron-updater"; export const UpdateAvailableChannel = "update-available"; export const AutoUpdateLogPrefix = "[UPDATE-CHECKER]"; -/** - * [, ] - */ -export type UpdateAvailableFromMain = [string, UpdateInfo]; +export type UpdateAvailableFromMain = [backChannel: string, updateInfo: UpdateInfo]; export function areArgsUpdateAvailableFromMain(args: unknown[]): args is UpdateAvailableFromMain { if (args.length !== 2) { @@ -32,7 +29,7 @@ export type BackchannelArg = { now: boolean; }; -export type UpdateAvailableToBackchannel = [BackchannelArg]; +export type UpdateAvailableToBackchannel = [updateDecision: BackchannelArg]; export function areArgsUpdateAvailableToBackchannel(args: unknown[]): args is UpdateAvailableToBackchannel { if (args.length !== 1) { diff --git a/src/common/utils/buildUrl.ts b/src/common/utils/buildUrl.ts index e3bad9b302..ba2b31d2d0 100644 --- a/src/common/utils/buildUrl.ts +++ b/src/common/utils/buildUrl.ts @@ -3,14 +3,20 @@ import { compile } from "path-to-regexp"; export interface IURLParams

{ params?: P; query?: Q; + fragment?: string; } export function buildURL

(path: string | any) { const pathBuilder = compile(String(path)); - return function ({ params, query }: IURLParams = {}) { + return function ({ params, query, fragment }: IURLParams = {}): string { const queryParams = query ? new URLSearchParams(Object.entries(query)).toString() : ""; + const parts = [ + pathBuilder(params), + queryParams && `?${queryParams}`, + fragment && `#${fragment}`, + ]; - return pathBuilder(params) + (queryParams ? `?${queryParams}` : ""); + return parts.filter(Boolean).join(""); }; } diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 13c74a285e..c0609379f0 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -4,9 +4,9 @@ import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api"; import type { WorkspaceId } from "../common/workspace-store"; import { action, comparer, computed, observable, reaction, toJS, when } from "mobx"; import { apiKubePrefix } from "../common/vars"; -import { broadcastMessage } from "../common/ipc"; +import { broadcastMessage, ClusterListNamespaceForbiddenChannel } from "../common/ipc"; import { ContextHandler } from "./context-handler"; -import { AuthorizationV1Api, CoreV1Api, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; +import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; import { Kubectl } from "./kubectl"; import { KubeconfigManager } from "./kubeconfig-manager"; import { loadConfig } from "../common/kube-helpers"; @@ -681,10 +681,13 @@ export class Cluster implements ClusterModel, ClusterState { return namespaceList.body.items.map(ns => ns.metadata.name); } catch (error) { const ctx = this.getProxyKubeconfig().getContextObject(this.contextName); + const namespaceList = [ctx.namespace].filter(Boolean); - if (ctx.namespace) return [ctx.namespace]; + if (namespaceList.length === 0 && error instanceof HttpError && error.statusCode === 403) { + broadcastMessage(ClusterListNamespaceForbiddenChannel); + } - return []; + return namespaceList; } } diff --git a/src/renderer/components/+cluster-settings/cluster-settings.tsx b/src/renderer/components/+cluster-settings/cluster-settings.tsx index 0cde390c47..07d275a274 100644 --- a/src/renderer/components/+cluster-settings/cluster-settings.tsx +++ b/src/renderer/components/+cluster-settings/cluster-settings.tsx @@ -15,6 +15,7 @@ import { clusterStore } from "../../../common/cluster-store"; import { PageLayout } from "../layout/page-layout"; import { requestMain } from "../../../common/ipc"; import { clusterActivateHandler, clusterRefreshHandler } from "../../../common/cluster-ipc"; +import { navigation } from "../../navigation"; interface Props extends RouteComponentProps { } @@ -30,6 +31,10 @@ export class ClusterSettings extends React.Component { } componentDidMount() { + const { hash } = navigation.location; + + document.getElementById(hash.slice(1))?.scrollIntoView(); + disposeOnUnmount(this, [ reaction(() => this.cluster, this.refreshCluster, { fireImmediately: true, diff --git a/src/renderer/components/+cluster-settings/components/cluster-accessible-namespaces.tsx b/src/renderer/components/+cluster-settings/components/cluster-accessible-namespaces.tsx index b538a59a95..d1ad7dbeef 100644 --- a/src/renderer/components/+cluster-settings/components/cluster-accessible-namespaces.tsx +++ b/src/renderer/components/+cluster-settings/components/cluster-accessible-namespaces.tsx @@ -16,7 +16,7 @@ export class ClusterAccessibleNamespaces extends React.Component { render() { return ( <> - +

This setting is useful for manually specifying which namespaces you have access to. This is useful when you do not have permissions to list namespaces.

{ render() { - const { compact, title, children } = this.props; - let { className } = this.props; - - className = cssNames("SubTitle", className, { + const { className, compact, title, children, id } = this.props; + const classNames = cssNames("SubTitle", className, { compact, }); return ( -
+
{title} {children}
); diff --git a/src/renderer/ipc/index.tsx b/src/renderer/ipc/index.tsx index b9644f7404..89a7394eba 100644 --- a/src/renderer/ipc/index.tsx +++ b/src/renderer/ipc/index.tsx @@ -1,10 +1,13 @@ import React from "react"; import { ipcRenderer, IpcRendererEvent } from "electron"; -import { areArgsUpdateAvailableFromMain, UpdateAvailableChannel, onCorrect, UpdateAvailableFromMain, BackchannelArg } from "../../common/ipc"; +import { areArgsUpdateAvailableFromMain, UpdateAvailableChannel, onCorrect, UpdateAvailableFromMain, BackchannelArg, ClusterListNamespaceForbiddenChannel, argArgsListNamespaceFordiddenArgs, ListNamespaceFordiddenArgs } from "../../common/ipc"; import { Notifications, notificationsStore } from "../components/notifications"; import { Button } from "../components/button"; import { isMac } from "../../common/vars"; import * as uuid from "uuid"; +import { clusterStore } from "../../common/cluster-store"; +import { navigate } from "../navigation"; +import { clusterSettingsURL } from "../components/+cluster-settings"; function sendToBackchannel(backchannel: string, notificationId: string, data: BackchannelArg): void { notificationsStore.remove(notificationId); @@ -42,7 +45,8 @@ function UpdateAvailableHandler(event: IpcRendererEvent, ...[backchannel, update
- ), { + ), + { id: notificationId, onClose() { sendToBackchannel(backchannel, notificationId, { doUpdate: false }); @@ -51,6 +55,42 @@ function UpdateAvailableHandler(event: IpcRendererEvent, ...[backchannel, update ); } +const listNamespacesForbiddenHandlerDisplayedAt = new Map(); +const intervalBetweenNotifications = 1000 * 60; // 60s + +function ListNamespacesForbiddenHandler(event: IpcRendererEvent, ...[clusterId]: ListNamespaceFordiddenArgs): void { + const lastDisplayedAt = listNamespacesForbiddenHandlerDisplayedAt.get(clusterId); + const wasDisplayed = Boolean(lastDisplayedAt); + const now = Date.now(); + + if (!wasDisplayed || (now - lastDisplayedAt) > intervalBetweenNotifications) { + listNamespacesForbiddenHandlerDisplayedAt.set(clusterId, now); + } else { + // don't bother the user too often + return; + } + + const notificationId = `list-namespaces-forbidden:${clusterId}`; + + Notifications.info( + ( +
+ Add Accessible Namespaces +

Cluster {clusterStore.active.name} does not have permissions to list namespaces. Please add the namespaces you have access to.

+
+
+
+ ), + { + id: notificationId, + } + ); +} + export function registerIpcHandlers() { onCorrect({ source: ipcRenderer, @@ -58,4 +98,10 @@ export function registerIpcHandlers() { listener: UpdateAvailableHandler, verifier: areArgsUpdateAvailableFromMain, }); + onCorrect({ + source: ipcRenderer, + channel: ClusterListNamespaceForbiddenChannel, + listener: ListNamespacesForbiddenHandler, + verifier: argArgsListNamespaceFordiddenArgs, + }); }