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