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 @@
export * from "./releases";
export * from "./release.route";
@@ -0,0 +1,86 @@
@import "release.mixins";
.ReleaseDetails {
&.light {
.AceEditor {
border: 1px solid gainsboro;
border-radius: $radius;
}
}
.DrawerItem {
align-items: center;
}
.status {
.Badge {
@include release-status-bgs;
&:first-child {
margin-left: 0;
}
}
}
.chart {
.upgrade {
min-width: 150px;
}
}
.resources {
.SubTitle {
text-transform: none;
padding-left: 0;
}
.Table {
margin-bottom: $margin * 4;
border: 1px solid var(--drawerSubtitleBackground);
border-radius: $radius;
overflow: auto;
@include custom-scrollbar();
.TableHead {
border-bottom: none;
.TableCell {
background-color: var(--drawerSubtitleBackground);
word-break: unset;
}
}
.TableCell {
text-overflow: unset;
word-break: break-word;
min-width: 100px;
&.name {
flex-basis: auto;
flex-grow: 0;
width: 230px;
}
&.volume {
flex-basis: 30%;
}
}
}
}
.notes {
white-space: pre-line;
font-family: "RobotoMono", monospace;
font-size: small;
}
.values {
.AceEditor {
min-height: 300px;
}
.AceEditor + .Button {
align-self: flex-start;
}
}
}
@@ -0,0 +1,245 @@
import "./release-details.scss";
import React, { Component } from "react";
import groupBy from "lodash/groupBy";
import isEqual from "lodash/isEqual";
import { observable, reaction } from "mobx";
import { Link } from "react-router-dom";
import { t, Trans } from "@lingui/macro";
import kebabCase from "lodash/kebabCase";
import { HelmRelease, helmReleasesApi, IReleaseDetails } from "../../api/endpoints/helm-releases.api";
import { HelmReleaseMenu } from "./release-menu";
import { Drawer, DrawerItem, DrawerTitle } from "../drawer";
import { Badge } from "../badge";
import { cssNames, stopPropagation } from "../../utils";
import { disposeOnUnmount, observer } from "mobx-react";
import { Spinner } from "../spinner";
import { Table, TableCell, TableHead, TableRow } from "../table";
import { AceEditor } from "../ace-editor";
import { Button } from "../button";
import { releaseStore } from "./release.store";
import { Notifications } from "../notifications";
import { createUpgradeChartTab } from "../dock/upgrade-chart.store";
import { getDetailsUrl } from "../../navigation";
import { _i18n } from "../../i18n";
import { themeStore } from "../../theme.store";
import { apiManager } from "../../api/api-manager";
import { SubTitle } from "../layout/sub-title";
import { secretsStore } from "../+config-secrets/secrets.store";
import { Secret } from "../../api/endpoints";
interface Props {
release: HelmRelease;
hideDetails(): void;
}
@observer
export class ReleaseDetails extends Component<Props> {
@observable details: IReleaseDetails;
@observable values = "";
@observable saving = false;
@observable releaseSecret: Secret;
@disposeOnUnmount
releaseSelector = reaction(() => this.props.release, release => {
if (!release) return;
this.loadDetails();
this.loadValues();
this.releaseSecret = null;
}
);
@disposeOnUnmount
secretWatcher = reaction(() => secretsStore.items.toJS(), () => {
if (!this.props.release) return;
const { getReleaseSecret } = releaseStore;
const { release } = this.props;
const secret = getReleaseSecret(release);
if (this.releaseSecret) {
if (isEqual(this.releaseSecret.getLabels(), secret.getLabels())) return;
this.loadDetails();
}
this.releaseSecret = secret;
});
async loadDetails() {
const { release } = this.props;
this.details = null;
this.details = await helmReleasesApi.get(release.getName(), release.getNs());
}
async loadValues() {
const { release } = this.props;
this.values = "";
this.values = await helmReleasesApi.getValues(release.getName(), release.getNs());
}
updateValues = async () => {
const { release } = this.props;
const name = release.getName();
const namespace = release.getNs()
const data = {
chart: release.getChart(),
repo: await release.getRepo(),
version: release.getVersion(),
values: this.values
};
this.saving = true;
try {
await releaseStore.update(name, namespace, data);
Notifications.ok(
<p>Release <b>{name}</b> successfully updated!</p>
);
} catch (err) {
Notifications.error(err);
}
this.saving = false;
}
upgradeVersion = () => {
const { release, hideDetails } = this.props;
createUpgradeChartTab(release);
hideDetails();
}
renderValues() {
const { values, saving } = this;
return (
<div className="values">
<DrawerTitle title={_i18n._(t`Values`)}/>
<div className="flex column gaps">
<AceEditor
mode="yaml"
value={values}
onChange={values => this.values = values}
/>
<Button
primary
label={_i18n._(t`Save`)}
waiting={saving}
onClick={this.updateValues}
/>
</div>
</div>
)
}
renderNotes() {
if (!this.details.info?.notes) return null;
const { notes } = this.details.info;
return (
<div className="notes">
{notes}
</div>
);
}
renderResources() {
const { resources } = this.details;
if (!resources) return null;
const groups = groupBy(resources, item => item.kind);
const tables = Object.entries(groups).map(([kind, items]) => {
return (
<React.Fragment key={kind}>
<SubTitle title={kind}/>
<Table scrollable={false}>
<TableHead sticky={false}>
<TableCell className="name">Name</TableCell>
{items[0].getNs() && <TableCell className="namespace">Namespace</TableCell>}
<TableCell className="age">Age</TableCell>
</TableHead>
{items.map(item => {
const name = item.getName();
const namespace = item.getNs();
const api = apiManager.getApi(item.metadata.selfLink);
const detailsUrl = api ? getDetailsUrl(api.getUrl({
name,
namespace,
})) : "";
return (
<TableRow key={item.getId()}>
<TableCell className="name">
{detailsUrl ? <Link to={detailsUrl}>{name}</Link> : name}
</TableCell>
{namespace && <TableCell className="namespace">{namespace}</TableCell>}
<TableCell className="age">{item.getAge()}</TableCell>
</TableRow>
);
})}
</Table>
</React.Fragment>
);
});
return (
<div className="resources">
{tables}
</div>
);
}
renderContent() {
const { release } = this.props;
const { details } = this;
if (!release) return null;
if (!details) {
return <Spinner center/>;
}
return (
<div>
<DrawerItem name={<Trans>Chart</Trans>} className="chart">
<div className="flex gaps align-center">
<span>{release.getChart()}</span>
<Button
primary
label={_i18n._(t`Upgrade`)}
className="box right upgrade"
onClick={this.upgradeVersion}
/>
</div>
</DrawerItem>
<DrawerItem name={<Trans>Updated</Trans>}>
{release.getUpdated()} <Trans>ago</Trans> ({release.updated})
</DrawerItem>
<DrawerItem name={<Trans>Namespace</Trans>}>
{release.getNs()}
</DrawerItem>
<DrawerItem name={<Trans>Version</Trans>} onClick={stopPropagation}>
<div className="version flex gaps align-center">
<span>
{release.getVersion()}
</span>
</div>
</DrawerItem>
<DrawerItem name={<Trans>Status</Trans>} className="status" labelsOnly>
<Badge
label={release.getStatus()}
className={cssNames("status", kebabCase(release.getStatus()))}
/>
</DrawerItem>
{this.renderValues()}
<DrawerTitle title={_i18n._(t`Notes`)}/>
{this.renderNotes()}
<DrawerTitle title={_i18n._(t`Resources`)}/>
{this.renderResources()}
</div>
)
}
render() {
const { release, hideDetails } = this.props
const title = release ? <Trans>Release: {release.getName()}</Trans> : ""
const toolbar = <HelmReleaseMenu release={release} toolbar hideDetails={hideDetails}/>
return (
<Drawer
className={cssNames("ReleaseDetails", themeStore.activeTheme.type)}
usePortal={true}
open={!!release}
title={title}
onClose={hideDetails}
toolbar={toolbar}
>
{this.renderContent()}
</Drawer>
)
}
}
@@ -0,0 +1,63 @@
import React from "react";
import { t, Trans } from "@lingui/macro";
import { HelmRelease } from "../../api/endpoints/helm-releases.api";
import { autobind, cssNames } from "../../utils";
import { releaseStore } from "./release.store";
import { MenuActions, MenuActionsProps } from "../menu/menu-actions";
import { MenuItem } from "../menu";
import { Icon } from "../icon";
import { ReleaseRollbackDialog } from "./release-rollback-dialog";
import { createUpgradeChartTab } from "../dock/upgrade-chart.store";
import { _i18n } from "../../i18n";
interface Props extends MenuActionsProps {
release: HelmRelease;
hideDetails?(): void;
}
export class HelmReleaseMenu extends React.Component<Props> {
@autobind()
remove() {
return releaseStore.remove(this.props.release);
}
@autobind()
upgrade() {
const { release, hideDetails } = this.props;
createUpgradeChartTab(release);
hideDetails && hideDetails();
}
@autobind()
rollback() {
ReleaseRollbackDialog.open(this.props.release);
}
renderContent() {
const { release, toolbar } = this.props;
if (!release) return;
const hasRollback = release && release.getRevision() > 1;
return (
<>
{hasRollback && (
<MenuItem onClick={this.rollback}>
<Icon material="history" interactive={toolbar} title={_i18n._(t`Rollback`)}/>
<span className="title"><Trans>Rollback</Trans></span>
</MenuItem>
)}
</>
)
}
render() {
const { className, release, ...menuProps } = this.props;
return (
<MenuActions
{...menuProps}
className={cssNames("HelmReleaseMenu", className)}
removeAction={this.remove}
children={this.renderContent()}
/>
);
}
}
@@ -0,0 +1,9 @@
.ReleaseRollbackDialog {
.WizardStep {
text-align: center;
.step-content {
padding: var(--wizard-spacing);
}
}
}
@@ -0,0 +1,109 @@
import "./release-rollback-dialog.scss";
import React from "react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { Dialog, DialogProps } from "../dialog";
import { Wizard, WizardStep } from "../wizard";
import { HelmRelease, helmReleasesApi, IReleaseRevision } from "../../api/endpoints/helm-releases.api";
import { releaseStore } from "./release.store";
import { Select, SelectOption } from "../select";
import { Notifications } from "../notifications";
import orderBy from "lodash/orderBy"
interface Props extends DialogProps {
}
@observer
export class ReleaseRollbackDialog extends React.Component<Props> {
@observable static isOpen = false;
@observable.ref static release: HelmRelease = null;
@observable isLoading = false;
@observable revision: IReleaseRevision;
@observable revisions = observable.array<IReleaseRevision>();
static open(release: HelmRelease) {
ReleaseRollbackDialog.isOpen = true;
ReleaseRollbackDialog.release = release;
}
static close() {
ReleaseRollbackDialog.isOpen = false;
}
get release(): HelmRelease {
return ReleaseRollbackDialog.release;
}
onOpen = async () => {
this.isLoading = true;
const currentRevision = this.release.getRevision();
let releases = await helmReleasesApi.getHistory(this.release.getName(), this.release.getNs());
releases = releases.filter(item => item.revision !== currentRevision); // remove current
releases = orderBy(releases, "revision", "desc"); // sort
this.revisions.replace(releases);
this.revision = this.revisions[0];
this.isLoading = false;
}
rollback = async () => {
const revisionNumber = this.revision.revision;
try {
await releaseStore.rollback(this.release.getName(), this.release.getNs(), revisionNumber);
this.close();
} catch (err) {
Notifications.error(err);
}
};
close = () => {
ReleaseRollbackDialog.close();
}
renderContent() {
const { revision, revisions } = this;
if (!revision) {
return <p><Trans>No revisions to rollback.</Trans></p>
}
return (
<div className="flex gaps align-center">
<b><Trans>Revision</Trans></b>
<Select
themeName="light"
value={revision}
options={revisions}
formatOptionLabel={({ value }: SelectOption<IReleaseRevision>) => `${value.revision} - ${value.chart}`}
onChange={({ value }: SelectOption<IReleaseRevision>) => this.revision = value}
/>
</div>
)
}
render() {
const { ...dialogProps } = this.props;
const releaseName = this.release ? this.release.getName() : "";
const header = <h5><Trans>Rollback <b>{releaseName}</b></Trans></h5>
return (
<Dialog
{...dialogProps}
className="ReleaseRollbackDialog"
isOpen={ReleaseRollbackDialog.isOpen}
onOpen={this.onOpen}
close={this.close}
>
<Wizard header={header} done={this.close}>
<WizardStep
scrollable={false}
nextLabel={<Trans>Rollback</Trans>}
next={this.rollback}
loading={this.isLoading}
>
{this.renderContent()}
</WizardStep>
</Wizard>
</Dialog>
)
}
}
@@ -0,0 +1,25 @@
$release-status-color-list: (
deployed: $colorSuccess,
failed: $colorError,
deleting: $colorWarning,
pendingInstall: $colorInfo,
pendingUpgrade: $colorInfo,
pendingRollback: $colorInfo,
);
@mixin release-status-bgs {
@each $status, $color in $release-status-color-list {
&.#{$status} {
color: white;
background: $color;
}
}
}
@mixin release-status-colors {
@each $status, $color in $release-status-color-list {
&.#{$status} {
color: $color;
}
}
}
@@ -0,0 +1,14 @@
import { RouteProps } from "react-router"
import { appsRoute } from "../+apps/apps.route";
import { buildURL } from "../../navigation";
export const releaseRoute: RouteProps = {
path: appsRoute.path + "/releases/:namespace?/:name?"
}
export interface IReleaseRouteParams {
name?: string;
namespace?: string;
}
export const releaseURL = buildURL<IReleaseRouteParams>(releaseRoute.path);
@@ -0,0 +1,112 @@
import isEqual from "lodash/isEqual";
import { action, observable, when, IReactionDisposer, reaction } from "mobx";
import { autobind } from "../../utils";
import { HelmRelease, helmReleasesApi, IReleaseCreatePayload, IReleaseUpdatePayload } from "../../api/endpoints/helm-releases.api";
import { ItemStore } from "../../item.store";
import { configStore } from "../../config.store";
import { secretsStore } from "../+config-secrets/secrets.store";
import { Secret } from "../../api/endpoints";
@autobind()
export class ReleaseStore extends ItemStore<HelmRelease> {
@observable releaseSecrets: Secret[] = [];
@observable secretWatcher: IReactionDisposer;
constructor() {
super();
when(() => secretsStore.isLoaded, () => {
this.releaseSecrets = this.getReleaseSecrets();
});
}
watch() {
this.secretWatcher = reaction(() => secretsStore.items.toJS(), () => {
if (this.isLoading) return;
const secrets = this.getReleaseSecrets();
const amountChanged = secrets.length !== this.releaseSecrets.length;
const labelsChanged = this.releaseSecrets.some(item => {
const secret = secrets.find(secret => secret.getId() == item.getId());
if (!secret) return;
return !isEqual(item.getLabels(), secret.getLabels());
});
if (amountChanged || labelsChanged) {
this.loadAll();
}
this.releaseSecrets = [...secrets];
})
}
unwatch() {
this.secretWatcher();
}
getReleaseSecrets() {
return secretsStore.getByLabel({ owner: "helm" });
}
getReleaseSecret(release: HelmRelease) {
const labels = {
owner: "helm",
name: release.getName()
}
return secretsStore.getByLabel(labels)
.filter(secret => secret.getNs() == release.getNs())[0];
}
@action
async loadAll() {
this.isLoading = true;
let items;
try {
const { isClusterAdmin, allowedNamespaces } = configStore;
items = await this.loadItems(!isClusterAdmin ? allowedNamespaces : null);
} finally {
if (items) {
items = this.sortItems(items);
this.items.replace(items);
}
this.isLoaded = true;
this.isLoading = false;
}
}
async loadItems(namespaces?: string[]) {
if (!namespaces) {
return helmReleasesApi.list();
}
else {
return Promise
.all(namespaces.map(namespace => helmReleasesApi.list(namespace)))
.then(items => items.flat());
}
}
async create(payload: IReleaseCreatePayload) {
const response = await helmReleasesApi.create(payload);
if (this.isLoaded) this.loadAll();
return response;
}
async update(name: string, namespace: string, payload: IReleaseUpdatePayload) {
const response = await helmReleasesApi.update(name, namespace, payload);
if (this.isLoaded) this.loadAll();
return response;
}
async rollback(name: string, namespace: string, revision: number) {
const response = await helmReleasesApi.rollback(name, namespace, revision);
if (this.isLoaded) this.loadAll();
return response;
}
async remove(release: HelmRelease) {
return super.removeItem(release, () => helmReleasesApi.delete(release.getName(), release.getNs()));
}
async removeSelectedItems() {
if (!this.selectedItems.length) return;
return Promise.all(this.selectedItems.map(this.remove));
}
}
export const releaseStore = new ReleaseStore();
@@ -0,0 +1,19 @@
@import "release.mixins";
.HelmReleases {
.TableCell {
&.status {
@include release-status-colors;
}
&.version {
> .Icon {
margin-left: $margin;
&.new-version {
color: $colorInfo;
}
}
}
}
}
@@ -0,0 +1,149 @@
import "./releases.scss";
import React, { Component } from "react";
import kebabCase from "lodash/kebabCase";
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { RouteComponentProps } from "react-router";
import { releaseStore } from "./release.store";
import { IReleaseRouteParams, releaseURL } from "./release.route";
import { HelmRelease } from "../../api/endpoints/helm-releases.api";
import { ReleaseDetails } from "./release-details";
import { ReleaseRollbackDialog } from "./release-rollback-dialog";
import { navigation } from "../../navigation";
import { ItemListLayout } from "../item-object-list/item-list-layout";
import { HelmReleaseMenu } from "./release-menu";
import { secretsStore } from "../+config-secrets/secrets.store";
enum sortBy {
name = "name",
namespace = "namespace",
revision = "revision",
chart = "chart",
status = "status",
updated = "update"
}
interface Props extends RouteComponentProps<IReleaseRouteParams> {
}
@observer
export class HelmReleases extends Component<Props> {
componentDidMount() {
// Watch for secrets associated with releases and react to their changes
releaseStore.watch();
}
componentWillUnmount() {
releaseStore.unwatch();
}
get selectedRelease() {
const { match: { params: { name, namespace } } } = this.props;
return releaseStore.items.find(release => {
return release.getName() == name && release.getNs() == namespace;
});
}
showDetails = (item: HelmRelease) => {
if (!item) {
navigation.merge(releaseURL())
}
else {
navigation.merge(releaseURL({
params: {
name: item.getName(),
namespace: item.getNs()
}
}))
}
}
hideDetails = () => {
this.showDetails(null);
}
renderRemoveDialogMessage(selectedItems: HelmRelease[]) {
const releaseNames = selectedItems.map(item => item.getName()).join(", ");
return (
<div>
<Trans>Remove <b>{releaseNames}</b>?</Trans>
<p className="warning">
<Trans>Note: StatefulSet Volumes won't be deleted automatically</Trans>
</p>
</div>
)
}
render() {
return (
<>
<ItemListLayout
className="HelmReleases"
store={releaseStore}
dependentStores={[secretsStore]}
sortingCallbacks={{
[sortBy.name]: (release: HelmRelease) => release.getName(),
[sortBy.namespace]: (release: HelmRelease) => release.getNs(),
[sortBy.revision]: (release: HelmRelease) => release.getRevision(),
[sortBy.chart]: (release: HelmRelease) => release.getChart(),
[sortBy.status]: (release: HelmRelease) => release.getStatus(),
[sortBy.updated]: (release: HelmRelease) => release.getUpdated(false, false),
}}
searchFilters={[
(release: HelmRelease) => release.getName(),
(release: HelmRelease) => release.getNs(),
(release: HelmRelease) => release.getChart(),
(release: HelmRelease) => release.getStatus(),
(release: HelmRelease) => release.getVersion(),
]}
renderHeaderTitle={<Trans>Releases</Trans>}
renderTableHeader={[
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
{ title: <Trans>Namespace</Trans>, className: "namespace", sortBy: sortBy.namespace },
{ title: <Trans>Chart</Trans>, className: "chart", sortBy: sortBy.chart },
{ title: <Trans>Revision</Trans>, className: "revision", sortBy: sortBy.revision },
{ title: <Trans>Version</Trans>, className: "version" },
{ title: <Trans>App Version</Trans>, className: "app-version" },
{ title: <Trans>Status</Trans>, className: "status", sortBy: sortBy.status },
{ title: <Trans>Updated</Trans>, className: "updated", sortBy: sortBy.updated },
]}
renderTableContents={(release: HelmRelease) => {
const version = release.getVersion();
return [
release.getName(),
release.getNs(),
release.getChart(),
release.getRevision(),
<>
{version}
</>,
release.appVersion,
{ title: release.getStatus(), className: kebabCase(release.getStatus()) },
release.getUpdated(),
]
}}
renderItemMenu={(release: HelmRelease) => {
return (
<HelmReleaseMenu
release={release}
removeConfirmationMessage={this.renderRemoveDialogMessage([release])}
/>
)
}}
customizeRemoveDialog={(selectedItems: HelmRelease[]) => ({
message: this.renderRemoveDialogMessage(selectedItems)
})}
detailsItem={this.selectedRelease}
onDetails={this.showDetails}
/>
<ReleaseDetails
release={this.selectedRelease}
hideDetails={this.hideDetails}
/>
<ReleaseRollbackDialog/>
</>
);
}
}