mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Merge branch 'master' into extension-auto-update
This commit is contained in:
163 files changed
+3307
-1656
No files matched your search
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
|
||||
import { CatalogEntityRegistry } from "../catalog-entity-registry";
|
||||
import "../../../common/catalog-entities";
|
||||
import { catalogCategoryRegistry } from "../../../common/catalog/catalog-category-registry";
|
||||
import { CatalogCategory, CatalogEntityData, CatalogEntityKindData } from "../catalog-entity";
|
||||
import { KubernetesCluster, WebLink } from "../../../common/catalog-entities";
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
|
||||
import { computed, observable, makeObservable, action } from "mobx";
|
||||
import { ipcRendererOn } from "../../common/ipc";
|
||||
import { catalogEntityRunListener, ipcRendererOn } from "../../common/ipc";
|
||||
import { CatalogCategory, CatalogEntity, CatalogEntityData, catalogCategoryRegistry, CatalogCategoryRegistry, CatalogEntityKindData } from "../../common/catalog";
|
||||
import "../../common/catalog-entities";
|
||||
import type { Cluster } from "../../main/cluster";
|
||||
@@ -28,14 +28,22 @@ import { ClusterStore } from "../../common/cluster-store";
|
||||
import { Disposer, iter } from "../utils";
|
||||
import { once } from "lodash";
|
||||
import logger from "../../common/logger";
|
||||
import { catalogEntityRunContext } from "./catalog-entity";
|
||||
import { CatalogRunEvent } from "../../common/catalog/catalog-run-event";
|
||||
import { ipcRenderer } from "electron";
|
||||
import { CatalogIpcEvents } from "../../common/ipc/catalog";
|
||||
import { navigate } from "../navigation";
|
||||
import { isMainFrame } from "process";
|
||||
|
||||
export type EntityFilter = (entity: CatalogEntity) => any;
|
||||
export type CatalogEntityOnBeforeRun = (event: CatalogRunEvent) => void | Promise<void>;
|
||||
|
||||
export const catalogEntityRunContext = {
|
||||
navigate: (url: string) => navigate(url),
|
||||
setCommandPaletteContext: (entity?: CatalogEntity) => {
|
||||
catalogEntityRegistry.activeEntity = entity;
|
||||
},
|
||||
};
|
||||
|
||||
export class CatalogEntityRegistry {
|
||||
@observable protected activeEntityId: string | undefined = undefined;
|
||||
protected _entities = observable.map<string, CatalogEntity>([], { deep: true });
|
||||
@@ -78,6 +86,16 @@ export class CatalogEntityRegistry {
|
||||
|
||||
// Make sure that we get items ASAP and not the next time one of them changes
|
||||
ipcRenderer.send(CatalogIpcEvents.INIT);
|
||||
|
||||
if (isMainFrame) {
|
||||
ipcRendererOn(catalogEntityRunListener, (event, id: string) => {
|
||||
const entity = this.getById(id);
|
||||
|
||||
if (entity) {
|
||||
this.onRun(entity);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@action updateItems(items: (CatalogEntityData & CatalogEntityKindData)[]) {
|
||||
|
||||
@@ -19,10 +19,7 @@
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { navigate } from "../navigation";
|
||||
import type { CatalogEntity } from "../../common/catalog";
|
||||
import { catalogEntityRegistry } from "./catalog-entity-registry";
|
||||
|
||||
export { catalogEntityRunContext } from "./catalog-entity-registry";
|
||||
export { CatalogCategory, CatalogEntity } from "../../common/catalog";
|
||||
export type {
|
||||
CatalogEntityData,
|
||||
@@ -33,10 +30,3 @@ export type {
|
||||
CatalogEntityContextMenu,
|
||||
CatalogEntityContextMenuContext,
|
||||
} from "../../common/catalog";
|
||||
|
||||
export const catalogEntityRunContext = {
|
||||
navigate: (url: string) => navigate(url),
|
||||
setCommandPaletteContext: (entity?: CatalogEntity) => {
|
||||
catalogEntityRegistry.activeEntity = entity;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { when } from "mobx";
|
||||
import { catalogCategoryRegistry } from "../../../common/catalog";
|
||||
import { catalogEntityRegistry } from "../catalog-entity-registry";
|
||||
import { isActiveRoute } from "../../navigation";
|
||||
import type { GeneralEntity } from "../../../common/catalog-entities";
|
||||
|
||||
export async function setEntityOnRouteMatch() {
|
||||
await when(() => catalogEntityRegistry.entities.size > 0);
|
||||
|
||||
const entities: GeneralEntity[] = catalogEntityRegistry.getItemsForCategory(catalogCategoryRegistry.getByName("General"));
|
||||
const activeEntity = entities.find(entity => isActiveRoute(entity.spec.path));
|
||||
|
||||
if (activeEntity) {
|
||||
catalogEntityRegistry.activeEntity = activeEntity;
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ import { SentryInit } from "../common/sentry";
|
||||
import { TerminalStore } from "./components/dock/terminal.store";
|
||||
import { AppPaths } from "../common/app-paths";
|
||||
import { registerCustomThemes } from "./components/monaco-editor";
|
||||
import { getDi } from "./components/getDi";
|
||||
import { getDi } from "./getDi";
|
||||
import { DiContextProvider } from "@ogre-tools/injectable-react";
|
||||
import type { DependencyInjectionContainer } from "@ogre-tools/injectable";
|
||||
import extensionLoaderInjectable from "../extensions/extension-loader/extension-loader.injectable";
|
||||
@@ -59,6 +59,7 @@ import bindProtocolAddRouteHandlersInjectable
|
||||
import type { LensProtocolRouterRenderer } from "./protocol-handler";
|
||||
import lensProtocolRouterRendererInjectable
|
||||
from "./protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.injectable";
|
||||
import commandOverlayInjectable from "./components/command-palette/command-overlay.injectable";
|
||||
|
||||
if (process.isMainFrame) {
|
||||
SentryInit();
|
||||
@@ -102,9 +103,6 @@ export async function bootstrap(comp: () => Promise<AppComponent>, di: Dependenc
|
||||
logger.info(`${logPrefix} initializing Registries`);
|
||||
initializers.initRegistries();
|
||||
|
||||
logger.info(`${logPrefix} initializing CommandRegistry`);
|
||||
initializers.initCommandRegistry();
|
||||
|
||||
logger.info(`${logPrefix} initializing EntitySettingsRegistry`);
|
||||
initializers.initEntitySettingsRegistry();
|
||||
|
||||
@@ -114,9 +112,6 @@ export async function bootstrap(comp: () => Promise<AppComponent>, di: Dependenc
|
||||
logger.info(`${logPrefix} initializing KubeObjectDetailRegistry`);
|
||||
initializers.initKubeObjectDetailRegistry();
|
||||
|
||||
logger.info(`${logPrefix} initializing WelcomeMenuRegistry`);
|
||||
initializers.initWelcomeMenuRegistry();
|
||||
|
||||
logger.info(`${logPrefix} initializing WorkloadsOverviewDetailRegistry`);
|
||||
initializers.initWorkloadsOverviewDetailRegistry();
|
||||
|
||||
@@ -127,7 +122,9 @@ export async function bootstrap(comp: () => Promise<AppComponent>, di: Dependenc
|
||||
initializers.initCatalogCategoryRegistryEntries();
|
||||
|
||||
logger.info(`${logPrefix} initializing Catalog`);
|
||||
initializers.initCatalog();
|
||||
initializers.initCatalog({
|
||||
openCommandDialog: di.inject(commandOverlayInjectable).open,
|
||||
});
|
||||
|
||||
const extensionLoader = di.inject(extensionLoaderInjectable);
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import { observable, makeObservable, when } from "mobx";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import { Redirect, Route, Router, Switch } from "react-router";
|
||||
import { history } from "./navigation";
|
||||
import { NotFound } from "./components/+404";
|
||||
import { UserManagement } from "./components/+user-management/user-management";
|
||||
import { ConfirmDialog } from "./components/confirm-dialog";
|
||||
import { ClusterOverview } from "./components/+cluster/cluster-overview";
|
||||
@@ -230,7 +229,11 @@ export class ClusterFrame extends React.Component {
|
||||
{this.renderExtensionTabLayoutRoutes()}
|
||||
{this.renderExtensionRoutes()}
|
||||
<Redirect exact from="/" to={this.startUrl}/>
|
||||
<Route component={NotFound}/>
|
||||
<Route render={({ location }) => {
|
||||
Notifications.error(`Unknown location ${location.pathname}, redirecting to main page.`);
|
||||
|
||||
return <Redirect to={this.startUrl} />;
|
||||
}} />
|
||||
</Switch>
|
||||
</MainLayout>
|
||||
<Notifications/>
|
||||
|
||||
@@ -45,15 +45,17 @@ export class HpaDetails extends React.Component<HpaDetailsProps> {
|
||||
|
||||
const renderName = (metric: IHpaMetric) => {
|
||||
switch (metric.type) {
|
||||
case HpaMetricType.Resource:
|
||||
const addition = metric.resource.targetAverageUtilization ? <>(as a percentage of request)</> : "";
|
||||
case HpaMetricType.Resource: {
|
||||
const addition = metric.resource.targetAverageUtilization
|
||||
? "(as a percentage of request)"
|
||||
: "";
|
||||
|
||||
return <>Resource {metric.resource.name} on Pods {addition}</>;
|
||||
|
||||
}
|
||||
case HpaMetricType.Pods:
|
||||
return <>{metric.pods.metricName} on Pods</>;
|
||||
|
||||
case HpaMetricType.Object:
|
||||
case HpaMetricType.Object: {
|
||||
const { target } = metric.object;
|
||||
const { kind, name } = target;
|
||||
const objectUrl = getDetailsUrl(apiManager.lookupApiLink(target, hpa));
|
||||
@@ -64,6 +66,7 @@ export class HpaDetails extends React.Component<HpaDetailsProps> {
|
||||
<Link to={objectUrl}>{kind}/{name}</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case HpaMetricType.External:
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -66,22 +66,15 @@ export class CRDStore extends KubeObjectStore<CustomResourceDefinition> {
|
||||
@computed get groups() {
|
||||
const groups: Record<string, CustomResourceDefinition[]> = {};
|
||||
|
||||
return this.items.reduce((groups, crd) => {
|
||||
const group = crd.getGroup();
|
||||
for (const crd of this.items) {
|
||||
(groups[crd.getGroup()] ??= []).push(crd);
|
||||
}
|
||||
|
||||
if (!groups[group]) groups[group] = [];
|
||||
groups[group].push(crd);
|
||||
|
||||
return groups;
|
||||
}, groups);
|
||||
return groups;
|
||||
}
|
||||
|
||||
getByGroup(group: string, pluralName: string) {
|
||||
const crdInGroup = this.groups[group];
|
||||
|
||||
if (!crdInGroup) return null;
|
||||
|
||||
return crdInGroup.find(crd => crd.getPluralName() === pluralName);
|
||||
return this.groups[group]?.find(crd => crd.getPluralName() === pluralName);
|
||||
}
|
||||
|
||||
getByObject(obj: KubeObject) {
|
||||
|
||||
+8
-12
@@ -19,18 +19,14 @@
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { HotbarStore } from "../../../common/hotbar-store";
|
||||
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
|
||||
import { computed } from "mobx";
|
||||
import { crdStore } from "./crd.store";
|
||||
|
||||
function hotbarIndex(id: string) {
|
||||
return HotbarStore.getInstance().hotbarIndex(id) + 1;
|
||||
}
|
||||
const customResourceDefinitionsInjectable = getInjectable({
|
||||
instantiate: () => computed(() => [...crdStore.items]),
|
||||
|
||||
export function hotbarDisplayLabel(id: string) : string {
|
||||
const hotbar = HotbarStore.getInstance().getById(id);
|
||||
lifecycle: lifecycleEnum.singleton,
|
||||
});
|
||||
|
||||
return `${hotbarIndex(id)}: ${hotbar.name}`;
|
||||
}
|
||||
|
||||
export function hotbarDisplayIndex(id: string) : string {
|
||||
return hotbarIndex(id).toString();
|
||||
}
|
||||
export default customResourceDefinitionsInjectable;
|
||||
@@ -34,7 +34,7 @@ import { mockWindow } from "../../../../../__mocks__/windowMock";
|
||||
import { AppPaths } from "../../../../common/app-paths";
|
||||
import extensionLoaderInjectable
|
||||
from "../../../../extensions/extension-loader/extension-loader.injectable";
|
||||
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
|
||||
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
|
||||
import { DiRender, renderFor } from "../../test-utils/renderFor";
|
||||
|
||||
mockWindow();
|
||||
|
||||
+1
-2
@@ -18,8 +18,7 @@
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { lifecycleEnum } from "@ogre-tools/injectable";
|
||||
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
|
||||
import { unpackExtension } from "./unpack-extension";
|
||||
import extensionLoaderInjectable from "../../../../../extensions/extension-loader/extension-loader.injectable";
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ export const getBaseRegistryUrl = ({ getRegistryUrlPreference }: Dependencies) =
|
||||
} catch (error) {
|
||||
Notifications.error(<p>Failed to get configured registry from <code>.npmrc</code>. Falling back to default registry</p>);
|
||||
console.warn("[EXTENSIONS]: failed to get configured registry from .npmrc", error);
|
||||
// fallthrough
|
||||
}
|
||||
// fallthrough
|
||||
}
|
||||
default:
|
||||
case ExtensionRegistryLocation.DEFAULT:
|
||||
|
||||
@@ -21,11 +21,10 @@
|
||||
|
||||
import React from "react";
|
||||
import { boundMethod, cssNames } from "../../utils";
|
||||
import { openPortForward, PortForwardItem, removePortForward } from "../../port-forward";
|
||||
import { openPortForward, PortForwardItem, removePortForward, PortForwardDialog, startPortForward, stopPortForward } from "../../port-forward";
|
||||
import { MenuActions, MenuActionsProps } from "../menu/menu-actions";
|
||||
import { MenuItem } from "../menu";
|
||||
import { Icon } from "../icon";
|
||||
import { PortForwardDialog } from "../../port-forward";
|
||||
import { Notifications } from "../notifications";
|
||||
|
||||
interface Props extends MenuActionsProps {
|
||||
@@ -45,6 +44,38 @@ export class PortForwardMenu extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
private startPortForwarding = async () => {
|
||||
const { portForward } = this.props;
|
||||
|
||||
const pf = await startPortForward(portForward);
|
||||
|
||||
if (pf.status === "Disabled") {
|
||||
const { name, kind, forwardPort } = portForward;
|
||||
|
||||
Notifications.error(`Error occurred starting port-forward, the local port ${forwardPort} may not be available or the ${kind} ${name} may not be reachable`);
|
||||
}
|
||||
};
|
||||
|
||||
renderStartStopMenuItem() {
|
||||
const { portForward, toolbar } = this.props;
|
||||
|
||||
if (portForward.status === "Active") {
|
||||
return (
|
||||
<MenuItem onClick={() => stopPortForward(portForward)}>
|
||||
<Icon material="stop" tooltip="Stop port-forward" interactive={toolbar} />
|
||||
<span className="title">Stop</span>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem onClick={this.startPortForwarding}>
|
||||
<Icon material="play_arrow" tooltip="Start port-forward" interactive={toolbar} />
|
||||
<span className="title">Start</span>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
const { portForward, toolbar } = this.props;
|
||||
|
||||
@@ -52,14 +83,17 @@ export class PortForwardMenu extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem onClick={() => openPortForward(this.props.portForward)}>
|
||||
<Icon material="open_in_browser" interactive={toolbar} tooltip="Open in browser" />
|
||||
<span className="title">Open</span>
|
||||
</MenuItem>
|
||||
{ portForward.status === "Active" &&
|
||||
<MenuItem onClick={() => openPortForward(portForward)}>
|
||||
<Icon material="open_in_browser" interactive={toolbar} tooltip="Open in browser" />
|
||||
<span className="title">Open</span>
|
||||
</MenuItem>
|
||||
}
|
||||
<MenuItem onClick={() => PortForwardDialog.open(portForward)}>
|
||||
<Icon material="edit" tooltip="Change port or protocol" interactive={toolbar} />
|
||||
<span className="title">Edit</span>
|
||||
</MenuItem>
|
||||
{this.renderStartStopMenuItem()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export class PortForwards extends React.Component<Props> {
|
||||
showDetails = (item: PortForwardItem) => {
|
||||
navigation.push(portForwardsURL({
|
||||
params: {
|
||||
forwardport: String(item.getForwardPort()),
|
||||
forwardport: item.getId(),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -24,13 +24,14 @@ import "./service-port-component.scss";
|
||||
import React from "react";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import type { Service, ServicePort } from "../../../common/k8s-api/endpoints";
|
||||
import { observable, makeObservable, reaction } from "mobx";
|
||||
import { observable, makeObservable, reaction, action } from "mobx";
|
||||
import { cssNames } from "../../utils";
|
||||
import { Notifications } from "../notifications";
|
||||
import { Button } from "../button";
|
||||
import { aboutPortForwarding, addPortForward, getPortForward, getPortForwards, openPortForward, PortForwardDialog, portForwardStore, predictProtocol, removePortForward } from "../../port-forward";
|
||||
import { aboutPortForwarding, addPortForward, getPortForward, getPortForwards, notifyErrorPortForwarding, openPortForward, PortForwardDialog, predictProtocol, removePortForward, startPortForward } from "../../port-forward";
|
||||
import type { ForwardedPort } from "../../port-forward";
|
||||
import { Spinner } from "../spinner";
|
||||
import logger from "../../../common/logger";
|
||||
|
||||
interface Props {
|
||||
service: Service;
|
||||
@@ -42,6 +43,7 @@ export class ServicePortComponent extends React.Component<Props> {
|
||||
@observable waiting = false;
|
||||
@observable forwardPort = 0;
|
||||
@observable isPortForwarded = false;
|
||||
@observable isActive = false;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
@@ -51,13 +53,14 @@ export class ServicePortComponent extends React.Component<Props> {
|
||||
|
||||
componentDidMount() {
|
||||
disposeOnUnmount(this, [
|
||||
reaction(() => [portForwardStore.portForwards, this.props.service], () => this.checkExistingPortForwarding()),
|
||||
reaction(() => this.props.service, () => this.checkExistingPortForwarding()),
|
||||
]);
|
||||
}
|
||||
|
||||
@action
|
||||
async checkExistingPortForwarding() {
|
||||
const { service, port } = this.props;
|
||||
const portForward: ForwardedPort = {
|
||||
let portForward: ForwardedPort = {
|
||||
kind: "service",
|
||||
name: service.getName(),
|
||||
namespace: service.getNs(),
|
||||
@@ -65,57 +68,66 @@ export class ServicePortComponent extends React.Component<Props> {
|
||||
forwardPort: this.forwardPort,
|
||||
};
|
||||
|
||||
let activePort: number;
|
||||
|
||||
try {
|
||||
activePort = await getPortForward(portForward) ?? 0;
|
||||
portForward = await getPortForward(portForward);
|
||||
} catch (error) {
|
||||
this.isPortForwarded = false;
|
||||
this.isActive = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.forwardPort = activePort;
|
||||
this.isPortForwarded = activePort ? true : false;
|
||||
this.forwardPort = portForward.forwardPort;
|
||||
this.isPortForwarded = true;
|
||||
this.isActive = portForward.status === "Active";
|
||||
}
|
||||
|
||||
@action
|
||||
async portForward() {
|
||||
const { service, port } = this.props;
|
||||
const portForward: ForwardedPort = {
|
||||
let portForward: ForwardedPort = {
|
||||
kind: "service",
|
||||
name: service.getName(),
|
||||
namespace: service.getNs(),
|
||||
port: port.port,
|
||||
forwardPort: this.forwardPort,
|
||||
protocol: predictProtocol(port.name),
|
||||
status: "Active",
|
||||
};
|
||||
|
||||
this.waiting = true;
|
||||
|
||||
try {
|
||||
// determine how many port-forwards are already active
|
||||
const { length } = await getPortForwards();
|
||||
// determine how many port-forwards already exist
|
||||
const { length } = getPortForwards();
|
||||
|
||||
this.forwardPort = await addPortForward(portForward);
|
||||
if (!this.isPortForwarded) {
|
||||
portForward = await addPortForward(portForward);
|
||||
} else if (!this.isActive) {
|
||||
portForward = await startPortForward(portForward);
|
||||
}
|
||||
|
||||
if (this.forwardPort) {
|
||||
portForward.forwardPort = this.forwardPort;
|
||||
this.forwardPort = portForward.forwardPort;
|
||||
|
||||
if (portForward.status === "Active") {
|
||||
openPortForward(portForward);
|
||||
this.isPortForwarded = true;
|
||||
|
||||
// if this is the first port-forward show the about notification
|
||||
if (!length) {
|
||||
aboutPortForwarding();
|
||||
}
|
||||
} else {
|
||||
notifyErrorPortForwarding(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`);
|
||||
}
|
||||
} catch (error) {
|
||||
Notifications.error(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`);
|
||||
this.checkExistingPortForwarding();
|
||||
logger.error("[SERVICE-PORT-COMPONENT]:", error, portForward);
|
||||
} finally {
|
||||
this.checkExistingPortForwarding();
|
||||
this.waiting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async stopPortForward() {
|
||||
const { service, port } = this.props;
|
||||
const portForward: ForwardedPort = {
|
||||
@@ -130,11 +142,11 @@ export class ServicePortComponent extends React.Component<Props> {
|
||||
|
||||
try {
|
||||
await removePortForward(portForward);
|
||||
this.isPortForwarded = false;
|
||||
} catch (error) {
|
||||
Notifications.error(`Error occurred stopping the port-forward from port ${portForward.forwardPort}.`);
|
||||
this.checkExistingPortForwarding();
|
||||
} finally {
|
||||
this.checkExistingPortForwarding();
|
||||
this.forwardPort = 0;
|
||||
this.waiting = false;
|
||||
}
|
||||
}
|
||||
@@ -142,7 +154,7 @@ export class ServicePortComponent extends React.Component<Props> {
|
||||
render() {
|
||||
const { port, service } = this.props;
|
||||
|
||||
const portForwardAction = async () => {
|
||||
const portForwardAction = action(async () => {
|
||||
if (this.isPortForwarded) {
|
||||
await this.stopPortForward();
|
||||
} else {
|
||||
@@ -155,16 +167,16 @@ export class ServicePortComponent extends React.Component<Props> {
|
||||
protocol: predictProtocol(port.name),
|
||||
};
|
||||
|
||||
PortForwardDialog.open(portForward, { openInBrowser: true });
|
||||
PortForwardDialog.open(portForward, { openInBrowser: true, onClose: () => this.checkExistingPortForwarding() });
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cssNames("ServicePortComponent", { waiting: this.waiting })}>
|
||||
<span title="Open in a browser" onClick={() => this.portForward()}>
|
||||
{port.toString()}
|
||||
</span>
|
||||
<Button primary onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop" : "Forward..."} </Button>
|
||||
<Button primary onClick={portForwardAction}> {this.isPortForwarded ? (this.isActive ? "Stop/Remove" : "Remove") : "Forward..."} </Button>
|
||||
{this.waiting && (
|
||||
<Spinner />
|
||||
)}
|
||||
|
||||
@@ -34,6 +34,7 @@ $service-status-color-list: (
|
||||
|
||||
$port-forward-status-color-list: (
|
||||
active: var(--colorOk),
|
||||
disabled: var(--colorSoftError)
|
||||
);
|
||||
|
||||
@mixin port-forward-status-colors {
|
||||
|
||||
@@ -27,7 +27,7 @@ import { ThemeStore } from "../../theme.store";
|
||||
import { UserStore } from "../../../common/user-store";
|
||||
import { Input } from "../input";
|
||||
import { isWindows } from "../../../common/vars";
|
||||
import { FormSwitch, Switcher } from "../switch";
|
||||
import { Switch } from "../switch";
|
||||
import moment from "moment-timezone";
|
||||
import { CONSTANTS, defaultExtensionRegistryUrl, ExtensionRegistryLocation } from "../../../common/user-store/preferences-helpers";
|
||||
import { action } from "mobx";
|
||||
@@ -86,16 +86,12 @@ export const Application = observer(() => {
|
||||
|
||||
<section id="terminalSelection">
|
||||
<SubTitle title="Terminal copy & paste" />
|
||||
<FormSwitch
|
||||
label="Copy on select and paste on right-click"
|
||||
control={
|
||||
<Switcher
|
||||
checked={userStore.terminalCopyOnSelect}
|
||||
onChange={v => userStore.terminalCopyOnSelect = v.target.checked}
|
||||
name="terminalCopyOnSelect"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
checked={userStore.terminalCopyOnSelect}
|
||||
onChange={() => userStore.terminalCopyOnSelect = !userStore.terminalCopyOnSelect}
|
||||
>
|
||||
Copy on select and paste on right-click
|
||||
</Switch>
|
||||
</section>
|
||||
|
||||
<hr/>
|
||||
@@ -135,16 +131,9 @@ export const Application = observer(() => {
|
||||
|
||||
<section id="other">
|
||||
<SubTitle title="Start-up"/>
|
||||
<FormSwitch
|
||||
control={
|
||||
<Switcher
|
||||
checked={userStore.openAtLogin}
|
||||
onChange={v => userStore.openAtLogin = v.target.checked}
|
||||
name="startup"
|
||||
/>
|
||||
}
|
||||
label="Automatically start Lens on login"
|
||||
/>
|
||||
<Switch checked={userStore.openAtLogin} onChange={() => userStore.openAtLogin = !userStore.openAtLogin}>
|
||||
Automatically start Lens on login
|
||||
</Switch>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import { UserStore } from "../../../common/user-store";
|
||||
import { FormSwitch, Switcher } from "../switch";
|
||||
import { Switch } from "../switch";
|
||||
import { Select } from "../select";
|
||||
import { SubTitle } from "../layout/sub-title";
|
||||
import { SubHeader } from "../layout/sub-header";
|
||||
@@ -45,15 +45,12 @@ export const Editor = observer(() => {
|
||||
<section>
|
||||
<div className="flex gaps justify-space-between">
|
||||
<div className="flex gaps align-center">
|
||||
<FormSwitch
|
||||
label={<SubHeader compact>Show minimap</SubHeader>}
|
||||
control={
|
||||
<Switcher
|
||||
checked={editorConfiguration.minimap.enabled}
|
||||
onChange={(evt, checked) => editorConfiguration.minimap.enabled = checked}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
checked={editorConfiguration.minimap.enabled}
|
||||
onChange={() => editorConfiguration.minimap.enabled = !editorConfiguration.minimap.enabled}
|
||||
>
|
||||
Show minimap
|
||||
</Switch>
|
||||
</div>
|
||||
<div className="flex gaps align-center">
|
||||
<SubHeader compact>Position</SubHeader>
|
||||
|
||||
@@ -26,7 +26,7 @@ import { getDefaultKubectlDownloadPath, UserStore } from "../../../common/user-s
|
||||
import { observer } from "mobx-react";
|
||||
import { bundledKubectlPath } from "../../../main/kubectl";
|
||||
import { SelectOption, Select } from "../select";
|
||||
import { FormSwitch, Switcher } from "../switch";
|
||||
import { Switch } from "../switch";
|
||||
import { packageMirrors } from "../../../common/user-store/preferences-helpers";
|
||||
|
||||
export const KubectlBinaries = observer(() => {
|
||||
@@ -48,16 +48,12 @@ export const KubectlBinaries = observer(() => {
|
||||
<>
|
||||
<section>
|
||||
<SubTitle title="Kubectl binary download"/>
|
||||
<FormSwitch
|
||||
control={
|
||||
<Switcher
|
||||
checked={userStore.downloadKubectlBinaries}
|
||||
onChange={v => userStore.downloadKubectlBinaries = v.target.checked}
|
||||
name="kubectl-download"
|
||||
/>
|
||||
}
|
||||
label="Download kubectl binaries matching the Kubernetes cluster version"
|
||||
/>
|
||||
<Switch
|
||||
checked={userStore.downloadKubectlBinaries}
|
||||
onChange={() => userStore.downloadKubectlBinaries = !userStore.downloadKubectlBinaries}
|
||||
>
|
||||
Download kubectl binaries matching the Kubernetes cluster version
|
||||
</Switch>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
@@ -24,10 +24,11 @@ import React from "react";
|
||||
import { UserStore } from "../../../common/user-store";
|
||||
import { Input } from "../input";
|
||||
import { SubTitle } from "../layout/sub-title";
|
||||
import { FormSwitch, Switcher } from "../switch";
|
||||
import { Switch } from "../switch";
|
||||
|
||||
export const LensProxy = observer(() => {
|
||||
const [proxy, setProxy] = React.useState(UserStore.getInstance().httpsProxy || "");
|
||||
const store = UserStore.getInstance();
|
||||
|
||||
return (
|
||||
<section id="proxy">
|
||||
@@ -50,16 +51,9 @@ export const LensProxy = observer(() => {
|
||||
|
||||
<section className="small">
|
||||
<SubTitle title="Certificate Trust"/>
|
||||
<FormSwitch
|
||||
control={
|
||||
<Switcher
|
||||
checked={UserStore.getInstance().allowUntrustedCAs}
|
||||
onChange={v => UserStore.getInstance().allowUntrustedCAs = v.target.checked}
|
||||
name="startup"
|
||||
/>
|
||||
}
|
||||
label="Allow untrusted Certificate Authorities"
|
||||
/>
|
||||
<Switch checked={store.allowUntrustedCAs} onChange={() => store.allowUntrustedCAs = !store.allowUntrustedCAs}>
|
||||
Allow untrusted Certificate Authorities
|
||||
</Switch>
|
||||
<small className="hint">
|
||||
This will make Lens to trust ANY certificate authority without any validations.{" "}
|
||||
Needed with some corporate proxies that do certificate re-writing.{" "}
|
||||
|
||||
@@ -20,45 +20,55 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/extend-expect";
|
||||
import { Welcome } from "../welcome";
|
||||
import { TopBarRegistry, WelcomeMenuRegistry, WelcomeBannerRegistry } from "../../../../extensions/registries";
|
||||
import { defaultWidth } from "../welcome";
|
||||
import { defaultWidth, Welcome } from "../welcome";
|
||||
import { computed } from "mobx";
|
||||
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
|
||||
import type { DiRender } from "../../test-utils/renderFor";
|
||||
import { renderFor } from "../../test-utils/renderFor";
|
||||
import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable";
|
||||
import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable";
|
||||
import { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
|
||||
import type { WelcomeBannerRegistration } from "../welcome-banner-items/welcome-banner-registration";
|
||||
|
||||
jest.mock(
|
||||
"electron",
|
||||
() => ({
|
||||
ipcRenderer: {
|
||||
on: jest.fn(),
|
||||
},
|
||||
app: {
|
||||
getPath: () => "tmp",
|
||||
},
|
||||
}),
|
||||
);
|
||||
jest.mock("electron", () => ({
|
||||
ipcRenderer: {
|
||||
on: jest.fn(),
|
||||
},
|
||||
app: {
|
||||
getPath: () => "tmp",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("<Welcome/>", () => {
|
||||
beforeEach(() => {
|
||||
TopBarRegistry.createInstance();
|
||||
WelcomeMenuRegistry.createInstance();
|
||||
WelcomeBannerRegistry.createInstance();
|
||||
});
|
||||
let render: DiRender;
|
||||
let di: ConfigurableDependencyInjectionContainer;
|
||||
let welcomeBannersStub: WelcomeBannerRegistration[];
|
||||
|
||||
afterEach(() => {
|
||||
TopBarRegistry.resetInstance();
|
||||
WelcomeMenuRegistry.resetInstance();
|
||||
WelcomeBannerRegistry.resetInstance();
|
||||
beforeEach(() => {
|
||||
di = getDiForUnitTesting();
|
||||
|
||||
render = renderFor(di);
|
||||
|
||||
welcomeBannersStub = [];
|
||||
|
||||
di.override(rendererExtensionsInjectable, () =>
|
||||
computed(() => [
|
||||
new TestExtension({
|
||||
id: "some-id",
|
||||
welcomeBanners: welcomeBannersStub,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders <Banner /> registered in WelcomeBannerRegistry and hide logo", async () => {
|
||||
const testId = "testId";
|
||||
|
||||
WelcomeBannerRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [
|
||||
{
|
||||
Banner: () => <div data-testid={testId} />,
|
||||
},
|
||||
]);
|
||||
welcomeBannersStub.push({
|
||||
Banner: () => <div data-testid={testId} />,
|
||||
});
|
||||
|
||||
const { container } = render(<Welcome />);
|
||||
|
||||
@@ -67,16 +77,15 @@ describe("<Welcome/>", () => {
|
||||
});
|
||||
|
||||
it("calculates max width from WelcomeBanner.width registered in WelcomeBannerRegistry", async () => {
|
||||
WelcomeBannerRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [
|
||||
{
|
||||
width: 100,
|
||||
Banner: () => <div />,
|
||||
},
|
||||
{
|
||||
width: 800,
|
||||
Banner: () => <div />,
|
||||
},
|
||||
]);
|
||||
welcomeBannersStub.push({
|
||||
width: 100,
|
||||
Banner: () => <div />,
|
||||
});
|
||||
|
||||
welcomeBannersStub.push({
|
||||
width: 800,
|
||||
Banner: () => <div />,
|
||||
});
|
||||
|
||||
render(<Welcome />);
|
||||
|
||||
@@ -92,3 +101,25 @@ describe("<Welcome/>", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class TestExtension extends LensRendererExtension {
|
||||
constructor({
|
||||
id,
|
||||
welcomeBanners,
|
||||
}: {
|
||||
id: string;
|
||||
welcomeBanners: WelcomeBannerRegistration[];
|
||||
}) {
|
||||
super({
|
||||
id,
|
||||
absolutePath: "irrelevant",
|
||||
isBundled: false,
|
||||
isCompatible: false,
|
||||
isEnabled: false,
|
||||
manifest: { name: id, version: "some-version" },
|
||||
manifestPath: "irrelevant",
|
||||
});
|
||||
|
||||
this.welcomeBanners = welcomeBanners;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
|
||||
import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable";
|
||||
import { computed } from "mobx";
|
||||
|
||||
const welcomeBannerItemsInjectable = getInjectable({
|
||||
instantiate: (di) => {
|
||||
const extensions = di.inject(rendererExtensionsInjectable);
|
||||
|
||||
return computed(() => [
|
||||
...extensions.get().flatMap((extension) => extension.welcomeBanners),
|
||||
]);
|
||||
},
|
||||
|
||||
lifecycle: lifecycleEnum.singleton,
|
||||
});
|
||||
|
||||
export default welcomeBannerItemsInjectable;
|
||||
+14
-13
@@ -19,16 +19,17 @@
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { createContainer } from "@ogre-tools/injectable";
|
||||
|
||||
export const getDi = () =>
|
||||
createContainer(
|
||||
getRequireContextForRendererCode,
|
||||
getRequireContextForCommonExtensionCode,
|
||||
);
|
||||
|
||||
const getRequireContextForRendererCode = () =>
|
||||
require.context("../", true, /\.injectable\.(ts|tsx)$/);
|
||||
|
||||
const getRequireContextForCommonExtensionCode = () =>
|
||||
require.context("../../extensions", true, /\.injectable\.(ts|tsx)$/);
|
||||
/**
|
||||
* WelcomeBannerRegistration is for an extension to register
|
||||
* Provide a Banner component to be renderered in the welcome screen.
|
||||
*/
|
||||
export interface WelcomeBannerRegistration {
|
||||
/**
|
||||
* The banner component to be shown on the welcome screen.
|
||||
*/
|
||||
Banner?: React.ComponentType
|
||||
/**
|
||||
* The banner width in px.
|
||||
*/
|
||||
width?: number
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
import { computed, IComputedValue } from "mobx";
|
||||
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
|
||||
import { navigate } from "../../../navigation";
|
||||
import { catalogURL } from "../../../../common/routes";
|
||||
|
||||
interface Dependencies {
|
||||
extensions: IComputedValue<LensRendererExtension[]>;
|
||||
}
|
||||
|
||||
export const getWelcomeMenuItems = ({ extensions }: Dependencies) => {
|
||||
const browseClusters = {
|
||||
title: "Browse Clusters in Catalog",
|
||||
icon: "view_list",
|
||||
click: () =>
|
||||
navigate(
|
||||
catalogURL({
|
||||
params: { group: "entity.k8slens.dev", kind: "KubernetesCluster" },
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
return computed(() => [
|
||||
browseClusters,
|
||||
...extensions.get().flatMap((extension) => extension.welcomeMenus),
|
||||
]);
|
||||
};
|
||||
+12
-13
@@ -18,18 +18,17 @@
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
|
||||
import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable";
|
||||
import { getWelcomeMenuItems } from "./get-welcome-menu-items";
|
||||
|
||||
import { catalogURL } from "../../common/routes";
|
||||
import { WelcomeMenuRegistry } from "../../extensions/registries";
|
||||
import { navigate } from "../navigation";
|
||||
const welcomeMenuItemsInjectable = getInjectable({
|
||||
instantiate: (di) =>
|
||||
getWelcomeMenuItems({
|
||||
extensions: di.inject(rendererExtensionsInjectable),
|
||||
}),
|
||||
|
||||
export function initWelcomeMenuRegistry() {
|
||||
WelcomeMenuRegistry.getInstance()
|
||||
.add([
|
||||
{
|
||||
title: "Browse Clusters in Catalog",
|
||||
icon: "view_list",
|
||||
click: () => navigate(catalogURL({ params: { group: "entity.k8slens.dev", kind: "KubernetesCluster" }} )),
|
||||
},
|
||||
]);
|
||||
}
|
||||
lifecycle: lifecycleEnum.singleton,
|
||||
});
|
||||
|
||||
export default welcomeMenuItemsInjectable;
|
||||
+5
-1
@@ -19,4 +19,8 @@
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
export * from "./not-found";
|
||||
export interface WelcomeMenuRegistration {
|
||||
title: string | (() => string);
|
||||
icon: string;
|
||||
click: () => void | Promise<void>;
|
||||
}
|
||||
@@ -22,78 +22,129 @@
|
||||
import "./welcome.scss";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { IComputedValue } from "mobx";
|
||||
import Carousel from "react-material-ui-carousel";
|
||||
import { Icon } from "../icon";
|
||||
import { productName, slackUrl } from "../../../common/vars";
|
||||
import { WelcomeMenuRegistry } from "../../../extensions/registries";
|
||||
import { WelcomeBannerRegistry } from "../../../extensions/registries";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import welcomeMenuItemsInjectable from "./welcome-menu-items/welcome-menu-items.injectable";
|
||||
import type { WelcomeMenuRegistration } from "./welcome-menu-items/welcome-menu-registration";
|
||||
import welcomeBannerItemsInjectable from "./welcome-banner-items/welcome-banner-items.injectable";
|
||||
import type { WelcomeBannerRegistration } from "./welcome-banner-items/welcome-banner-registration";
|
||||
|
||||
export const defaultWidth = 320;
|
||||
|
||||
@observer
|
||||
export class Welcome extends React.Component {
|
||||
render() {
|
||||
const welcomeBanner = WelcomeBannerRegistry.getInstance().getItems();
|
||||
interface Dependencies {
|
||||
welcomeMenuItems: IComputedValue<WelcomeMenuRegistration[]>
|
||||
welcomeBannerItems: IComputedValue<WelcomeBannerRegistration[]>
|
||||
}
|
||||
|
||||
// if there is banner with specified width, use it to calculate the width of the container
|
||||
const maxWidth = welcomeBanner.reduce((acc, curr) => {
|
||||
const currWidth = curr.width ?? 0;
|
||||
const NonInjectedWelcome: React.FC<Dependencies> = ({ welcomeMenuItems, welcomeBannerItems }) => {
|
||||
const welcomeBanners = welcomeBannerItems.get();
|
||||
|
||||
if (acc > currWidth) {
|
||||
return acc;
|
||||
}
|
||||
// if there is banner with specified width, use it to calculate the width of the container
|
||||
const maxWidth = welcomeBanners.reduce((acc, curr) => {
|
||||
const currWidth = curr.width ?? 0;
|
||||
|
||||
return currWidth;
|
||||
}, defaultWidth);
|
||||
if (acc > currWidth) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center Welcome align-center">
|
||||
<div style={{ width: `${maxWidth}px` }} data-testid="welcome-banner-container">
|
||||
{welcomeBanner.length > 0 ? (
|
||||
<Carousel
|
||||
stopAutoPlayOnHover={true}
|
||||
indicators={welcomeBanner.length > 1}
|
||||
autoPlay={true}
|
||||
navButtonsAlwaysInvisible={true}
|
||||
indicatorIconButtonProps={{
|
||||
style: {
|
||||
color: "var(--iconActiveBackground)",
|
||||
},
|
||||
}}
|
||||
activeIndicatorIconButtonProps={{
|
||||
style: {
|
||||
color: "var(--iconActiveColor)",
|
||||
},
|
||||
}}
|
||||
interval={8000}
|
||||
return currWidth;
|
||||
}, defaultWidth);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center Welcome align-center">
|
||||
<div
|
||||
style={{ width: `${maxWidth}px` }}
|
||||
data-testid="welcome-banner-container"
|
||||
>
|
||||
{welcomeBanners.length > 0 ? (
|
||||
<Carousel
|
||||
stopAutoPlayOnHover={true}
|
||||
indicators={welcomeBanners.length > 1}
|
||||
autoPlay={true}
|
||||
navButtonsAlwaysInvisible={true}
|
||||
indicatorIconButtonProps={{
|
||||
style: {
|
||||
color: "var(--iconActiveBackground)",
|
||||
},
|
||||
}}
|
||||
activeIndicatorIconButtonProps={{
|
||||
style: {
|
||||
color: "var(--iconActiveColor)",
|
||||
},
|
||||
}}
|
||||
interval={8000}
|
||||
>
|
||||
{welcomeBanners.map((item, index) => (
|
||||
<item.Banner key={index} />
|
||||
))}
|
||||
</Carousel>
|
||||
) : (
|
||||
<Icon svg="logo-lens" className="logo" />
|
||||
)}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
style={{ width: `${defaultWidth}px` }}
|
||||
data-testid="welcome-text-container"
|
||||
>
|
||||
<h2>Welcome to {productName} 5!</h2>
|
||||
|
||||
<p>
|
||||
To get you started we have auto-detected your clusters in your
|
||||
kubeconfig file and added them to the catalog, your centralized
|
||||
view for managing all your cloud-native resources.
|
||||
<br />
|
||||
<br />
|
||||
If you have any questions or feedback, please join our{" "}
|
||||
<a
|
||||
href={slackUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="link"
|
||||
>
|
||||
Lens Community slack channel
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<ul
|
||||
className="block"
|
||||
style={{ width: `${defaultWidth}px` }}
|
||||
data-testid="welcome-menu-container"
|
||||
>
|
||||
{welcomeBanner.map((item, index) =>
|
||||
<item.Banner key={index} />,
|
||||
)}
|
||||
</Carousel>
|
||||
) : <Icon svg="logo-lens" className="logo" />}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div style={{ width: `${defaultWidth}px` }} data-testid="welcome-text-container">
|
||||
<h2>Welcome to {productName} 5!</h2>
|
||||
|
||||
<p>
|
||||
To get you started we have auto-detected your clusters in your kubeconfig file and added them to the catalog, your centralized view for managing all your cloud-native resources.
|
||||
<br /><br />
|
||||
If you have any questions or feedback, please join our <a href={slackUrl} target="_blank" rel="noreferrer" className="link">Lens Community slack channel</a>.
|
||||
</p>
|
||||
|
||||
<ul className="block" style={{ width: `${defaultWidth}px` }} data-testid="welcome-menu-container">
|
||||
{WelcomeMenuRegistry.getInstance().getItems().map((item, index) => (
|
||||
<li key={index} className="flex grid-12" onClick={() => item.click()}>
|
||||
<Icon material={item.icon} className="box col-1" /> <a className="box col-10">{typeof item.title === "string" ? item.title : item.title()}</a> <Icon material="navigate_next" className="box col-1" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{welcomeMenuItems.get().map((item, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex grid-12"
|
||||
onClick={() => item.click()}
|
||||
>
|
||||
<Icon material={item.icon} className="box col-1" />
|
||||
<a className="box col-10">
|
||||
{typeof item.title === "string"
|
||||
? item.title
|
||||
: item.title()}
|
||||
</a>
|
||||
<Icon material="navigate_next" className="box col-1" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Welcome = withInjectables<Dependencies>(
|
||||
observer(NonInjectedWelcome),
|
||||
|
||||
{
|
||||
getProps: (di) => ({
|
||||
welcomeMenuItems: di.inject(welcomeMenuItemsInjectable),
|
||||
welcomeBannerItems: di.inject(welcomeBannerItemsInjectable),
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -24,13 +24,14 @@ import "./pod-container-port.scss";
|
||||
import React from "react";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import type { Pod } from "../../../common/k8s-api/endpoints";
|
||||
import { observable, makeObservable, reaction } from "mobx";
|
||||
import { action, observable, makeObservable, reaction } from "mobx";
|
||||
import { cssNames } from "../../utils";
|
||||
import { Notifications } from "../notifications";
|
||||
import { Button } from "../button";
|
||||
import { aboutPortForwarding, addPortForward, getPortForward, getPortForwards, openPortForward, PortForwardDialog, portForwardStore, predictProtocol, removePortForward } from "../../port-forward";
|
||||
import { aboutPortForwarding, addPortForward, getPortForward, getPortForwards, notifyErrorPortForwarding, openPortForward, PortForwardDialog, predictProtocol, removePortForward, startPortForward } from "../../port-forward";
|
||||
import type { ForwardedPort } from "../../port-forward";
|
||||
import { Spinner } from "../spinner";
|
||||
import logger from "../../../common/logger";
|
||||
|
||||
interface Props {
|
||||
pod: Pod;
|
||||
@@ -46,6 +47,7 @@ export class PodContainerPort extends React.Component<Props> {
|
||||
@observable waiting = false;
|
||||
@observable forwardPort = 0;
|
||||
@observable isPortForwarded = false;
|
||||
@observable isActive = false;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
@@ -55,13 +57,14 @@ export class PodContainerPort extends React.Component<Props> {
|
||||
|
||||
componentDidMount() {
|
||||
disposeOnUnmount(this, [
|
||||
reaction(() => [portForwardStore.portForwards, this.props.pod], () => this.checkExistingPortForwarding()),
|
||||
reaction(() => this.props.pod, () => this.checkExistingPortForwarding()),
|
||||
]);
|
||||
}
|
||||
|
||||
@action
|
||||
async checkExistingPortForwarding() {
|
||||
const { pod, port } = this.props;
|
||||
const portForward: ForwardedPort = {
|
||||
let portForward: ForwardedPort = {
|
||||
kind: "pod",
|
||||
name: pod.getName(),
|
||||
namespace: pod.getNs(),
|
||||
@@ -69,57 +72,64 @@ export class PodContainerPort extends React.Component<Props> {
|
||||
forwardPort: this.forwardPort,
|
||||
};
|
||||
|
||||
let activePort: number;
|
||||
|
||||
try {
|
||||
activePort = await getPortForward(portForward) ?? 0;
|
||||
portForward = await getPortForward(portForward);
|
||||
} catch (error) {
|
||||
this.isPortForwarded = false;
|
||||
this.isActive = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.forwardPort = activePort;
|
||||
this.isPortForwarded = activePort ? true : false;
|
||||
this.forwardPort = portForward.forwardPort;
|
||||
this.isPortForwarded = true;
|
||||
this.isActive = portForward.status === "Active";
|
||||
}
|
||||
|
||||
@action
|
||||
async portForward() {
|
||||
const { pod, port } = this.props;
|
||||
const portForward: ForwardedPort = {
|
||||
let portForward: ForwardedPort = {
|
||||
kind: "pod",
|
||||
name: pod.getName(),
|
||||
namespace: pod.getNs(),
|
||||
port: port.containerPort,
|
||||
forwardPort: this.forwardPort,
|
||||
protocol: predictProtocol(port.name),
|
||||
status: "Active",
|
||||
};
|
||||
|
||||
this.waiting = true;
|
||||
|
||||
try {
|
||||
// determine how many port-forwards are already active
|
||||
const { length } = await getPortForwards();
|
||||
// determine how many port-forwards already exist
|
||||
const { length } = getPortForwards();
|
||||
|
||||
this.forwardPort = await addPortForward(portForward);
|
||||
if (!this.isPortForwarded) {
|
||||
portForward = await addPortForward(portForward);
|
||||
} else if (!this.isActive) {
|
||||
portForward = await startPortForward(portForward);
|
||||
}
|
||||
|
||||
if (this.forwardPort) {
|
||||
portForward.forwardPort = this.forwardPort;
|
||||
if (portForward.status === "Active") {
|
||||
openPortForward(portForward);
|
||||
this.isPortForwarded = true;
|
||||
|
||||
// if this is the first port-forward show the about notification
|
||||
if (!length) {
|
||||
aboutPortForwarding();
|
||||
}
|
||||
} else {
|
||||
notifyErrorPortForwarding(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`);
|
||||
}
|
||||
} catch (error) {
|
||||
Notifications.error(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`);
|
||||
this.checkExistingPortForwarding();
|
||||
logger.error("[POD-CONTAINER-PORT]:", error, portForward);
|
||||
} finally {
|
||||
this.checkExistingPortForwarding();
|
||||
this.waiting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async stopPortForward() {
|
||||
const { pod, port } = this.props;
|
||||
const portForward: ForwardedPort = {
|
||||
@@ -134,11 +144,11 @@ export class PodContainerPort extends React.Component<Props> {
|
||||
|
||||
try {
|
||||
await removePortForward(portForward);
|
||||
this.isPortForwarded = false;
|
||||
} catch (error) {
|
||||
Notifications.error(`Error occurred stopping the port-forward from port ${portForward.forwardPort}.`);
|
||||
this.checkExistingPortForwarding();
|
||||
} finally {
|
||||
this.checkExistingPortForwarding();
|
||||
this.forwardPort = 0;
|
||||
this.waiting = false;
|
||||
}
|
||||
}
|
||||
@@ -148,7 +158,7 @@ export class PodContainerPort extends React.Component<Props> {
|
||||
const { name, containerPort, protocol } = port;
|
||||
const text = `${name ? `${name}: ` : ""}${containerPort}/${protocol}`;
|
||||
|
||||
const portForwardAction = async () => {
|
||||
const portForwardAction = action(async () => {
|
||||
if (this.isPortForwarded) {
|
||||
await this.stopPortForward();
|
||||
} else {
|
||||
@@ -161,16 +171,16 @@ export class PodContainerPort extends React.Component<Props> {
|
||||
protocol: predictProtocol(port.name),
|
||||
};
|
||||
|
||||
PortForwardDialog.open(portForward, { openInBrowser: true });
|
||||
PortForwardDialog.open(portForward, { openInBrowser: true, onClose: () => this.checkExistingPortForwarding() });
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cssNames("PodContainerPort", { waiting: this.waiting })}>
|
||||
<span title="Open in a browser" onClick={() => this.portForward()}>
|
||||
{text}
|
||||
</span>
|
||||
<Button primary onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop" : "Forward..."} </Button>
|
||||
<Button primary onClick={portForwardAction}> {this.isPortForwarded ? (this.isActive ? "Stop/Remove" : "Remove") : "Forward..."} </Button>
|
||||
{this.waiting && (
|
||||
<Spinner />
|
||||
)}
|
||||
|
||||
@@ -19,40 +19,49 @@
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { computed } from "mobx";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import { computed, IComputedValue } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import { broadcastMessage, catalogEntityRunListener } from "../../../common/ipc";
|
||||
import type { CatalogEntity } from "../../api/catalog-entity";
|
||||
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
|
||||
import { CommandOverlay } from "../command-palette";
|
||||
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
|
||||
import { Select } from "../select";
|
||||
|
||||
@observer
|
||||
export class ActivateEntityCommand extends React.Component {
|
||||
@computed get options() {
|
||||
return catalogEntityRegistry.items.map(entity => ({
|
||||
label: `${entity.kind}: ${entity.getName()}`,
|
||||
value: entity,
|
||||
}));
|
||||
}
|
||||
|
||||
onSelect(entity: CatalogEntity): void {
|
||||
catalogEntityRegistry.onRun(entity);
|
||||
CommandOverlay.close();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Select
|
||||
menuPortalTarget={null}
|
||||
onChange={(v) => this.onSelect(v.value)}
|
||||
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
|
||||
menuIsOpen={true}
|
||||
options={this.options}
|
||||
autoFocus={true}
|
||||
escapeClearsValue={false}
|
||||
placeholder="Activate entity ..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
interface Dependencies {
|
||||
closeCommandOverlay: () => void;
|
||||
entities: IComputedValue<CatalogEntity[]>;
|
||||
}
|
||||
|
||||
const NonInjectedActivateEntityCommand = observer(({ closeCommandOverlay, entities }: Dependencies) => {
|
||||
const options = entities.get().map(entity => ({
|
||||
label: `${entity.kind}: ${entity.getName()}`,
|
||||
value: entity,
|
||||
}));
|
||||
|
||||
const onSelect = (entity: CatalogEntity): void => {
|
||||
broadcastMessage(catalogEntityRunListener, entity.getId());
|
||||
closeCommandOverlay();
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
menuPortalTarget={null}
|
||||
onChange={(v) => onSelect(v.value)}
|
||||
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
|
||||
menuIsOpen={true}
|
||||
options={options}
|
||||
autoFocus={true}
|
||||
escapeClearsValue={false}
|
||||
placeholder="Activate entity ..."
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const ActivateEntityCommand = withInjectables<Dependencies>(NonInjectedActivateEntityCommand, {
|
||||
getProps: di => ({
|
||||
closeCommandOverlay: di.inject(commandOverlayInjectable).close,
|
||||
entities: computed(() => [...catalogEntityRegistry.items]),
|
||||
}),
|
||||
});
|
||||
@@ -91,6 +91,15 @@ html, body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#terminal-init {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 0;
|
||||
visibility: hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
|
||||
@@ -21,21 +21,26 @@
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CommandOverlay } from "../command-palette";
|
||||
import { Input } from "../input";
|
||||
import { isUrl } from "../input/input_validators";
|
||||
import { WeblinkStore } from "../../../common/weblink-store";
|
||||
import { computed, makeObservable, observable } from "mobx";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
|
||||
|
||||
interface Dependencies {
|
||||
closeCommandOverlay: () => void;
|
||||
}
|
||||
|
||||
|
||||
@observer
|
||||
export class WeblinkAddCommand extends React.Component {
|
||||
class NonInjectedWeblinkAddCommand extends React.Component<Dependencies> {
|
||||
@observable url = "";
|
||||
@observable nameHidden = true;
|
||||
@observable dirty = false;
|
||||
|
||||
constructor(props: {}) {
|
||||
constructor(props: Dependencies) {
|
||||
super(props);
|
||||
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
@@ -55,8 +60,7 @@ export class WeblinkAddCommand extends React.Component {
|
||||
name: name || this.url,
|
||||
url: this.url,
|
||||
});
|
||||
|
||||
CommandOverlay.close();
|
||||
this.props.closeCommandOverlay();
|
||||
}
|
||||
|
||||
@computed get showValidation() {
|
||||
@@ -100,3 +104,10 @@ export class WeblinkAddCommand extends React.Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const WeblinkAddCommand = withInjectables<Dependencies>(NonInjectedWeblinkAddCommand, {
|
||||
getProps: (di, props) => ({
|
||||
closeCommandOverlay: di.inject(commandOverlayInjectable).close,
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
@@ -22,19 +22,31 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Icon } from "../icon";
|
||||
import { HotbarStore } from "../../../common/hotbar-store";
|
||||
import { CommandOverlay } from "../command-palette";
|
||||
import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
|
||||
import { HotbarSwitchCommand } from "../hotbar/hotbar-switch-command";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
|
||||
|
||||
export const ActiveHotbarName = observer(() => {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center"
|
||||
data-testid="current-hotbar-name"
|
||||
onClick={() => CommandOverlay.open(<HotbarSwitchCommand />)}
|
||||
>
|
||||
<Icon material="bookmarks" className="mr-2" size={14}/>
|
||||
{HotbarStore.getInstance().getActive()?.name}
|
||||
</div>
|
||||
);
|
||||
interface Dependencies {
|
||||
openCommandOverlay: (component: React.ReactElement) => void;
|
||||
activeHotbarName: () => string | undefined;
|
||||
}
|
||||
|
||||
const NonInjectedActiveHotbarName = observer(({ openCommandOverlay, activeHotbarName }: Dependencies) => (
|
||||
<div
|
||||
className="flex items-center"
|
||||
data-testid="current-hotbar-name"
|
||||
onClick={() => openCommandOverlay(<HotbarSwitchCommand />)}
|
||||
>
|
||||
<Icon material="bookmarks" className="mr-2" size={14} />
|
||||
{activeHotbarName()}
|
||||
</div>
|
||||
));
|
||||
|
||||
export const ActiveHotbarName = withInjectables<Dependencies>(NonInjectedActiveHotbarName, {
|
||||
getProps: (di, props) => ({
|
||||
activeHotbarName: () => di.inject(hotbarManagerInjectable).getActive()?.name,
|
||||
openCommandOverlay: di.inject(commandOverlayInjectable).open,
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
@@ -21,21 +21,19 @@
|
||||
|
||||
import React from "react";
|
||||
import mockFs from "mock-fs";
|
||||
import { render, fireEvent } from "@testing-library/react";
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/extend-expect";
|
||||
import { BottomBar } from "./bottom-bar";
|
||||
import { StatusBarRegistry } from "../../../extensions/registries";
|
||||
import { HotbarStore } from "../../../common/hotbar-store";
|
||||
import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
|
||||
import { AppPaths } from "../../../common/app-paths";
|
||||
import { CommandOverlay } from "../command-palette";
|
||||
import { HotbarSwitchCommand } from "../hotbar/hotbar-switch-command";
|
||||
import { ActiveHotbarName } from "./active-hotbar-name";
|
||||
|
||||
jest.mock("../command-palette", () => ({
|
||||
CommandOverlay: {
|
||||
open: jest.fn(),
|
||||
},
|
||||
}));
|
||||
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
|
||||
import { DiRender, renderFor } from "../test-utils/renderFor";
|
||||
import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable";
|
||||
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
|
||||
import { getEmptyHotbar } from "../../../common/hotbar-types";
|
||||
|
||||
AppPaths.init();
|
||||
|
||||
@@ -55,7 +53,12 @@ jest.mock("electron", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const foobarHotbar = getEmptyHotbar("foobar");
|
||||
|
||||
describe("<BottomBar />", () => {
|
||||
let di: ConfigurableDependencyInjectionContainer;
|
||||
let render: DiRender;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockOpts = {
|
||||
"tmp": {
|
||||
@@ -63,14 +66,19 @@ describe("<BottomBar />", () => {
|
||||
},
|
||||
};
|
||||
|
||||
di = getDiForUnitTesting();
|
||||
render = renderFor(di);
|
||||
|
||||
mockFs(mockOpts);
|
||||
StatusBarRegistry.createInstance();
|
||||
HotbarStore.createInstance();
|
||||
|
||||
di.override(hotbarManagerInjectable, () => ({
|
||||
getActive: () => foobarHotbar,
|
||||
} as any));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
StatusBarRegistry.resetInstance();
|
||||
HotbarStore.resetInstance();
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
@@ -80,24 +88,20 @@ describe("<BottomBar />", () => {
|
||||
expect(container).toBeInstanceOf(HTMLElement);
|
||||
});
|
||||
|
||||
it("renders w/o errors when .getItems() returns unexpected (not type compliant) data", async () => {
|
||||
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => undefined);
|
||||
expect(() => render(<BottomBar />)).not.toThrow();
|
||||
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => "hello");
|
||||
expect(() => render(<BottomBar />)).not.toThrow();
|
||||
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => 6);
|
||||
expect(() => render(<BottomBar />)).not.toThrow();
|
||||
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => null);
|
||||
expect(() => render(<BottomBar />)).not.toThrow();
|
||||
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => []);
|
||||
expect(() => render(<BottomBar />)).not.toThrow();
|
||||
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [{}]);
|
||||
expect(() => render(<BottomBar />)).not.toThrow();
|
||||
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => { return {};});
|
||||
it.each([
|
||||
undefined,
|
||||
"hello",
|
||||
6,
|
||||
null,
|
||||
[],
|
||||
[{}],
|
||||
{},
|
||||
])("renders w/o errors when .getItems() returns not type compliant (%p)", val => {
|
||||
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => val);
|
||||
expect(() => render(<BottomBar />)).not.toThrow();
|
||||
});
|
||||
|
||||
it("renders items [{item: React.ReactNode}] (4.0.0-rc.1)", async () => {
|
||||
it("renders items [{item: React.ReactNode}] (4.0.0-rc.1)", () => {
|
||||
const testId = "testId";
|
||||
const text = "heee";
|
||||
|
||||
@@ -106,10 +110,10 @@ describe("<BottomBar />", () => {
|
||||
]);
|
||||
const { getByTestId } = render(<BottomBar />);
|
||||
|
||||
expect(await getByTestId(testId)).toHaveTextContent(text);
|
||||
expect(getByTestId(testId)).toHaveTextContent(text);
|
||||
});
|
||||
|
||||
it("renders items [{item: () => React.ReactNode}] (4.0.0-rc.1+)", async () => {
|
||||
it("renders items [{item: () => React.ReactNode}] (4.0.0-rc.1+)", () => {
|
||||
const testId = "testId";
|
||||
const text = "heee";
|
||||
|
||||
@@ -118,33 +122,25 @@ describe("<BottomBar />", () => {
|
||||
]);
|
||||
const { getByTestId } = render(<BottomBar />);
|
||||
|
||||
expect(await getByTestId(testId)).toHaveTextContent(text);
|
||||
expect(getByTestId(testId)).toHaveTextContent(text);
|
||||
});
|
||||
|
||||
it("show default hotbar name", () => {
|
||||
it("shows active hotbar name", () => {
|
||||
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [
|
||||
{ item: () => <ActiveHotbarName/> },
|
||||
]);
|
||||
const { getByTestId } = render(<BottomBar />);
|
||||
|
||||
expect(getByTestId("current-hotbar-name")).toHaveTextContent("default");
|
||||
});
|
||||
|
||||
it("show active hotbar name", () => {
|
||||
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [
|
||||
{ item: () => <ActiveHotbarName/> },
|
||||
]);
|
||||
const { getByTestId } = render(<BottomBar />);
|
||||
|
||||
HotbarStore.getInstance().add({
|
||||
id: "new",
|
||||
name: "new",
|
||||
}, { setActive: true });
|
||||
|
||||
expect(getByTestId("current-hotbar-name")).toHaveTextContent("new");
|
||||
expect(getByTestId("current-hotbar-name")).toHaveTextContent("foobar");
|
||||
});
|
||||
|
||||
it("opens command palette on click", () => {
|
||||
const mockOpen = jest.fn();
|
||||
|
||||
di.override(commandOverlayInjectable, () => ({
|
||||
open: mockOpen,
|
||||
}) as any);
|
||||
|
||||
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [
|
||||
{ item: () => <ActiveHotbarName/> },
|
||||
]);
|
||||
@@ -153,7 +149,8 @@ describe("<BottomBar />", () => {
|
||||
|
||||
fireEvent.click(activeHotbar);
|
||||
|
||||
expect(CommandOverlay.open).toHaveBeenCalledWith(<HotbarSwitchCommand />);
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledWith(<HotbarSwitchCommand />);
|
||||
});
|
||||
|
||||
it("sort positioned items properly", () => {
|
||||
|
||||
@@ -38,9 +38,9 @@ import * as routes from "../../../common/routes";
|
||||
import { DeleteClusterDialog } from "../delete-cluster-dialog";
|
||||
import { reaction } from "mobx";
|
||||
import { navigation } from "../../navigation";
|
||||
import { setEntityOnRouteMatch } from "../../../main/catalog-sources/helpers/general-active-sync";
|
||||
import { TopBar } from "../layout/topbar";
|
||||
import { setEntityOnRouteMatch } from "../../api/helpers/general-active-sync";
|
||||
import { catalogURL, getPreviousTabUrl } from "../../../common/routes";
|
||||
import { TopBar } from "../layout/top-bar/top-bar";
|
||||
|
||||
@observer
|
||||
export class ClusterManager extends React.Component {
|
||||
|
||||
@@ -21,68 +21,100 @@
|
||||
|
||||
|
||||
import "./command-container.scss";
|
||||
import { observer } from "mobx-react";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import { Dialog } from "../dialog";
|
||||
import { ipcRendererOn } from "../../../common/ipc";
|
||||
import { CommandDialog } from "./command-dialog";
|
||||
import type { ClusterId } from "../../../common/cluster-types";
|
||||
import commandOverlayInjectable, { CommandOverlay } from "./command-overlay.injectable";
|
||||
import { isMac } from "../../../common/vars";
|
||||
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
|
||||
import { CommandRegistration, CommandRegistry } from "../../../extensions/registries/command-registry";
|
||||
import { CommandOverlay } from "./command-overlay";
|
||||
import { broadcastMessage, ipcRendererOn } from "../../../common/ipc";
|
||||
import { getMatchedClusterId } from "../../navigation";
|
||||
import type { Disposer } from "../../utils";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import windowAddEventListenerInjectable from "../../window/event-listener.injectable";
|
||||
|
||||
export interface CommandContainerProps {
|
||||
clusterId?: ClusterId;
|
||||
}
|
||||
|
||||
interface Dependencies {
|
||||
addWindowEventListener: <K extends keyof WindowEventMap>(type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions) => Disposer;
|
||||
commandOverlay: CommandOverlay,
|
||||
}
|
||||
|
||||
@observer
|
||||
export class CommandContainer extends React.Component<CommandContainerProps> {
|
||||
class NonInjectedCommandContainer extends React.Component<CommandContainerProps & Dependencies> {
|
||||
private escHandler(event: KeyboardEvent) {
|
||||
const { commandOverlay } = this.props;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
CommandOverlay.close();
|
||||
commandOverlay.close();
|
||||
}
|
||||
}
|
||||
|
||||
private findCommandById(commandId: string) {
|
||||
return CommandRegistry.getInstance().getItems().find((command) => command.id === commandId);
|
||||
}
|
||||
handleCommandPalette = () => {
|
||||
const { commandOverlay } = this.props;
|
||||
const clusterIsActive = getMatchedClusterId() !== undefined;
|
||||
|
||||
private runCommand(command: CommandRegistration) {
|
||||
command.action({
|
||||
entity: catalogEntityRegistry.activeEntity,
|
||||
});
|
||||
if (clusterIsActive) {
|
||||
broadcastMessage(`command-palette:${catalogEntityRegistry.activeEntity.getId()}:open`);
|
||||
} else {
|
||||
commandOverlay.open(<CommandDialog />);
|
||||
}
|
||||
};
|
||||
|
||||
onKeyboardShortcut(action: () => void) {
|
||||
return ({ key, shiftKey, ctrlKey, altKey, metaKey }: KeyboardEvent) => {
|
||||
const ctrlOrCmd = isMac ? metaKey && !ctrlKey : !metaKey && ctrlKey;
|
||||
|
||||
if (key === "p" && shiftKey && ctrlOrCmd && !altKey) {
|
||||
action();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.clusterId) {
|
||||
ipcRendererOn(`command-palette:run-action:${this.props.clusterId}`, (event, commandId: string) => {
|
||||
const command = this.findCommandById(commandId);
|
||||
const { clusterId, addWindowEventListener, commandOverlay } = this.props;
|
||||
|
||||
if (command) {
|
||||
this.runCommand(command);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ipcRendererOn("command-palette:open", () => {
|
||||
CommandOverlay.open(<CommandDialog />);
|
||||
});
|
||||
}
|
||||
window.addEventListener("keyup", (e) => this.escHandler(e), true);
|
||||
const action = clusterId
|
||||
? () => commandOverlay.open(<CommandDialog />)
|
||||
: this.handleCommandPalette;
|
||||
const ipcChannel = clusterId
|
||||
? `command-palette:${clusterId}:open`
|
||||
: "command-palette:open";
|
||||
|
||||
disposeOnUnmount(this, [
|
||||
ipcRendererOn(ipcChannel, action),
|
||||
addWindowEventListener("keydown", this.onKeyboardShortcut(action)),
|
||||
addWindowEventListener("keyup", (e) => this.escHandler(e), true),
|
||||
]);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { commandOverlay } = this.props;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={CommandOverlay.isOpen}
|
||||
isOpen={commandOverlay.isOpen}
|
||||
animated={true}
|
||||
onClose={CommandOverlay.close}
|
||||
onClose={commandOverlay.close}
|
||||
modal={false}
|
||||
>
|
||||
<div id="command-container">
|
||||
{CommandOverlay.component}
|
||||
{commandOverlay.component}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const CommandContainer = withInjectables<Dependencies, CommandContainerProps>(NonInjectedCommandContainer, {
|
||||
getProps: (di, props) => ({
|
||||
addWindowEventListener: di.inject(windowAddEventListenerInjectable),
|
||||
commandOverlay: di.inject(commandOverlayInjectable),
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
@@ -21,108 +21,107 @@
|
||||
|
||||
|
||||
import { Select } from "../select";
|
||||
import { computed, makeObservable, observable } from "mobx";
|
||||
import type { IComputedValue } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import { CommandRegistry } from "../../../extensions/registries/command-registry";
|
||||
import { CommandOverlay } from "./command-overlay";
|
||||
import { broadcastMessage } from "../../../common/ipc";
|
||||
import { navigate } from "../../navigation";
|
||||
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
|
||||
import React, { useState } from "react";
|
||||
import commandOverlayInjectable from "./command-overlay.injectable";
|
||||
import type { CatalogEntity } from "../../../common/catalog";
|
||||
import { clusterViewURL } from "../../../common/routes";
|
||||
import { navigate } from "../../navigation";
|
||||
import { broadcastMessage } from "../../../common/ipc";
|
||||
import { IpcRendererNavigationEvents } from "../../navigation/events";
|
||||
import type { RegisteredCommand } from "./registered-commands/commands";
|
||||
import { iter } from "../../utils";
|
||||
import { orderBy } from "lodash";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import registeredCommandsInjectable from "./registered-commands/registered-commands.injectable";
|
||||
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
|
||||
|
||||
@observer
|
||||
export class CommandDialog extends React.Component {
|
||||
@observable menuIsOpen = true;
|
||||
@observable searchValue: any = undefined;
|
||||
interface Dependencies {
|
||||
commands: IComputedValue<Map<string, RegisteredCommand>>;
|
||||
activeEntity?: CatalogEntity;
|
||||
closeCommandOverlay: () => void;
|
||||
}
|
||||
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
}
|
||||
const NonInjectedCommandDialog = observer(({ commands, activeEntity, closeCommandOverlay }: Dependencies) => {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
@computed get activeEntity(): CatalogEntity | undefined {
|
||||
return catalogEntityRegistry.activeEntity;
|
||||
}
|
||||
|
||||
@computed get options() {
|
||||
const registry = CommandRegistry.getInstance();
|
||||
|
||||
const context = {
|
||||
entity: this.activeEntity,
|
||||
};
|
||||
|
||||
return registry.getItems().filter((command) => {
|
||||
if (command.scope === "entity" && !this.activeEntity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return command.isActive?.(context) ?? true;
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
.map((command) => ({
|
||||
value: command.id,
|
||||
label: command.title,
|
||||
}))
|
||||
.sort((a, b) => a.label > b.label ? 1 : -1);
|
||||
}
|
||||
|
||||
private onChange(value: string) {
|
||||
const registry = CommandRegistry.getInstance();
|
||||
const command = registry.getItems().find((cmd) => cmd.id === value);
|
||||
const executeAction = (commandId: string) => {
|
||||
const command = commands.get().get(commandId);
|
||||
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
CommandOverlay.close();
|
||||
closeCommandOverlay();
|
||||
command.action({
|
||||
entity: activeEntity,
|
||||
navigate: (url, opts = {}) => {
|
||||
const { forceRootFrame = false } = opts;
|
||||
|
||||
if (command.scope === "global") {
|
||||
command.action({
|
||||
entity: this.activeEntity,
|
||||
});
|
||||
} else if(this.activeEntity) {
|
||||
navigate(clusterViewURL({
|
||||
params: {
|
||||
clusterId: this.activeEntity.metadata.uid,
|
||||
},
|
||||
}));
|
||||
broadcastMessage(`command-palette:run-action:${this.activeEntity.metadata.uid}`, command.id);
|
||||
}
|
||||
} catch(error) {
|
||||
if (forceRootFrame) {
|
||||
broadcastMessage(IpcRendererNavigationEvents.NAVIGATE_IN_APP, url);
|
||||
} else {
|
||||
navigate(url);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[COMMAND-DIALOG] failed to execute command", command.id, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Select
|
||||
menuPortalTarget={null}
|
||||
onChange={v => this.onChange(v.value)}
|
||||
components={{
|
||||
DropdownIndicator: null,
|
||||
IndicatorSeparator: null,
|
||||
}}
|
||||
menuIsOpen={this.menuIsOpen}
|
||||
options={this.options}
|
||||
autoFocus={true}
|
||||
escapeClearsValue={false}
|
||||
data-test-id="command-palette-search"
|
||||
placeholder="Type a command or search…"
|
||||
onInputChange={(newValue, { action }) => {
|
||||
if (action === "input-change") {
|
||||
this.searchValue = newValue;
|
||||
}
|
||||
}}
|
||||
inputValue={this.searchValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
const context = {
|
||||
entity: activeEntity,
|
||||
};
|
||||
const activeCommands = iter.filter(commands.get().values(), command => {
|
||||
try {
|
||||
return command.isActive(context);
|
||||
} catch (error) {
|
||||
console.error(`[COMMAND-DIALOG]: isActive for ${command.id} threw an error, defaulting to false`, error);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
const options = Array.from(activeCommands, ({ id, title }) => ({
|
||||
value: id,
|
||||
label: typeof title === "function"
|
||||
? title(context)
|
||||
: title,
|
||||
}));
|
||||
|
||||
// Make sure the options are in the correct order
|
||||
orderBy(options, "label", "asc");
|
||||
|
||||
return (
|
||||
<Select
|
||||
menuPortalTarget={null}
|
||||
onChange={v => executeAction(v.value)}
|
||||
components={{
|
||||
DropdownIndicator: null,
|
||||
IndicatorSeparator: null,
|
||||
}}
|
||||
menuIsOpen
|
||||
options={options}
|
||||
autoFocus={true}
|
||||
escapeClearsValue={false}
|
||||
data-test-id="command-palette-search"
|
||||
placeholder="Type a command or search…"
|
||||
onInputChange={(newValue, { action }) => {
|
||||
if (action === "input-change") {
|
||||
setSearchValue(newValue);
|
||||
}
|
||||
}}
|
||||
inputValue={searchValue}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const CommandDialog = withInjectables<Dependencies>(NonInjectedCommandDialog, {
|
||||
getProps: di => ({
|
||||
commands: di.inject(registeredCommandsInjectable),
|
||||
// TODO: replace with injection
|
||||
activeEntity: catalogEntityRegistry.activeEntity,
|
||||
closeCommandOverlay: di.inject(commandOverlayInjectable).close,
|
||||
}),
|
||||
});
|
||||
+19
-11
@@ -19,29 +19,37 @@
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
|
||||
import { observable } from "mobx";
|
||||
import React from "react";
|
||||
|
||||
export class CommandOverlay {
|
||||
static #component = observable.box<React.ReactElement | null>(null, { deep: false });
|
||||
#component = observable.box<React.ReactElement | null>(null, { deep: false });
|
||||
|
||||
static get isOpen(): boolean {
|
||||
return Boolean(CommandOverlay.#component.get());
|
||||
get isOpen(): boolean {
|
||||
return Boolean(this.#component.get());
|
||||
}
|
||||
|
||||
static open(component: React.ReactElement) {
|
||||
open = (component: React.ReactElement) => {
|
||||
if (!React.isValidElement(component)) {
|
||||
throw new TypeError("CommandOverlay.open must be passed a valid ReactElement");
|
||||
}
|
||||
|
||||
CommandOverlay.#component.set(component);
|
||||
}
|
||||
this.#component.set(component);
|
||||
};
|
||||
|
||||
static close() {
|
||||
CommandOverlay.#component.set(null);
|
||||
}
|
||||
close = () => {
|
||||
this.#component.set(null);
|
||||
};
|
||||
|
||||
static get component(): React.ReactElement | null {
|
||||
return CommandOverlay.#component.get();
|
||||
get component(): React.ReactElement | null {
|
||||
return this.#component.get();
|
||||
}
|
||||
}
|
||||
|
||||
const commandOverlayInjectable = getInjectable({
|
||||
instantiate: () => new CommandOverlay(),
|
||||
lifecycle: lifecycleEnum.singleton,
|
||||
});
|
||||
|
||||
export default commandOverlayInjectable;
|
||||
@@ -21,4 +21,4 @@
|
||||
|
||||
export * from "./command-container";
|
||||
export * from "./command-dialog";
|
||||
export * from "./command-overlay";
|
||||
export * from "./command-overlay.injectable";
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import type { CatalogEntity } from "../../../../common/catalog";
|
||||
|
||||
/**
|
||||
* The context given to commands when executed
|
||||
*/
|
||||
export interface CommandContext {
|
||||
entity?: CatalogEntity;
|
||||
}
|
||||
|
||||
export interface CommandActionNavigateOptions {
|
||||
/**
|
||||
* If `true` then the navigate will only navigate on the root frame and not
|
||||
* within a cluster
|
||||
* @default false
|
||||
*/
|
||||
forceRootFrame?: boolean;
|
||||
}
|
||||
|
||||
export interface CommandActionContext extends CommandContext {
|
||||
navigate: (url: string, opts?: CommandActionNavigateOptions) => void;
|
||||
}
|
||||
|
||||
export interface CommandRegistration {
|
||||
/**
|
||||
* The ID of the command, must be globally unique
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The display name of the command in the command pallet
|
||||
*/
|
||||
title: string | ((context: CommandContext) => string);
|
||||
|
||||
/**
|
||||
* @deprecated use `isActive` instead since there is always an entity active
|
||||
*/
|
||||
scope?: "global" | "entity";
|
||||
|
||||
/**
|
||||
* The function to run when this command is selected
|
||||
*/
|
||||
action: (context: CommandActionContext) => void;
|
||||
|
||||
/**
|
||||
* A function that determines if the command is active.
|
||||
*
|
||||
* @default () => true
|
||||
*/
|
||||
isActive?: (context: CommandContext) => boolean;
|
||||
}
|
||||
|
||||
export type RegisteredCommand = Required<Omit<CommandRegistration, "scope">>;
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import * as routes from "../../../../common/routes";
|
||||
import { EntitySettingRegistry, RegisteredEntitySetting } from "../../../../extensions/registries";
|
||||
import { createTerminalTab } from "../../dock/terminal.store";
|
||||
import { HotbarAddCommand } from "../../hotbar/hotbar-add-command";
|
||||
import { HotbarRemoveCommand } from "../../hotbar/hotbar-remove-command";
|
||||
import { HotbarSwitchCommand } from "../../hotbar/hotbar-switch-command";
|
||||
import { HotbarRenameCommand } from "../../hotbar/hotbar-rename-command";
|
||||
import { ActivateEntityCommand } from "../../activate-entity-command";
|
||||
import type { CommandContext, CommandRegistration } from "./commands";
|
||||
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
|
||||
import commandOverlayInjectable from "../command-overlay.injectable";
|
||||
|
||||
export function isKubernetesClusterActive(context: CommandContext): boolean {
|
||||
return context.entity?.kind === "KubernetesCluster";
|
||||
}
|
||||
|
||||
interface Dependencies {
|
||||
openCommandDialog: (component: React.ReactElement) => void;
|
||||
getEntitySettingItems: (kind: string, apiVersion: string, source?: string) => RegisteredEntitySetting[];
|
||||
}
|
||||
|
||||
function getInternalCommands({ openCommandDialog, getEntitySettingItems }: Dependencies): CommandRegistration[] {
|
||||
return [
|
||||
{
|
||||
id: "app.showPreferences",
|
||||
title: "Preferences: Open",
|
||||
action: ({ navigate }) => navigate(routes.preferencesURL(), {
|
||||
forceRootFrame: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewHelmCharts",
|
||||
title: "Cluster: View Helm Charts",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.helmChartsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewHelmReleases",
|
||||
title: "Cluster: View Helm Releases",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.releaseURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewConfigMaps",
|
||||
title: "Cluster: View ConfigMaps",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.configMapsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewSecrets",
|
||||
title: "Cluster: View Secrets",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.secretsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewResourceQuotas",
|
||||
title: "Cluster: View ResourceQuotas",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.resourceQuotaURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewLimitRanges",
|
||||
title: "Cluster: View LimitRanges",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.limitRangeURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewHorizontalPodAutoscalers",
|
||||
title: "Cluster: View HorizontalPodAutoscalers (HPA)",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.hpaURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewPodDisruptionBudget",
|
||||
title: "Cluster: View PodDisruptionBudgets",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.pdbURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewServices",
|
||||
title: "Cluster: View Services",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.servicesURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewEndpoints",
|
||||
title: "Cluster: View Endpoints",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.endpointURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewIngresses",
|
||||
title: "Cluster: View Ingresses",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.ingressURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewNetworkPolicies",
|
||||
title: "Cluster: View NetworkPolicies",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.networkPoliciesURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewNodes",
|
||||
title: "Cluster: View Nodes",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.nodesURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewPods",
|
||||
title: "Cluster: View Pods",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.podsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewDeployments",
|
||||
title: "Cluster: View Deployments",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.deploymentsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewDaemonSets",
|
||||
title: "Cluster: View DaemonSets",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.daemonSetsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewStatefulSets",
|
||||
title: "Cluster: View StatefulSets",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.statefulSetsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewJobs",
|
||||
title: "Cluster: View Jobs",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.jobsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewCronJobs",
|
||||
title: "Cluster: View CronJobs",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.cronJobsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewCustomResourceDefinitions",
|
||||
title: "Cluster: View Custom Resource Definitions",
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(routes.crdURL()),
|
||||
},
|
||||
{
|
||||
id: "entity.viewSettings",
|
||||
title: ({ entity }) => `${entity.kind}/${entity.getName()}: View Settings`,
|
||||
action: ({ entity, navigate }) => navigate(`/entity/${entity.getId()}/settings`, {
|
||||
forceRootFrame: true,
|
||||
}),
|
||||
isActive: ({ entity }) => {
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getEntitySettingItems(entity.kind, entity.apiVersion, entity.metadata.source).length > 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cluster.openTerminal",
|
||||
title: "Cluster: Open terminal",
|
||||
action: () => createTerminalTab(),
|
||||
isActive: isKubernetesClusterActive,
|
||||
},
|
||||
{
|
||||
id: "hotbar.switchHotbar",
|
||||
title: "Hotbar: Switch ...",
|
||||
action: () => openCommandDialog(<HotbarSwitchCommand />),
|
||||
},
|
||||
{
|
||||
id: "hotbar.addHotbar",
|
||||
title: "Hotbar: Add Hotbar ...",
|
||||
action: () => openCommandDialog(<HotbarAddCommand />),
|
||||
},
|
||||
{
|
||||
id: "hotbar.removeHotbar",
|
||||
title: "Hotbar: Remove Hotbar ...",
|
||||
action: () => openCommandDialog(<HotbarRemoveCommand />),
|
||||
},
|
||||
{
|
||||
id: "hotbar.renameHotbar",
|
||||
title: "Hotbar: Rename Hotbar ...",
|
||||
action: () => openCommandDialog(<HotbarRenameCommand />),
|
||||
},
|
||||
{
|
||||
id: "catalog.searchEntities",
|
||||
title: "Catalog: Activate Entity ...",
|
||||
action: () => openCommandDialog(<ActivateEntityCommand />),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const internalCommandsInjectable = getInjectable({
|
||||
instantiate: (di) => getInternalCommands({
|
||||
openCommandDialog: di.inject(commandOverlayInjectable).open,
|
||||
getEntitySettingItems: EntitySettingRegistry
|
||||
.getInstance()
|
||||
.getItemsForKind,
|
||||
}),
|
||||
lifecycle: lifecycleEnum.singleton,
|
||||
});
|
||||
|
||||
export default internalCommandsInjectable;
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
|
||||
import { computed, IComputedValue } from "mobx";
|
||||
import type { CustomResourceDefinition } from "../../../../common/k8s-api/endpoints";
|
||||
import customResourceDefinitionsInjectable from "../../+custom-resources/custom-resources.injectable";
|
||||
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
|
||||
import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable";
|
||||
import type { CommandRegistration, RegisteredCommand } from "./commands";
|
||||
import internalCommandsInjectable, { isKubernetesClusterActive } from "./internal-commands.injectable";
|
||||
|
||||
interface Dependencies {
|
||||
extensions: IComputedValue<LensRendererExtension[]>;
|
||||
customResourceDefinitions: IComputedValue<CustomResourceDefinition[]>;
|
||||
internalCommands: CommandRegistration[];
|
||||
}
|
||||
|
||||
const instantiateRegisteredCommands = ({ extensions, customResourceDefinitions, internalCommands }: Dependencies) => computed(() => {
|
||||
const result = new Map<string, RegisteredCommand>();
|
||||
const commands = [
|
||||
...internalCommands,
|
||||
...extensions.get().flatMap(e => e.commands),
|
||||
...customResourceDefinitions.get().map((command): CommandRegistration => ({
|
||||
id: `cluster.view.${command.getResourceKind()}`,
|
||||
title: `Cluster: View ${command.getResourceKind()}`,
|
||||
isActive: isKubernetesClusterActive,
|
||||
action: ({ navigate }) => navigate(command.getResourceUrl()),
|
||||
})),
|
||||
];
|
||||
|
||||
for (const { scope, isActive = () => true, ...command } of commands) {
|
||||
if (!result.has(command.id)) {
|
||||
result.set(command.id, { ...command, isActive });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const registeredCommandsInjectable = getInjectable({
|
||||
instantiate: (di) => instantiateRegisteredCommands({
|
||||
extensions: di.inject(rendererExtensionsInjectable),
|
||||
customResourceDefinitions: di.inject(customResourceDefinitionsInjectable),
|
||||
internalCommands: di.inject(internalCommandsInjectable),
|
||||
}),
|
||||
|
||||
lifecycle: lifecycleEnum.singleton,
|
||||
});
|
||||
|
||||
export default registeredCommandsInjectable;
|
||||
+1
-1
@@ -23,7 +23,7 @@ import { KubeConfig } from "@kubernetes/client-node";
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import mockFs from "mock-fs";
|
||||
import React from "react";
|
||||
import selectEvent from "react-select-event";
|
||||
import * as selectEvent from "react-select-event";
|
||||
|
||||
import { Cluster } from "../../../../main/cluster";
|
||||
import { DeleteClusterDialog } from "../delete-cluster-dialog";
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom/extend-expect";
|
||||
import { render } from "@testing-library/react";
|
||||
import selectEvent from "react-select-event";
|
||||
import * as selectEvent from "react-select-event";
|
||||
|
||||
import { Pod } from "../../../../common/k8s-api/endpoints";
|
||||
import { LogResourceSelector } from "../log-resource-selector";
|
||||
|
||||
@@ -34,17 +34,9 @@ import { clipboard } from "electron";
|
||||
import logger from "../../../common/logger";
|
||||
|
||||
export class Terminal {
|
||||
public static readonly spawningPool = (() => {
|
||||
// terminal element must be in DOM before attaching via xterm.open(elem)
|
||||
// https://xtermjs.org/docs/api/terminal/classes/terminal/#open
|
||||
const pool = document.createElement("div");
|
||||
|
||||
pool.className = "terminal-init";
|
||||
pool.style.cssText = "position: absolute; top: 0; left: 0; height: 0; visibility: hidden; overflow: hidden";
|
||||
document.body.appendChild(pool);
|
||||
|
||||
return pool;
|
||||
})();
|
||||
public static get spawningPool() {
|
||||
return document.getElementById("terminal-init");
|
||||
}
|
||||
|
||||
static async preloadFonts() {
|
||||
const fontPath = require("../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires
|
||||
|
||||
@@ -133,7 +133,8 @@ export class FilePicker extends React.Component<Props> {
|
||||
switch (onOverSizeLimit) {
|
||||
case OverSizeLimitStyle.FILTER:
|
||||
return files.filter(file => file.size <= maxSize );
|
||||
case OverSizeLimitStyle.REJECT:
|
||||
|
||||
case OverSizeLimitStyle.REJECT: {
|
||||
const firstFileToLarge = files.find(file => file.size > maxSize);
|
||||
|
||||
if (firstFileToLarge) {
|
||||
@@ -141,6 +142,7 @@ export class FilePicker extends React.Component<Props> {
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +158,9 @@ export class FilePicker extends React.Component<Props> {
|
||||
switch (onOverTotalSizeLimit) {
|
||||
case OverTotalSizeLimitStyle.FILTER_LARGEST:
|
||||
files = _.orderBy(files, ["size"]);
|
||||
case OverTotalSizeLimitStyle.FILTER_LAST:
|
||||
|
||||
// fallthrough
|
||||
case OverTotalSizeLimitStyle.FILTER_LAST: {
|
||||
let newTotalSize = totalSize;
|
||||
|
||||
for (;files.length > 0;) {
|
||||
@@ -168,6 +172,7 @@ export class FilePicker extends React.Component<Props> {
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
case OverTotalSizeLimitStyle.REJECT:
|
||||
throw `Total file size to upload is too large. Expected at most ${maxTotalSize}. Found ${totalSize}.`;
|
||||
}
|
||||
|
||||
@@ -21,13 +21,17 @@
|
||||
|
||||
import "@testing-library/jest-dom/extend-expect";
|
||||
import { HotbarRemoveCommand } from "../hotbar-remove-command";
|
||||
import { render, fireEvent } from "@testing-library/react";
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { ThemeStore } from "../../../theme.store";
|
||||
import { UserStore } from "../../../../common/user-store";
|
||||
import { Notifications } from "../../notifications";
|
||||
import mockFs from "mock-fs";
|
||||
import { AppPaths } from "../../../../common/app-paths";
|
||||
import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable";
|
||||
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
|
||||
import { type DiRender, renderFor } from "../../test-utils/renderFor";
|
||||
import hotbarManagerInjectable from "../../../../common/hotbar-store.injectable";
|
||||
import { UserStore } from "../../../../common/user-store";
|
||||
import { ThemeStore } from "../../../theme.store";
|
||||
import { ConfirmDialog } from "../../confirm-dialog";
|
||||
import type { HotbarStore } from "../../../../common/hotbar-store";
|
||||
|
||||
jest.mock("electron", () => ({
|
||||
app: {
|
||||
@@ -55,45 +59,58 @@ const mockHotbars: { [id: string]: any } = {
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock("../../../../common/hotbar-store", () => ({
|
||||
HotbarStore: {
|
||||
getInstance: () => ({
|
||||
hotbars: [mockHotbars["1"]],
|
||||
getById: (id: string) => mockHotbars[id],
|
||||
remove: () => {},
|
||||
hotbarIndex: () => 0,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("<HotbarRemoveCommand />", () => {
|
||||
let di: ConfigurableDependencyInjectionContainer;
|
||||
let render: DiRender;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
"tmp": {},
|
||||
});
|
||||
di = getDiForUnitTesting();
|
||||
render = renderFor(di);
|
||||
|
||||
UserStore.createInstance();
|
||||
ThemeStore.createInstance();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
UserStore.resetInstance();
|
||||
ThemeStore.resetInstance();
|
||||
mockFs.restore();
|
||||
UserStore.resetInstance();
|
||||
});
|
||||
|
||||
it("renders w/o errors", () => {
|
||||
di.override(hotbarManagerInjectable, () => ({
|
||||
hotbars: [mockHotbars["1"]],
|
||||
getById: (id: string) => mockHotbars[id],
|
||||
remove: () => { },
|
||||
hotbarIndex: () => 0,
|
||||
getDisplayLabel: () => "1: Default",
|
||||
}) as any as HotbarStore);
|
||||
|
||||
const { container } = render(<HotbarRemoveCommand/>);
|
||||
|
||||
expect(container).toBeInstanceOf(HTMLElement);
|
||||
});
|
||||
|
||||
it("displays error notification if user tries to remove last hotbar", () => {
|
||||
const spy = jest.spyOn(Notifications, "error");
|
||||
const { getByText } = render(<HotbarRemoveCommand/>);
|
||||
it("calls remove if you click on the entry", () => {
|
||||
const removeMock = jest.fn();
|
||||
|
||||
di.override(hotbarManagerInjectable, () => ({
|
||||
hotbars: [mockHotbars["1"]],
|
||||
getById: (id: string) => mockHotbars[id],
|
||||
remove: removeMock,
|
||||
hotbarIndex: () => 0,
|
||||
getDisplayLabel: () => "1: Default",
|
||||
}) as any as HotbarStore);
|
||||
|
||||
const { getByText } = render(
|
||||
<>
|
||||
<HotbarRemoveCommand />
|
||||
<ConfirmDialog />
|
||||
</>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByText("1: Default"));
|
||||
fireEvent.click(getByText("Remove Hotbar"));
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
expect(removeMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -21,44 +21,53 @@
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { HotbarStore } from "../../../common/hotbar-store";
|
||||
import { CommandOverlay } from "../command-palette";
|
||||
import { Input, InputValidator } from "../input";
|
||||
import type { CreateHotbarData, CreateHotbarOptions } from "../../../common/hotbar-types";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
|
||||
import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
|
||||
import uniqueHotbarNameInjectable from "../input/validators/unique-hotbar-name.injectable";
|
||||
|
||||
export const uniqueHotbarName: InputValidator = {
|
||||
condition: ({ required }) => required,
|
||||
message: () => "Hotbar with this name already exists",
|
||||
validate: value => !HotbarStore.getInstance().getByName(value),
|
||||
};
|
||||
interface Dependencies {
|
||||
closeCommandOverlay: () => void;
|
||||
addHotbar: (data: CreateHotbarData, { setActive }?: CreateHotbarOptions) => void;
|
||||
uniqueHotbarName: InputValidator;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class HotbarAddCommand extends React.Component {
|
||||
onSubmit = (name: string) => {
|
||||
const NonInjectedHotbarAddCommand = observer(({ closeCommandOverlay, addHotbar, uniqueHotbarName }: Dependencies) => {
|
||||
const onSubmit = (name: string) => {
|
||||
if (!name.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
HotbarStore.getInstance().add({ name }, { setActive: true });
|
||||
CommandOverlay.close();
|
||||
addHotbar({ name }, { setActive: true });
|
||||
closeCommandOverlay();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Hotbar name"
|
||||
autoFocus={true}
|
||||
theme="round-black"
|
||||
data-test-id="command-palette-hotbar-add-name"
|
||||
validators={uniqueHotbarName}
|
||||
onSubmit={this.onSubmit}
|
||||
dirty={true}
|
||||
showValidationLine={true}
|
||||
/>
|
||||
<small className="hint">
|
||||
Please provide a new hotbar name (Press "Enter" to confirm or "Escape" to cancel)
|
||||
</small>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Hotbar name"
|
||||
autoFocus={true}
|
||||
theme="round-black"
|
||||
data-test-id="command-palette-hotbar-add-name"
|
||||
validators={uniqueHotbarName}
|
||||
onSubmit={onSubmit}
|
||||
dirty={true}
|
||||
showValidationLine={true}
|
||||
/>
|
||||
<small className="hint">
|
||||
Please provide a new hotbar name (Press "Enter" to confirm or "Escape" to cancel)
|
||||
</small>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const HotbarAddCommand = withInjectables<Dependencies>(NonInjectedHotbarAddCommand, {
|
||||
getProps: (di, props) => ({
|
||||
closeCommandOverlay: di.inject(commandOverlayInjectable).close,
|
||||
addHotbar: di.inject(hotbarManagerInjectable).add,
|
||||
uniqueHotbarName: di.inject(uniqueHotbarNameInjectable),
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
@@ -22,51 +22,44 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Select } from "../select";
|
||||
import { computed, makeObservable } from "mobx";
|
||||
import { HotbarStore } from "../../../common/hotbar-store";
|
||||
import { hotbarDisplayLabel } from "./hotbar-display-label";
|
||||
import { CommandOverlay } from "../command-palette";
|
||||
import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
|
||||
import { ConfirmDialog } from "../confirm-dialog";
|
||||
import { Notifications } from "../notifications";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
|
||||
import type { Hotbar } from "../../../common/hotbar-types";
|
||||
|
||||
@observer
|
||||
export class HotbarRemoveCommand extends React.Component {
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
}
|
||||
interface Dependencies {
|
||||
closeCommandOverlay: () => void;
|
||||
hotbarManager: {
|
||||
hotbars: Hotbar[];
|
||||
getById: (id: string) => Hotbar | undefined;
|
||||
remove: (hotbar: Hotbar) => void;
|
||||
getDisplayLabel: (hotbar: Hotbar) => string;
|
||||
};
|
||||
}
|
||||
|
||||
@computed get options() {
|
||||
return HotbarStore.getInstance().hotbars.map((hotbar) => {
|
||||
return { value: hotbar.id, label: hotbarDisplayLabel(hotbar.id) };
|
||||
});
|
||||
}
|
||||
const NonInjectedHotbarRemoveCommand = observer(({ closeCommandOverlay, hotbarManager }: Dependencies) => {
|
||||
const options = hotbarManager.hotbars.map(hotbar => ({
|
||||
value: hotbar.id,
|
||||
label: hotbarManager.getDisplayLabel(hotbar),
|
||||
}));
|
||||
|
||||
onChange(id: string): void {
|
||||
const hotbarStore = HotbarStore.getInstance();
|
||||
const hotbar = hotbarStore.getById(id);
|
||||
|
||||
CommandOverlay.close();
|
||||
const onChange = (id: string): void => {
|
||||
const hotbar = hotbarManager.getById(id);
|
||||
|
||||
if (!hotbar) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hotbarStore.hotbars.length === 1) {
|
||||
Notifications.error("Can't remove the last hotbar");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
closeCommandOverlay();
|
||||
// TODO: make confirm dialog injectable
|
||||
ConfirmDialog.open({
|
||||
okButtonProps: {
|
||||
label: `Remove Hotbar`,
|
||||
label: "Remove Hotbar",
|
||||
primary: false,
|
||||
accent: true,
|
||||
},
|
||||
ok: () => {
|
||||
hotbarStore.remove(hotbar);
|
||||
},
|
||||
ok: () => hotbarManager.remove(hotbar),
|
||||
message: (
|
||||
<div className="confirm flex column gaps">
|
||||
<p>
|
||||
@@ -75,19 +68,26 @@ export class HotbarRemoveCommand extends React.Component {
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Select
|
||||
menuPortalTarget={null}
|
||||
onChange={(v) => this.onChange(v.value)}
|
||||
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
|
||||
menuIsOpen={true}
|
||||
options={this.options}
|
||||
autoFocus={true}
|
||||
escapeClearsValue={false}
|
||||
placeholder="Remove hotbar" />
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Select
|
||||
menuPortalTarget={null}
|
||||
onChange={(v) => onChange(v.value)}
|
||||
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
|
||||
menuIsOpen={true}
|
||||
options={options}
|
||||
autoFocus={true}
|
||||
escapeClearsValue={false}
|
||||
placeholder="Remove hotbar"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const HotbarRemoveCommand = withInjectables<Dependencies>(NonInjectedHotbarRemoveCommand, {
|
||||
getProps: (di, props) => ({
|
||||
closeCommandOverlay: di.inject(commandOverlayInjectable).close,
|
||||
hotbarManager: di.inject(hotbarManagerInjectable),
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
@@ -19,81 +19,61 @@
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Select } from "../select";
|
||||
import { action, computed, makeObservable, observable } from "mobx";
|
||||
import { HotbarStore } from "../../../common/hotbar-store";
|
||||
import { hotbarDisplayLabel } from "./hotbar-display-label";
|
||||
import { Input } from "../input";
|
||||
import { uniqueHotbarName } from "./hotbar-add-command";
|
||||
import { CommandOverlay } from "../command-palette";
|
||||
import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
|
||||
import { Input, InputValidator } from "../input";
|
||||
import type { Hotbar } from "../../../common/hotbar-types";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
|
||||
import uniqueHotbarNameInjectable from "../input/validators/unique-hotbar-name.injectable";
|
||||
|
||||
@observer
|
||||
export class HotbarRenameCommand extends React.Component {
|
||||
@observable hotbarId = "";
|
||||
@observable hotbarName = "";
|
||||
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
@computed get options() {
|
||||
return HotbarStore.getInstance().hotbars.map((hotbar) => {
|
||||
return { value: hotbar.id, label: hotbarDisplayLabel(hotbar.id) };
|
||||
});
|
||||
}
|
||||
|
||||
@action onSelect = (id: string) => {
|
||||
this.hotbarId = id;
|
||||
this.hotbarName = HotbarStore.getInstance().getById(this.hotbarId).name;
|
||||
interface Dependencies {
|
||||
closeCommandOverlay: () => void;
|
||||
hotbarManager: {
|
||||
hotbars: Hotbar[];
|
||||
getById: (id: string) => Hotbar | undefined;
|
||||
setHotbarName: (id: string, name: string) => void;
|
||||
getDisplayLabel: (hotbar: Hotbar) => string;
|
||||
};
|
||||
uniqueHotbarName: InputValidator;
|
||||
}
|
||||
|
||||
onSubmit = (name: string) => {
|
||||
const NonInjectedHotbarRenameCommand = observer(({ closeCommandOverlay, hotbarManager, uniqueHotbarName }: Dependencies) => {
|
||||
const [hotbarId, setHotbarId] = useState("");
|
||||
const [hotbarName, setHotbarName] = useState("");
|
||||
|
||||
const options = hotbarManager.hotbars.map(hotbar => ({
|
||||
value: hotbar.id,
|
||||
label: hotbarManager.getDisplayLabel(hotbar),
|
||||
}));
|
||||
|
||||
const onSelect = (id: string) => {
|
||||
setHotbarId(id);
|
||||
setHotbarName(hotbarManager.getById(id).name);
|
||||
};
|
||||
const onSubmit = (name: string) => {
|
||||
if (!name.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hotbarStore = HotbarStore.getInstance();
|
||||
const hotbar = HotbarStore.getInstance().getById(this.hotbarId);
|
||||
|
||||
if (!hotbar) {
|
||||
return;
|
||||
}
|
||||
|
||||
hotbarStore.setHotbarName(this.hotbarId, name);
|
||||
CommandOverlay.close();
|
||||
hotbarManager.setHotbarName(hotbarId, name);
|
||||
closeCommandOverlay();
|
||||
};
|
||||
|
||||
renderHotbarList() {
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
menuPortalTarget={null}
|
||||
onChange={(v) => this.onSelect(v.value)}
|
||||
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
|
||||
menuIsOpen={true}
|
||||
options={this.options}
|
||||
autoFocus={true}
|
||||
escapeClearsValue={false}
|
||||
placeholder="Rename hotbar"/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderNameInput() {
|
||||
if (hotbarId) {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
trim={true}
|
||||
value={this.hotbarName}
|
||||
onChange={v => this.hotbarName = v}
|
||||
value={hotbarName}
|
||||
onChange={setHotbarName}
|
||||
placeholder="New hotbar name"
|
||||
autoFocus={true}
|
||||
theme="round-black"
|
||||
validators={uniqueHotbarName}
|
||||
onSubmit={this.onSubmit}
|
||||
onSubmit={onSubmit}
|
||||
showValidationLine={true}
|
||||
/>
|
||||
<small className="hint">
|
||||
@@ -103,12 +83,25 @@ export class HotbarRenameCommand extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Select
|
||||
menuPortalTarget={null}
|
||||
onChange={(v) => onSelect(v.value)}
|
||||
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
|
||||
menuIsOpen={true}
|
||||
options={options}
|
||||
autoFocus={true}
|
||||
escapeClearsValue={false}
|
||||
placeholder="Rename hotbar"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{!this.hotbarId ? this.renderHotbarList() : this.renderNameInput()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
export const HotbarRenameCommand = withInjectables<Dependencies>(NonInjectedHotbarRenameCommand, {
|
||||
getProps: (di, props) => ({
|
||||
closeCommandOverlay: di.inject(commandOverlayInjectable).close,
|
||||
hotbarManager: di.inject(hotbarManagerInjectable),
|
||||
uniqueHotbarName: di.inject(uniqueHotbarNameInjectable),
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
@@ -23,38 +23,52 @@ import "./hotbar-selector.scss";
|
||||
import React from "react";
|
||||
import { Icon } from "../icon";
|
||||
import { Badge } from "../badge";
|
||||
import { HotbarStore } from "../../../common/hotbar-store";
|
||||
import { CommandOverlay } from "../command-palette";
|
||||
import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
|
||||
import { HotbarSwitchCommand } from "./hotbar-switch-command";
|
||||
import { hotbarDisplayIndex } from "./hotbar-display-label";
|
||||
import { TooltipPosition } from "../tooltip";
|
||||
import { observer } from "mobx-react";
|
||||
import type { Hotbar } from "../../../common/hotbar-types";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
|
||||
|
||||
interface Props {
|
||||
export interface HotbarSelectorProps {
|
||||
hotbar: Hotbar;
|
||||
}
|
||||
|
||||
export const HotbarSelector = observer(({ hotbar }: Props) => {
|
||||
const store = HotbarStore.getInstance();
|
||||
interface Dependencies {
|
||||
hotbarManager: {
|
||||
switchToPrevious: () => void;
|
||||
switchToNext: () => void;
|
||||
getActive: () => Hotbar;
|
||||
getDisplayIndex: (hotbar: Hotbar) => string;
|
||||
};
|
||||
openCommandOverlay: (component: React.ReactElement) => void;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="HotbarSelector flex align-center">
|
||||
<Icon material="play_arrow" className="previous box" onClick={() => store.switchToPrevious()} />
|
||||
<div className="box grow flex align-center">
|
||||
<Badge
|
||||
id="hotbarIndex"
|
||||
small
|
||||
label={hotbarDisplayIndex(store.activeHotbarId)}
|
||||
onClick={() => CommandOverlay.open(<HotbarSwitchCommand />)}
|
||||
tooltip={{
|
||||
preferredPositions: [TooltipPosition.TOP, TooltipPosition.TOP_LEFT],
|
||||
children: hotbar.name,
|
||||
}}
|
||||
className="SelectorIndex"
|
||||
/>
|
||||
</div>
|
||||
<Icon material="play_arrow" className="next box" onClick={() => store.switchToNext()} />
|
||||
const NonInjectedHotbarSelector = observer(({ hotbar, hotbarManager, openCommandOverlay }: HotbarSelectorProps & Dependencies) => (
|
||||
<div className="HotbarSelector flex align-center">
|
||||
<Icon material="play_arrow" className="previous box" onClick={() => hotbarManager.switchToPrevious()} />
|
||||
<div className="box grow flex align-center">
|
||||
<Badge
|
||||
id="hotbarIndex"
|
||||
small
|
||||
label={hotbarManager.getDisplayIndex(hotbarManager.getActive())}
|
||||
onClick={() => openCommandOverlay(<HotbarSwitchCommand />)}
|
||||
tooltip={{
|
||||
preferredPositions: [TooltipPosition.TOP, TooltipPosition.TOP_LEFT],
|
||||
children: hotbar.name,
|
||||
}}
|
||||
className="SelectorIndex"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
<Icon material="play_arrow" className="next box" onClick={() => hotbarManager.switchToNext()} />
|
||||
</div>
|
||||
));
|
||||
|
||||
export const HotbarSelector = withInjectables<Dependencies, HotbarSelectorProps>(NonInjectedHotbarSelector, {
|
||||
getProps: (di, props) => ({
|
||||
hotbarManager: di.inject(hotbarManagerInjectable),
|
||||
openCommandOverlay: di.inject(commandOverlayInjectable).open,
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
@@ -22,73 +22,82 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Select } from "../select";
|
||||
import { computed, makeObservable } from "mobx";
|
||||
import { HotbarStore } from "../../../common/hotbar-store";
|
||||
import { CommandOverlay } from "../command-palette";
|
||||
import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
|
||||
import type { CommandOverlay } from "../command-palette";
|
||||
import { HotbarAddCommand } from "./hotbar-add-command";
|
||||
import { HotbarRemoveCommand } from "./hotbar-remove-command";
|
||||
import { hotbarDisplayLabel } from "./hotbar-display-label";
|
||||
import { HotbarRenameCommand } from "./hotbar-rename-command";
|
||||
import type { Hotbar } from "../../../common/hotbar-types";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
|
||||
|
||||
@observer
|
||||
export class HotbarSwitchCommand extends React.Component {
|
||||
private static addActionId = "__add__";
|
||||
private static removeActionId = "__remove__";
|
||||
private static renameActionId = "__rename__";
|
||||
const addActionId = "__add__";
|
||||
const removeActionId = "__remove__";
|
||||
const renameActionId = "__rename__";
|
||||
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
@computed get options() {
|
||||
const hotbarStore = HotbarStore.getInstance();
|
||||
const options = hotbarStore.hotbars.map((hotbar) => {
|
||||
return { value: hotbar.id, label: hotbarDisplayLabel(hotbar.id) };
|
||||
});
|
||||
|
||||
options.push({ value: HotbarSwitchCommand.addActionId, label: "Add hotbar ..." });
|
||||
|
||||
if (hotbarStore.hotbars.length > 1) {
|
||||
options.push({ value: HotbarSwitchCommand.removeActionId, label: "Remove hotbar ..." });
|
||||
}
|
||||
|
||||
options.push({ value: HotbarSwitchCommand.renameActionId, label: "Rename hotbar ..." });
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
onChange(idOrAction: string): void {
|
||||
switch (idOrAction) {
|
||||
case HotbarSwitchCommand.addActionId:
|
||||
CommandOverlay.open(<HotbarAddCommand />);
|
||||
|
||||
return;
|
||||
case HotbarSwitchCommand.removeActionId:
|
||||
CommandOverlay.open(<HotbarRemoveCommand />);
|
||||
|
||||
return;
|
||||
case HotbarSwitchCommand.renameActionId:
|
||||
CommandOverlay.open(<HotbarRenameCommand />);
|
||||
|
||||
return;
|
||||
default:
|
||||
HotbarStore.getInstance().activeHotbarId = idOrAction;
|
||||
CommandOverlay.close();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Select
|
||||
menuPortalTarget={null}
|
||||
onChange={(v) => this.onChange(v.value)}
|
||||
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
|
||||
menuIsOpen={true}
|
||||
options={this.options}
|
||||
autoFocus={true}
|
||||
escapeClearsValue={false}
|
||||
placeholder="Switch to hotbar" />
|
||||
);
|
||||
}
|
||||
interface HotbarManager {
|
||||
hotbars: Hotbar[];
|
||||
setActiveHotbar: (id: string) => void;
|
||||
getDisplayLabel: (hotbar: Hotbar) => string;
|
||||
}
|
||||
|
||||
interface Dependencies {
|
||||
hotbarManager: HotbarManager
|
||||
commandOverlay: CommandOverlay;
|
||||
}
|
||||
|
||||
function getHotbarSwitchOptions(hotbarManager: HotbarManager) {
|
||||
const options = hotbarManager.hotbars.map(hotbar => ({
|
||||
value: hotbar.id,
|
||||
label: hotbarManager.getDisplayLabel(hotbar),
|
||||
}));
|
||||
|
||||
options.push({ value: addActionId, label: "Add hotbar ..." });
|
||||
|
||||
if (hotbarManager.hotbars.length > 1) {
|
||||
options.push({ value: removeActionId, label: "Remove hotbar ..." });
|
||||
}
|
||||
|
||||
options.push({ value: renameActionId, label: "Rename hotbar ..." });
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
const NonInjectedHotbarSwitchCommand = observer(({ hotbarManager, commandOverlay }: Dependencies) => {
|
||||
const options = getHotbarSwitchOptions(hotbarManager);
|
||||
|
||||
const onChange = (idOrAction: string): void => {
|
||||
switch (idOrAction) {
|
||||
case addActionId:
|
||||
return commandOverlay.open(<HotbarAddCommand />);
|
||||
case removeActionId:
|
||||
return commandOverlay.open(<HotbarRemoveCommand />);
|
||||
case renameActionId:
|
||||
return commandOverlay.open(<HotbarRenameCommand />);
|
||||
default:
|
||||
hotbarManager.setActiveHotbar(idOrAction);
|
||||
commandOverlay.close();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
menuPortalTarget={null}
|
||||
onChange={(v) => onChange(v.value)}
|
||||
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
|
||||
menuIsOpen={true}
|
||||
options={options}
|
||||
autoFocus={true}
|
||||
escapeClearsValue={false}
|
||||
placeholder="Switch to hotbar"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const HotbarSwitchCommand = withInjectables<Dependencies>(NonInjectedHotbarSwitchCommand, {
|
||||
getProps: (di, props) => ({
|
||||
hotbarManager: di.inject(hotbarManagerInjectable),
|
||||
commandOverlay: di.inject(commandOverlayInjectable),
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
@@ -73,6 +73,7 @@ export class Icon extends React.PureComponent<IconProps> {
|
||||
switch (evt.nativeEvent.code) {
|
||||
case "Space":
|
||||
|
||||
// fallthrough
|
||||
case "Enter": {
|
||||
// eslint-disable-next-line react/no-find-dom-node
|
||||
const icon = findDOMNode(this) as HTMLElement;
|
||||
|
||||
@@ -103,6 +103,10 @@ export class Input extends React.Component<InputProps, State> {
|
||||
submitted: false,
|
||||
};
|
||||
|
||||
componentWillUnmount(): void {
|
||||
this.setDirtyOnChange.cancel();
|
||||
}
|
||||
|
||||
setValue(value = "") {
|
||||
if (value !== this.getValue()) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set;
|
||||
|
||||
@@ -39,7 +39,7 @@ export const isRequired: InputValidator = {
|
||||
export const isEmail: InputValidator = {
|
||||
condition: ({ type }) => type === "email",
|
||||
message: () => `Wrong email format`,
|
||||
validate: value => !!value.match(/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/),
|
||||
validate: value => !!value.match(/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/),
|
||||
};
|
||||
|
||||
export const isNumber: InputValidator = {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
|
||||
import hotbarManagerInjectable from "../../../../common/hotbar-store.injectable";
|
||||
import type { InputValidator } from "../input_validators";
|
||||
|
||||
const uniqueHotbarNameInjectable = getInjectable({
|
||||
instantiate: di => ({
|
||||
condition: ({ required }) => required,
|
||||
message: () => "Hotbar with this name already exists",
|
||||
validate: value => !di.inject(hotbarManagerInjectable).getByName(value),
|
||||
} as InputValidator),
|
||||
lifecycle: lifecycleEnum.singleton,
|
||||
});
|
||||
|
||||
export default uniqueHotbarNameInjectable;
|
||||
@@ -31,7 +31,6 @@ import { apiManager } from "../../../common/k8s-api/api-manager";
|
||||
import { crdStore } from "../+custom-resources/crd.store";
|
||||
import { KubeObjectMenu } from "../kube-object-menu";
|
||||
import { KubeObjectDetailRegistry } from "../../api/kube-object-detail-registry";
|
||||
import logger from "../../../main/logger";
|
||||
import { CrdResourceDetails } from "../+custom-resources";
|
||||
import { KubeObjectMeta } from "../kube-object-meta";
|
||||
import { hideDetails, kubeDetailsUrlParam } from "../kube-detail-params";
|
||||
@@ -62,7 +61,7 @@ export class KubeObjectDetails extends React.Component {
|
||||
.getStore(this.path)
|
||||
?.getByPath(this.path);
|
||||
} catch (error) {
|
||||
logger.error(`[KUBE-OBJECT-DETAILS]: failed to get store or object: ${error}`, { path: this.path });
|
||||
console.error(`[KUBE-OBJECT-DETAILS]: failed to get store or object: ${error}`, { path: this.path });
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
import { hideDetails } from "../../kube-detail-params";
|
||||
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
|
||||
|
||||
export const hideDetailsInjectable = getInjectable({
|
||||
const hideDetailsInjectable = getInjectable({
|
||||
instantiate: () => hideDetails,
|
||||
lifecycle: lifecycleEnum.singleton,
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ import type { KubeObjectMenuRegistration } from "../../../extensions/registries"
|
||||
import { KubeObjectMenuRegistry } from "../../../extensions/registries";
|
||||
import { ConfirmDialog } from "../confirm-dialog";
|
||||
import asyncFn, { AsyncFnMock } from "@async-fn/jest";
|
||||
import { getDiForUnitTesting } from "../getDiForUnitTesting";
|
||||
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
|
||||
|
||||
import clusterInjectable from "./dependencies/cluster.injectable";
|
||||
import hideDetailsInjectable from "./dependencies/hide-details.injectable";
|
||||
@@ -60,8 +60,7 @@ describe("kube-object-menu", () => {
|
||||
}) as Cluster);
|
||||
|
||||
di.override(apiManagerInjectable, () => ({
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars-ts
|
||||
getStore: api => undefined,
|
||||
getStore: api => void api,
|
||||
}) as ApiManager);
|
||||
|
||||
di.override(hideDetailsInjectable, () => () => {});
|
||||
@@ -280,5 +279,5 @@ const addDynamicMenuItem = ({
|
||||
|
||||
const kubeObjectMenuRegistry = di.inject(kubeObjectMenuRegistryInjectable);
|
||||
|
||||
kubeObjectMenuRegistry.add(dynamicMenuItemStub);
|
||||
kubeObjectMenuRegistry.add([dynamicMenuItemStub]);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
|
||||
import { computed } from "mobx";
|
||||
import rendererExtensionsInjectable from "../../../../../extensions/renderer-extensions.injectable";
|
||||
|
||||
const topBarItemsInjectable = getInjectable({
|
||||
instantiate: (di) => {
|
||||
const extensions = di.inject(rendererExtensionsInjectable);
|
||||
|
||||
return computed(() =>
|
||||
extensions.get().flatMap((extension) => extension.topBarItems),
|
||||
);
|
||||
},
|
||||
|
||||
lifecycle: lifecycleEnum.singleton,
|
||||
});
|
||||
|
||||
export default topBarItemsInjectable;
|
||||
+5
-13
@@ -18,18 +18,10 @@
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
interface TopBarComponents {
|
||||
Item: React.ComponentType;
|
||||
}
|
||||
|
||||
import React from "react";
|
||||
import { TabLayout } from "../layout/tab-layout";
|
||||
|
||||
export class NotFound extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<TabLayout className="NotFound" contentClass="flex">
|
||||
<p className="box center">
|
||||
Page not found
|
||||
</p>
|
||||
</TabLayout>
|
||||
);
|
||||
}
|
||||
export interface TopBarRegistration {
|
||||
components: TopBarComponents;
|
||||
}
|
||||
+19
-13
@@ -20,23 +20,29 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render, fireEvent } from "@testing-library/react";
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/extend-expect";
|
||||
import { TopBar } from "../topbar";
|
||||
import { TopBarRegistry } from "../../../../extensions/registries";
|
||||
import { TopBar } from "./top-bar";
|
||||
import { IpcMainWindowEvents } from "../../../../main/window-manager";
|
||||
import { broadcastMessage } from "../../../../common/ipc";
|
||||
import * as vars from "../../../../common/vars";
|
||||
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
|
||||
import { DiRender, renderFor } from "../../test-utils/renderFor";
|
||||
|
||||
const mockConfig = vars as { isWindows: boolean, isLinux: boolean };
|
||||
const mockConfig = vars as { isWindows: boolean; isLinux: boolean };
|
||||
|
||||
jest.mock("../../../../common/ipc");
|
||||
|
||||
jest.mock("../../../../common/vars", () => {
|
||||
const SemVer = require("semver").SemVer;
|
||||
|
||||
const versionStub = new SemVer("1.0.0");
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
isWindows: null,
|
||||
isLinux: null,
|
||||
appSemVer: versionStub,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -57,20 +63,20 @@ jest.mock("@electron/remote", () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe("<Tobar/> in Windows and Linux", () => {
|
||||
beforeEach(() => {
|
||||
TopBarRegistry.createInstance();
|
||||
});
|
||||
describe("<TopBar/> in Windows and Linux", () => {
|
||||
let render: DiRender;
|
||||
|
||||
afterEach(() => {
|
||||
TopBarRegistry.resetInstance();
|
||||
beforeEach(() => {
|
||||
const di = getDiForUnitTesting();
|
||||
|
||||
render = renderFor(di);
|
||||
});
|
||||
|
||||
it("shows window controls on Windows", () => {
|
||||
mockConfig.isWindows = true;
|
||||
mockConfig.isLinux = false;
|
||||
|
||||
const { getByTestId } = render(<TopBar/>);
|
||||
const { getByTestId } = render(<TopBar />);
|
||||
|
||||
expect(getByTestId("window-menu")).toBeInTheDocument();
|
||||
expect(getByTestId("window-minimize")).toBeInTheDocument();
|
||||
@@ -82,7 +88,7 @@ describe("<Tobar/> in Windows and Linux", () => {
|
||||
mockConfig.isWindows = false;
|
||||
mockConfig.isLinux = true;
|
||||
|
||||
const { getByTestId } = render(<TopBar/>);
|
||||
const { getByTestId } = render(<TopBar />);
|
||||
|
||||
expect(getByTestId("window-menu")).toBeInTheDocument();
|
||||
expect(getByTestId("window-minimize")).toBeInTheDocument();
|
||||
@@ -93,7 +99,7 @@ describe("<Tobar/> in Windows and Linux", () => {
|
||||
it("triggers ipc events on click", () => {
|
||||
mockConfig.isWindows = true;
|
||||
|
||||
const { getByTestId } = render(<TopBar/>);
|
||||
const { getByTestId } = render(<TopBar />);
|
||||
|
||||
const menu = getByTestId("window-menu");
|
||||
const minimize = getByTestId("window-minimize");
|
||||
File renamed without changes.
+20
-10
@@ -20,14 +20,23 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render, fireEvent } from "@testing-library/react";
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/extend-expect";
|
||||
import { TopBar } from "../topbar";
|
||||
import { TopBarRegistry } from "../../../../extensions/registries";
|
||||
import { TopBar } from "./top-bar";
|
||||
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
|
||||
import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable";
|
||||
import { DiRender, renderFor } from "../../test-utils/renderFor";
|
||||
import topBarItemsInjectable from "./top-bar-items/top-bar-items.injectable";
|
||||
import { computed } from "mobx";
|
||||
|
||||
jest.mock("../../../../common/vars", () => {
|
||||
const SemVer = require("semver").SemVer;
|
||||
|
||||
const versionStub = new SemVer("1.0.0");
|
||||
|
||||
return {
|
||||
isMac: true,
|
||||
appSemVer: versionStub,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -76,12 +85,13 @@ jest.mock("@electron/remote", () => {
|
||||
});
|
||||
|
||||
describe("<TopBar/>", () => {
|
||||
beforeEach(() => {
|
||||
TopBarRegistry.createInstance();
|
||||
});
|
||||
let di: ConfigurableDependencyInjectionContainer;
|
||||
let render: DiRender;
|
||||
|
||||
afterEach(() => {
|
||||
TopBarRegistry.resetInstance();
|
||||
beforeEach(() => {
|
||||
di = getDiForUnitTesting();
|
||||
|
||||
render = renderFor(di);
|
||||
});
|
||||
|
||||
it("renders w/o errors", () => {
|
||||
@@ -129,13 +139,13 @@ describe("<TopBar/>", () => {
|
||||
const testId = "testId";
|
||||
const text = "an item";
|
||||
|
||||
TopBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [
|
||||
di.override(topBarItemsInjectable, () => computed(() => [
|
||||
{
|
||||
components: {
|
||||
Item: () => <span data-testid={testId}>{text}</span>,
|
||||
},
|
||||
},
|
||||
]);
|
||||
]));
|
||||
|
||||
const { getByTestId } = render(<TopBar/>);
|
||||
|
||||
+45
-37
@@ -19,22 +19,28 @@
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import styles from "./topbar.module.scss";
|
||||
import styles from "./top-bar.module.scss";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { TopBarRegistry } from "../../../extensions/registries";
|
||||
import { Icon } from "../icon";
|
||||
import type { IComputedValue } from "mobx";
|
||||
import { Icon } from "../../icon";
|
||||
import { webContents, getCurrentWindow } from "@electron/remote";
|
||||
import { observable } from "mobx";
|
||||
import { broadcastMessage, ipcRendererOn } from "../../../common/ipc";
|
||||
import { watchHistoryState } from "../../remote-helpers/history-updater";
|
||||
import { isActiveRoute, navigate } from "../../navigation";
|
||||
import { catalogRoute, catalogURL } from "../../../common/routes";
|
||||
import { IpcMainWindowEvents } from "../../../main/window-manager";
|
||||
import { isLinux, isWindows } from "../../../common/vars";
|
||||
import { cssNames } from "../../utils";
|
||||
import { broadcastMessage, ipcRendererOn } from "../../../../common/ipc";
|
||||
import { watchHistoryState } from "../../../remote-helpers/history-updater";
|
||||
import { isActiveRoute, navigate } from "../../../navigation";
|
||||
import { catalogRoute, catalogURL } from "../../../../common/routes";
|
||||
import { IpcMainWindowEvents } from "../../../../main/window-manager";
|
||||
import { isLinux, isWindows } from "../../../../common/vars";
|
||||
import { cssNames } from "../../../utils";
|
||||
import topBarItemsInjectable from "./top-bar-items/top-bar-items.injectable";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import type { TopBarRegistration } from "./top-bar-registration";
|
||||
|
||||
interface Props extends React.HTMLAttributes<any> {
|
||||
interface Props extends React.HTMLAttributes<any> {}
|
||||
|
||||
interface Dependencies {
|
||||
items: IComputedValue<TopBarRegistration[]>;
|
||||
}
|
||||
|
||||
const prevEnabled = observable.box(false);
|
||||
@@ -48,34 +54,10 @@ ipcRendererOn("history:can-go-forward", (event, state: boolean) => {
|
||||
nextEnabled.set(state);
|
||||
});
|
||||
|
||||
export const TopBar = observer(({ children, ...rest }: Props) => {
|
||||
const NonInjectedTopBar = (({ items, children, ...rest }: Props & Dependencies) => {
|
||||
const elem = useRef<HTMLDivElement>();
|
||||
const window = useMemo(() => getCurrentWindow(), []);
|
||||
|
||||
const renderRegisteredItems = () => {
|
||||
const items = TopBarRegistry.getInstance().getItems();
|
||||
|
||||
if (!Array.isArray(items)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{items.map((registration, index) => {
|
||||
if (!registration?.components?.Item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
<registration.components.Item />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const openContextMenu = () => {
|
||||
broadcastMessage(IpcMainWindowEvents.OPEN_CONTEXT_MENU);
|
||||
};
|
||||
@@ -156,7 +138,7 @@ export const TopBar = observer(({ children, ...rest }: Props) => {
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.controls}>
|
||||
{renderRegisteredItems()}
|
||||
{renderRegisteredItems(items.get())}
|
||||
{children}
|
||||
{(isWindows || isLinux) && (
|
||||
<div className={cssNames(styles.windowButtons, { [styles.linuxButtons]: isLinux })}>
|
||||
@@ -174,3 +156,29 @@ export const TopBar = observer(({ children, ...rest }: Props) => {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const renderRegisteredItems = (items: TopBarRegistration[]) => (
|
||||
<div>
|
||||
{items.map((registration, index) => {
|
||||
if (!registration?.components?.Item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
<registration.components.Item />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
|
||||
export const TopBar = withInjectables(observer(NonInjectedTopBar), {
|
||||
getProps: (di, props) => ({
|
||||
items: di.inject(topBarItemsInjectable),
|
||||
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
@@ -27,7 +27,7 @@ import { observer } from "mobx-react";
|
||||
import { boundMethod, cssNames } from "../../utils";
|
||||
import { ConfirmDialog } from "../confirm-dialog";
|
||||
import { Icon, IconProps } from "../icon";
|
||||
import { Menu, MenuItem, MenuProps } from "../menu";
|
||||
import { Menu, MenuItem, MenuProps } from "./menu";
|
||||
import uniqueId from "lodash/uniqueId";
|
||||
import isString from "lodash/isString";
|
||||
|
||||
|
||||
@@ -243,7 +243,9 @@ export class Menu extends React.Component<MenuProps, State> {
|
||||
break;
|
||||
|
||||
case "Space":
|
||||
case "Enter":
|
||||
// fallthrough
|
||||
|
||||
case "Enter": {
|
||||
const focusedItem = this.focusedItem;
|
||||
|
||||
if (focusedItem) {
|
||||
@@ -251,10 +253,12 @@ export class Menu extends React.Component<MenuProps, State> {
|
||||
evt.preventDefault();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "ArrowUp":
|
||||
this.focusNextItem(true);
|
||||
break;
|
||||
|
||||
case "ArrowDown":
|
||||
this.focusNextItem();
|
||||
break;
|
||||
|
||||
@@ -24,7 +24,8 @@ import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { action, computed, makeObservable, observable, reaction } from "mobx";
|
||||
import { editor, Uri } from "monaco-editor";
|
||||
import { MonacoTheme, MonacoValidator, monacoValidators } from "./index";
|
||||
import type { MonacoTheme } from "./monaco-themes";
|
||||
import { MonacoValidator, monacoValidators } from "./monaco-validators";
|
||||
import { debounce, merge } from "lodash";
|
||||
import { cssNames, disposer } from "../../utils";
|
||||
import { UserStore } from "../../../common/user-store";
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/extend-expect";
|
||||
import { Switch } from "..";
|
||||
|
||||
describe("<Switch/>", () => {
|
||||
it("renders w/o errors", () => {
|
||||
const { container } = render(<Switch />);
|
||||
|
||||
expect(container).toBeInstanceOf(HTMLElement);
|
||||
});
|
||||
|
||||
it("render label text", () => {
|
||||
const { getByLabelText } = render(<Switch>Test label</Switch>);
|
||||
|
||||
expect(getByLabelText("Test label")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("passes disabled and checked attributes to input", () => {
|
||||
const { container } = render(<Switch checked disabled/>);
|
||||
const checkbox = container.querySelector("input[type=checkbox]");
|
||||
|
||||
expect(checkbox).toHaveAttribute("disabled");
|
||||
expect(checkbox).toHaveAttribute("checked");
|
||||
});
|
||||
|
||||
it("onClick event fired", () => {
|
||||
const onClick = jest.fn();
|
||||
const { getByTestId } = render(<Switch onClick={onClick}/>);
|
||||
const switcher = getByTestId("switch");
|
||||
|
||||
fireEvent.click(switcher);
|
||||
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onClick event not fired for disabled item", () => {
|
||||
const onClick = jest.fn();
|
||||
const { getByTestId } = render(<Switch onClick={onClick} disabled/>);
|
||||
const switcher = getByTestId("switch");
|
||||
|
||||
fireEvent.click(switcher);
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,9 @@ const useStyles = makeStyles({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated Use <Switch/> instead from "../switch.tsx".
|
||||
*/
|
||||
export function FormSwitch(props: FormControlLabelProps) {
|
||||
const classes = useStyles();
|
||||
|
||||
|
||||
@@ -21,3 +21,4 @@
|
||||
|
||||
export * from "./switcher";
|
||||
export * from "./form-switcher";
|
||||
export * from "./switch";
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
.Switch {
|
||||
--thumb-size: 2rem;
|
||||
--thumb-color: hsl(0 0% 100%);
|
||||
--thumb-color-highlight: hsl(0 0% 100% / 25%);
|
||||
|
||||
--track-size: calc(var(--thumb-size) * 2);
|
||||
--track-padding: 2px;
|
||||
--track-color-inactive: hsl(80 0% 35%);
|
||||
--track-color-active: hsl(110, 60%, 60%);
|
||||
|
||||
--thumb-position: 0%;
|
||||
--thumb-transition-duration: .25s;
|
||||
|
||||
--hover-highlight-size: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2ch;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--textColorAccent);
|
||||
font-weight: 500;
|
||||
|
||||
& > input {
|
||||
padding: var(--track-padding);
|
||||
background: var(--track-color-inactive);
|
||||
inline-size: var(--track-size);
|
||||
block-size: var(--thumb-size);
|
||||
border-radius: var(--track-size);
|
||||
|
||||
appearance: none;
|
||||
pointer-events: none;
|
||||
border: none;
|
||||
outline-offset: 5px;
|
||||
box-sizing: content-box;
|
||||
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid: [track] 1fr / [track] 1fr;
|
||||
|
||||
transition: background-color .25s ease;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
grid-area: track;
|
||||
inline-size: var(--thumb-size);
|
||||
block-size: var(--thumb-size);
|
||||
background: var(--thumb-color);
|
||||
box-shadow: 0 0 0 var(--hover-highlight-size) var(--thumb-color-highlight);
|
||||
border-radius: 50%;
|
||||
transform: translateX(var(--thumb-position));
|
||||
transition:
|
||||
transform var(--thumb-transition-duration) ease,
|
||||
box-shadow .25s ease;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover::before {
|
||||
--hover-highlight-size: .5rem;
|
||||
}
|
||||
|
||||
&:checked {
|
||||
background: var(--track-color-active);
|
||||
--thumb-position: 100%;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
--track-color-inactive: hsl(80 0% 30%);
|
||||
--thumb-color: transparent;
|
||||
|
||||
&::before {
|
||||
cursor: not-allowed;
|
||||
box-shadow: inset 0 0 0 2px hsl(0 0% 0% / 40%);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--blue);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@include theme-light {
|
||||
.Switch {
|
||||
--thumb-color-highlight: hsl(0 0% 0% / 25%);
|
||||
|
||||
& > input {
|
||||
&:disabled {
|
||||
--track-color-inactive: hsl(80 0% 80%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import styles from "./switch.module.scss";
|
||||
|
||||
import React, { ChangeEvent, HTMLProps } from "react";
|
||||
import { cssNames } from "../../utils";
|
||||
|
||||
interface Props extends Omit<HTMLProps<HTMLInputElement>, "onChange"> {
|
||||
onChange?: (checked: boolean, event: ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export function Switch({ children, disabled, onChange, ...props }: Props) {
|
||||
return (
|
||||
<label className={cssNames(styles.Switch, { [styles.disabled]: disabled })} data-testid="switch">
|
||||
{children}
|
||||
<input type="checkbox" role="switch" disabled={disabled} onChange={(event) => onChange?.(props.checked, event)} {...props}/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,9 @@ interface Props extends SwitchProps {
|
||||
classes: Styles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use <Switch/> instead from "../switch.tsx".
|
||||
*/
|
||||
export const Switcher = withStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
|
||||
@@ -20,8 +20,7 @@
|
||||
*/
|
||||
|
||||
import styles from "./react-table.module.scss";
|
||||
import React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { useFlexLayout, useSortBy, useTable, UseTableOptions } from "react-table";
|
||||
import { Icon } from "../icon";
|
||||
import { cssNames } from "../../utils";
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { createContainer } from "@ogre-tools/injectable";
|
||||
import { setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
|
||||
|
||||
export function getDi() {
|
||||
const di = createContainer(
|
||||
getRequireContextForRendererCode,
|
||||
getRequireContextForCommonExtensionCode,
|
||||
getRequireContextForCommonCode,
|
||||
);
|
||||
|
||||
setLegacyGlobalDiForExtensionApi(di);
|
||||
|
||||
return di;
|
||||
}
|
||||
|
||||
const getRequireContextForRendererCode = () =>
|
||||
require.context("./", true, /\.injectable\.(ts|tsx)$/);
|
||||
|
||||
const getRequireContextForCommonCode = () =>
|
||||
require.context("../common", true, /\.injectable\.(ts|tsx)$/);
|
||||
|
||||
const getRequireContextForCommonExtensionCode = () =>
|
||||
require.context("../extensions", true, /\.injectable\.(ts|tsx)$/);
|
||||
+12
-18
@@ -21,27 +21,20 @@
|
||||
|
||||
import glob from "glob";
|
||||
import { memoize } from "lodash/fp";
|
||||
|
||||
import {
|
||||
createContainer,
|
||||
ConfigurableDependencyInjectionContainer,
|
||||
} from "@ogre-tools/injectable";
|
||||
import { createContainer } from "@ogre-tools/injectable";
|
||||
|
||||
export const getDiForUnitTesting = () => {
|
||||
const di: ConfigurableDependencyInjectionContainer = createContainer();
|
||||
const di = createContainer();
|
||||
|
||||
getInjectableFilePaths()
|
||||
.map(key => {
|
||||
const injectable = require(key).default;
|
||||
for (const filePath of getInjectableFilePaths()) {
|
||||
const injectableInstance = require(filePath).default;
|
||||
|
||||
return {
|
||||
id: key,
|
||||
...injectable,
|
||||
aliases: [injectable, ...(injectable.aliases || [])],
|
||||
};
|
||||
})
|
||||
|
||||
.forEach(injectable => di.register(injectable));
|
||||
di.register({
|
||||
id: filePath,
|
||||
...injectableInstance,
|
||||
aliases: [injectableInstance, ...(injectableInstance.aliases || [])],
|
||||
});
|
||||
}
|
||||
|
||||
di.preventSideEffects();
|
||||
|
||||
@@ -50,5 +43,6 @@ export const getDiForUnitTesting = () => {
|
||||
|
||||
const getInjectableFilePaths = memoize(() => [
|
||||
...glob.sync("./**/*.injectable.{ts,tsx}", { cwd: __dirname }),
|
||||
...glob.sync("../../extensions/**/*.injectable.{ts,tsx}", { cwd: __dirname }),
|
||||
...glob.sync("../common/**/*.injectable.{ts,tsx}", { cwd: __dirname }),
|
||||
...glob.sync("../extensions/**/*.injectable.{ts,tsx}", { cwd: __dirname }),
|
||||
]);
|
||||
@@ -22,32 +22,12 @@
|
||||
import React from "react";
|
||||
import fs from "fs";
|
||||
import "../../common/catalog-entities/kubernetes-cluster";
|
||||
import { WebLinkCategory } from "../../common/catalog-entities";
|
||||
import { ClusterStore } from "../../common/cluster-store";
|
||||
import { catalogCategoryRegistry } from "../api/catalog-category-registry";
|
||||
import { WeblinkAddCommand } from "../components/catalog-entities/weblink-add-command";
|
||||
import { CommandOverlay } from "../components/command-palette";
|
||||
import { loadConfigFromString } from "../../common/kube-helpers";
|
||||
import { DeleteClusterDialog } from "../components/delete-cluster-dialog";
|
||||
|
||||
function initWebLinks() {
|
||||
WebLinkCategory.onAdd = () => CommandOverlay.open(<WeblinkAddCommand />);
|
||||
}
|
||||
|
||||
function initKubernetesClusters() {
|
||||
catalogCategoryRegistry
|
||||
.getForGroupKind("entity.k8slens.dev", "KubernetesCluster")
|
||||
.on("contextMenuOpen", (entity, context) => {
|
||||
if (entity.metadata?.source == "local") {
|
||||
context.menuItems.push({
|
||||
title: "Delete",
|
||||
icon: "delete",
|
||||
onClick: () => onClusterDelete(entity.metadata.uid),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onClusterDelete(clusterId: string) {
|
||||
const cluster = ClusterStore.getInstance().getById(clusterId);
|
||||
|
||||
@@ -64,7 +44,29 @@ async function onClusterDelete(clusterId: string) {
|
||||
DeleteClusterDialog.open({ cluster, config });
|
||||
}
|
||||
|
||||
export function initCatalog() {
|
||||
initWebLinks();
|
||||
initKubernetesClusters();
|
||||
interface Dependencies {
|
||||
openCommandDialog: (component: React.ReactElement) => void;
|
||||
}
|
||||
|
||||
export function initCatalog({ openCommandDialog }: Dependencies) {
|
||||
catalogCategoryRegistry
|
||||
.getForGroupKind("entity.k8slens.dev", "WebLink")
|
||||
.on("catalogAddMenu", ctx => {
|
||||
ctx.menuItems.push({
|
||||
title: "Add web link",
|
||||
icon: "public",
|
||||
onClick: () => openCommandDialog(<WeblinkAddCommand />),
|
||||
});
|
||||
});
|
||||
catalogCategoryRegistry
|
||||
.getForGroupKind("entity.k8slens.dev", "KubernetesCluster")
|
||||
.on("contextMenuOpen", (entity, context) => {
|
||||
if (entity.metadata?.source == "local") {
|
||||
context.menuItems.push({
|
||||
title: "Delete",
|
||||
icon: "delete",
|
||||
onClick: () => onClusterDelete(entity.metadata.uid),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import * as routes from "../../common/routes";
|
||||
import { CommandRegistry } from "../../extensions/registries";
|
||||
import { getActiveClusterEntity } from "../api/catalog-entity-registry";
|
||||
import { CommandOverlay } from "../components/command-palette";
|
||||
import { createTerminalTab } from "../components/dock/terminal.store";
|
||||
import { HotbarAddCommand } from "../components/hotbar/hotbar-add-command";
|
||||
import { HotbarRemoveCommand } from "../components/hotbar/hotbar-remove-command";
|
||||
import { HotbarSwitchCommand } from "../components/hotbar/hotbar-switch-command";
|
||||
import { navigate } from "../navigation";
|
||||
import { HotbarRenameCommand } from "../components/hotbar/hotbar-rename-command";
|
||||
import { ActivateEntityCommand } from "../components/activate-entity-command";
|
||||
|
||||
export function initCommandRegistry() {
|
||||
CommandRegistry.getInstance()
|
||||
.add([
|
||||
{
|
||||
id: "app.showPreferences",
|
||||
title: "Preferences: Open",
|
||||
scope: "global",
|
||||
action: () => navigate(routes.preferencesURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewHelmCharts",
|
||||
title: "Cluster: View Helm Charts",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.helmChartsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewHelmReleases",
|
||||
title: "Cluster: View Helm Releases",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.releaseURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewConfigMaps",
|
||||
title: "Cluster: View ConfigMaps",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.configMapsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewSecrets",
|
||||
title: "Cluster: View Secrets",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.secretsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewResourceQuotas",
|
||||
title: "Cluster: View ResourceQuotas",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.resourceQuotaURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewLimitRanges",
|
||||
title: "Cluster: View LimitRanges",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.limitRangeURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewHorizontalPodAutoscalers",
|
||||
title: "Cluster: View HorizontalPodAutoscalers (HPA)",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.hpaURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewPodDisruptionBudget",
|
||||
title: "Cluster: View PodDisruptionBudgets",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.pdbURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewServices",
|
||||
title: "Cluster: View Services",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.servicesURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewEndpoints",
|
||||
title: "Cluster: View Endpoints",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.endpointURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewIngresses",
|
||||
title: "Cluster: View Ingresses",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.ingressURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewNetworkPolicies",
|
||||
title: "Cluster: View NetworkPolicies",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.networkPoliciesURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewNodes",
|
||||
title: "Cluster: View Nodes",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.nodesURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewPods",
|
||||
title: "Cluster: View Pods",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.podsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewDeployments",
|
||||
title: "Cluster: View Deployments",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.deploymentsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewDaemonSets",
|
||||
title: "Cluster: View DaemonSets",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.daemonSetsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewStatefulSets",
|
||||
title: "Cluster: View StatefulSets",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.statefulSetsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewJobs",
|
||||
title: "Cluster: View Jobs",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.jobsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewCronJobs",
|
||||
title: "Cluster: View CronJobs",
|
||||
scope: "entity",
|
||||
action: () => navigate(routes.cronJobsURL()),
|
||||
},
|
||||
{
|
||||
id: "cluster.viewCurrentClusterSettings",
|
||||
title: "Cluster: View Settings",
|
||||
scope: "global",
|
||||
action: () => navigate(routes.entitySettingsURL({
|
||||
params: {
|
||||
entityId: getActiveClusterEntity()?.id,
|
||||
},
|
||||
})),
|
||||
isActive: (context) => !!context.entity,
|
||||
},
|
||||
{
|
||||
id: "cluster.openTerminal",
|
||||
title: "Cluster: Open terminal",
|
||||
scope: "entity",
|
||||
action: () => createTerminalTab(),
|
||||
isActive: (context) => !!context.entity,
|
||||
},
|
||||
{
|
||||
id: "hotbar.switchHotbar",
|
||||
title: "Hotbar: Switch ...",
|
||||
scope: "global",
|
||||
action: () => CommandOverlay.open(<HotbarSwitchCommand />),
|
||||
},
|
||||
{
|
||||
id: "hotbar.addHotbar",
|
||||
title: "Hotbar: Add Hotbar ...",
|
||||
scope: "global",
|
||||
action: () => CommandOverlay.open(<HotbarAddCommand />),
|
||||
},
|
||||
{
|
||||
id: "hotbar.removeHotbar",
|
||||
title: "Hotbar: Remove Hotbar ...",
|
||||
scope: "global",
|
||||
action: () => CommandOverlay.open(<HotbarRemoveCommand />),
|
||||
},
|
||||
{
|
||||
id: "hotbar.renameHotbar",
|
||||
title: "Hotbar: Rename Hotbar ...",
|
||||
scope: "global",
|
||||
action: () => CommandOverlay.open(<HotbarRenameCommand />),
|
||||
},
|
||||
{
|
||||
id: "catalog.searchEntities",
|
||||
title: "Catalog: Activate Entity ...",
|
||||
scope: "global",
|
||||
action: () => CommandOverlay.open(<ActivateEntityCommand />),
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -21,13 +21,11 @@
|
||||
|
||||
export * from "./catalog-entity-detail-registry";
|
||||
export * from "./catalog";
|
||||
export * from "./command-registry";
|
||||
export * from "./entity-settings-registry";
|
||||
export * from "./ipc";
|
||||
export * from "./kube-object-detail-registry";
|
||||
export * from "./kube-object-menu-registry";
|
||||
export * from "./registries";
|
||||
export * from "./welcome-menu-registry";
|
||||
export * from "./workloads-overview-detail-registry";
|
||||
export * from "./catalog-category-registry";
|
||||
export * from "./status-bar-registry";
|
||||
@@ -26,15 +26,11 @@ export function initRegistries() {
|
||||
registries.CatalogEntityDetailRegistry.createInstance();
|
||||
registries.ClusterPageMenuRegistry.createInstance();
|
||||
registries.ClusterPageRegistry.createInstance();
|
||||
registries.CommandRegistry.createInstance();
|
||||
registries.EntitySettingRegistry.createInstance();
|
||||
registries.GlobalPageRegistry.createInstance();
|
||||
registries.KubeObjectDetailRegistry.createInstance();
|
||||
registries.KubeObjectMenuRegistry.createInstance();
|
||||
registries.KubeObjectStatusRegistry.createInstance();
|
||||
registries.StatusBarRegistry.createInstance();
|
||||
registries.WelcomeMenuRegistry.createInstance();
|
||||
registries.WelcomeBannerRegistry.createInstance();
|
||||
registries.WorkloadsOverviewDetailRegistry.createInstance();
|
||||
registries.TopBarRegistry.createInstance();
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export function isActiveRoute(route: string | string[] | RouteProps): boolean {
|
||||
return !!matchRoute(route);
|
||||
}
|
||||
|
||||
export function getMatchedClusterId(): string {
|
||||
export function getMatchedClusterId(): string | undefined {
|
||||
const matched = matchPath<ClusterViewRouteParams>(navigation.location.pathname, {
|
||||
exact: true,
|
||||
path: clusterViewRoute.path,
|
||||
|
||||
@@ -27,18 +27,20 @@ import { observer } from "mobx-react";
|
||||
import { Dialog, DialogProps } from "../components/dialog";
|
||||
import { Wizard, WizardStep } from "../components/wizard";
|
||||
import { Input } from "../components/input";
|
||||
import { Notifications } from "../components/notifications";
|
||||
import { cssNames } from "../utils";
|
||||
import { cssNames, noop } from "../utils";
|
||||
import { addPortForward, getPortForwards, modifyPortForward } from "./port-forward.store";
|
||||
import type { ForwardedPort } from "./port-forward-item";
|
||||
import { aboutPortForwarding, openPortForward } from ".";
|
||||
import { openPortForward } from "./port-forward-utils";
|
||||
import { aboutPortForwarding, notifyErrorPortForwarding } from "./port-forward-notify";
|
||||
import { Checkbox } from "../components/checkbox";
|
||||
import logger from "../../common/logger";
|
||||
|
||||
interface Props extends Partial<DialogProps> {
|
||||
}
|
||||
|
||||
interface PortForwardDialogOpenOptions {
|
||||
openInBrowser: boolean
|
||||
openInBrowser: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const dialogState = observable.object({
|
||||
@@ -46,6 +48,7 @@ const dialogState = observable.object({
|
||||
data: null as ForwardedPort,
|
||||
useHttps: false,
|
||||
openInBrowser: false,
|
||||
onClose: noop,
|
||||
});
|
||||
|
||||
@observer
|
||||
@@ -58,11 +61,12 @@ export class PortForwardDialog extends Component<Props> {
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
static open(portForward: ForwardedPort, options: PortForwardDialogOpenOptions = { openInBrowser: false }) {
|
||||
static open(portForward: ForwardedPort, options: PortForwardDialogOpenOptions = { openInBrowser: false, onClose: noop }) {
|
||||
dialogState.isOpen = true;
|
||||
dialogState.data = portForward;
|
||||
dialogState.useHttps = portForward.protocol === "https";
|
||||
dialogState.openInBrowser = options.openInBrowser;
|
||||
dialogState.onClose = options.onClose;
|
||||
}
|
||||
|
||||
static close() {
|
||||
@@ -84,43 +88,47 @@ export class PortForwardDialog extends Component<Props> {
|
||||
this.desiredPort = this.currentPort;
|
||||
};
|
||||
|
||||
onClose = () => {
|
||||
};
|
||||
|
||||
changePort = (value: string) => {
|
||||
this.desiredPort = Number(value);
|
||||
};
|
||||
|
||||
startPortForward = async () => {
|
||||
const { portForward } = this;
|
||||
let { portForward } = this;
|
||||
const { currentPort, desiredPort, close } = this;
|
||||
|
||||
try {
|
||||
// determine how many port-forwards are already active
|
||||
const { length } = await getPortForwards();
|
||||
|
||||
let port: number;
|
||||
// determine how many port-forwards already exist
|
||||
const { length } = getPortForwards();
|
||||
|
||||
portForward.protocol = dialogState.useHttps ? "https" : "http";
|
||||
|
||||
if (currentPort) {
|
||||
port = await modifyPortForward(portForward, desiredPort);
|
||||
const wasRunning = portForward.status === "Active";
|
||||
|
||||
portForward = await modifyPortForward(portForward, desiredPort);
|
||||
|
||||
if (wasRunning && portForward.status === "Disabled") {
|
||||
notifyErrorPortForwarding(`Error occurred starting port-forward, the local port ${portForward.forwardPort} may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`);
|
||||
}
|
||||
} else {
|
||||
portForward.forwardPort = desiredPort;
|
||||
port = await addPortForward(portForward);
|
||||
portForward = await addPortForward(portForward);
|
||||
|
||||
// if this is the first port-forward show the about notification
|
||||
if (!length) {
|
||||
aboutPortForwarding();
|
||||
if (portForward.status === "Disabled") {
|
||||
notifyErrorPortForwarding(`Error occurred starting port-forward, the local port ${portForward.forwardPort} may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`);
|
||||
} else {
|
||||
// if this is the first port-forward show the about notification
|
||||
if (!length) {
|
||||
aboutPortForwarding();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dialogState.openInBrowser) {
|
||||
portForward.forwardPort = port;
|
||||
if (portForward.status === "Active" && dialogState.openInBrowser) {
|
||||
openPortForward(portForward);
|
||||
}
|
||||
} catch (err) {
|
||||
Notifications.error(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`);
|
||||
} catch (error) {
|
||||
logger.error(`[PORT-FORWARD-DIALOG]: ${error}`, portForward);
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
@@ -175,14 +183,14 @@ export class PortForwardDialog extends Component<Props> {
|
||||
isOpen={dialogState.isOpen}
|
||||
className={cssNames("PortForwardDialog", className)}
|
||||
onOpen={this.onOpen}
|
||||
onClose={this.onClose}
|
||||
onClose={dialogState.onClose}
|
||||
close={this.close}
|
||||
>
|
||||
<Wizard header={header} done={this.close}>
|
||||
<WizardStep
|
||||
contentClass="flex gaps column"
|
||||
next={this.startPortForward}
|
||||
nextLabel={this.currentPort === 0 ? "Start" : "Restart"}
|
||||
nextLabel={this.currentPort === 0 ? "Start" : "Modify"}
|
||||
>
|
||||
{this.renderContents()}
|
||||
</WizardStep>
|
||||
|
||||
@@ -23,33 +23,34 @@
|
||||
import type { ItemObject } from "../../common/item.store";
|
||||
import { autoBind } from "../../common/utils";
|
||||
|
||||
export type ForwardedPortStatus = "Active" | "Disabled";
|
||||
export interface ForwardedPort {
|
||||
clusterId?: string;
|
||||
kind: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
port: number;
|
||||
forwardPort: number;
|
||||
protocol?: string;
|
||||
status?: ForwardedPortStatus;
|
||||
}
|
||||
|
||||
export class PortForwardItem implements ItemObject {
|
||||
clusterId: string;
|
||||
kind: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
port: number;
|
||||
forwardPort: number;
|
||||
protocol: string;
|
||||
status: ForwardedPortStatus;
|
||||
|
||||
constructor(pf: ForwardedPort) {
|
||||
this.clusterId = pf.clusterId;
|
||||
this.kind = pf.kind;
|
||||
this.namespace = pf.namespace;
|
||||
this.name = pf.name;
|
||||
this.port = pf.port;
|
||||
this.forwardPort = pf.forwardPort;
|
||||
this.protocol = pf.protocol ?? "http";
|
||||
this.status = pf.status ?? "Active";
|
||||
|
||||
autoBind(this);
|
||||
}
|
||||
@@ -62,12 +63,8 @@ export class PortForwardItem implements ItemObject {
|
||||
return this.namespace;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.forwardPort;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return String(this.forwardPort);
|
||||
return `${this.namespace}-${this.kind}-${this.name}:${this.port}`;
|
||||
}
|
||||
|
||||
getKind() {
|
||||
@@ -87,16 +84,17 @@ export class PortForwardItem implements ItemObject {
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return "Active"; // to-do allow port-forward-items to be stopped (without removing them)
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getSearchFields() {
|
||||
return [
|
||||
this.name,
|
||||
this.id,
|
||||
this.namespace,
|
||||
this.kind,
|
||||
this.port,
|
||||
this.forwardPort,
|
||||
this.status,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -56,3 +56,34 @@ export function aboutPortForwarding() {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function notifyErrorPortForwarding(msg: string) {
|
||||
const notificationId = `port-forward-error-notification-${getHostedClusterId()}`;
|
||||
|
||||
Notifications.error(
|
||||
(
|
||||
<div className="flex column gaps">
|
||||
<b>Port Forwarding</b>
|
||||
<p>
|
||||
{msg}
|
||||
</p>
|
||||
<div className="flex gaps row align-left box grow">
|
||||
<Button
|
||||
active
|
||||
outlined
|
||||
label="Check Port Forwarding"
|
||||
onClick={() => {
|
||||
navigate(portForwardsURL());
|
||||
notificationsStore.remove(notificationId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
id: notificationId,
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ export function openPortForward(portForward: ForwardedPort) {
|
||||
openExternal(browseTo)
|
||||
.catch(error => {
|
||||
logger.error(`failed to open in browser: ${error}`, {
|
||||
clusterId: portForward.clusterId,
|
||||
port: portForward.port,
|
||||
kind: portForward.kind,
|
||||
namespace: portForward.namespace,
|
||||
|
||||
@@ -20,10 +20,11 @@
|
||||
*/
|
||||
|
||||
|
||||
import { makeObservable, observable, reaction } from "mobx";
|
||||
import { action, makeObservable, observable, reaction } from "mobx";
|
||||
import { ItemStore } from "../../common/item.store";
|
||||
import { autoBind, createStorage, disposer, getHostedClusterId } from "../utils";
|
||||
import { autoBind, createStorage, disposer } from "../utils";
|
||||
import { ForwardedPort, PortForwardItem } from "./port-forward-item";
|
||||
import { notifyErrorPortForwarding } from "./port-forward-notify";
|
||||
import { apiBase } from "../api";
|
||||
import { waitUntilFree } from "tcp-port-used";
|
||||
import logger from "../../common/logger";
|
||||
@@ -31,7 +32,7 @@ import logger from "../../common/logger";
|
||||
export class PortForwardStore extends ItemStore<PortForwardItem> {
|
||||
private storage = createStorage<ForwardedPort[] | undefined>("port_forwards", undefined);
|
||||
|
||||
@observable portForwards: PortForwardItem[];
|
||||
@observable portForwards: PortForwardItem[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -46,35 +47,44 @@ export class PortForwardStore extends ItemStore<PortForwardItem> {
|
||||
|
||||
const savedPortForwards = this.storage.get(); // undefined on first load
|
||||
|
||||
if (Array.isArray(savedPortForwards)) {
|
||||
if (Array.isArray(savedPortForwards) && savedPortForwards.length > 0) {
|
||||
logger.info("[PORT-FORWARD-STORE] starting saved port-forwards");
|
||||
await Promise.all(savedPortForwards.map(addPortForward));
|
||||
|
||||
// add the disabled ones
|
||||
await Promise.all(savedPortForwards.filter(pf => pf.status === "Disabled").map(addPortForward));
|
||||
|
||||
// add the active ones (assume active if the status is undefined, for backward compatibilty) and check if they started successfully
|
||||
const results = await Promise.allSettled(savedPortForwards.filter(pf => !pf.status || pf.status === "Active").map(addPortForward));
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected" || result.value.status === "Disabled") {
|
||||
notifyErrorPortForwarding("One or more port-forwards could not be started");
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch() {
|
||||
return disposer(
|
||||
reaction(() => this.portForwards, () => this.loadAll()),
|
||||
reaction(() => portForwardStore.portForwards.slice(), () => portForwardStore.loadAll()),
|
||||
);
|
||||
}
|
||||
|
||||
loadAll() {
|
||||
return this.loadItems(async () => {
|
||||
const portForwards = await getPortForwards(getHostedClusterId());
|
||||
return this.loadItems(() => {
|
||||
const portForwards = getPortForwards();
|
||||
|
||||
this.storage.set(portForwards);
|
||||
|
||||
this.reset();
|
||||
this.portForwards = [];
|
||||
portForwards.map(pf => this.portForwards.push(new PortForwardItem(pf)));
|
||||
|
||||
return this.portForwards;
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.portForwards = [];
|
||||
}
|
||||
|
||||
async removeSelectedItems() {
|
||||
return Promise.all(this.selectedItems.map(removePortForward));
|
||||
}
|
||||
@@ -94,82 +104,248 @@ interface PortForwardResult {
|
||||
port: number;
|
||||
}
|
||||
|
||||
interface PortForwardsResult {
|
||||
portForwards: ForwardedPort[];
|
||||
function portForwardsEqual(portForward: ForwardedPort) {
|
||||
return (pf: ForwardedPort) => (
|
||||
pf.kind == portForward.kind &&
|
||||
pf.name == portForward.name &&
|
||||
pf.namespace == portForward.namespace &&
|
||||
pf.port == portForward.port
|
||||
);
|
||||
}
|
||||
|
||||
export async function addPortForward(portForward: ForwardedPort): Promise<number> {
|
||||
const { port, forwardPort } = portForward;
|
||||
function findPortForward(portForward: ForwardedPort) {
|
||||
return portForwardStore.portForwards.find(portForwardsEqual(portForward));
|
||||
|
||||
}
|
||||
|
||||
const setPortForward = action((portForward: ForwardedPort) => {
|
||||
const index = portForwardStore.portForwards.findIndex(portForwardsEqual(portForward));
|
||||
|
||||
if (index < 0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
portForwardStore.portForwards[index] = new PortForwardItem(portForward);
|
||||
});
|
||||
|
||||
/**
|
||||
* start an existing port-forward
|
||||
* @param portForward the port-forward to start. If the forwardPort field is 0 then an arbitrary port will be
|
||||
* used
|
||||
*
|
||||
* @returns the port-forward with updated status ("Active" if successfully started, "Disabled" otherwise) and
|
||||
* forwardPort
|
||||
*
|
||||
* @throws if the port-forward does not already exist in the store
|
||||
*/
|
||||
export const startPortForward = action( async (portForward: ForwardedPort): Promise<ForwardedPort> => {
|
||||
const pf = findPortForward(portForward);
|
||||
|
||||
if (!pf) {
|
||||
throw new Error("cannot start non-existent port-forward");
|
||||
}
|
||||
|
||||
const { port, forwardPort } = pf;
|
||||
let response: PortForwardResult;
|
||||
|
||||
try {
|
||||
const protocol = portForward.protocol ?? "http";
|
||||
const protocol = pf.protocol ?? "http";
|
||||
|
||||
response = await apiBase.post<PortForwardResult>(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`, { query: { port, forwardPort, protocol }});
|
||||
response = await apiBase.post<PortForwardResult>(`/pods/port-forward/${pf.namespace}/${pf.kind}/${pf.name}`, { query: { port, forwardPort, protocol }});
|
||||
|
||||
// expecting the received port to be the specified port, unless the specified port is 0, which indicates any available port is suitable
|
||||
if (portForward.forwardPort && response?.port && response.port != +portForward.forwardPort) {
|
||||
logger.warn(`[PORT-FORWARD-STORE] specified ${portForward.forwardPort} got ${response.port}`);
|
||||
if (pf.forwardPort && response?.port && response.port != +pf.forwardPort) {
|
||||
logger.warn(`[PORT-FORWARD-STORE] specified ${pf.forwardPort}, got ${response.port}`);
|
||||
}
|
||||
|
||||
pf.forwardPort = response.port;
|
||||
pf.status = "Active";
|
||||
|
||||
} catch (error) {
|
||||
logger.warn("[PORT-FORWARD-STORE] Error adding port-forward:", error, portForward);
|
||||
throw (error);
|
||||
logger.warn(`[PORT-FORWARD-STORE] Error starting port-forward: ${error}`, pf);
|
||||
pf.status = "Disabled";
|
||||
}
|
||||
portForwardStore.reset();
|
||||
|
||||
return response?.port;
|
||||
}
|
||||
setPortForward(pf);
|
||||
|
||||
export async function getPortForward(portForward: ForwardedPort): Promise<number> {
|
||||
return pf as ForwardedPort;
|
||||
});
|
||||
|
||||
/**
|
||||
* add a port-forward to the store and optionally start it
|
||||
* @param portForward the port-forward to add. If the port-forward already exists in the store it will be
|
||||
* returned with its current state. If the forwardPort field is 0 then an arbitrary port will be
|
||||
* used. If the status field is "Active" or not present then an attempt is made to start the port-forward.
|
||||
*
|
||||
* @returns the port-forward with updated status ("Active" if successfully started, "Disabled" otherwise) and
|
||||
* forwardPort
|
||||
*/
|
||||
export const addPortForward = action(async (portForward: ForwardedPort): Promise<ForwardedPort> => {
|
||||
const pf = findPortForward(portForward);
|
||||
|
||||
if (pf) {
|
||||
return pf;
|
||||
}
|
||||
|
||||
portForwardStore.portForwards.push(new PortForwardItem(portForward));
|
||||
|
||||
if (!portForward.status) {
|
||||
portForward.status = "Active";
|
||||
}
|
||||
|
||||
if (portForward.status === "Active") {
|
||||
portForward = await startPortForward(portForward);
|
||||
}
|
||||
|
||||
return portForward;
|
||||
});
|
||||
|
||||
async function getActivePortForward(portForward: ForwardedPort): Promise<ForwardedPort> {
|
||||
const { port, forwardPort, protocol } = portForward;
|
||||
let response: PortForwardResult;
|
||||
|
||||
try {
|
||||
response = await apiBase.get<PortForwardResult>(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`, { query: { port, forwardPort, protocol }});
|
||||
} catch (error) {
|
||||
logger.warn("[PORT-FORWARD-STORE] Error getting port-forward:", error, portForward);
|
||||
throw (error);
|
||||
logger.warn(`[PORT-FORWARD-STORE] Error getting active port-forward: ${error}`, portForward);
|
||||
}
|
||||
|
||||
return response?.port;
|
||||
portForward.status = response?.port ? "Active" : "Disabled";
|
||||
portForward.forwardPort = response?.port;
|
||||
|
||||
return portForward;
|
||||
}
|
||||
|
||||
export async function modifyPortForward(portForward: ForwardedPort, desiredPort: number): Promise<number> {
|
||||
let port = 0;
|
||||
/**
|
||||
* get a port-forward from the store, with up-to-date status
|
||||
* @param portForward the port-forward to get.
|
||||
*
|
||||
* @returns the port-forward with updated status ("Active" if running, "Disabled" if not) and
|
||||
* forwardPort used.
|
||||
*
|
||||
* @throws if the port-forward does not exist in the store
|
||||
*/
|
||||
export async function getPortForward(portForward: ForwardedPort): Promise<ForwardedPort> {
|
||||
if (!findPortForward(portForward)) {
|
||||
throw new Error("port-forward not found");
|
||||
}
|
||||
|
||||
await removePortForward(portForward);
|
||||
portForward.forwardPort = desiredPort;
|
||||
port = await addPortForward(portForward);
|
||||
let pf: ForwardedPort;
|
||||
|
||||
portForwardStore.reset();
|
||||
try {
|
||||
// check if the port-forward is active, and if so check if it has the same local port
|
||||
pf = await getActivePortForward(portForward);
|
||||
|
||||
return port;
|
||||
if (pf.forwardPort && pf.forwardPort !== portForward.forwardPort) {
|
||||
logger.warn(`[PORT-FORWARD-STORE] local port, expected ${pf.forwardPort}, got ${portForward.forwardPort}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// port is not active
|
||||
}
|
||||
|
||||
return pf;
|
||||
}
|
||||
|
||||
/**
|
||||
* modifies a port-forward in the store, including the forwardPort and protocol
|
||||
* @param portForward the port-forward to modify.
|
||||
*
|
||||
* @returns the port-forward after being modified.
|
||||
*/
|
||||
export const modifyPortForward = action(async (portForward: ForwardedPort, desiredPort: number): Promise<ForwardedPort> => {
|
||||
const pf = findPortForward(portForward);
|
||||
|
||||
if (!pf) {
|
||||
throw new Error("port-forward not found");
|
||||
}
|
||||
|
||||
if (pf.status === "Active") {
|
||||
try {
|
||||
await stopPortForward(pf);
|
||||
} catch {
|
||||
// ignore, assume it is stopped and proceed to restart it
|
||||
}
|
||||
|
||||
pf.forwardPort = desiredPort;
|
||||
pf.protocol = portForward.protocol ?? "http";
|
||||
setPortForward(pf);
|
||||
|
||||
return await startPortForward(pf);
|
||||
}
|
||||
|
||||
pf.forwardPort = desiredPort;
|
||||
setPortForward(pf);
|
||||
|
||||
return pf as ForwardedPort;
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* stop an existing port-forward. Its status is set to "Disabled" after successfully stopped.
|
||||
* @param portForward the port-forward to stop.
|
||||
*
|
||||
* @throws if the port-forward could not be stopped. Its status is unchanged
|
||||
*/
|
||||
export const stopPortForward = action(async (portForward: ForwardedPort) => {
|
||||
const pf = findPortForward(portForward);
|
||||
|
||||
if (!pf) {
|
||||
logger.warn("[PORT-FORWARD-STORE] Error getting port-forward: port-forward not found", portForward);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
export async function removePortForward(portForward: ForwardedPort) {
|
||||
const { port, forwardPort } = portForward;
|
||||
|
||||
try {
|
||||
await apiBase.del(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`, { query: { port, forwardPort }});
|
||||
await waitUntilFree(+forwardPort, 200, 1000);
|
||||
} catch (error) {
|
||||
logger.warn("[PORT-FORWARD-STORE] Error removing port-forward:", error, portForward);
|
||||
logger.warn(`[PORT-FORWARD-STORE] Error stopping active port-forward: ${error}`, portForward);
|
||||
throw (error);
|
||||
}
|
||||
portForwardStore.reset();
|
||||
}
|
||||
|
||||
export async function getPortForwards(clusterId?: string): Promise<ForwardedPort[]> {
|
||||
try {
|
||||
const response = await apiBase.get<PortForwardsResult>("/pods/port-forwards", { query: { clusterId }});
|
||||
pf.status = "Disabled";
|
||||
setPortForward(pf);
|
||||
});
|
||||
|
||||
return response.portForwards;
|
||||
} catch (error) {
|
||||
logger.warn("[PORT-FORWARD-STORE] Error getting all port-forwards:", error);
|
||||
/**
|
||||
* remove and stop an existing port-forward.
|
||||
* @param portForward the port-forward to remove.
|
||||
*/
|
||||
export const removePortForward = action(async (portForward: ForwardedPort) => {
|
||||
const pf = findPortForward(portForward);
|
||||
|
||||
return [];
|
||||
if (!pf) {
|
||||
const error = new Error("port-forward not found");
|
||||
|
||||
logger.warn(`[PORT-FORWARD-STORE] Error getting port-forward: ${error}`, portForward);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await stopPortForward(portForward);
|
||||
} catch (error) {
|
||||
if (pf.status === "Active") {
|
||||
logger.warn(`[PORT-FORWARD-STORE] Error removing port-forward: ${error}`, portForward);
|
||||
}
|
||||
}
|
||||
|
||||
const index = portForwardStore.portForwards.findIndex(portForwardsEqual(portForward));
|
||||
|
||||
if (index >= 0 ) {
|
||||
portForwardStore.portForwards.splice(index, 1);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* gets the list of port-forwards in the store
|
||||
*
|
||||
* @returns the port-forwards
|
||||
*/
|
||||
export function getPortForwards(): ForwardedPort[] {
|
||||
return portForwardStore.portForwards;
|
||||
}
|
||||
|
||||
export const portForwardStore = new PortForwardStore();
|
||||
@@ -6,6 +6,7 @@
|
||||
<body>
|
||||
|
||||
<div id="app"></div>
|
||||
<div id="terminal-init"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -27,6 +27,7 @@ import lensDarkThemeJson from "./themes/lens-dark.json";
|
||||
import lensLightThemeJson from "./themes/lens-light.json";
|
||||
import type { SelectOption } from "./components/select";
|
||||
import type { MonacoEditorProps } from "./components/monaco-editor";
|
||||
import { defaultTheme } from "../common/vars";
|
||||
|
||||
export type ThemeId = string;
|
||||
|
||||
@@ -40,7 +41,6 @@ export interface Theme {
|
||||
}
|
||||
|
||||
export class ThemeStore extends Singleton {
|
||||
static readonly defaultTheme = "lens-dark";
|
||||
protected styles: HTMLStyleElement;
|
||||
|
||||
// bundled themes from `themes/${themeId}.json`
|
||||
@@ -54,7 +54,7 @@ export class ThemeStore extends Singleton {
|
||||
}
|
||||
|
||||
@computed get activeTheme(): Theme {
|
||||
return this.themes.get(this.activeThemeId) ?? this.themes.get(ThemeStore.defaultTheme);
|
||||
return this.themes.get(this.activeThemeId) ?? this.themes.get(defaultTheme);
|
||||
}
|
||||
|
||||
@computed get themeOptions(): SelectOption<string>[] {
|
||||
|
||||
@@ -50,7 +50,9 @@ export function createStorage<T>(key: string, defaultValue: T) {
|
||||
|
||||
try {
|
||||
storage.data = await fse.readJson(filePath);
|
||||
} catch {} finally {
|
||||
} catch {
|
||||
// ignore error
|
||||
} finally {
|
||||
if (!isTestEnv) {
|
||||
logger.info(`${logPrefix} loading finished for ${filePath}`);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
// Helper for working with storages (e.g. window.localStorage, NodeJS/file-system, etc.)
|
||||
import { action, comparer, makeObservable, observable, toJS, when } from "mobx";
|
||||
import produce, { Draft, isDraft } from "immer";
|
||||
import { produce, Draft, isDraft } from "immer";
|
||||
import { isEqual, isPlainObject } from "lodash";
|
||||
import logger from "../../main/logger";
|
||||
import { getHostedClusterId } from "../../common/utils";
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
|
||||
import type { Disposer } from "../utils";
|
||||
|
||||
function addWindowEventListener<K extends keyof WindowEventMap>(type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions): Disposer {
|
||||
window.addEventListener(type, listener, options);
|
||||
|
||||
return () => void window.removeEventListener(type, listener);
|
||||
}
|
||||
|
||||
const windowAddEventListenerInjectable = getInjectable({
|
||||
instantiate: () => addWindowEventListener,
|
||||
lifecycle: lifecycleEnum.singleton,
|
||||
});
|
||||
|
||||
export default windowAddEventListenerInjectable;
|
||||
Reference in new issue
Block a user