Lens restructure (#540)

Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
Roman authored and GitHub committed 2020-06-30 14:35:16 +03:00
1 parent e810eab39f
commit b7974827d2
786 files changed
+14567 -24066

No files matched your search

@@ -0,0 +1,2 @@
.CreateResource {
}
@@ -0,0 +1,26 @@
import { autobind } from "../../utils";
import { DockTabStore } from "./dock-tab.store";
import { dockStore, IDockTab, TabKind } from "./dock.store";
@autobind()
export class CreateResourceStore extends DockTabStore<string> {
constructor() {
super({
storageName: "create_resource"
});
}
}
export const createResourceStore = new CreateResourceStore();
export function createResourceTab(tabParams: Partial<IDockTab> = {}) {
return dockStore.createTab({
kind: TabKind.CREATE_RESOURCE,
title: "Create resource",
...tabParams
});
}
export function isCreateResourceTab(tab: IDockTab) {
return tab && tab.kind === TabKind.CREATE_RESOURCE;
}
@@ -0,0 +1,85 @@
import "./create-resource.scss";
import React from "react";
import jsYaml from "js-yaml"
import { observable } from "mobx";
import { observer } from "mobx-react";
import { Plural, t, Trans } from "@lingui/macro";
import { cssNames } from "../../utils";
import { createResourceStore } from "./create-resource.store";
import { IDockTab } from "./dock.store";
import { EditorPanel } from "./editor-panel";
import { InfoPanel } from "./info-panel";
import { resourceApplierApi } from "../../api/endpoints/resource-applier.api";
import { _i18n } from "../../i18n";
import { JsonApiErrorParsed } from "../../api/json-api";
import { Notifications } from "../notifications";
interface Props {
className?: string;
tab: IDockTab;
}
@observer
export class CreateResource extends React.Component<Props> {
@observable error = ""
get tabId() {
return this.props.tab.id;
}
get data() {
return createResourceStore.getData(this.tabId);
}
onChange = (value: string, error?: string) => {
createResourceStore.setData(this.tabId, value);
this.error = error;
}
create = async () => {
if (this.error) return;
const resources = jsYaml.safeLoadAll(this.data)
.filter(v => !!v) // skip empty documents if "---" pasted at the beginning or end
const createdResources: string[] = [];
const errors: string[] = [];
await Promise.all(
resources.map(data => {
return resourceApplierApi.update(data)
.then(item => createdResources.push(item.getName()))
.catch((err: JsonApiErrorParsed) => errors.push(err.toString()))
})
);
if (errors.length) {
errors.forEach(Notifications.error);
if (!createdResources.length) throw errors[0];
}
return (
<p>
<Plural value={createdResources.length} one="Resource" other="Resources"/>{" "}
<Trans><b>{createdResources.join(", ")}</b> successfully created</Trans>
</p>
)
}
render() {
const { tabId, data, error, create, onChange } = this;
const { className } = this.props;
return (
<div className={cssNames("CreateResource flex column", className)}>
<EditorPanel
tabId={tabId}
value={data}
onChange={onChange}
/>
<InfoPanel
tabId={tabId}
error={error}
submit={create}
submitLabel={_i18n._(t`Create`)}
showNotifications={false}
/>
</div>
)
}
}
@@ -0,0 +1,34 @@
.DockTab {
padding: $padding;
padding-right: 0;
.Icon {
&.material {
--size: var(--small-size);
}
&.svg {
--size: 15px;
}
}
.label {
.title {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
}
}
&.pinned {
padding-right: $padding;
}
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: $padding;
}
}
@@ -0,0 +1,58 @@
import { autorun, observable, reaction } from "mobx";
import { autobind, createStorage } from "../../utils";
import { dockStore, TabId } from "./dock.store";
interface Options<T = any> {
storageName?: string; // name to sync data with localStorage
storageSerializer?: (data: T) => Partial<T>; // allow to customize data before saving to localStorage
}
@autobind()
export class DockTabStore<T = any> {
protected data = observable.map<TabId, T>([]);
constructor(protected options: Options<T> = {}) {
const { storageName } = options;
// auto-save to local-storage
if (storageName) {
const storage = createStorage<[TabId, T][]>(storageName, []);
this.data.replace(storage.get());
reaction(() => this.serializeData(), (data: T | any) => storage.set(data));
}
// clear data for closed tabs
autorun(() => {
const currentTabs = dockStore.tabs.map(tab => tab.id);
Array.from(this.data.keys()).forEach(tabId => {
if (!currentTabs.includes(tabId)) {
this.clearData(tabId);
}
})
});
}
protected serializeData() {
const { storageSerializer } = this.options;
return Array.from(this.data).map(([tabId, tabData]) => {
if (storageSerializer) return [tabId, storageSerializer(tabData)]
return [tabId, tabData];
})
}
getData(tabId: TabId) {
return this.data.get(tabId);
}
setData(tabId: TabId, data: T) {
this.data.set(tabId, data);
}
clearData(tabId: TabId) {
this.data.delete(tabId);
}
reset() {
this.data.clear();
}
}
+51
View File
@@ -0,0 +1,51 @@
import "./dock-tab.scss"
import React from "react"
import { observer } from "mobx-react";
import { t } from "@lingui/macro";
import { autobind, cssNames, prevDefault } from "../../utils";
import { dockStore, IDockTab } from "./dock.store";
import { Tab, TabProps } from "../tabs";
import { Icon } from "../icon";
import { _i18n } from "../../i18n";
export interface DockTabProps extends TabProps<IDockTab> {
moreActions?: React.ReactNode;
}
@observer
export class DockTab extends React.Component<DockTabProps> {
get tabId() {
return this.props.value.id;
}
@autobind()
close() {
dockStore.closeTab(this.tabId);
}
render() {
const { className, moreActions, ...tabProps } = this.props;
const { title, pinned } = tabProps.value;
const label = (
<div className="flex gaps align-center">
<span className="title" title={title}>{title}</span>
{moreActions}
{!pinned && (
<Icon
small material="close"
title={_i18n._(t`Close (Ctrl+W)`)}
onClick={prevDefault(this.close)}
/>
)}
</div>
)
return (
<Tab
{...tabProps}
className={cssNames("DockTab", className, { pinned })}
label={label}
/>
)
}
}
+80
View File
@@ -0,0 +1,80 @@
.Dock {
$borderColor: $borderColor;
position: relative;
background: $dockHeadBackground;
display: flex;
flex-direction: column;
&.isOpen {
&.fullSize {
position: fixed;
top: 0;
right: 0;
left: 0;
bottom: 0;
z-index: 100;
> .resizer {
display: none;
}
}
}
&:not(.isOpen) {
height: auto !important;
.Tab:not(:focus):after {
display: none;
}
}
.tabs-container {
padding: 0 $padding * 2;
border-top: 1px solid $borderColor;
flex-shrink: 0;
.Tabs:empty + .toolbar {
padding-left: 0;
}
.toolbar {
min-height: $unit * 4;
padding-left: $padding;
user-select: none;
}
}
.tab-content {
position: relative;
background: $terminalBackground;
flex: 1;
overflow: hidden;
transition: flex 60ms ease-in-out;
> *:not(.Spinner) {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
}
}
.resizer {
$height: 12px;
position: absolute;
top: -$height / 2;
left: 0;
right: 0;
bottom: 100%;
height: $height;
cursor: row-resize;
z-index: 10;
&.disabled {
pointer-events: none;
}
}
}
+192
View File
@@ -0,0 +1,192 @@
import MD5 from "crypto-js/md5";
import { action, computed, IReactionOptions, observable, reaction } from "mobx";
import { autobind, createStorage } from "../../utils";
import throttle from "lodash/throttle"
export type TabId = string;
export enum TabKind {
TERMINAL = "terminal",
CREATE_RESOURCE = "create-resource",
EDIT_RESOURCE = "edit-resource",
INSTALL_CHART = "install-chart",
UPGRADE_CHART = "upgrade-chart",
}
export interface IDockTab {
id?: TabId;
kind: TabKind;
title?: string;
pinned?: boolean; // not closable
}
@autobind()
export class DockStore {
protected initialTabs: IDockTab[] = [
{ id: "terminal", kind: TabKind.TERMINAL, title: "Terminal" },
];
protected storage = createStorage("dock", {}); // keep settings in localStorage
public defaultTabId = this.initialTabs[0].id;
public minHeight = 100;
@observable isOpen = false;
@observable fullSize = false;
@observable height = this.defaultHeight;
@observable tabs = observable.array<IDockTab>(this.initialTabs);
@observable selectedTabId = this.defaultTabId;
@computed get selectedTab() {
return this.tabs.find(tab => tab.id === this.selectedTabId);
}
get defaultHeight() {
return Math.round(window.innerHeight / 2.5);
}
get maxHeight() {
const mainLayoutHeader = 40;
const mainLayoutTabs = 33;
const mainLayoutMargin = 16;
const dockTabs = 33;
return window.innerHeight - mainLayoutHeader - mainLayoutTabs - mainLayoutMargin - dockTabs;
}
constructor() {
Object.assign(this, this.storage.get());
reaction(() => ({
isOpen: this.isOpen,
selectedTabId: this.selectedTabId,
height: this.height,
tabs: this.tabs.slice(),
}), data => {
this.storage.set(data);
});
// adjust terminal height if window size changes
this.checkMaxHeight();
window.addEventListener("resize", throttle(this.checkMaxHeight, 250));
}
protected checkMaxHeight() {
if (!this.height) {
this.setHeight(this.defaultHeight || this.minHeight);
}
if (this.height > this.maxHeight) {
this.setHeight(this.maxHeight);
}
}
onResize(callback: () => void, options?: IReactionOptions) {
return reaction(() => [this.height, this.fullSize], callback, options);
}
onTabChange(callback: (tabId: TabId) => void, options?: IReactionOptions) {
return reaction(() => this.selectedTabId, callback, options);
}
hasTabs() {
return this.tabs.length > 0;
}
@action
open(fullSize?: boolean) {
this.isOpen = true;
if (typeof fullSize === "boolean") {
this.fullSize = fullSize;
}
}
@action
close() {
this.isOpen = false;
}
@action
toggle() {
if (this.isOpen) this.close();
else this.open();
}
@action
toggleFillSize() {
if (!this.isOpen) this.open();
this.fullSize = !this.fullSize;
}
getTabById(tabId: TabId) {
return this.tabs.find(tab => tab.id === tabId);
}
protected getNewTabNumber(kind: TabKind) {
const tabNumbers = this.tabs
.filter(tab => tab.kind === kind)
.map(tab => {
const tabNumber = +tab.title.match(/\d+/);
return tabNumber === 0 ? 1 : tabNumber; // tab without a number is first
});
for (let i = 1; ; i++) {
if (!tabNumbers.includes(i)) return i;
}
}
@action
createTab(anonTab: IDockTab, addNumber = true): IDockTab {
const tabId = MD5(Math.random().toString() + Date.now()).toString();
const tab: IDockTab = { id: tabId, ...anonTab };
if (addNumber) {
const tabNumber = this.getNewTabNumber(tab.kind);
if (tabNumber > 1) tab.title += ` (${tabNumber})`
}
this.tabs.push(tab);
this.selectTab(tab.id);
this.open();
return tab;
}
@action
async closeTab(tabId: TabId) {
const tab = this.getTabById(tabId);
if (!tab || tab.pinned) {
return;
}
this.tabs.remove(tab);
if (this.selectedTabId === tab.id) {
if (this.tabs.length) {
const newTab = this.tabs.slice(-1)[0]; // last
if (newTab.kind === TabKind.TERMINAL) {
// close the dock when selected sibling inactive terminal tab
const { terminalStore } = await import("./terminal.store");
if (!terminalStore.isConnected(newTab.id)) this.close();
}
this.selectTab(newTab.id);
}
else {
this.selectedTabId = null;
this.close();
}
}
}
@action
selectTab(tabId: TabId) {
const tab = this.getTabById(tabId);
this.selectedTabId = tab ? tab.id : null;
}
@action
setHeight(height: number) {
this.height = Math.max(0, Math.min(height, this.maxHeight));
}
@action
reset() {
this.selectedTabId = this.defaultTabId;
this.tabs.replace(this.initialTabs);
this.height = this.defaultHeight;
this.close();
}
}
export const dockStore = new DockStore();
+153
View File
@@ -0,0 +1,153 @@
import "./dock.scss";
import React, { Fragment } from "react";
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { autobind, cssNames, prevDefault } from "../../utils";
import { Draggable, DraggableState } from "../draggable";
import { Icon } from "../icon";
import { Tabs } from "../tabs/tabs";
import { MenuItem } from "../menu";
import { MenuActions } from "../menu/menu-actions";
import { dockStore, IDockTab } from "./dock.store";
import { DockTab } from "./dock-tab";
import { TerminalTab } from "./terminal-tab";
import { TerminalWindow } from "./terminal-window";
import { CreateResource } from "./create-resource";
import { InstallChart } from "./install-chart";
import { EditResource } from "./edit-resource";
import { UpgradeChart } from "./upgrade-chart";
import { createTerminalTab, isTerminalTab } from "./terminal.store";
import { createResourceTab, isCreateResourceTab } from "./create-resource.store";
import { isEditResourceTab } from "./edit-resource.store";
import { isInstallChartTab } from "./install-chart.store";
import { isUpgradeChartTab } from "./upgrade-chart.store";
interface Props {
className?: string;
}
@observer
export class Dock extends React.Component<Props> {
onResizeStart = () => {
const { isOpen, open, setHeight, minHeight } = dockStore;
if (!isOpen) {
open();
setHeight(minHeight);
}
}
onResize = ({ offsetY }: DraggableState) => {
const { isOpen, close, height, setHeight, minHeight, defaultHeight } = dockStore;
const newHeight = height + offsetY;
if (height > newHeight && newHeight < minHeight) {
setHeight(defaultHeight);
close();
}
else if (isOpen) {
setHeight(newHeight);
}
}
onKeydown = (evt: React.KeyboardEvent<HTMLElement>) => {
const { close, closeTab, selectedTab, fullSize, toggleFillSize } = dockStore;
if (!selectedTab) return;
const { code, ctrlKey, shiftKey } = evt.nativeEvent;
if (shiftKey && code === "Escape") {
close();
}
if (ctrlKey && code === "KeyW") {
if (selectedTab.pinned) close();
else closeTab(selectedTab.id);
}
}
onChangeTab = (tab: IDockTab) => {
const { open, selectTab } = dockStore;
open();
selectTab(tab.id);
}
@autobind()
renderTab(tab: IDockTab) {
if (isTerminalTab(tab)) {
return <TerminalTab value={tab}/>
}
if (isCreateResourceTab(tab) || isEditResourceTab(tab)) {
return <DockTab value={tab} icon="edit"/>
}
if (isInstallChartTab(tab) || isUpgradeChartTab(tab)) {
return <DockTab value={tab} icon={<Icon svg="install"/>}/>
}
}
renderTabContent() {
const { isOpen, height, selectedTab: tab } = dockStore;
if (!isOpen || !tab) return;
return (
<div className="tab-content" style={{ flexBasis: height }}>
{isCreateResourceTab(tab) && <CreateResource tab={tab}/>}
{isEditResourceTab(tab) && <EditResource tab={tab}/>}
{isInstallChartTab(tab) && <InstallChart tab={tab}/>}
{isUpgradeChartTab(tab) && <UpgradeChart tab={tab}/>}
{isTerminalTab(tab) && <TerminalWindow tab={tab}/>}
</div>
)
}
render() {
const { className } = this.props;
const { isOpen, toggle, tabs, toggleFillSize, selectedTab, hasTabs, fullSize } = dockStore;
return (
<div
className={cssNames("Dock", className, { isOpen, fullSize })}
onKeyDown={this.onKeydown}
tabIndex={-1}
>
<Draggable
className={cssNames("resizer", { disabled: !hasTabs() })}
horizontal={false}
onStart={this.onResizeStart}
onEnter={this.onResize}
/>
<div className="tabs-container flex align-center" onDoubleClick={prevDefault(toggle)}>
<Tabs
autoFocus={isOpen}
className="dock-tabs"
value={selectedTab} onChange={this.onChangeTab}
children={tabs.map(tab => <Fragment key={tab.id}>{this.renderTab(tab)}</Fragment>)}
/>
<div className="toolbar flex gaps align-center box grow">
<div className="dock-menu box grow">
<MenuActions usePortal triggerIcon={{ material: "add", className: "new-dock-tab", tooltip: <Trans>New tab</Trans> }} closeOnScroll={false}>
<MenuItem className="create-terminal-tab" onClick={() => createTerminalTab()}>
<Icon small svg="terminal" size={15}/>
<Trans>Terminal session</Trans>
</MenuItem>
<MenuItem className="create-resource-tab" onClick={() => createResourceTab()}>
<Icon small material="create"/>
<Trans>Create resource</Trans>
</MenuItem>
</MenuActions>
</div>
{hasTabs() && (
<>
<Icon
material={fullSize ? "fullscreen_exit": "fullscreen"}
tooltip={fullSize ? <Trans>Exit full size mode</Trans> : <Trans>Fit to window</Trans>}
onClick={toggleFillSize}
/>
<Icon
material={`keyboard_arrow_${isOpen ? "down" : "up"}`}
tooltip={isOpen ? <Trans>Minimize</Trans> : <Trans>Open</Trans>}
onClick={toggle}
/>
</>
)}
</div>
</div>
{this.renderTabContent()}
</div>
)
}
}
@@ -0,0 +1,2 @@
.EditResource {
}
@@ -0,0 +1,90 @@
import { autobind, noop } from "../../utils";
import { DockTabStore } from "./dock-tab.store";
import { autorun, IReactionDisposer } from "mobx";
import { dockStore, IDockTab, TabKind } from "./dock.store";
import { KubeObject } from "../../api/kube-object";
import { apiManager } from "../../api/api-manager";
export interface KubeEditResource {
resource: string; // resource path, e.g. /api/v1/namespaces/default
draft?: string; // edited draft in yaml
}
@autobind()
export class EditResourceStore extends DockTabStore<KubeEditResource> {
private watchers = new Map<string /*tabId*/, IReactionDisposer>();
constructor() {
super({
storageName: "edit_resource_store",
storageSerializer: ({ draft, ...data }) => data, // skip saving draft in local-storage
});
autorun(() => {
Array.from(this.data).forEach(([tabId, { resource }]) => {
if (this.watchers.get(tabId)) {
return;
}
this.watchers.set(tabId, autorun(() => {
const store = apiManager.getStore(resource);
if (store) {
const isActiveTab = dockStore.isOpen && dockStore.selectedTabId === tabId;
const obj = store.getByPath(resource);
// preload resource for editing
if (!obj && !store.isLoaded && !store.isLoading && isActiveTab) {
store.loadFromPath(resource).catch(noop);
}
// auto-close tab when resource removed from store
else if (!obj && store.isLoaded) {
dockStore.closeTab(tabId);
}
}
}, {
delay: 100 // make sure all stores initialized
}));
})
});
}
getTabByResource(object: KubeObject): IDockTab {
const [tabId] = Array.from(this.data).find(([tabId, { resource }]) => {
return object.selfLink === resource;
}) || [];
return dockStore.getTabById(tabId);
}
reset() {
super.reset();
Array.from(this.watchers).forEach(([tabId, dispose]) => {
this.watchers.delete(tabId);
dispose();
})
}
}
export const editResourceStore = new EditResourceStore();
export function editResourceTab(object: KubeObject, tabParams: Partial<IDockTab> = {}) {
// use existing tab if already opened
let tab = editResourceStore.getTabByResource(object);
if (tab) {
dockStore.open();
dockStore.selectTab(tab.id);
}
// or create new tab
if (!tab) {
tab = dockStore.createTab({
title: `${object.kind}: ${object.getName()}`,
kind: TabKind.EDIT_RESOURCE,
...tabParams
}, false);
editResourceStore.setData(tab.id, {
resource: object.selfLink,
});
}
return tab;
}
export function isEditResourceTab(tab: IDockTab) {
return tab && tab.kind === TabKind.EDIT_RESOURCE;
}
@@ -0,0 +1,115 @@
import "./edit-resource.scss";
import React from "react";
import { autorun, observable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import jsYaml from "js-yaml"
import { t, Trans } from "@lingui/macro";
import { IDockTab } from "./dock.store";
import { cssNames } from "../../utils";
import { editResourceStore } from "./edit-resource.store";
import { InfoPanel } from "./info-panel";
import { Badge } from "../badge";
import { EditorPanel } from "./editor-panel";
import { Spinner } from "../spinner";
import { _i18n } from "../../i18n";
import { apiManager } from "../../api/api-manager";
import { KubeObject } from "../../api/kube-object";
interface Props {
className?: string;
tab: IDockTab;
}
@observer
export class EditResource extends React.Component<Props> {
@observable error = "";
@disposeOnUnmount
autoDumpResourceOnInit = autorun(() => {
if (!this.tabData) return;
if (this.tabData.draft === undefined && this.resource) {
this.saveDraft(this.resource);
}
});
get tabId() {
return this.props.tab.id;
}
get tabData() {
return editResourceStore.getData(this.tabId);
}
get resource(): KubeObject {
const { resource } = this.tabData;
const store = apiManager.getStore(resource);
if (store) {
return store.getByPath(resource);
}
}
saveDraft(draft: string | object) {
if (typeof draft === "object") {
draft = draft ? jsYaml.dump(draft) : undefined;
}
editResourceStore.setData(this.tabId, {
...this.tabData,
draft: draft,
});
}
onChange = (draft: string, error?: string) => {
this.error = error;
this.saveDraft(draft);
}
save = async () => {
if (this.error) {
return;
}
const { resource, draft } = this.tabData;
const store = apiManager.getStore(resource);
const updatedResource = await store.update(this.resource, jsYaml.safeLoad(draft));
this.saveDraft(updatedResource); // update with new resourceVersion to avoid further errors on save
const resourceType = updatedResource.kind;
const resourceName = updatedResource.getName();
return (
<p>
<Trans>{resourceType} <b>{resourceName}</b> updated.</Trans>
</p>
);
}
render() {
const { tabId, resource, tabData, error, onChange, save } = this;
const { draft } = tabData;
if (!resource || draft === undefined) {
return <Spinner center/>;
}
const { kind, getNs, getName } = resource;
return (
<div className={cssNames("EditResource flex column", this.props.className)}>
<EditorPanel
tabId={tabId}
value={draft}
onChange={onChange}
/>
<InfoPanel
tabId={tabId}
error={error}
submit={save}
submitLabel={_i18n._(t`Save`)}
submittingMessage={_i18n._(t`Applying..`)}
controls={(
<div className="resource-info flex gaps align-center">
<span><Trans>Kind</Trans>:</span> <Badge label={kind}/>
<span><Trans>Name</Trans>:</span><Badge label={getName()}/>
<span><Trans>Namespace</Trans>:</span> <Badge label={getNs() || "global"}/>
</div>
)}
/>
</div>
)
}
}
@@ -0,0 +1,81 @@
import React from "react";
import jsYaml from "js-yaml"
import { observable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { cssNames } from "../../utils";
import { AceEditor } from "../ace-editor";
import { dockStore, TabId } from "./dock.store";
import { DockTabStore } from "./dock-tab.store";
import { Ace } from "ace-builds";
interface Props {
className?: string;
tabId: TabId;
value: string;
onChange(value: string, error?: string): void;
}
@observer
export class EditorPanel extends React.Component<Props> {
static cursorPos = new DockTabStore<Ace.Point>();
public editor: AceEditor;
@observable yamlError = ""
componentDidMount() {
// validate and run callback with optional error
this.onChange(this.props.value || "");
disposeOnUnmount(this, [
dockStore.onTabChange(this.onTabChange, { delay: 250 }),
dockStore.onResize(this.onResize, { delay: 250 }),
])
}
validate(value: string) {
try {
jsYaml.safeLoadAll(value);
this.yamlError = "";
} catch (err) {
this.yamlError = err.toString();
}
}
onTabChange = () => {
this.editor.focus();
}
onResize = () => {
this.editor.resize();
}
onCursorPosChange = (pos: Ace.Point) => {
EditorPanel.cursorPos.setData(this.props.tabId, pos);
}
onChange = (value: string) => {
this.validate(value);
if (this.props.onChange) {
this.props.onChange(value, this.yamlError);
}
}
render() {
const { value, tabId } = this.props;
let { className } = this.props;
className = cssNames("EditorPanel", className);
const cursorPos = EditorPanel.cursorPos.getData(tabId);
return (
<AceEditor
autoFocus mode="yaml"
className={className}
value={value}
cursorPos={cursorPos}
onChange={this.onChange}
onCursorPosChange={this.onCursorPosChange}
ref={e => this.editor = e}
/>
)
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./dock"
@@ -0,0 +1,48 @@
.InfoPanel {
@include hidden-scrollbar;
background: $dockInfoBackground;
border-top: 1px solid $dockInfoBorderColor;
padding: $padding $padding * 2;
flex-shrink: 0;
.Spinner {
margin-right: $padding;
}
> .controls {
white-space: nowrap;
&:empty {
display: none;
}
&:not(:empty) + .info {
border: 1px solid $borderColor;
border-top: 0;
border-bottom: 0;
min-height: 25px;
padding-left: $padding;
padding-right: $padding;
}
}
> .info {
@include hidden-scrollbar;
min-width: 40px; // min-space for icon
flex: 1 1;
white-space: nowrap;
text-overflow: ellipsis;
> div {
padding-right: $padding;
flex-shrink: 0;
}
.Icon {
margin: 0;
margin-right: $padding;
}
}
}
+137
View File
@@ -0,0 +1,137 @@
import "./info-panel.scss";
import React, { Component, ReactNode } from "react";
import { computed, observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { cssNames } from "../../utils";
import { Button } from "../button";
import { Icon } from "../icon";
import { Spinner } from "../spinner";
import { dockStore, TabId } from "./dock.store";
import { Notifications } from "../notifications";
interface Props extends OptionalProps {
tabId: TabId;
submit: () => Promise<ReactNode | string>;
}
interface OptionalProps {
className?: string;
error?: string;
controls?: ReactNode;
submitLabel?: ReactNode;
submittingMessage?: ReactNode;
disableSubmit?: boolean;
showSubmitClose?: boolean;
showInlineInfo?: boolean;
showNotifications?: boolean;
}
@observer
export class InfoPanel extends Component<Props> {
static defaultProps: OptionalProps = {
submitLabel: <Trans>Submit</Trans>,
submittingMessage: <Trans>Submitting..</Trans>,
showSubmitClose: true,
showInlineInfo: true,
showNotifications: true,
}
@observable.ref result: ReactNode;
@observable error = "";
@observable waiting = false;
componentDidMount() {
disposeOnUnmount(this, [
reaction(() => this.props.tabId, () => {
this.result = ""
this.error = ""
this.waiting = false
})
])
}
@computed get errorInfo() {
return this.error || this.props.error;
}
submit = async () => {
const { showNotifications } = this.props;
this.result = "";
this.error = "";
this.waiting = true;
try {
this.result = await this.props.submit().finally(() => {
this.waiting = false;
});
if (showNotifications) Notifications.ok(this.result);
} catch (error) {
this.error = error.toString();
if (showNotifications) Notifications.error(this.error);
throw error;
}
}
submitAndClose = async () => {
await this.submit();
this.close();
}
close = () => {
dockStore.closeTab(this.props.tabId);
}
renderInfo() {
if (!this.props.showInlineInfo) {
return;
}
const { result, errorInfo } = this;
return (
<>
{result && (
<div className="success flex align-center">
<Icon material="done"/> <span>{result}</span>
</div>
)}
{errorInfo && (
<div className="error flex align-center">
<Icon material="error_outline"/>
<span>{errorInfo}</span>
</div>
)}
</>
)
}
render() {
const { className, controls, submitLabel, disableSubmit, error, submittingMessage, showSubmitClose } = this.props;
const { submit, close, submitAndClose, waiting } = this;
const isDisabled = !!(disableSubmit || waiting || error);
return (
<div className={cssNames("InfoPanel flex gaps align-center", className)}>
<div className="controls">
{controls}
</div>
<div className="info flex gaps align-center">
{waiting ? <><Spinner/> {submittingMessage}</> : this.renderInfo()}
</div>
<Button plain label={<Trans>Cancel</Trans>} onClick={close}/>
<Button
primary active
label={submitLabel}
onClick={submit}
disabled={isDisabled}
/>
{showSubmitClose && (
<Button
primary active
label={<Trans>{submitLabel} & Close</Trans>}
onClick={submitAndClose}
disabled={isDisabled}
/>
)}
</div>
);
}
}
@@ -0,0 +1,16 @@
.InstallChart {
.Select {
&.NamespaceSelect {
min-width: 130px;
}
&.chart-version {
min-width: 80px;
}
}
}
.InstallChartDone {
--flex-gap: #{$padding * 1.5};
padding: $padding * 2;
}
@@ -0,0 +1,97 @@
import { action, autorun } from "mobx";
import { dockStore, IDockTab, TabId, TabKind } from "./dock.store";
import { DockTabStore } from "./dock-tab.store";
import { t } from "@lingui/macro";
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api";
import { IReleaseUpdateDetails } from "../../api/endpoints/helm-releases.api";
import { _i18n } from "../../i18n";
export interface IChartInstallData {
name: string;
repo: string;
version: string;
values?: string;
releaseName?: string;
description?: string;
namespace?: string;
lastVersion?: boolean;
}
export class InstallChartStore extends DockTabStore<IChartInstallData> {
public versions = new DockTabStore<string[]>();
public details = new DockTabStore<IReleaseUpdateDetails>();
constructor() {
super({
storageName: "install_charts"
});
autorun(() => {
const { selectedTab, isOpen } = dockStore;
if (!isInstallChartTab(selectedTab)) return;
if (isOpen) {
this.loadData();
}
}, { delay: 250 })
}
@action
async loadData(tabId = dockStore.selectedTabId) {
const { values } = this.getData(tabId);
const versions = this.versions.getData(tabId);
return Promise.all([
!versions && this.loadVersions(tabId),
!values && this.loadValues(tabId),
])
}
@action
async loadVersions(tabId: TabId) {
const { repo, name } = this.getData(tabId);
this.versions.clearData(tabId); // reset
const charts = await helmChartsApi.get(repo, name);
const versions = charts.versions.map(chartVersion => chartVersion.version);
this.versions.setData(tabId, versions);
}
@action
async loadValues(tabId: TabId) {
const data = this.getData(tabId);
const { repo, name, version } = data;
let values = "";
const fetchValues = async (retry = 1, maxRetries = 3) => {
values = await helmChartsApi.getValues(repo, name, version);
if (values || retry == maxRetries) return;
await fetchValues(retry + 1);
};
this.setData(tabId, { ...data, values: undefined }); // reset
await fetchValues();
this.setData(tabId, { ...data, values });
}
}
export const installChartStore = new InstallChartStore();
export function createInstallChartTab(chart: HelmChart, tabParams: Partial<IDockTab> = {}) {
const { name, repo, version } = chart;
const tab = dockStore.createTab({
kind: TabKind.INSTALL_CHART,
title: _i18n._(t`Helm Install: ${repo}/${name}`),
...tabParams
}, false);
installChartStore.setData(tab.id, {
name,
repo,
version,
namespace: "default",
releaseName: "",
description: "",
});
return tab;
}
export function isInstallChartTab(tab: IDockTab) {
return tab && tab.kind === TabKind.INSTALL_CHART;
}
@@ -0,0 +1,194 @@
import "./install-chart.scss";
import React, { Component } from "react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { t, Trans } from "@lingui/macro";
import { dockStore, IDockTab } from "./dock.store";
import { InfoPanel } from "./info-panel";
import { Badge } from "../badge";
import { NamespaceSelect } from "../+namespaces/namespace-select";
import { autobind, prevDefault } from "../../utils";
import { IChartInstallData, installChartStore } from "./install-chart.store";
import { Spinner } from "../spinner";
import { Icon } from "../icon";
import { Button } from "../button";
import { releaseURL } from "../+apps-releases";
import { releaseStore } from "../+apps-releases/release.store";
import { LogsDialog } from "../dialog/logs-dialog";
import { Select, SelectOption } from "../select";
import { Input } from "../input";
import { EditorPanel } from "./editor-panel";
import { navigate } from "../../navigation";
import { _i18n } from "../../i18n";
interface Props {
tab: IDockTab;
}
@observer
export class InstallChart extends Component<Props> {
@observable error = "";
@observable showNotes = false;
get values() {
return this.chartData.values;
}
get chartData() {
return installChartStore.getData(this.tabId);
}
get tabId() {
return this.props.tab.id;
}
get versions() {
return installChartStore.versions.getData(this.tabId);
}
get releaseDetails() {
return installChartStore.details.getData(this.tabId);
}
@autobind()
viewRelease() {
const { release } = this.releaseDetails;
navigate(releaseURL({
params: {
name: release.name,
namespace: release.namespace
}
}));
dockStore.closeTab(this.tabId);
}
@autobind()
save(data: Partial<IChartInstallData>) {
const chart = { ...this.chartData, ...data };
installChartStore.setData(this.tabId, chart);
}
@autobind()
onVersionChange(option: SelectOption) {
const version = option.value;
this.save({ version, values: "" });
installChartStore.loadValues(this.tabId);
}
@autobind()
onValuesChange(values: string, error?: string) {
this.error = error;
this.save({ values });
}
@autobind()
onNamespaceChange(opt: SelectOption) {
this.save({ namespace: opt.value });
}
@autobind()
onReleaseNameChange(name: string) {
this.save({ releaseName: name });
}
install = async () => {
const { repo, name, version, namespace, values, releaseName } = this.chartData;
const details = await releaseStore.create({
name: releaseName || undefined,
chart: name,
repo, namespace, version, values,
});
installChartStore.details.setData(this.tabId, details);
return (
<p><Trans>Chart Release <b>{details.release.name}</b> successfully created.</Trans></p>
);
}
render() {
const { tabId, chartData, values, versions, install } = this;
if (!chartData || chartData.values === undefined || !versions) {
return <Spinner center/>;
}
if (this.releaseDetails) {
return (
<div className="InstallChartDone flex column gaps align-center justify-center">
<p>
<Icon material="check" big sticker/>
</p>
<p><Trans>Installation complete!</Trans></p>
<div className="flex gaps align-center">
<Button
autoFocus primary
label={_i18n._(t`View Helm Release`)}
onClick={prevDefault(this.viewRelease)}
/>
<Button
plain active
label={_i18n._(t`Show Notes`)}
onClick={() => this.showNotes = true}
/>
</div>
<LogsDialog
title={_i18n._(t`Helm Chart Install`)}
isOpen={this.showNotes}
close={() => this.showNotes = false}
logs={this.releaseDetails.log}
/>
</div>
)
}
const { repo, name, version, namespace, releaseName } = chartData;
const panelControls = (
<div className="install-controls flex gaps align-center">
<span><Trans>Chart</Trans></span>
<Badge label={`${repo}/${name}`} title={_i18n._(t`Repo/Name`)}/>
<span><Trans>Version</Trans></span>
<Select
className="chart-version"
value={version}
options={versions}
onChange={this.onVersionChange}
menuPlacement="top"
themeName="outlined"
/>
<span><Trans>Namespace</Trans></span>
<NamespaceSelect
showIcons={false}
menuPlacement="top"
themeName="outlined"
value={namespace}
onChange={this.onNamespaceChange}
/>
<Input
placeholder={_i18n._(t`Name (optional)`)}
title={_i18n._(t`Release name`)}
maxLength={50}
value={releaseName}
onChange={this.onReleaseNameChange}
/>
</div>
);
return (
<div className="InstallChart flex column">
<EditorPanel
tabId={tabId}
value={values}
onChange={this.onValuesChange}
/>
<InfoPanel
tabId={tabId}
controls={panelControls}
error={this.error}
submit={install}
submitLabel={_i18n._(t`Install`)}
submittingMessage={_i18n._(t`Installing...`)}
showSubmitClose={false}
/>
</div>
);
}
}
@@ -0,0 +1,3 @@
.TerminalTab {
}
@@ -0,0 +1,60 @@
import "./terminal-tab.scss"
import React from "react"
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { autobind, cssNames } from "../../utils";
import { DockTab, DockTabProps } from "./dock-tab";
import { Icon } from "../icon";
import { terminalStore } from "./terminal.store";
import { dockStore } from "./dock.store";
import { reaction } from "mobx";
interface Props extends DockTabProps {
}
@observer
export class TerminalTab extends React.Component<Props> {
componentDidMount() {
reaction(() => this.isDisconnected === true, () => {
dockStore.closeTab(this.tabId)
})
}
get tabId() {
return this.props.value.id;
}
get isDisconnected() {
return terminalStore.isDisconnected(this.tabId);
}
@autobind()
reconnect() {
terminalStore.reconnect(this.tabId);
}
render() {
const tabIcon = <Icon svg="terminal"/>;
const className = cssNames("TerminalTab", this.props.className, {
disconnected: this.isDisconnected,
});
return (
<DockTab
{...this.props}
className={className}
icon={tabIcon}
moreActions={this.isDisconnected && (
<Icon
small
material="refresh"
className="restart-icon"
tooltip={<Trans>Restart session</Trans>}
onClick={this.reconnect}
/>
)}
/>
)
}
}
@@ -0,0 +1,28 @@
@import "~xterm";
.TerminalWindow {
margin: $padding;
margin-left: $padding * 2;
margin-top: $padding * 2;
&.light {
.xterm-viewport {
@include custom-scrollbar(dark);
}
}
> .xterm {
overflow: hidden;
}
.xterm-viewport {
@include custom-scrollbar;
}
// fix: safari won't handle paste event for textarea with zero size block
.xterm-helper-textarea {
width: 10px !important;
height: 10px !important;
pointer-events: none;
}
}
@@ -0,0 +1,45 @@
import "./terminal-window.scss";
import React from "react";
import { reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { cssNames } from "../../utils";
import { IDockTab } from "./dock.store";
import { Terminal } from "./terminal";
import { terminalStore } from "./terminal.store";
import { themeStore } from "../../theme.store";
interface Props {
className?: string;
tab: IDockTab;
}
@observer
export class TerminalWindow extends React.Component<Props> {
public elem: HTMLElement;
public terminal: Terminal;
componentDidMount() {
disposeOnUnmount(this, [
reaction(() => this.props.tab.id, tabId => this.activate(tabId), {
fireImmediately: true
})
])
}
activate(tabId = this.props.tab.id) {
if (this.terminal) this.terminal.detach(); // detach previous
this.terminal = terminalStore.getTerminal(tabId);
this.terminal.attachTo(this.elem);
}
render() {
const { className } = this.props;
return (
<div
className={cssNames("TerminalWindow", className, themeStore.activeTheme.type)}
ref={e => this.elem = e}
/>
)
}
}
@@ -0,0 +1,119 @@
import { autorun, observable } from "mobx";
import { t } from "@lingui/macro";
import { autobind } from "../../utils";
import { Terminal } from "./terminal";
import { TerminalApi } from "../../api/terminal-api";
import { dockStore, IDockTab, TabId, TabKind } from "./dock.store";
import { WebSocketApiState } from "../../api/websocket-api";
import { _i18n } from "../../i18n";
import { themeStore } from "../../theme.store";
export interface ITerminalTab extends IDockTab {
node?: string; // activate node shell mode
}
export function isTerminalTab(tab: IDockTab) {
return tab && tab.kind === TabKind.TERMINAL;
}
export function createTerminalTab(tabParams: Partial<ITerminalTab> = {}) {
return dockStore.createTab({
kind: TabKind.TERMINAL,
title: _i18n._(t`Terminal`),
...tabParams
});
}
@autobind()
export class TerminalStore {
protected terminals = new Map<TabId, Terminal>();
protected connections = observable.map<TabId, TerminalApi>();
constructor() {
// connect active tab
autorun(() => {
const { selectedTab, isOpen } = dockStore;
if (!isTerminalTab(selectedTab)) return;
if (isOpen) {
this.connect(selectedTab.id);
}
});
// disconnect closed tabs
autorun(() => {
const currentTabs = dockStore.tabs.map(tab => tab.id);
for (const [tabId] of this.connections) {
if (!currentTabs.includes(tabId)) this.disconnect(tabId);
}
});
}
async connect(tabId: TabId) {
if (this.isConnected(tabId)) {
return;
}
const tab: ITerminalTab = dockStore.getTabById(tabId);
const api = new TerminalApi({
id: tabId,
node: tab.node,
colorTheme: themeStore.activeTheme.type
});
const terminal = new Terminal(tabId, api);
this.connections.set(tabId, api);
this.terminals.set(tabId, terminal);
}
disconnect(tabId: TabId) {
if (!this.isConnected(tabId)) {
return;
}
const terminal = this.terminals.get(tabId);
const terminalApi = this.connections.get(tabId);
terminal.destroy();
terminalApi.destroy();
this.connections.delete(tabId);
this.terminals.delete(tabId);
}
reconnect(tabId: TabId) {
const terminalApi = this.connections.get(tabId);
if (terminalApi) terminalApi.connect();
}
isConnected(tabId: TabId) {
return !!this.connections.get(tabId);
}
isDisconnected(tabId: TabId) {
const terminalApi = this.connections.get(tabId);
if (terminalApi) {
return terminalApi.readyState === WebSocketApiState.CLOSED;
}
}
sendCommand(command: string, options: { enter?: boolean; newTab?: boolean; tabId?: TabId } = {}) {
const { enter, newTab, tabId } = options;
const { selectTab, getTabById } = dockStore;
const tab = tabId && getTabById(tabId);
if (tab) selectTab(tabId);
if (newTab) createTerminalTab();
const terminalApi = this.connections.get(dockStore.selectedTabId);
if (terminalApi) {
terminalApi.sendCommand(command + (enter ? "\r" : ""));
}
}
getTerminal(tabId: TabId) {
return this.terminals.get(tabId);
}
reset() {
[...this.connections].forEach(([tabId]) => {
this.disconnect(tabId);
});
}
}
export const terminalStore = new TerminalStore();
+196
View File
@@ -0,0 +1,196 @@
import debounce from "lodash/debounce";
import { autorun } from "mobx";
import { Terminal as XTerm } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { dockStore, TabId } from "./dock.store";
import { TerminalApi } from "../../api/terminal-api";
import { themeStore } from "../../theme.store";
import { autobind } from "../../utils/autobind";
export class Terminal {
static spawningPool: HTMLElement;
static init() {
// 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);
Terminal.spawningPool = pool;
}
static async preloadFonts(){
const fontPath = require("../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires
const fontFace = new FontFace("RobotoMono", `url(${fontPath})`);
await fontFace.load();
document.fonts.add(fontFace);
}
public xterm: XTerm;
public fitAddon: FitAddon;
public scrollPos = 0;
public disposers: Function[] = [];
@autobind()
protected setTheme(colors = themeStore.activeTheme.colors) {
// Replacing keys stored in styles to format accepted by terminal
// E.g. terminalBrightBlack -> brightBlack
const colorPrefix = "terminal"
const terminalColors = Object.entries(colors)
.filter(([name]) => name.startsWith(colorPrefix))
.reduce<any>((colors, [name, color]) => {
const colorName = name.split("").slice(colorPrefix.length);
colorName[0] = colorName[0].toLowerCase();
colors[colorName.join("")] = color;
return colors;
}, {});
this.xterm.setOption("theme", terminalColors);
}
get elem() {
return this.xterm.element;
}
get viewport() {
return this.xterm.element.querySelector(".xterm-viewport");
}
constructor(public tabId: TabId, protected api: TerminalApi) {
this.init();
}
get isActive() {
const { isOpen, selectedTabId } = dockStore;
return isOpen && selectedTabId === this.tabId;
}
attachTo(parentElem: HTMLElement) {
parentElem.appendChild(this.elem);
this.onActivate();
}
detach() {
Terminal.spawningPool.appendChild(this.elem);
}
async init() {
if (this.xterm) {
return;
}
this.xterm = new XTerm({
cursorBlink: true,
cursorStyle: "bar",
fontSize: 13,
fontFamily: "RobotoMono"
});
// enable terminal addons
this.fitAddon = new FitAddon();
this.xterm.loadAddon(this.fitAddon);
this.xterm.open(Terminal.spawningPool);
this.xterm.registerLinkMatcher(/https?:\/\/[^\s]+/i, this.onClickLink);
this.xterm.attachCustomKeyEventHandler(this.keyHandler);
// bind events
const onResizeDisposer = dockStore.onResize(this.onResize);
const onData = this.xterm.onData(this.onData);
const onThemeChangeDisposer = autorun(() => this.setTheme(themeStore.activeTheme.colors));
this.viewport.addEventListener("scroll", this.onScroll);
this.api.onReady.addListener(this.onClear, { once: true }); // clear status logs (connecting..)
this.api.onData.addListener(this.onApiData);
window.addEventListener("resize", this.onResize);
// add clean-up handlers to be called on destroy
this.disposers.push(
onResizeDisposer,
onThemeChangeDisposer,
() => onData.dispose(),
() => this.fitAddon.dispose(),
() => this.api.removeAllListeners(),
() => window.removeEventListener("resize", this.onResize),
);
}
destroy() {
if (!this.xterm) return;
this.disposers.forEach(dispose => dispose());
this.disposers = [];
this.xterm.dispose();
this.xterm = null;
}
fit = () => {
this.fitAddon.fit();
const { cols, rows } = this.xterm;
this.api.sendTerminalSize(cols, rows);
};
fitLazy = debounce(this.fit, 250);
focus = () => {
this.xterm.focus();
}
onApiData = (data: string) => {
this.xterm.write(data);
}
onData = (data: string) => {
if (!this.api.isReady) return;
this.api.sendCommand(data);
}
onScroll = () => {
this.scrollPos = this.viewport.scrollTop;
}
onClear = () => {
this.xterm.clear();
}
onResize = () => {
if (!this.isActive) return;
this.fitLazy();
}
onActivate = () => {
this.fit();
setTimeout(() => this.focus(), 250); // delay used to prevent focus on active tab
this.viewport.scrollTop = this.scrollPos; // restore last scroll position
}
onClickLink = (evt: MouseEvent, link: string) => {
window.open(link, "_blank");
}
keyHandler = (evt: KeyboardEvent): boolean => {
const { code, ctrlKey, type } = evt;
// Handle custom hotkey bindings
if (ctrlKey) {
switch (code) {
// Ctrl+C: prevent terminal exit on windows / linux (?)
case "KeyC":
if (this.xterm.hasSelection()) return false;
break;
// Ctrl+W: prevent unexpected terminal tab closing, e.g. editing file in vim
// https://github.com/kontena/lens-app/issues/156#issuecomment-534906480
case "KeyW":
evt.preventDefault();
break;
}
}
// Pass the event above in DOM for <Dock/> to handle common actions
if (!evt.defaultPrevented) {
this.elem.dispatchEvent(new KeyboardEvent(type, evt));
}
return true;
}
}
Terminal.init();
@@ -0,0 +1,2 @@
.UpgradeChart {
}
@@ -0,0 +1,123 @@
import { action, autorun, IReactionDisposer, reaction } from "mobx";
import { t } from "@lingui/macro";
import { dockStore, IDockTab, TabId, TabKind } from "./dock.store";
import { DockTabStore } from "./dock-tab.store";
import { HelmRelease, helmReleasesApi } from "../../api/endpoints/helm-releases.api";
import { releaseStore } from "../+apps-releases/release.store";
import { _i18n } from "../../i18n";
export interface IChartUpgradeData {
releaseName: string;
releaseNamespace: string;
}
export class UpgradeChartStore extends DockTabStore<IChartUpgradeData> {
private watchers = new Map<string, IReactionDisposer>();
values = new DockTabStore<string>();
constructor() {
super({
storageName: "chart_releases"
});
autorun(() => {
const { selectedTab, isOpen } = dockStore;
if (!isUpgradeChartTab(selectedTab)) return;
if (isOpen) {
this.loadData(selectedTab.id);
}
}, { delay: 250 });
autorun(() => {
const objects = [...this.data.values()];
objects.forEach(({ releaseName }) => this.createReleaseWatcher(releaseName));
});
}
private createReleaseWatcher(releaseName: string) {
if (this.watchers.get(releaseName)) {
return;
}
const dispose = reaction(() => {
const release = releaseStore.getByName(releaseName);
if (release) return release.getRevision(); // watch changes only by revision
},
release => {
const releaseTab = this.getTabByRelease(releaseName);
if (!releaseStore.isLoaded || !releaseTab) {
return;
}
// auto-reload values if was loaded before
if (release) {
if (dockStore.selectedTab === releaseTab && this.values.getData(releaseTab.id)) {
this.loadValues(releaseTab.id);
}
}
// clean up watcher, close tab if release not exists / was removed
else {
dispose();
this.watchers.delete(releaseName);
dockStore.closeTab(releaseTab.id);
}
});
this.watchers.set(releaseName, dispose);
}
isLoading(tabId = dockStore.selectedTabId) {
const values = this.values.getData(tabId);
return !releaseStore.isLoaded || values === undefined;
}
@action
async loadData(tabId: TabId) {
const values = this.values.getData(tabId);
await Promise.all([
!releaseStore.isLoaded && releaseStore.loadAll(),
!values && this.loadValues(tabId)
]);
}
@action
async loadValues(tabId: TabId) {
this.values.clearData(tabId); // reset
const { releaseName, releaseNamespace } = this.getData(tabId);
const values = await helmReleasesApi.getValues(releaseName, releaseNamespace);
this.values.setData(tabId, values);
}
getTabByRelease(releaseName: string): IDockTab {
const item = [...this.data].find(item => item[1].releaseName === releaseName);
if (item) {
const [tabId] = item;
return dockStore.getTabById(tabId)
}
}
}
export const upgradeChartStore = new UpgradeChartStore();
export function createUpgradeChartTab(release: HelmRelease, tabParams: Partial<IDockTab> = {}) {
let tab = upgradeChartStore.getTabByRelease(release.getName());
if (tab) {
dockStore.open();
dockStore.selectTab(tab.id);
}
if (!tab) {
tab = dockStore.createTab({
kind: TabKind.UPGRADE_CHART,
title: _i18n._(t`Helm Upgrade: ${release.getName()}`),
...tabParams
}, false);
upgradeChartStore.setData(tab.id, {
releaseName: release.getName(),
releaseNamespace: release.getNs()
})
}
return tab;
}
export function isUpgradeChartTab(tab: IDockTab) {
return tab && tab.kind === TabKind.UPGRADE_CHART;
}
@@ -0,0 +1,132 @@
import "./upgrade-chart.scss"
import React from "react";
import { observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { t, Trans } from "@lingui/macro";
import { cssNames } from "../../utils";
import { IDockTab } from "./dock.store";
import { InfoPanel } from "./info-panel";
import { upgradeChartStore } from "./upgrade-chart.store";
import { Spinner } from "../spinner";
import { releaseStore } from "../+apps-releases/release.store";
import { Badge } from "../badge";
import { EditorPanel } from "./editor-panel";
import { helmChartStore, IChartVersion } from "../+apps-helm-charts/helm-chart.store";
import { HelmRelease } from "../../api/endpoints/helm-releases.api";
import { Select, SelectOption } from "../select";
import { _i18n } from "../../i18n";
interface Props {
className?: string;
tab: IDockTab;
}
@observer
export class UpgradeChart extends React.Component<Props> {
@observable error: string;
@observable versions = observable.array<IChartVersion>();
@observable version: IChartVersion;
componentDidMount() {
this.loadVersions();
disposeOnUnmount(this, [
reaction(() => this.release, () => this.loadVersions())
]);
}
get tabId() {
return this.props.tab.id;
}
get release(): HelmRelease {
const tabData = upgradeChartStore.getData(this.tabId);
if (!tabData) return;
return releaseStore.getByName(tabData.releaseName);
}
get value() {
return upgradeChartStore.values.getData(this.tabId);
}
async loadVersions() {
if (!this.release) return;
this.version = null;
this.versions.clear();
const versions = await helmChartStore.getVersions(this.release.getChart());
this.versions.replace(versions);
this.version = this.versions[0];
}
onChange = (value: string, error?: string) => {
upgradeChartStore.values.setData(this.tabId, value);
this.error = error;
}
upgrade = async () => {
if (this.error) return;
const { version, repo } = this.version;
const releaseName = this.release.getName();
const releaseNs = this.release.getNs()
await releaseStore.update(releaseName, releaseNs, {
chart: this.release.getChart(),
values: this.value,
repo, version,
});
return (
<p>
<Trans>Release <b>{releaseName}</b> successfully upgraded to version <b>{version}</b></Trans>
</p>
)
}
formatVersionLabel = ({ value }: SelectOption<IChartVersion>) => {
const chartName = this.release.getChart();
const { repo, version } = value;
return `${repo}/${chartName}-${version}`;
}
render() {
const { tabId, release, value, error, onChange, upgrade, versions, version } = this;
const { className } = this.props;
if (!release || upgradeChartStore.isLoading() || !version) {
return <Spinner center/>;
}
const currentVersion = release.getVersion();
const controlsAndInfo = (
<div className="upgrade flex gaps align-center">
<span><Trans>Release</Trans></span> <Badge label={release.getName()}/>
<span><Trans>Namespace</Trans></span> <Badge label={release.getNs()}/>
<span><Trans>Version</Trans></span> <Badge label={currentVersion}/>
<span><Trans>Upgrade version</Trans></span>
<Select
className="chart-version"
menuPlacement="top"
themeName="outlined"
value={version}
options={versions}
formatOptionLabel={this.formatVersionLabel}
onChange={({ value }: SelectOption) => this.version = value}
/>
</div>
)
return (
<div className={cssNames("UpgradeChart flex column", className)}>
<EditorPanel
tabId={tabId}
value={value}
onChange={onChange}
/>
<InfoPanel
tabId={tabId}
error={error}
submit={upgrade}
submitLabel={_i18n._(t`Upgrade`)}
submittingMessage={_i18n._(t`Updating..`)}
controls={controlsAndInfo}
/>
</div>
)
}
}