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 @@
|
||||
export * from "./not-found"
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { MainLayout } from "../layout/main-layout";
|
||||
|
||||
export class NotFound extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<MainLayout className="NotFound" contentClass="flex" footer={null}>
|
||||
<p className="box center">
|
||||
<Trans>Page not found</Trans>
|
||||
</p>
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.HelmChartDetails {
|
||||
.intro-logo {
|
||||
margin-right: $margin * 2;
|
||||
background: $helmLogoBackground;
|
||||
border-radius: $radius;
|
||||
max-width: 150px;
|
||||
max-height: 100px;
|
||||
padding: $padding;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.intro-contents {
|
||||
.description {
|
||||
font-weight: bold;
|
||||
color: $textColorAccent;
|
||||
padding-bottom: $padding;
|
||||
|
||||
.Button {
|
||||
padding-left: $padding * 3;
|
||||
padding-right: $padding * 3;
|
||||
margin-left: $margin * 2;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.version {
|
||||
.Select {
|
||||
width: 80px;
|
||||
min-width: 80px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.Icon {
|
||||
margin-right: $margin;
|
||||
}
|
||||
}
|
||||
|
||||
.maintainers {
|
||||
a {
|
||||
display: inline-block;
|
||||
margin-right: $margin;
|
||||
}
|
||||
}
|
||||
|
||||
.DrawerItem {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-description {
|
||||
margin-top: $margin * 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import "./helm-chart-details.scss";
|
||||
|
||||
import React, { Component } from "react";
|
||||
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { autorun, observable } from "mobx";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import { Drawer, DrawerItem } from "../drawer";
|
||||
import { autobind, stopPropagation } from "../../utils";
|
||||
import { MarkdownViewer } from "../markdown-viewer";
|
||||
import { Spinner } from "../spinner";
|
||||
import { CancelablePromise } from "../../utils/cancelableFetch";
|
||||
import { Button } from "../button";
|
||||
import { Select, SelectOption } from "../select";
|
||||
import { createInstallChartTab } from "../dock/install-chart.store";
|
||||
import { Badge } from "../badge";
|
||||
import { _i18n } from "../../i18n";
|
||||
|
||||
interface Props {
|
||||
chart: HelmChart;
|
||||
hideDetails(): void;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class HelmChartDetails extends Component<Props> {
|
||||
@observable chartVersions: HelmChart[];
|
||||
@observable selectedChart: HelmChart;
|
||||
@observable description: string = null;
|
||||
|
||||
private chartPromise: CancelablePromise<{ readme: string; versions: HelmChart[] }>;
|
||||
|
||||
@disposeOnUnmount
|
||||
chartSelector = autorun(async () => {
|
||||
if (!this.props.chart) return;
|
||||
this.chartVersions = null;
|
||||
this.selectedChart = null;
|
||||
this.description = null;
|
||||
this.loadChartData();
|
||||
this.chartPromise.then(data => {
|
||||
this.description = data.readme;
|
||||
this.chartVersions = data.versions;
|
||||
this.selectedChart = data.versions[0];
|
||||
});
|
||||
});
|
||||
|
||||
loadChartData(version?: string) {
|
||||
const { chart: { name, repo } } = this.props;
|
||||
if (this.chartPromise) this.chartPromise.cancel();
|
||||
this.chartPromise = helmChartsApi.get(repo, name, version);
|
||||
}
|
||||
|
||||
@autobind()
|
||||
onVersionChange(opt: SelectOption) {
|
||||
const version = opt.value;
|
||||
this.selectedChart = this.chartVersions.find(chart => chart.version === version);
|
||||
this.description = null;
|
||||
this.loadChartData(version);
|
||||
this.chartPromise.then(data => {
|
||||
this.description = data.readme
|
||||
});
|
||||
}
|
||||
|
||||
@autobind()
|
||||
install() {
|
||||
createInstallChartTab(this.selectedChart);
|
||||
this.props.hideDetails()
|
||||
}
|
||||
|
||||
renderIntroduction() {
|
||||
const { selectedChart, chartVersions, onVersionChange } = this;
|
||||
const placeholder = require("./helm-placeholder.svg");
|
||||
return (
|
||||
<div className="introduction flex align-flex-start">
|
||||
<img
|
||||
className="intro-logo"
|
||||
src={selectedChart.getIcon() || placeholder}
|
||||
onError={(event) => event.currentTarget.src = placeholder}
|
||||
/>
|
||||
<div className="intro-contents box grow">
|
||||
<div className="description flex align-center justify-space-between">
|
||||
{selectedChart.getDescription()}
|
||||
<Button primary label={_i18n._(t`Install`)} onClick={this.install}/>
|
||||
</div>
|
||||
<DrawerItem name={_i18n._(t`Version`)} className="version" onClick={stopPropagation}>
|
||||
<Select
|
||||
themeName="outlined"
|
||||
menuPortalTarget={null}
|
||||
options={chartVersions.map(chart => chart.version)}
|
||||
value={selectedChart.getVersion()}
|
||||
onChange={onVersionChange}
|
||||
/>
|
||||
</DrawerItem>
|
||||
<DrawerItem name={_i18n._(t`Home`)}>
|
||||
<a href={selectedChart.getHome()} target="_blank">{selectedChart.getHome()}</a>
|
||||
</DrawerItem>
|
||||
<DrawerItem name={_i18n._(t`Maintainers`)} className="maintainers">
|
||||
{selectedChart.getMaintainers().map(({ name, email, url }) =>
|
||||
<a key={name} href={url ? url : `mailto:${email}`} target="_blank">{name}</a>
|
||||
)}
|
||||
</DrawerItem>
|
||||
{selectedChart.getKeywords().length > 0 && (
|
||||
<DrawerItem name={_i18n._(t`Keywords`)} labelsOnly>
|
||||
{selectedChart.getKeywords().map(key => <Badge key={key} label={key}/>)}
|
||||
</DrawerItem>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
if (this.selectedChart === null || this.description === null) return <Spinner center/>;
|
||||
return (
|
||||
<div className="box grow">
|
||||
{this.renderIntroduction()}
|
||||
<div className="chart-description">
|
||||
<MarkdownViewer markdown={this.description}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { chart, hideDetails } = this.props;
|
||||
const title = chart ? <Trans>Chart: {chart.getFullName()}</Trans> : "";
|
||||
return (
|
||||
<Drawer
|
||||
className="HelmChartDetails"
|
||||
usePortal={true}
|
||||
open={!!chart}
|
||||
title={title}
|
||||
onClose={hideDetails}
|
||||
>
|
||||
{this.renderContent()}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import semver from "semver"
|
||||
import { observable } from "mobx";
|
||||
import { autobind } from "../../utils";
|
||||
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api";
|
||||
import { ItemStore } from "../../item.store";
|
||||
import flatten from "lodash/flatten"
|
||||
|
||||
export interface IChartVersion {
|
||||
repo: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class HelmChartStore extends ItemStore<HelmChart> {
|
||||
@observable versions = observable.map<string, IChartVersion[]>();
|
||||
|
||||
loadAll() {
|
||||
return this.loadItems(() => helmChartsApi.list());
|
||||
}
|
||||
|
||||
getByName(name: string, repo: string) {
|
||||
return this.items.find(chart => chart.getName() === name && chart.getRepository() === repo);
|
||||
}
|
||||
|
||||
protected sortVersions = (versions: IChartVersion[]) => {
|
||||
return versions.sort((first, second) => {
|
||||
return semver.compare(second.version, first.version)
|
||||
});
|
||||
};
|
||||
|
||||
async getVersions(chartName: string, force?: boolean): Promise<IChartVersion[]> {
|
||||
let versions = this.versions.get(chartName);
|
||||
if (versions && !force) {
|
||||
return versions;
|
||||
}
|
||||
const loadVersions = (repo: string) => {
|
||||
return helmChartsApi.get(repo, chartName).then(({ versions }) => {
|
||||
return versions.map(chart => ({
|
||||
repo: repo,
|
||||
version: chart.getVersion()
|
||||
}))
|
||||
})
|
||||
};
|
||||
if (!this.isLoaded) {
|
||||
await this.loadAll();
|
||||
}
|
||||
const repos = this.items
|
||||
.filter(chart => chart.getName() === chartName)
|
||||
.map(chart => chart.getRepository());
|
||||
versions = await Promise.all(repos.map(loadVersions))
|
||||
.then(flatten)
|
||||
.then(this.sortVersions);
|
||||
|
||||
this.versions.set(chartName, versions);
|
||||
return versions;
|
||||
}
|
||||
|
||||
reset() {
|
||||
super.reset();
|
||||
this.versions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const helmChartStore = new HelmChartStore();
|
||||
@@ -0,0 +1,14 @@
|
||||
import { RouteProps } from "react-router"
|
||||
import { appsRoute } from "../+apps/apps.route";
|
||||
import { buildURL } from "../../navigation";
|
||||
|
||||
export const helmChartsRoute: RouteProps = {
|
||||
path: appsRoute.path + "/charts/:repo?/:chartName?"
|
||||
}
|
||||
|
||||
export interface IHelmChartsRouteParams {
|
||||
chartName?: string;
|
||||
repo?: string;
|
||||
}
|
||||
|
||||
export const helmChartsURL = buildURL<IHelmChartsRouteParams>(helmChartsRoute.path)
|
||||
@@ -0,0 +1,62 @@
|
||||
.HelmCharts {
|
||||
.SearchInput {
|
||||
width: 70%;
|
||||
margin: auto;
|
||||
|
||||
> label {
|
||||
padding: $padding $padding * 2;
|
||||
}
|
||||
}
|
||||
|
||||
.TableCell {
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&.name {
|
||||
flex-grow: 1.3;
|
||||
}
|
||||
|
||||
&.icon {
|
||||
display: flex;
|
||||
flex-grow: 0.3;
|
||||
padding: 0;
|
||||
|
||||
figure {
|
||||
$iconSize: $unit * 3.5;
|
||||
width: $iconSize;
|
||||
height: $iconSize;
|
||||
border-radius: 50%;
|
||||
background: $helmImgBackground url(helm-placeholder.svg) center center no-repeat;
|
||||
background-size: 72%; // bg size looks same as image on top of it
|
||||
margin: auto;
|
||||
|
||||
img {
|
||||
object-fit: contain;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
visibility: hidden;
|
||||
border-radius: inherit;
|
||||
background-color: $helmImgBackground;
|
||||
padding: $padding / 2;
|
||||
|
||||
&.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.description {
|
||||
flex-grow: 2.5;
|
||||
}
|
||||
|
||||
&.repository {
|
||||
&.stable {
|
||||
color: $helmStableRepo;
|
||||
}
|
||||
|
||||
&.incubator {
|
||||
color: $helmIncubatorRepo;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import "./helm-charts.scss";
|
||||
|
||||
import React, { Component } from "react";
|
||||
import { RouteComponentProps } from "react-router";
|
||||
import { observer } from "mobx-react";
|
||||
import { helmChartsURL, IHelmChartsRouteParams } from "./helm-charts.route";
|
||||
import { helmChartStore } from "./helm-chart.store";
|
||||
import { HelmChart } from "../../api/endpoints/helm-charts.api";
|
||||
import { HelmChartDetails } from "./helm-chart-details";
|
||||
import { navigation } from "../../navigation";
|
||||
import { ItemListLayout } from "../item-object-list/item-list-layout";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { SearchInput } from "../input";
|
||||
|
||||
enum sortBy {
|
||||
name = "name",
|
||||
repo = "repo",
|
||||
}
|
||||
|
||||
interface Props extends RouteComponentProps<IHelmChartsRouteParams> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class HelmCharts extends Component<Props> {
|
||||
componentDidMount() {
|
||||
helmChartStore.loadAll();
|
||||
}
|
||||
|
||||
get selectedChart() {
|
||||
const { match: { params: { chartName, repo } } } = this.props
|
||||
return helmChartStore.getByName(chartName, repo);
|
||||
}
|
||||
|
||||
showDetails = (chart: HelmChart) => {
|
||||
if (!chart) {
|
||||
navigation.merge(helmChartsURL())
|
||||
}
|
||||
else {
|
||||
navigation.merge(helmChartsURL({
|
||||
params: {
|
||||
chartName: chart.getName(),
|
||||
repo: chart.getRepository(),
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
hideDetails = () => {
|
||||
this.showDetails(null);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<ItemListLayout
|
||||
className="HelmCharts"
|
||||
store={helmChartStore}
|
||||
isClusterScoped={true}
|
||||
isSelectable={false}
|
||||
sortingCallbacks={{
|
||||
[sortBy.name]: (chart: HelmChart) => chart.getName(),
|
||||
[sortBy.repo]: (chart: HelmChart) => chart.getRepository(),
|
||||
}}
|
||||
searchFilters={[
|
||||
(chart: HelmChart) => chart.getName(),
|
||||
(chart: HelmChart) => chart.getVersion(),
|
||||
(chart: HelmChart) => chart.getAppVersion(),
|
||||
(chart: HelmChart) => chart.getKeywords(),
|
||||
]}
|
||||
filterItems={[
|
||||
(items: HelmChart[]) => items.filter(item => !item.deprecated)
|
||||
]}
|
||||
customizeHeader={() => (
|
||||
<SearchInput placeholder={_i18n._(t`Search Helm Charts`)}/>
|
||||
)}
|
||||
renderTableHeader={[
|
||||
{ className: "icon" },
|
||||
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
|
||||
{ title: <Trans>Description</Trans>, className: "description" },
|
||||
{ title: <Trans>Version</Trans>, className: "version" },
|
||||
{ title: <Trans>App Version</Trans>, className: "app-version" },
|
||||
{ title: <Trans>Repository</Trans>, className: "repository", sortBy: sortBy.repo },
|
||||
|
||||
]}
|
||||
renderTableContents={(chart: HelmChart) => [
|
||||
<figure>
|
||||
<img
|
||||
src={chart.getIcon() || require("./helm-placeholder.svg")}
|
||||
onLoad={evt => evt.currentTarget.classList.add("visible")}
|
||||
/>
|
||||
</figure>,
|
||||
chart.getName(),
|
||||
chart.getDescription(),
|
||||
chart.getVersion(),
|
||||
chart.getAppVersion(),
|
||||
{ title: chart.getRepository(), className: chart.getRepository().toLowerCase() }
|
||||
]}
|
||||
detailsItem={this.selectedChart}
|
||||
onDetails={this.showDetails}
|
||||
/>
|
||||
<HelmChartDetails
|
||||
chart={this.selectedChart}
|
||||
hideDetails={this.hideDetails}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg enable-background="new 0 0 722.8 702" viewBox="0 0 722.8 702" xmlns="http://www.w3.org/2000/svg"><g fill="#929ba6"><path d="m318 299.5c2.1 1.6 4.8 2.5 7.6 2.5 6.9 0 12.6-5.5 12.9-12.3l.3-.2 4.3-76.7c-5.2.6-10.4 1.5-15.6 2.7-28.5 6.5-53.2 20.5-72.6 39.5l62.9 44.6z"/><path d="m309.5 411.9c-1.4-5.9-6.6-9.9-12.4-10-.8 0-1.7.1-2.5.2l-.1-.2-75.5 12.8c11.7 32.2 33.4 58.5 60.8 76.1l29.2-70.7-.2-.3c1.1-2.4 1.4-5.2.7-7.9z"/><path d="m284.4 357.5c2.5-.7 4.9-2.2 6.7-4.4 4.3-5.4 3.6-13.2-1.6-17.8l.1-.3-57.4-51.4c-17 27.8-25.1 61.1-21.4 95.3l73.6-21.2z"/><path d="m340.2 380 21.2 10.2 21.1-10.1 5.3-22.9-14.6-18.2h-23.6l-14.6 18.2z"/><path d="m384.2 289.4c.1 2.6 1 5.2 2.8 7.5 4.3 5.4 12.1 6.4 17.7 2.4l.2.1 62.5-44.3c-23.6-23.1-54.4-38.2-87.6-42.2z"/><path d="m490.3 283.7-57.1 51.1v.2c-2 1.7-3.5 4.1-4.1 6.8-1.5 6.8 2.5 13.5 9.2 15.3l.1.3 74 21.3c1.6-16 .6-32.5-3.2-49-3.9-16.8-10.4-32.2-18.9-46z"/><path d="m372.8 439.6c-1.2-2.3-3.2-4.3-5.8-5.5-2-.9-4-1.4-6-1.3-4.5.2-8.7 2.6-10.9 6.8h-.1l-37.1 67.1c25.7 8.8 54.1 10.7 82.5 4.2 5.1-1.2 10-2.5 14.9-4.2l-37.3-67.1z"/><path d="m711.7 425-60.4-262.2c-3.2-13.7-12.5-25.3-25.3-31.4l-244.4-116.8c-7.1-3.4-14.8-4.9-22.7-4.5-6.2.3-12.3 1.9-17.9 4.5l-244.3 116.7c-12.8 6.1-22.1 17.7-25.3 31.4l-60.2 262.3c-2.8 12.2-.5 25 6.3 35.5.8 1.3 1.7 2.5 2.7 3.7l169.1 210.3c8.9 11 22.3 17.4 36.5 17.4l271.2-.1c14.2 0 27.7-6.4 36.5-17.4l169.1-210.3c8.9-10.9 12.2-25.4 9.1-39.1zm-93-3.2c-1.8 7.8-10.2 12.6-18.9 10.7-.1 0-.2 0-.2 0-.1 0-.2-.1-.3-.1-1.2-.3-2.7-.5-3.8-.8-5-1.3-8.6-3.3-13.1-5.1-9.7-3.5-17.7-6.4-25.5-7.5-4-.3-6 1.6-8.2 3-1.1-.2-4.4-.8-6.2-1.1-14 44-43.9 82.2-84.3 106.1.7 1.7 1.9 5.3 2.4 5.9-.9 2.5-2.3 4.8-1.1 8.6 2.8 7.4 7.4 14.6 13 23.2 2.7 4 5.4 7.1 7.8 11.7.6 1.1 1.3 2.8 1.9 3.9 3.8 8 1 17.3-6.2 20.8-7.3 3.5-16.3-.2-20.2-8.3-.6-1.1-1.3-2.7-1.8-3.8-2.1-4.7-2.8-8.8-4.2-13.4-3.3-9.7-6-17.8-10-24.6-2.2-3.3-5-3.7-7.5-4.5-.5-.8-2.2-4-3.1-5.6-8.1 3.1-16.4 5.6-25.1 7.6-37.9 8.6-75.9 5.1-109.9-7.9l-3.3 6c-2.5.7-4.8 1.3-6.3 3.1-5.3 6.4-7.5 16.6-11.3 26.3-1.5 4.6-2.1 8.7-4.2 13.4-.5 1.1-1.3 2.6-1.8 3.7-3.9 8.1-12.9 11.7-20.2 8.2-7.2-3.5-10-12.7-6.2-20.8.6-1.2 1.3-2.8 1.9-3.9 2.4-4.6 5.2-7.7 7.8-11.7 5.5-8.7 10.4-16.4 13.2-23.8.7-2.4-.3-5.8-1.3-8.3l2.7-6.4c-38.9-23.1-69.7-59.8-84.3-105.3l-6.4 1.1c-1.7-1-5.1-3.2-8.4-3-7.8 1.1-15.8 4-25.5 7.5-4.5 1.7-8.1 3.7-13.1 5-1.1.3-2.6.6-3.8.8-.1 0-.2.1-.3.1s-.2 0-.2 0c-8.7 1.9-17.1-2.9-18.9-10.7s3.8-15.7 12.4-17.8c.1 0 .2 0 .2-.1h.1c1.2-.3 2.8-.7 3.9-.9 5.1-1 9.2-.7 14-1.1 10.2-1.1 18.7-1.9 26.2-4.3 2.4-1 4.7-4.3 6.3-6.3l6.1-1.8c-6.9-47.5 4.8-94.2 29.8-131.9l-4.7-4.2c-.3-1.8-.7-6-2.9-8.4-5.8-5.4-13-9.9-21.8-15.3-4.2-2.4-8-4-12.1-7.1-.9-.7-2.1-1.7-3-2.4-.1-.1-.1-.1-.2-.2-7-5.6-8.6-15.2-3.6-21.6 2.8-3.6 7.2-5.3 11.7-5.2 3.5.1 7.1 1.4 10.2 3.8 1 .8 2.4 1.8 3.2 2.6 3.9 3.4 6.3 6.7 9.6 10.2 7.2 7.3 13.2 13.4 19.7 17.8 3.4 2 6.1 1.2 8.7.8.8.6 3.7 2.6 5.3 3.8 24.9-26.4 57.6-46 95.6-54.6 8.8-2 17.7-3.3 26.4-4.1l.3-6.2c1.9-1.9 4.1-4.6 4.8-7.6.6-7.9-.4-16.3-1.6-26.5-.7-4.8-1.8-8.7-2-13.9 0-1.1 0-2.5 0-3.8 0-.1 0-.3 0-.4 0-9 6.5-16.2 14.6-16.2s14.6 7.3 14.6 16.2c0 1.3.1 3 0 4.2-.2 5.2-1.3 9.1-2 13.9-1.2 10.2-2.3 18.7-1.7 26.5.6 3.9 2.9 5.5 4.8 7.3 0 1.1.2 4.6.3 6.5 46.5 4.1 89.7 25.4 121.4 58.7l5.6-4c1.9.1 6 .7 8.9-1 6.5-4.4 12.5-10.5 19.7-17.8 3.3-3.5 5.7-6.8 9.7-10.2.9-.8 2.3-1.8 3.2-2.6 7-5.6 16.8-5 21.8 1.3s3.4 16-3.6 21.6c-1 .8-2.3 1.9-3.2 2.6-4.2 3.1-8 4.7-12.2 7.1-8.7 5.4-16 9.9-21.8 15.3-2.7 2.9-2.5 5.7-2.8 8.3-.8.7-3.7 3.3-5.2 4.7 12.6 18.8 22.1 40.1 27.4 63.3 5.3 23.1 6.1 46.1 3.1 68.3l5.9 1.7c1.1 1.5 3.2 5.2 6.3 6.3 7.5 2.4 16 3.2 26.2 4.3 4.8.4 8.9.2 14 1.1 1.2.2 3 .7 4.2 1 8.9 2.4 14.4 10.4 12.6 18.2z"/><path d="m428 401.7c-1-.2-2-.3-3-.2-1.7.1-3.3.5-4.9 1.3-6.2 3-9 10.4-6.2 16.7l-.1.1 29.6 71.4c28.5-18.2 49.8-45.3 61-76.6l-76.2-12.9z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,2 @@
|
||||
export * from "./helm-charts";
|
||||
export * from "./helm-charts.route";
|
||||
@@ -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/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { RouteProps } from "react-router";
|
||||
import { buildURL } from "../../navigation";
|
||||
|
||||
export const appsRoute: RouteProps = {
|
||||
path: "/apps",
|
||||
};
|
||||
|
||||
export const appsURL = buildURL(appsRoute.path);
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Redirect, Route, Switch } from "react-router";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { MainLayout, TabRoute } from "../layout/main-layout";
|
||||
import { HelmCharts, helmChartsRoute, helmChartsURL } from "../+apps-helm-charts";
|
||||
import { HelmReleases, releaseRoute, releaseURL } from "../+apps-releases";
|
||||
import { namespaceStore } from "../+namespaces/namespace.store";
|
||||
|
||||
@observer
|
||||
export class Apps extends React.Component {
|
||||
static get tabRoutes(): TabRoute[] {
|
||||
const query = namespaceStore.getContextParams();
|
||||
return [
|
||||
{
|
||||
title: <Trans>Charts</Trans>,
|
||||
component: HelmCharts,
|
||||
url: helmChartsURL(),
|
||||
path: helmChartsRoute.path,
|
||||
},
|
||||
{
|
||||
title: <Trans>Releases</Trans>,
|
||||
component: HelmReleases,
|
||||
url: releaseURL({ query }),
|
||||
path: releaseRoute.path,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
render() {
|
||||
const tabRoutes = Apps.tabRoutes;
|
||||
return (
|
||||
<MainLayout className="Apps" tabs={tabRoutes}>
|
||||
<Switch>
|
||||
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
|
||||
<Redirect to={tabRoutes[0].url}/>
|
||||
</Switch>
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./apps";
|
||||
export * from "./apps.route";
|
||||
@@ -0,0 +1,58 @@
|
||||
.ClusterIssues {
|
||||
min-height: 350px;
|
||||
position: relative;
|
||||
|
||||
@include media("<1024px") {
|
||||
grid-column-start: 1!important;
|
||||
grid-column-end: 1!important;
|
||||
}
|
||||
|
||||
&.wide {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 3;
|
||||
}
|
||||
|
||||
.SubHeader {
|
||||
.Icon {
|
||||
font-size: 130%;
|
||||
color: $colorError;
|
||||
}
|
||||
}
|
||||
|
||||
.Table {
|
||||
.TableHead {
|
||||
background-color: transparent;
|
||||
border-bottom: 1px solid $borderFaintColor;
|
||||
|
||||
.TableCell {
|
||||
padding-top: 0;
|
||||
padding-bottom: floor($padding / 1.33);
|
||||
}
|
||||
}
|
||||
|
||||
.TableCell {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&.message {
|
||||
flex-grow: 3;
|
||||
}
|
||||
|
||||
&.object {
|
||||
flex-grow: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-issues {
|
||||
.Icon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ok-title {
|
||||
font-size: large;
|
||||
color: $textColorAccent;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import "./cluster-issues.scss"
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { computed } from "mobx";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { Icon } from "../icon";
|
||||
import { SubHeader } from "../layout/sub-header";
|
||||
import { Table, TableCell, TableHead, TableRow } from "../table";
|
||||
import { nodesStore } from "../+nodes/nodes.store";
|
||||
import { eventStore } from "../+events/event.store";
|
||||
import { autobind, cssNames, prevDefault } from "../../utils";
|
||||
import { getSelectedDetails, showDetails } from "../../navigation";
|
||||
import { ItemObject } from "../../item.store";
|
||||
import { Spinner } from "../spinner";
|
||||
import { themeStore } from "../../theme.store";
|
||||
import { lookupApiLink } from "../../api/kube-api";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface IWarning extends ItemObject {
|
||||
kind: string;
|
||||
message: string;
|
||||
selfLink: string;
|
||||
}
|
||||
|
||||
enum sortBy {
|
||||
type = "type",
|
||||
object = "object"
|
||||
}
|
||||
|
||||
@observer
|
||||
export class ClusterIssues extends React.Component<Props> {
|
||||
private sortCallbacks = {
|
||||
[sortBy.type]: (warning: IWarning) => warning.kind,
|
||||
[sortBy.object]: (warning: IWarning) => warning.getName(),
|
||||
};
|
||||
|
||||
@computed get warnings() {
|
||||
const warnings: IWarning[] = [];
|
||||
|
||||
// Node bad conditions
|
||||
nodesStore.items.forEach(node => {
|
||||
const { kind, selfLink, getId, getName } = node
|
||||
node.getWarningConditions().forEach(({ message }) => {
|
||||
warnings.push({
|
||||
kind,
|
||||
getId,
|
||||
getName,
|
||||
selfLink,
|
||||
message,
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
// Warning events for Workloads
|
||||
const events = eventStore.getWarnings();
|
||||
events.forEach(error => {
|
||||
const { message, involvedObject } = error;
|
||||
const { uid, name, kind } = involvedObject;
|
||||
warnings.push({
|
||||
getId: () => uid,
|
||||
getName: () => name,
|
||||
message,
|
||||
kind,
|
||||
selfLink: lookupApiLink(involvedObject, error),
|
||||
});
|
||||
})
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
getTableRow(uid: string) {
|
||||
const { warnings } = this;
|
||||
const warning = warnings.find(warn => warn.getId() == uid);
|
||||
const { getId, getName, message, kind, selfLink } = warning;
|
||||
return (
|
||||
<TableRow
|
||||
key={getId()}
|
||||
sortItem={warning}
|
||||
selected={selfLink === getSelectedDetails()}
|
||||
onClick={prevDefault(() => showDetails(selfLink))}
|
||||
>
|
||||
<TableCell className="message">
|
||||
{message}
|
||||
</TableCell>
|
||||
<TableCell className="object">
|
||||
{getName()}
|
||||
</TableCell>
|
||||
<TableCell className="kind">
|
||||
{kind}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
const { warnings } = this;
|
||||
if (!eventStore.isLoaded) {
|
||||
return (
|
||||
<Spinner center/>
|
||||
);
|
||||
}
|
||||
if (!warnings.length) {
|
||||
return (
|
||||
<div className="no-issues flex column box grow gaps align-center justify-center">
|
||||
<div><Icon material="check" big sticker/></div>
|
||||
<div className="ok-title"><Trans>No issues found</Trans></div>
|
||||
<span><Trans>Everything is fine in the Cluster</Trans></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<SubHeader>
|
||||
<Icon material="error_outline"/>{" "}
|
||||
<Trans>Warnings: {warnings.length}</Trans>
|
||||
</SubHeader>
|
||||
<Table
|
||||
items={warnings}
|
||||
virtual
|
||||
selectable
|
||||
sortable={this.sortCallbacks}
|
||||
sortByDefault={{ sortBy: sortBy.object, orderBy: "asc" }}
|
||||
sortSyncWithUrl={false}
|
||||
getTableRow={this.getTableRow}
|
||||
className={cssNames("box grow", themeStore.activeTheme.type)}
|
||||
>
|
||||
<TableHead nowrap>
|
||||
<TableCell className="message"><Trans>Message</Trans></TableCell>
|
||||
<TableCell className="object" sortBy={sortBy.object}><Trans>Object</Trans></TableCell>
|
||||
<TableCell className="kind" sortBy={sortBy.type}><Trans>Type</Trans></TableCell>
|
||||
</TableHead>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={cssNames("ClusterIssues flex column", this.props.className)}>
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.ClusterMetricSwitchers {
|
||||
margin-bottom: $margin * 2;
|
||||
|
||||
.metric-switch {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import "./cluster-metric-switchers.scss";
|
||||
|
||||
import React from "react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { observer } from "mobx-react";
|
||||
import { nodesStore } from "../+nodes/nodes.store";
|
||||
import { cssNames } from "../../utils";
|
||||
import { Radio, RadioGroup } from "../radio";
|
||||
import { clusterStore, MetricNodeRole, MetricType } from "./cluster.store";
|
||||
|
||||
export const ClusterMetricSwitchers = observer(() => {
|
||||
const { metricType, metricNodeRole, getMetricsValues, metrics } = clusterStore;
|
||||
const { masterNodes, workerNodes } = nodesStore;
|
||||
const metricsValues = getMetricsValues(metrics);
|
||||
const disableRoles = !masterNodes.length || !workerNodes.length;
|
||||
const disableMetrics = !metricsValues.length;
|
||||
return (
|
||||
<div className="ClusterMetricSwitchers flex gaps">
|
||||
<div className="box grow">
|
||||
<RadioGroup
|
||||
asButtons
|
||||
className={cssNames("RadioGroup flex gaps", { disabled: disableRoles })}
|
||||
value={metricNodeRole}
|
||||
onChange={(metric: MetricNodeRole) => clusterStore.metricNodeRole = metric}
|
||||
>
|
||||
<Radio label={<Trans>Master</Trans>} value={MetricNodeRole.MASTER}/>
|
||||
<Radio label={<Trans>Worker</Trans>} value={MetricNodeRole.WORKER}/>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="box grow metric-switch">
|
||||
<RadioGroup
|
||||
asButtons
|
||||
className={cssNames("RadioGroup flex gaps", { disabled: disableMetrics })}
|
||||
value={metricType}
|
||||
onChange={(value: MetricType) => clusterStore.metricType = value}
|
||||
>
|
||||
<Radio label={<Trans>CPU</Trans>} value={MetricType.CPU}/>
|
||||
<Radio label={<Trans>Memory</Trans>} value={MetricType.MEMORY}/>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
.ClusterMetrics {
|
||||
position: relative;
|
||||
min-height: 280px;
|
||||
|
||||
.Chart {
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin-top: -45px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import "./cluster-metrics.scss";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ChartOptions, ChartPoint } from "chart.js";
|
||||
import { clusterStore, MetricType } from "./cluster.store";
|
||||
import { BarChart } from "../chart";
|
||||
import { bytesToUnits } from "../../utils";
|
||||
import { Spinner } from "../spinner";
|
||||
import { ZebraStripes } from "../chart/zebra-stripes.plugin";
|
||||
import { ClusterNoMetrics } from "./cluster-no-metrics";
|
||||
import { ClusterMetricSwitchers } from "./cluster-metric-switchers";
|
||||
import { getMetricLastPoints } from "../../api/endpoints/metrics.api";
|
||||
|
||||
export const ClusterMetrics = observer(() => {
|
||||
const { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics, liveMetrics } = clusterStore;
|
||||
const { memoryCapacity, cpuCapacity } = getMetricLastPoints(clusterStore.metrics);
|
||||
const metricValues = getMetricsValues(metrics);
|
||||
const liveMetricValues = getMetricsValues(liveMetrics);
|
||||
const colors = { cpu: "#3D90CE", memory: "#C93DCE" };
|
||||
const data = metricValues.map(value => ({
|
||||
x: value[0],
|
||||
y: parseFloat(value[1]).toFixed(3)
|
||||
}));
|
||||
|
||||
const datasets = [{
|
||||
id: metricType + metricNodeRole,
|
||||
label: metricType.toUpperCase() + " usage",
|
||||
borderColor: colors[metricType],
|
||||
data: data
|
||||
}];
|
||||
const cpuOptions: ChartOptions = {
|
||||
scales: {
|
||||
yAxes: [{
|
||||
ticks: {
|
||||
suggestedMax: cpuCapacity,
|
||||
callback: (value) => value
|
||||
}
|
||||
}]
|
||||
},
|
||||
tooltips: {
|
||||
callbacks: {
|
||||
label: ({ index }, data) => {
|
||||
const value = data.datasets[0].data[index] as ChartPoint;
|
||||
return value.y.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const memoryOptions: ChartOptions = {
|
||||
scales: {
|
||||
yAxes: [{
|
||||
ticks: {
|
||||
suggestedMax: memoryCapacity,
|
||||
callback: (value: string) => !value ? 0 : bytesToUnits(parseInt(value))
|
||||
}
|
||||
}]
|
||||
},
|
||||
tooltips: {
|
||||
callbacks: {
|
||||
label: ({ index }, data) => {
|
||||
const value = data.datasets[0].data[index] as ChartPoint;
|
||||
return bytesToUnits(parseInt(value.y as string), 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const options = metricType === MetricType.CPU ? cpuOptions : memoryOptions;
|
||||
|
||||
const renderMetrics = () => {
|
||||
if ((!metricValues.length || !liveMetricValues.length) && !metricsLoaded) {
|
||||
return <Spinner center/>;
|
||||
}
|
||||
if (!memoryCapacity || !cpuCapacity) {
|
||||
return <ClusterNoMetrics className="empty"/>
|
||||
}
|
||||
return (
|
||||
<BarChart
|
||||
name={`${metricNodeRole}-${metricType}`}
|
||||
options={options}
|
||||
data={{ datasets }}
|
||||
timeLabelStep={5}
|
||||
showLegend={false}
|
||||
plugins={[ZebraStripes]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ClusterMetrics flex column">
|
||||
<ClusterMetricSwitchers/>
|
||||
{renderMetrics()}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import { Icon } from "../icon";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { cssNames } from "../../utils";
|
||||
|
||||
interface Props {
|
||||
className: string;
|
||||
}
|
||||
|
||||
export function ClusterNoMetrics({ className }: Props) {
|
||||
return (
|
||||
<div className={cssNames("ClusterNoMetrics flex column box grow justify-center align-center", className)}>
|
||||
<Icon material="info"/>
|
||||
<p><Trans>Metrics are not available due to missing or invalid Prometheus configuration.</Trans></p>
|
||||
<p><Trans>Right click cluster icon to open cluster settings.</Trans></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
.ClusterPieCharts {
|
||||
background: transparent!important;
|
||||
padding: 0!important;
|
||||
|
||||
.empty {
|
||||
background: $contentColor;
|
||||
min-height: 280px;
|
||||
text-align: center;
|
||||
padding: $padding * 2;
|
||||
}
|
||||
|
||||
.NodeCharts {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chart {
|
||||
--flex-gap: #{$padding * 2};
|
||||
background: $contentColor;
|
||||
padding: $padding * 2 $padding;
|
||||
|
||||
.chart-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.legend {
|
||||
--flex-gap: #{$padding};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import "./cluster-pie-charts.scss";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { clusterStore, MetricNodeRole } from "./cluster.store";
|
||||
import { Spinner } from "../spinner";
|
||||
import { Icon } from "../icon";
|
||||
import { nodesStore } from "../+nodes/nodes.store";
|
||||
import { ChartData, PieChart } from "../chart";
|
||||
import { ClusterNoMetrics } from "./cluster-no-metrics";
|
||||
import { bytesToUnits } from "../../utils";
|
||||
import { themeStore } from "../../theme.store";
|
||||
import { getMetricLastPoints } from "../../api/endpoints/metrics.api";
|
||||
|
||||
export const ClusterPieCharts = observer(() => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const renderLimitWarning = () => {
|
||||
return (
|
||||
<div className="node-warning flex gaps align-center">
|
||||
<Icon material="info"/>
|
||||
<p><Trans>Specified limits are higher than node capacity!</Trans></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderCharts = () => {
|
||||
const data = getMetricLastPoints(clusterStore.metrics);
|
||||
const { memoryUsage, memoryRequests, memoryCapacity, memoryLimits } = data;
|
||||
const { cpuUsage, cpuRequests, cpuCapacity, cpuLimits } = data;
|
||||
const { podUsage, podCapacity } = data;
|
||||
const cpuLimitsOverload = cpuLimits > cpuCapacity;
|
||||
const memoryLimitsOverload = memoryLimits > memoryCapacity;
|
||||
const defaultColor = themeStore.activeTheme.colors.pieChartDefaultColor;
|
||||
|
||||
if (!memoryCapacity || !cpuCapacity || !podCapacity) return null;
|
||||
const cpuData: ChartData = {
|
||||
datasets: [
|
||||
{
|
||||
data: [
|
||||
cpuUsage,
|
||||
cpuUsage ? cpuCapacity - cpuUsage : 1,
|
||||
],
|
||||
backgroundColor: [
|
||||
"#c93dce",
|
||||
defaultColor,
|
||||
],
|
||||
id: "cpuUsage"
|
||||
},
|
||||
{
|
||||
data: [
|
||||
cpuRequests,
|
||||
cpuRequests ? cpuCapacity - cpuRequests : 1,
|
||||
],
|
||||
backgroundColor: [
|
||||
"#4caf50",
|
||||
defaultColor,
|
||||
],
|
||||
id: "cpuRequests"
|
||||
},
|
||||
{
|
||||
data: [
|
||||
cpuLimits,
|
||||
cpuLimitsOverload ? 0 : cpuCapacity - cpuLimits,
|
||||
],
|
||||
backgroundColor: [
|
||||
"#3d90ce",
|
||||
defaultColor,
|
||||
],
|
||||
id: "cpuLimits"
|
||||
},
|
||||
],
|
||||
labels: [
|
||||
i18n._(t`Usage`) + `: ${cpuUsage ? cpuUsage.toFixed(2) : "N/A"}`,
|
||||
i18n._(t`Requests`) + `: ${cpuRequests ? cpuRequests.toFixed(2) : "N/A"}`,
|
||||
i18n._(t`Limits`) + `: ${cpuLimits ? cpuLimits.toFixed(2) : "N/A"}`,
|
||||
i18n._(t`Capacity`) + `: ${cpuCapacity || "N/A"}`
|
||||
]
|
||||
};
|
||||
const memoryData: ChartData = {
|
||||
datasets: [
|
||||
{
|
||||
data: [
|
||||
memoryUsage,
|
||||
memoryUsage ? memoryCapacity - memoryUsage : 1,
|
||||
],
|
||||
backgroundColor: [
|
||||
"#c93dce",
|
||||
defaultColor,
|
||||
],
|
||||
id: "memoryUsage"
|
||||
},
|
||||
{
|
||||
data: [
|
||||
memoryRequests,
|
||||
memoryRequests ? memoryCapacity - memoryRequests : 1,
|
||||
],
|
||||
backgroundColor: [
|
||||
"#4caf50",
|
||||
defaultColor,
|
||||
],
|
||||
id: "memoryRequests"
|
||||
},
|
||||
{
|
||||
data: [
|
||||
memoryLimits,
|
||||
memoryLimitsOverload ? 0 : memoryCapacity - memoryLimits,
|
||||
],
|
||||
backgroundColor: [
|
||||
"#3d90ce",
|
||||
defaultColor,
|
||||
],
|
||||
id: "memoryLimits"
|
||||
},
|
||||
],
|
||||
labels: [
|
||||
i18n._(t`Usage`) + `: ${bytesToUnits(memoryUsage)}`,
|
||||
i18n._(t`Requests`) + `: ${bytesToUnits(memoryRequests)}`,
|
||||
i18n._(t`Limits`) + `: ${bytesToUnits(memoryLimits)}`,
|
||||
i18n._(t`Capacity`) + `: ${bytesToUnits(memoryCapacity)}`,
|
||||
]
|
||||
};
|
||||
const podsData: ChartData = {
|
||||
datasets: [
|
||||
{
|
||||
data: [
|
||||
podUsage,
|
||||
podUsage ? podCapacity - podUsage : 1,
|
||||
],
|
||||
backgroundColor: [
|
||||
"#4caf50",
|
||||
defaultColor,
|
||||
],
|
||||
id: "podUsage"
|
||||
},
|
||||
],
|
||||
labels: [
|
||||
i18n._(t`Usage`) + `: ${podUsage || 0}`,
|
||||
i18n._(t`Capacity`) + `: ${podCapacity}`,
|
||||
]
|
||||
};
|
||||
return (
|
||||
<div className="NodeCharts flex justify-center box grow gaps">
|
||||
<div className="chart flex column align-center box grow">
|
||||
<PieChart
|
||||
data={cpuData}
|
||||
title={i18n._(t`CPU`)}
|
||||
legendColors={["#c93dce", "#4caf50", "#3d90ce", defaultColor]}
|
||||
/>
|
||||
{cpuLimitsOverload && renderLimitWarning()}
|
||||
</div>
|
||||
<div className="chart flex column align-center box grow">
|
||||
<PieChart
|
||||
data={memoryData}
|
||||
title={i18n._(t`Memory`)}
|
||||
legendColors={["#c93dce", "#4caf50", "#3d90ce", defaultColor]}
|
||||
/>
|
||||
{memoryLimitsOverload && renderLimitWarning()}
|
||||
</div>
|
||||
<div className="chart flex column align-center box grow">
|
||||
<PieChart
|
||||
data={podsData}
|
||||
title={i18n._(t`Pods`)}
|
||||
legendColors={["#4caf50", defaultColor]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
const { masterNodes, workerNodes } = nodesStore;
|
||||
const { metricNodeRole, metricsLoaded } = clusterStore;
|
||||
const nodes = metricNodeRole === MetricNodeRole.MASTER ? masterNodes : workerNodes;
|
||||
if (!nodes.length) {
|
||||
return (
|
||||
<div className="empty flex column box grow align-center justify-center">
|
||||
<Icon material="info"/>
|
||||
<Trans>No Nodes Available.</Trans>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!metricsLoaded) {
|
||||
return (
|
||||
<div className="flex justify-center align-center box grow empty">
|
||||
<Spinner/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { memoryCapacity, cpuCapacity, podCapacity } = getMetricLastPoints(clusterStore.metrics);
|
||||
if (!memoryCapacity || !cpuCapacity || !podCapacity) {
|
||||
return <ClusterNoMetrics className="empty"/>;
|
||||
}
|
||||
return renderCharts();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ClusterPieCharts flex">
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { RouteProps } from "react-router";
|
||||
import { buildURL } from "../../navigation";
|
||||
|
||||
export const clusterRoute: RouteProps = {
|
||||
path: "/cluster"
|
||||
}
|
||||
|
||||
export const clusterURL = buildURL(clusterRoute.path)
|
||||
@@ -0,0 +1,24 @@
|
||||
.Cluster {
|
||||
$gridGap: $margin * 2;
|
||||
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 650px;
|
||||
display: grid;
|
||||
grid-gap: $gridGap;
|
||||
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
||||
grid-template-rows: 1fr 1fr;
|
||||
|
||||
@include media(">1600px") {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
> *:not(.Spinner) {
|
||||
padding: $gridGap;
|
||||
background: $contentColor;
|
||||
|
||||
> .SubHeader {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { observable, reaction, when } from "mobx";
|
||||
import { KubeObjectStore } from "../../kube-object.store";
|
||||
import { Cluster, clusterApi, IClusterMetrics } from "../../api/endpoints";
|
||||
import { autobind, createStorage } from "../../utils";
|
||||
import { IMetricsReqParams, normalizeMetrics } from "../../api/endpoints/metrics.api";
|
||||
import { nodesStore } from "../+nodes/nodes.store";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
|
||||
export enum MetricType {
|
||||
MEMORY = "memory",
|
||||
CPU = "cpu"
|
||||
}
|
||||
|
||||
export enum MetricNodeRole {
|
||||
MASTER = "master",
|
||||
WORKER = "worker"
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class ClusterStore extends KubeObjectStore<Cluster> {
|
||||
api = clusterApi
|
||||
|
||||
@observable metrics: Partial<IClusterMetrics> = {};
|
||||
@observable liveMetrics: Partial<IClusterMetrics> = {};
|
||||
@observable metricsLoaded = false;
|
||||
@observable metricType: MetricType;
|
||||
@observable metricNodeRole: MetricNodeRole;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.resetMetrics();
|
||||
|
||||
// sync user setting with local storage
|
||||
const storage = createStorage("cluster_metric_switchers", {});
|
||||
Object.assign(this, storage.get());
|
||||
reaction(() => {
|
||||
const { metricType, metricNodeRole } = this;
|
||||
return { metricType, metricNodeRole }
|
||||
},
|
||||
settings => storage.set(settings)
|
||||
);
|
||||
|
||||
// auto-update metrics
|
||||
reaction(() => this.metricNodeRole, () => {
|
||||
if (!this.metricsLoaded) return;
|
||||
this.metrics = {};
|
||||
this.liveMetrics = {};
|
||||
this.metricsLoaded = false;
|
||||
this.getAllMetrics();
|
||||
});
|
||||
|
||||
// check which node type to select
|
||||
reaction(() => nodesStore.items.length, () => {
|
||||
const { masterNodes, workerNodes } = nodesStore;
|
||||
if (!masterNodes.length) this.metricNodeRole = MetricNodeRole.WORKER;
|
||||
if (!workerNodes.length) this.metricNodeRole = MetricNodeRole.MASTER;
|
||||
});
|
||||
}
|
||||
|
||||
async loadMetrics(params?: IMetricsReqParams) {
|
||||
await when(() => nodesStore.isLoaded);
|
||||
const { masterNodes, workerNodes } = nodesStore;
|
||||
const nodes = this.metricNodeRole === MetricNodeRole.MASTER && masterNodes.length ? masterNodes : workerNodes;
|
||||
return clusterApi.getMetrics(nodes.map(node => node.getName()), params);
|
||||
}
|
||||
|
||||
async getAllMetrics() {
|
||||
await this.getMetrics();
|
||||
await this.getLiveMetrics();
|
||||
this.metricsLoaded = true;
|
||||
}
|
||||
|
||||
async getMetrics() {
|
||||
this.metrics = await this.loadMetrics();
|
||||
}
|
||||
|
||||
async getLiveMetrics() {
|
||||
const step = 3;
|
||||
const range = 15;
|
||||
const end = Date.now() / 1000;
|
||||
const start = end - range;
|
||||
this.liveMetrics = await this.loadMetrics({ start, end, step, range });
|
||||
}
|
||||
|
||||
getMetricsValues(source: Partial<IClusterMetrics>): [number, string][] {
|
||||
switch (this.metricType) {
|
||||
case MetricType.CPU:
|
||||
return normalizeMetrics(source.cpuUsage).data.result[0].values
|
||||
case MetricType.MEMORY:
|
||||
return normalizeMetrics(source.memoryUsage).data.result[0].values
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
resetMetrics() {
|
||||
this.metrics = {};
|
||||
this.metricsLoaded = false;
|
||||
this.metricType = MetricType.CPU;
|
||||
this.metricNodeRole = MetricNodeRole.WORKER;
|
||||
}
|
||||
|
||||
reset() {
|
||||
super.reset();
|
||||
this.resetMetrics();
|
||||
}
|
||||
}
|
||||
|
||||
export const clusterStore = new ClusterStore();
|
||||
apiManager.registerStore(clusterApi, clusterStore);
|
||||
@@ -0,0 +1,73 @@
|
||||
import "./cluster.scss"
|
||||
|
||||
import React from "react";
|
||||
import { computed, reaction } from "mobx";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import { MainLayout } from "../layout/main-layout";
|
||||
import { ClusterIssues } from "./cluster-issues";
|
||||
import { Spinner } from "../spinner";
|
||||
import { cssNames, interval, isElectron } from "../../utils";
|
||||
import { ClusterPieCharts } from "./cluster-pie-charts";
|
||||
import { ClusterMetrics } from "./cluster-metrics";
|
||||
import { nodesStore } from "../+nodes/nodes.store";
|
||||
import { podsStore } from "../+workloads-pods/pods.store";
|
||||
import { clusterStore } from "./cluster.store";
|
||||
import { eventStore } from "../+events/event.store";
|
||||
import { isAllowedResource } from "../../api/rbac";
|
||||
|
||||
@observer
|
||||
export class Cluster extends React.Component {
|
||||
private watchers = [
|
||||
interval(60, () => clusterStore.getMetrics()),
|
||||
interval(20, () => eventStore.loadAll())
|
||||
];
|
||||
|
||||
private dependentStores = [nodesStore, podsStore];
|
||||
|
||||
async componentDidMount() {
|
||||
const { dependentStores } = this;
|
||||
if (!isAllowedResource("nodes")) {
|
||||
dependentStores.splice(dependentStores.indexOf(nodesStore), 1)
|
||||
}
|
||||
this.watchers.forEach(watcher => watcher.start(true));
|
||||
|
||||
await Promise.all([
|
||||
...dependentStores.map(store => store.loadAll()),
|
||||
clusterStore.getAllMetrics()
|
||||
]);
|
||||
|
||||
disposeOnUnmount(this, [
|
||||
...dependentStores.map(store => store.subscribe()),
|
||||
() => this.watchers.forEach(watcher => watcher.stop()),
|
||||
reaction(
|
||||
() => clusterStore.metricNodeRole,
|
||||
() => this.watchers.forEach(watcher => watcher.restart())
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
@computed get isLoaded() {
|
||||
return (
|
||||
nodesStore.isLoaded &&
|
||||
podsStore.isLoaded
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isLoaded } = this;
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className="Cluster">
|
||||
{!isLoaded && <Spinner center/>}
|
||||
{isLoaded && (
|
||||
<>
|
||||
<ClusterMetrics/>
|
||||
<ClusterPieCharts/>
|
||||
<ClusterIssues className={cssNames({ wide: isElectron })}/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./cluster.routes"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
$hpa-status-colors: (
|
||||
abletoscale: $colorOk,
|
||||
scalingactive: $colorInfo,
|
||||
scalinglimited: $colorSoftError,
|
||||
);
|
||||
|
||||
@mixin hpa-status-bgc {
|
||||
@each $status, $color in $hpa-status-colors {
|
||||
&.#{$status} {
|
||||
background: $color;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@import "autoscaler.mixins";
|
||||
|
||||
.HpaDetails {
|
||||
.Badge {
|
||||
@include hpa-status-bgc;
|
||||
}
|
||||
|
||||
.metrics .Table {
|
||||
.TableCell {
|
||||
word-break: break-word;
|
||||
|
||||
&.name {
|
||||
flex-grow: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import "./hpa-details.scss";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { DrawerItem, DrawerTitle } from "../drawer";
|
||||
import { Badge } from "../badge";
|
||||
import { KubeObjectDetailsProps } from "../kube-object";
|
||||
import { cssNames } from "../../utils";
|
||||
import { HorizontalPodAutoscaler, hpaApi, HpaMetricType, IHpaMetric } from "../../api/endpoints/hpa.api";
|
||||
import { KubeEventDetails } from "../+events/kube-event-details";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { Table, TableCell, TableHead, TableRow } from "../table";
|
||||
import { getDetailsUrl } from "../../navigation";
|
||||
import { lookupApiLink } from "../../api/kube-api";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
import { KubeObjectMeta } from "../kube-object/kube-object-meta";
|
||||
|
||||
interface Props extends KubeObjectDetailsProps<HorizontalPodAutoscaler> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class HpaDetails extends React.Component<Props> {
|
||||
renderMetrics() {
|
||||
const { object: hpa } = this.props;
|
||||
|
||||
const renderName = (metric: IHpaMetric) => {
|
||||
switch (metric.type) {
|
||||
case HpaMetricType.Resource:
|
||||
const addition = metric.resource.targetAverageUtilization ? <Trans>(as a percentage of request)</Trans> : "";
|
||||
return <Trans>Resource {metric.resource.name} on Pods {addition}</Trans>;
|
||||
|
||||
case HpaMetricType.Pods:
|
||||
return <Trans>{metric.pods.metricName} on Pods</Trans>;
|
||||
|
||||
case HpaMetricType.Object:
|
||||
const { target } = metric.object;
|
||||
const { kind, name } = target;
|
||||
const objectUrl = getDetailsUrl(lookupApiLink(target, hpa));
|
||||
return (
|
||||
<Trans>
|
||||
{metric.object.metricName} on{" "}
|
||||
<Link to={objectUrl}>{kind}/{name}</Link>
|
||||
</Trans>
|
||||
);
|
||||
case HpaMetricType.External:
|
||||
return (
|
||||
<Trans>
|
||||
{metric.external.metricName} on{" "}
|
||||
{JSON.stringify(metric.external.selector)}
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableCell className="name"><Trans>Name</Trans></TableCell>
|
||||
<TableCell className="metrics"><Trans>Current / Target</Trans></TableCell>
|
||||
</TableHead>
|
||||
{
|
||||
hpa.getMetrics().map((metric, index) => {
|
||||
const name = renderName(metric);
|
||||
const values = hpa.getMetricValues(metric);
|
||||
return (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="name">{name}</TableCell>
|
||||
<TableCell className="metrics">{values}</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
}
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { object: hpa } = this.props;
|
||||
if (!hpa) return;
|
||||
const { scaleTargetRef } = hpa.spec;
|
||||
return (
|
||||
<div className="HpaDetails">
|
||||
<KubeObjectMeta object={hpa}/>
|
||||
|
||||
<DrawerItem name={<Trans>Reference</Trans>}>
|
||||
{scaleTargetRef && (
|
||||
<Link to={getDetailsUrl(lookupApiLink(scaleTargetRef, hpa))}>
|
||||
{scaleTargetRef.kind}/{scaleTargetRef.name}
|
||||
</Link>
|
||||
)}
|
||||
</DrawerItem>
|
||||
|
||||
<DrawerItem name={<Trans>Min Pods</Trans>}>
|
||||
{hpa.getMinPods()}
|
||||
</DrawerItem>
|
||||
|
||||
<DrawerItem name={<Trans>Max Pods</Trans>}>
|
||||
{hpa.getMaxPods()}
|
||||
</DrawerItem>
|
||||
|
||||
<DrawerItem name={<Trans>Replicas</Trans>}>
|
||||
{hpa.getReplicas()}
|
||||
</DrawerItem>
|
||||
|
||||
<DrawerItem name={<Trans>Status</Trans>} labelsOnly>
|
||||
{hpa.getConditions().map(({ type, tooltip, isReady }) => {
|
||||
if (!isReady) return null;
|
||||
return (
|
||||
<Badge
|
||||
key={type}
|
||||
label={type}
|
||||
tooltip={tooltip}
|
||||
className={cssNames({ [type.toLowerCase()]: isReady })}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</DrawerItem>
|
||||
|
||||
<DrawerTitle title="Metrics"/>
|
||||
<div className="metrics">
|
||||
{this.renderMetrics()}
|
||||
</div>
|
||||
|
||||
<KubeEventDetails object={hpa}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
apiManager.registerViews(hpaApi, {
|
||||
Details: HpaDetails,
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { RouteProps } from "react-router";
|
||||
import { buildURL } from "../../navigation";
|
||||
|
||||
export const hpaRoute: RouteProps = {
|
||||
path: "/hpa"
|
||||
}
|
||||
|
||||
export interface IHpaRouteParams {
|
||||
}
|
||||
|
||||
export const hpaURL = buildURL<IHpaRouteParams>(hpaRoute.path)
|
||||
@@ -0,0 +1,26 @@
|
||||
@import "autoscaler.mixins";
|
||||
|
||||
.HorizontalPodAutoscalers {
|
||||
.TableCell {
|
||||
&.name {
|
||||
flex: 1.5;
|
||||
}
|
||||
|
||||
&.metrics {
|
||||
flex: 1.5;
|
||||
}
|
||||
|
||||
&.status {
|
||||
flex: 1.5;
|
||||
@include table-cell-labels-offsets;
|
||||
|
||||
.Badge {
|
||||
@include hpa-status-bgc;
|
||||
}
|
||||
}
|
||||
|
||||
&.age {
|
||||
flex: .5;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeObjectStore } from "../../kube-object.store";
|
||||
import { HorizontalPodAutoscaler, hpaApi } from "../../api/endpoints/hpa.api";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
|
||||
@autobind()
|
||||
export class HPAStore extends KubeObjectStore<HorizontalPodAutoscaler> {
|
||||
api = hpaApi
|
||||
}
|
||||
|
||||
export const hpaStore = new HPAStore();
|
||||
apiManager.registerStore(hpaApi, hpaStore);
|
||||
@@ -0,0 +1,99 @@
|
||||
import "./hpa.scss"
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { RouteComponentProps } from "react-router";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu";
|
||||
import { KubeObjectListLayout } from "../kube-object";
|
||||
import { IHpaRouteParams } from "./hpa.route";
|
||||
import { HorizontalPodAutoscaler, hpaApi } from "../../api/endpoints/hpa.api";
|
||||
import { hpaStore } from "./hpa.store";
|
||||
import { Badge } from "../badge";
|
||||
import { cssNames } from "../../utils";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
|
||||
enum sortBy {
|
||||
name = "name",
|
||||
namespace = "namespace",
|
||||
minPods = "min-pods",
|
||||
maxPods = "max-pods",
|
||||
replicas = "replicas",
|
||||
age = "age",
|
||||
}
|
||||
|
||||
interface Props extends RouteComponentProps<IHpaRouteParams> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class HorizontalPodAutoscalers extends React.Component<Props> {
|
||||
getTargets(hpa: HorizontalPodAutoscaler) {
|
||||
const metrics = hpa.getMetrics();
|
||||
const metricsRemainCount = metrics.length - 1;
|
||||
const metricsRemain = metrics.length > 1 ? <Trans>{metricsRemainCount} more...</Trans> : null;
|
||||
const metricValues = hpa.getMetricValues(metrics[0]);
|
||||
return <p>{metricValues} {metricsRemain && "+"}{metricsRemain}</p>;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<KubeObjectListLayout
|
||||
className="HorizontalPodAutoscalers" store={hpaStore}
|
||||
sortingCallbacks={{
|
||||
[sortBy.name]: (item: HorizontalPodAutoscaler) => item.getName(),
|
||||
[sortBy.namespace]: (item: HorizontalPodAutoscaler) => item.getNs(),
|
||||
[sortBy.minPods]: (item: HorizontalPodAutoscaler) => item.getMinPods(),
|
||||
[sortBy.maxPods]: (item: HorizontalPodAutoscaler) => item.getMaxPods(),
|
||||
[sortBy.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas()
|
||||
}}
|
||||
searchFilters={[
|
||||
(item: HorizontalPodAutoscaler) => item.getSearchFields()
|
||||
]}
|
||||
renderHeaderTitle={<Trans>Horizontal Pod Autoscalers</Trans>}
|
||||
renderTableHeader={[
|
||||
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
|
||||
{ title: <Trans>Namespace</Trans>, className: "namespace", sortBy: sortBy.namespace },
|
||||
{ title: <Trans>Metrics</Trans>, className: "metrics" },
|
||||
{ title: <Trans>Min Pods</Trans>, className: "min-pods", sortBy: sortBy.minPods },
|
||||
{ title: <Trans>Max Pods</Trans>, className: "max-pods", sortBy: sortBy.maxPods },
|
||||
{ title: <Trans>Replicas</Trans>, className: "replicas", sortBy: sortBy.replicas },
|
||||
{ title: <Trans>Age</Trans>, className: "age", sortBy: sortBy.age },
|
||||
{ title: <Trans>Status</Trans>, className: "status" },
|
||||
]}
|
||||
renderTableContents={(hpa: HorizontalPodAutoscaler) => [
|
||||
hpa.getName(),
|
||||
hpa.getNs(),
|
||||
this.getTargets(hpa),
|
||||
hpa.getMinPods(),
|
||||
hpa.getMaxPods(),
|
||||
hpa.getReplicas(),
|
||||
hpa.getAge(),
|
||||
hpa.getConditions().map(({ type, tooltip, isReady }) => {
|
||||
if (!isReady) return null;
|
||||
return (
|
||||
<Badge
|
||||
key={type}
|
||||
label={type}
|
||||
tooltip={tooltip}
|
||||
className={cssNames(type.toLowerCase())}
|
||||
/>
|
||||
)
|
||||
})
|
||||
]}
|
||||
renderItemMenu={(item: HorizontalPodAutoscaler) => {
|
||||
return <HpaMenu object={item}/>
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function HpaMenu(props: KubeObjectMenuProps<HorizontalPodAutoscaler>) {
|
||||
return (
|
||||
<KubeObjectMenu {...props}/>
|
||||
)
|
||||
}
|
||||
|
||||
apiManager.registerViews(hpaApi, {
|
||||
Menu: HpaMenu,
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./hpa"
|
||||
export * from "./hpa-details"
|
||||
export * from "./hpa.route"
|
||||
@@ -0,0 +1,15 @@
|
||||
.ConfigMapDetails {
|
||||
.data {
|
||||
margin-bottom: $margin * 2;
|
||||
|
||||
.name {
|
||||
color: $textColorSecondary;
|
||||
font-weight: $font-weight-bold;
|
||||
padding-bottom: $padding / 2;
|
||||
}
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
margin-top: $margin;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import "./config-map-details.scss";
|
||||
|
||||
import React from "react";
|
||||
import { autorun, observable } from "mobx";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { DrawerTitle } from "../drawer";
|
||||
import { Notifications } from "../notifications";
|
||||
import { Input } from "../input";
|
||||
import { Button } from "../button";
|
||||
import { KubeEventDetails } from "../+events/kube-event-details";
|
||||
import { configMapsStore } from "./config-maps.store";
|
||||
import { KubeObjectDetailsProps } from "../kube-object";
|
||||
import { ConfigMap, configMapApi } from "../../api/endpoints";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
import { KubeObjectMeta } from "../kube-object/kube-object-meta";
|
||||
|
||||
interface Props extends KubeObjectDetailsProps<ConfigMap> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class ConfigMapDetails extends React.Component<Props> {
|
||||
@observable isSaving = false;
|
||||
@observable data = observable.map();
|
||||
|
||||
async componentDidMount() {
|
||||
disposeOnUnmount(this, [
|
||||
autorun(() => {
|
||||
const { object: configMap } = this.props;
|
||||
if (configMap) {
|
||||
this.data.replace(configMap.data); // refresh
|
||||
}
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
save = async () => {
|
||||
const { object: configMap } = this.props;
|
||||
try {
|
||||
this.isSaving = true;
|
||||
await configMapsStore.update(configMap, { ...configMap, data: this.data.toJSON() });
|
||||
Notifications.ok(
|
||||
<p>
|
||||
<Trans>ConfigMap <b>{configMap.getName()}</b> successfully updated.</Trans>
|
||||
</p>
|
||||
);
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { object: configMap } = this.props;
|
||||
if (!configMap) return null;
|
||||
const data = Object.entries(this.data.toJSON());
|
||||
return (
|
||||
<div className="ConfigMapDetails">
|
||||
<KubeObjectMeta object={configMap}/>
|
||||
{
|
||||
data.length > 0 && (
|
||||
<>
|
||||
<DrawerTitle title={<Trans>Data</Trans>}/>
|
||||
{
|
||||
data.map(([name, value]) => {
|
||||
return (
|
||||
<div key={name} className="data">
|
||||
<div className="name">{name}</div>
|
||||
<div className="flex gaps align-flex-start">
|
||||
<Input
|
||||
multiLine
|
||||
theme="round-black"
|
||||
className="box grow"
|
||||
value={value}
|
||||
onChange={v => this.data.set(name, v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
<Button
|
||||
primary
|
||||
label={<Trans>Save</Trans>} waiting={this.isSaving}
|
||||
className="save-btn"
|
||||
onClick={this.save}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<KubeEventDetails object={configMap}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
apiManager.registerViews(configMapApi, {
|
||||
Details: ConfigMapDetails
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { RouteProps } from "react-router";
|
||||
import { buildURL } from "../../navigation";
|
||||
|
||||
export const configMapsRoute: RouteProps = {
|
||||
path: "/configmaps"
|
||||
}
|
||||
|
||||
export interface IConfigMapsRouteParams {
|
||||
}
|
||||
|
||||
export const configMapsURL = buildURL<IConfigMapsRouteParams>(configMapsRoute.path);
|
||||
@@ -0,0 +1,15 @@
|
||||
.ConfigMaps {
|
||||
.TableCell {
|
||||
&.name {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
&.keys {
|
||||
flex: 2.5;
|
||||
}
|
||||
|
||||
&.age {
|
||||
flex: .5;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { KubeObjectStore } from "../../kube-object.store";
|
||||
import { autobind } from "../../utils";
|
||||
import { ConfigMap, configMapApi } from "../../api/endpoints/configmap.api";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
|
||||
@autobind()
|
||||
export class ConfigMapsStore extends KubeObjectStore<ConfigMap> {
|
||||
api = configMapApi
|
||||
}
|
||||
|
||||
export const configMapsStore = new ConfigMapsStore();
|
||||
apiManager.registerStore(configMapApi, configMapsStore);
|
||||
@@ -0,0 +1,69 @@
|
||||
import "./config-maps.scss"
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { RouteComponentProps } from "react-router";
|
||||
import { configMapsStore } from "./config-maps.store";
|
||||
import { ConfigMap, configMapApi } from "../../api/endpoints/configmap.api";
|
||||
import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu";
|
||||
import { KubeObjectListLayout } from "../kube-object";
|
||||
import { IConfigMapsRouteParams } from "./config-maps.route";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
|
||||
enum sortBy {
|
||||
name = "name",
|
||||
namespace = "namespace",
|
||||
keys = "keys",
|
||||
age = "age",
|
||||
}
|
||||
|
||||
interface Props extends RouteComponentProps<IConfigMapsRouteParams> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class ConfigMaps extends React.Component<Props> {
|
||||
render() {
|
||||
return (
|
||||
<KubeObjectListLayout
|
||||
className="ConfigMaps" store={configMapsStore}
|
||||
sortingCallbacks={{
|
||||
[sortBy.name]: (item: ConfigMap) => item.getName(),
|
||||
[sortBy.namespace]: (item: ConfigMap) => item.getNs(),
|
||||
[sortBy.keys]: (item: ConfigMap) => item.getKeys(),
|
||||
[sortBy.age]: (item: ConfigMap) => item.metadata.creationTimestamp,
|
||||
}}
|
||||
searchFilters={[
|
||||
(item: ConfigMap) => item.getSearchFields(),
|
||||
(item: ConfigMap) => item.getKeys()
|
||||
]}
|
||||
renderHeaderTitle={<Trans>Config Maps</Trans>}
|
||||
renderTableHeader={[
|
||||
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
|
||||
{ title: <Trans>Namespace</Trans>, className: "namespace", sortBy: sortBy.namespace },
|
||||
{ title: <Trans>Keys</Trans>, className: "keys", sortBy: sortBy.keys },
|
||||
{ title: <Trans>Age</Trans>, className: "age", sortBy: sortBy.age },
|
||||
]}
|
||||
renderTableContents={(configMap: ConfigMap) => [
|
||||
configMap.getName(),
|
||||
configMap.getNs(),
|
||||
configMap.getKeys().join(", "),
|
||||
configMap.getAge(),
|
||||
]}
|
||||
renderItemMenu={(item: ConfigMap) => {
|
||||
return <ConfigMapMenu object={item}/>
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function ConfigMapMenu(props: KubeObjectMenuProps<ConfigMap>) {
|
||||
return (
|
||||
<KubeObjectMenu {...props}/>
|
||||
)
|
||||
}
|
||||
|
||||
apiManager.registerViews(configMapApi, {
|
||||
Menu: ConfigMapMenu,
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./config-maps.route"
|
||||
export * from "./config-maps"
|
||||
export * from "./config-map-details"
|
||||
@@ -0,0 +1,38 @@
|
||||
.AddQuotaDialog {
|
||||
.quota-select {
|
||||
flex-basis: 55%;
|
||||
}
|
||||
|
||||
.quota-entries {
|
||||
margin: -$margin / 2;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quota {
|
||||
--flex-gap: #{$padding};
|
||||
border: 1px solid $halfGray;
|
||||
border-radius: $radius;
|
||||
margin: $margin / 2;
|
||||
padding: $padding / 2 $padding;
|
||||
transition: all 150ms ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: inset 0 0 0 1px $borderColor;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: $contentColor;
|
||||
}
|
||||
|
||||
.Icon:hover {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import "./add-quota-dialog.scss";
|
||||
|
||||
import React from "react";
|
||||
import { computed, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { Dialog, DialogProps } from "../dialog";
|
||||
import { Wizard, WizardStep } from "../wizard";
|
||||
import { Input } from "../input";
|
||||
import { systemName } from "../input/input.validators";
|
||||
import { IResourceQuotaValues, resourceQuotaApi } from "../../api/endpoints/resource-quota.api";
|
||||
import { Select } from "../select";
|
||||
import { Icon } from "../icon";
|
||||
import { Button } from "../button";
|
||||
import { Notifications } from "../notifications";
|
||||
import { NamespaceSelect } from "../+namespaces/namespace-select";
|
||||
import { SubTitle } from "../layout/sub-title";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class AddQuotaDialog extends React.Component<Props> {
|
||||
@observable static isOpen = false;
|
||||
|
||||
static defaultQuotas: IResourceQuotaValues = {
|
||||
"limits.cpu": "",
|
||||
"limits.memory": "",
|
||||
"requests.cpu": "",
|
||||
"requests.memory": "",
|
||||
"requests.storage": "",
|
||||
"persistentvolumeclaims": "",
|
||||
"count/pods": "",
|
||||
"count/persistentvolumeclaims": "",
|
||||
"count/services": "",
|
||||
"count/secrets": "",
|
||||
"count/configmaps": "",
|
||||
"count/replicationcontrollers": "",
|
||||
"count/deployments.apps": "",
|
||||
"count/replicasets.apps": "",
|
||||
"count/statefulsets.apps": "",
|
||||
"count/jobs.batch": "",
|
||||
"count/cronjobs.batch": "",
|
||||
"count/deployments.extensions": "",
|
||||
};
|
||||
|
||||
public defaultNamespace = "default"
|
||||
|
||||
@observable quotaName = "";
|
||||
@observable quotaSelectValue = "";
|
||||
@observable quotaInputValue = "";
|
||||
@observable namespace = this.defaultNamespace;
|
||||
@observable quotas = AddQuotaDialog.defaultQuotas;
|
||||
|
||||
static open() {
|
||||
AddQuotaDialog.isOpen = true;
|
||||
}
|
||||
|
||||
static close() {
|
||||
AddQuotaDialog.isOpen = false;
|
||||
}
|
||||
|
||||
@computed get quotaEntries() {
|
||||
return Object.entries(this.quotas)
|
||||
.filter(([type, value]) => !!value.trim());
|
||||
}
|
||||
|
||||
@computed get quotaOptions() {
|
||||
return Object.keys(this.quotas).map(quota => {
|
||||
const isCompute = quota.endsWith(".cpu") || quota.endsWith(".memory");
|
||||
const isStorage = quota.endsWith(".storage") || quota === "persistentvolumeclaims";
|
||||
const isCount = quota.startsWith("count/");
|
||||
const icon = isCompute ? "memory" : isStorage ? "storage" : isCount ? "looks_one" : "";
|
||||
return {
|
||||
label: icon ? <span className="nobr"><Icon material={icon}/> {quota}</span> : quota,
|
||||
value: quota,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
setQuota = () => {
|
||||
if (!this.quotaSelectValue) return;
|
||||
this.quotas[this.quotaSelectValue] = this.quotaInputValue;
|
||||
this.quotaInputValue = "";
|
||||
}
|
||||
|
||||
close = () => {
|
||||
AddQuotaDialog.close();
|
||||
}
|
||||
|
||||
reset = () => {
|
||||
this.quotaName = "";
|
||||
this.quotaSelectValue = "";
|
||||
this.quotaInputValue = "";
|
||||
this.namespace = this.defaultNamespace;
|
||||
this.quotas = AddQuotaDialog.defaultQuotas;
|
||||
}
|
||||
|
||||
addQuota = async () => {
|
||||
try {
|
||||
const { quotaName, namespace } = this;
|
||||
const quotas = this.quotaEntries.reduce<IResourceQuotaValues>((quotas, [name, value]) => {
|
||||
quotas[name] = value;
|
||||
return quotas;
|
||||
}, {});
|
||||
await resourceQuotaApi.create({ namespace, name: quotaName }, {
|
||||
spec: {
|
||||
hard: quotas
|
||||
}
|
||||
});
|
||||
this.close();
|
||||
} catch (err) {
|
||||
Notifications.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
onInputQuota = (evt: React.KeyboardEvent) => {
|
||||
switch (evt.key) {
|
||||
case "Enter":
|
||||
this.setQuota();
|
||||
evt.preventDefault(); // don't submit form
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { ...dialogProps } = this.props;
|
||||
const header = <h5><Trans>Create ResourceQuota</Trans></h5>;
|
||||
return (
|
||||
<Dialog
|
||||
{...dialogProps}
|
||||
className="AddQuotaDialog"
|
||||
isOpen={AddQuotaDialog.isOpen}
|
||||
close={this.close}
|
||||
>
|
||||
<Wizard header={header} done={this.close}>
|
||||
<WizardStep
|
||||
contentClass="flex gaps column"
|
||||
disabledNext={!this.namespace}
|
||||
nextLabel={<Trans>Create</Trans>}
|
||||
next={this.addQuota}
|
||||
>
|
||||
<div className="flex gaps">
|
||||
<Input
|
||||
required autoFocus
|
||||
placeholder={_i18n._(t`ResourceQuota name`)}
|
||||
validators={systemName}
|
||||
value={this.quotaName} onChange={v => this.quotaName = v.toLowerCase()}
|
||||
className="box grow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SubTitle title={<Trans>Namespace</Trans>}/>
|
||||
<NamespaceSelect
|
||||
value={this.namespace}
|
||||
placeholder={_i18n._(t`Namespace`)}
|
||||
themeName="light"
|
||||
className="box grow"
|
||||
onChange={({ value }) => this.namespace = value}
|
||||
/>
|
||||
|
||||
<SubTitle title={<Trans>Values</Trans>}/>
|
||||
<div className="flex gaps align-center">
|
||||
<Select
|
||||
className="quota-select"
|
||||
themeName="light"
|
||||
placeholder={_i18n._(t`Select a quota..`)}
|
||||
options={this.quotaOptions}
|
||||
value={this.quotaSelectValue}
|
||||
onChange={({ value }) => this.quotaSelectValue = value}
|
||||
/>
|
||||
<Input
|
||||
maxLength={10}
|
||||
placeholder={_i18n._(t`Value`)}
|
||||
value={this.quotaInputValue}
|
||||
onChange={v => this.quotaInputValue = v}
|
||||
onKeyDown={this.onInputQuota}
|
||||
className="box grow"
|
||||
/>
|
||||
<Button round primary onClick={this.setQuota}>
|
||||
<Icon
|
||||
material={this.quotas[this.quotaSelectValue] ? "edit" : "add"}
|
||||
tooltip={_i18n._(t`Set quota`)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="quota-entries">
|
||||
{this.quotaEntries.map(([quota, value]) => {
|
||||
return (
|
||||
<div key={quota} className="quota flex gaps inline align-center">
|
||||
<div className="name">{quota}</div>
|
||||
<div className="value">{value}</div>
|
||||
<Icon material="clear" onClick={() => this.quotas[quota] = ""}/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</WizardStep>
|
||||
</Wizard>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./resource-quotas.route"
|
||||
export * from "./resource-quotas"
|
||||
export * from "./resource-quota-details"
|
||||
@@ -0,0 +1,16 @@
|
||||
.ResourceQuotaDetails {
|
||||
.quota-list {
|
||||
.param {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
padding-bottom: $padding * 2;
|
||||
|
||||
.LineProgress {
|
||||
margin-top: 3px;
|
||||
width: 100%;
|
||||
color: $colorInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import "./resource-quota-details.scss";
|
||||
import React from "react";
|
||||
import kebabCase from "lodash/kebabCase";
|
||||
import { observer } from "mobx-react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { DrawerItem, DrawerTitle } from "../drawer";
|
||||
import { cpuUnitsToNumber, cssNames, unitsToBytes } from "../../utils";
|
||||
import { KubeObjectDetailsProps } from "../kube-object";
|
||||
import { ResourceQuota, resourceQuotaApi } from "../../api/endpoints/resource-quota.api";
|
||||
import { LineProgress } from "../line-progress";
|
||||
import { Table, TableCell, TableHead, TableRow } from "../table";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
import { KubeObjectMeta } from "../kube-object/kube-object-meta";
|
||||
|
||||
interface Props extends KubeObjectDetailsProps<ResourceQuota> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class ResourceQuotaDetails extends React.Component<Props> {
|
||||
renderQuotas = (quota: ResourceQuota) => {
|
||||
const { hard, used } = quota.status
|
||||
if (!hard || !used) return null
|
||||
const transformUnit = (name: string, value: string) => {
|
||||
if (name.includes("memory") || name.includes("storage")) {
|
||||
return unitsToBytes(value)
|
||||
}
|
||||
if (name.includes("cpu")) {
|
||||
return cpuUnitsToNumber(value)
|
||||
}
|
||||
return parseInt(value)
|
||||
}
|
||||
return Object.entries(hard).map(([name, value]) => {
|
||||
if (!used[name]) return null
|
||||
const current = transformUnit(name, used[name])
|
||||
const max = transformUnit(name, value)
|
||||
return (
|
||||
<div key={name} className={cssNames("param", kebabCase(name))}>
|
||||
<span className="title">{name}</span>
|
||||
<span className="value">{used[name]} / {value}</span>
|
||||
<LineProgress
|
||||
max={max}
|
||||
value={current}
|
||||
tooltip={
|
||||
<p><Trans>Set</Trans>: {value}. <Trans>Used</Trans>: {Math.ceil(current / max * 100) + "%"}</p>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const { object: quota } = this.props;
|
||||
if (!quota) return null;
|
||||
return (
|
||||
<div className="ResourceQuotaDetails">
|
||||
<KubeObjectMeta object={quota}/>
|
||||
|
||||
<DrawerItem name={<Trans>Quotas</Trans>} className="quota-list">
|
||||
{this.renderQuotas(quota)}
|
||||
</DrawerItem>
|
||||
|
||||
{quota.getScopeSelector().length > 0 && (
|
||||
<>
|
||||
<DrawerTitle title={<Trans>Scope Selector</Trans>}/>
|
||||
<Table className="paths">
|
||||
<TableHead>
|
||||
<TableCell><Trans>Operator</Trans></TableCell>
|
||||
<TableCell><Trans>Scope name</Trans></TableCell>
|
||||
<TableCell><Trans>Values</Trans></TableCell>
|
||||
</TableHead>
|
||||
{
|
||||
quota.getScopeSelector().map((selector, index) => {
|
||||
const { operator, scopeName, values } = selector;
|
||||
return (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{operator}</TableCell>
|
||||
<TableCell>{scopeName}</TableCell>
|
||||
<TableCell>{values.join(", ")}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
apiManager.registerViews(resourceQuotaApi, {
|
||||
Details: ResourceQuotaDetails
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { RouteProps } from "react-router";
|
||||
import { buildURL } from "../../navigation";
|
||||
|
||||
export const resourceQuotaRoute: RouteProps = {
|
||||
path: "/resourcequotas"
|
||||
}
|
||||
|
||||
export interface IResourceQuotaRouteParams {
|
||||
}
|
||||
|
||||
export const resourceQuotaURL = buildURL<IResourceQuotaRouteParams>(resourceQuotaRoute.path);
|
||||
@@ -0,0 +1,2 @@
|
||||
.ResourceQuotas {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { KubeObjectStore } from "../../kube-object.store";
|
||||
import { autobind } from "../../utils";
|
||||
import { ResourceQuota, resourceQuotaApi } from "../../api/endpoints/resource-quota.api";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
|
||||
@autobind()
|
||||
export class ResourceQuotasStore extends KubeObjectStore<ResourceQuota> {
|
||||
api = resourceQuotaApi
|
||||
}
|
||||
|
||||
export const resourceQuotaStore = new ResourceQuotasStore();
|
||||
apiManager.registerStore(resourceQuotaApi, resourceQuotaStore);
|
||||
@@ -0,0 +1,73 @@
|
||||
import "./resource-quotas.scss";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { RouteComponentProps } from "react-router";
|
||||
import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu";
|
||||
import { KubeObjectListLayout } from "../kube-object";
|
||||
import { ResourceQuota, resourceQuotaApi } from "../../api/endpoints/resource-quota.api";
|
||||
import { AddQuotaDialog } from "./add-quota-dialog";
|
||||
import { resourceQuotaStore } from "./resource-quotas.store";
|
||||
import { IResourceQuotaRouteParams } from "./resource-quotas.route";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
|
||||
enum sortBy {
|
||||
name = "name",
|
||||
namespace = "namespace",
|
||||
age = "age"
|
||||
}
|
||||
|
||||
interface Props extends RouteComponentProps<IResourceQuotaRouteParams> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class ResourceQuotas extends React.Component<Props> {
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<KubeObjectListLayout
|
||||
className="ResourceQuotas" store={resourceQuotaStore}
|
||||
sortingCallbacks={{
|
||||
[sortBy.name]: (item: ResourceQuota) => item.getName(),
|
||||
[sortBy.namespace]: (item: ResourceQuota) => item.getNs(),
|
||||
[sortBy.age]: (item: ResourceQuota) => item.metadata.creationTimestamp,
|
||||
}}
|
||||
searchFilters={[
|
||||
(item: ResourceQuota) => item.getSearchFields(),
|
||||
(item: ResourceQuota) => item.getName(),
|
||||
]}
|
||||
renderHeaderTitle={<Trans>Resource Quotas</Trans>}
|
||||
renderTableHeader={[
|
||||
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
|
||||
{ title: <Trans>Namespace</Trans>, className: "namespace", sortBy: sortBy.namespace },
|
||||
{ title: <Trans>Age</Trans>, className: "age", sortBy: sortBy.age },
|
||||
]}
|
||||
renderTableContents={(resourceQuota: ResourceQuota) => [
|
||||
resourceQuota.getName(),
|
||||
resourceQuota.getNs(),
|
||||
resourceQuota.getAge(),
|
||||
]}
|
||||
renderItemMenu={(item: ResourceQuota) => {
|
||||
return <ResourceQuotaMenu object={item}/>
|
||||
}}
|
||||
addRemoveButtons={{
|
||||
onAdd: () => AddQuotaDialog.open(),
|
||||
addTooltip: <Trans>Create new ResourceQuota</Trans>
|
||||
}}
|
||||
/>
|
||||
<AddQuotaDialog/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function ResourceQuotaMenu(props: KubeObjectMenuProps<ResourceQuota>) {
|
||||
return (
|
||||
<KubeObjectMenu {...props}/>
|
||||
);
|
||||
}
|
||||
|
||||
apiManager.registerViews(resourceQuotaApi, {
|
||||
Menu: ResourceQuotaMenu,
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
.AddSecretDialog {
|
||||
--flex-gap: #{$margin * 1.5};
|
||||
|
||||
.Icon {
|
||||
--color-active: black;
|
||||
}
|
||||
|
||||
.remove-icon {
|
||||
flex: 0 0;
|
||||
width: 18px; // icon size
|
||||
}
|
||||
|
||||
.fields-title {
|
||||
display: grid;
|
||||
grid-template-columns: min-content auto;
|
||||
align-items: center;
|
||||
gap: $margin / 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import "./add-secret-dialog.scss"
|
||||
|
||||
import React from "react";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { Dialog, DialogProps } from "../dialog";
|
||||
import { Wizard, WizardStep } from "../wizard";
|
||||
import { Input } from "../input";
|
||||
import { systemName } from "../input/input.validators";
|
||||
import { Secret, secretsApi, SecretType } from "../../api/endpoints";
|
||||
import { SubTitle } from "../layout/sub-title";
|
||||
import { NamespaceSelect } from "../+namespaces/namespace-select";
|
||||
import { Select, SelectOption } from "../select";
|
||||
import { Icon } from "../icon";
|
||||
import { IKubeObjectMetadata } from "../../api/kube-object";
|
||||
import { base64 } from "../../utils";
|
||||
import { Notifications } from "../notifications";
|
||||
import { showDetails } from "../../navigation";
|
||||
import upperFirst from "lodash/upperFirst";
|
||||
|
||||
interface Props extends Partial<DialogProps> {
|
||||
}
|
||||
|
||||
interface ISecretTemplateField {
|
||||
key: string;
|
||||
value?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
interface ISecretTemplate {
|
||||
[field: string]: ISecretTemplateField[];
|
||||
annotations?: ISecretTemplateField[];
|
||||
labels?: ISecretTemplateField[];
|
||||
data?: ISecretTemplateField[];
|
||||
}
|
||||
|
||||
type ISecretField = keyof ISecretTemplate;
|
||||
|
||||
@observer
|
||||
export class AddSecretDialog extends React.Component<Props> {
|
||||
@observable static isOpen = false;
|
||||
|
||||
static open() {
|
||||
AddSecretDialog.isOpen = true;
|
||||
}
|
||||
|
||||
static close() {
|
||||
AddSecretDialog.isOpen = false;
|
||||
}
|
||||
|
||||
private secretTemplate: { [p: string]: ISecretTemplate } = {
|
||||
[SecretType.Opaque]: {},
|
||||
[SecretType.ServiceAccountToken]: {
|
||||
annotations: [
|
||||
{ key: "kubernetes.io/service-account.name", required: true },
|
||||
{ key: "kubernetes.io/service-account.uid", required: true }
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
get types() {
|
||||
return Object.keys(this.secretTemplate) as SecretType[];
|
||||
}
|
||||
|
||||
@observable secret = this.secretTemplate;
|
||||
@observable name = "";
|
||||
@observable namespace = "default";
|
||||
@observable type = SecretType.Opaque;
|
||||
|
||||
reset = () => {
|
||||
this.name = "";
|
||||
this.secret = this.secretTemplate;
|
||||
}
|
||||
|
||||
close = () => {
|
||||
AddSecretDialog.close();
|
||||
}
|
||||
|
||||
private getDataFromFields = (fields: ISecretTemplateField[] = [], processValue?: (val: string) => string) => {
|
||||
return fields.reduce<any>((data, field) => {
|
||||
const { key, value } = field;
|
||||
if (key) {
|
||||
data[key] = processValue ? processValue(value) : value;
|
||||
}
|
||||
return data;
|
||||
}, {})
|
||||
}
|
||||
|
||||
createSecret = async () => {
|
||||
const { name, namespace, type } = this;
|
||||
const { data = [], labels = [], annotations = [] } = this.secret[type];
|
||||
const secret: Partial<Secret> = {
|
||||
type: type,
|
||||
data: this.getDataFromFields(data, val => val ? base64.encode(val) : ""),
|
||||
metadata: {
|
||||
name: name,
|
||||
namespace: namespace,
|
||||
annotations: this.getDataFromFields(annotations),
|
||||
labels: this.getDataFromFields(labels),
|
||||
} as IKubeObjectMetadata
|
||||
}
|
||||
try {
|
||||
const newSecret = await secretsApi.create({ namespace, name }, secret);
|
||||
showDetails(newSecret.selfLink);
|
||||
this.reset();
|
||||
this.close();
|
||||
} catch (err) {
|
||||
Notifications.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
addField = (field: ISecretField) => {
|
||||
const fields = this.secret[this.type][field] || [];
|
||||
fields.push({ key: "", value: "" });
|
||||
this.secret[this.type][field] = fields;
|
||||
}
|
||||
|
||||
removeField = (field: ISecretField, index: number) => {
|
||||
const fields = this.secret[this.type][field] || [];
|
||||
fields.splice(index, 1);
|
||||
}
|
||||
|
||||
renderFields(field: ISecretField) {
|
||||
const fields = this.secret[this.type][field] || [];
|
||||
return (
|
||||
<>
|
||||
<SubTitle compact className="fields-title" title={upperFirst(field.toString())}>
|
||||
<Icon
|
||||
small
|
||||
tooltip={_i18n._(t`Add field`)}
|
||||
material="add_circle_outline"
|
||||
onClick={() => this.addField(field)}
|
||||
/>
|
||||
</SubTitle>
|
||||
<div className="secret-fields">
|
||||
{fields.map((item, index) => {
|
||||
const { key = "", value = "", required } = item;
|
||||
return (
|
||||
<div key={index} className="secret-field flex gaps auto align-center">
|
||||
<Input
|
||||
className="key"
|
||||
placeholder={_i18n._(t`Name`)}
|
||||
title={key}
|
||||
tabIndex={required ? -1 : 0}
|
||||
readOnly={required}
|
||||
value={key} onChange={v => item.key = v}
|
||||
/>
|
||||
<Input
|
||||
multiLine maxRows={5}
|
||||
required={required}
|
||||
className="value"
|
||||
placeholder={_i18n._(t`Value`)}
|
||||
value={value} onChange={v => item.value = v}
|
||||
/>
|
||||
<Icon
|
||||
small
|
||||
disabled={required}
|
||||
tooltip={required ? <Trans>Required field</Trans> : <Trans>Remove field</Trans>}
|
||||
className="remove-icon"
|
||||
material="remove_circle_outline"
|
||||
onClick={() => this.removeField(field, index)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { ...dialogProps } = this.props;
|
||||
const { namespace, name, type } = this;
|
||||
const header = <h5><Trans>Create Secret</Trans></h5>;
|
||||
return (
|
||||
<Dialog
|
||||
{...dialogProps}
|
||||
className="AddSecretDialog"
|
||||
isOpen={AddSecretDialog.isOpen}
|
||||
close={this.close}
|
||||
>
|
||||
<Wizard header={header} done={this.close}>
|
||||
<WizardStep contentClass="flow column" nextLabel={<Trans>Create</Trans>} next={this.createSecret}>
|
||||
<div className="secret-name">
|
||||
<SubTitle title={<Trans>Secret name</Trans>}/>
|
||||
<Input
|
||||
autoFocus required
|
||||
placeholder={_i18n._(t`Name`)}
|
||||
validators={systemName}
|
||||
value={name} onChange={v => this.name = v}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex auto gaps">
|
||||
<div className="secret-namespace">
|
||||
<SubTitle title={<Trans>Namespace</Trans>}/>
|
||||
<NamespaceSelect
|
||||
themeName="light"
|
||||
value={namespace}
|
||||
onChange={({ value }) => this.namespace = value}
|
||||
/>
|
||||
</div>
|
||||
<div className="secret-type">
|
||||
<SubTitle title={<Trans>Secret type</Trans>}/>
|
||||
<Select
|
||||
themeName="light"
|
||||
options={this.types}
|
||||
value={type} onChange={({ value }: SelectOption) => this.type = value}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{this.renderFields("annotations")}
|
||||
{this.renderFields("labels")}
|
||||
{this.renderFields("data")}
|
||||
</WizardStep>
|
||||
</Wizard>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./secrets.route"
|
||||
export * from "./secrets"
|
||||
export * from "./secret-details"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
.SecretDetails {
|
||||
.data {
|
||||
margin-bottom: $margin * 2;
|
||||
|
||||
.name {
|
||||
color: $textColorSecondary;
|
||||
font-weight: $font-weight-bold;
|
||||
padding-bottom: $padding / 2;
|
||||
}
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
margin-top: $margin;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import "./secret-details.scss";
|
||||
|
||||
import React from "react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { autorun, observable } from "mobx";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { DrawerItem, DrawerTitle } from "../drawer";
|
||||
import { Input } from "../input";
|
||||
import { Button } from "../button";
|
||||
import { Notifications } from "../notifications";
|
||||
import { base64 } from "../../utils";
|
||||
import { Icon } from "../icon";
|
||||
import { secretsStore } from "./secrets.store";
|
||||
import { KubeObjectDetailsProps } from "../kube-object";
|
||||
import { Secret, secretsApi } from "../../api/endpoints";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
import { KubeObjectMeta } from "../kube-object/kube-object-meta";
|
||||
|
||||
interface Props extends KubeObjectDetailsProps<Secret> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class SecretDetails extends React.Component<Props> {
|
||||
@observable isSaving = false;
|
||||
@observable data: { [name: string]: string } = {};
|
||||
@observable revealSecret: { [name: string]: boolean } = {};
|
||||
|
||||
async componentDidMount() {
|
||||
disposeOnUnmount(this, [
|
||||
autorun(() => {
|
||||
const { object: secret } = this.props;
|
||||
if (secret) {
|
||||
this.data = secret.data;
|
||||
this.revealSecret = {};
|
||||
}
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
saveSecret = async () => {
|
||||
const { object: secret } = this.props;
|
||||
this.isSaving = true;
|
||||
try {
|
||||
await secretsStore.update(secret, { ...secret, data: this.data });
|
||||
Notifications.ok(<Trans>Secret successfully updated.</Trans>);
|
||||
} catch (err) {
|
||||
Notifications.error(err);
|
||||
}
|
||||
this.isSaving = false;
|
||||
}
|
||||
|
||||
editData = (name: string, value: string, encoded: boolean) => {
|
||||
this.data[name] = encoded ? value : base64.encode(value);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { object: secret } = this.props;
|
||||
if (!secret) return null;
|
||||
return (
|
||||
<div className="SecretDetails">
|
||||
<KubeObjectMeta object={secret}/>
|
||||
<DrawerItem name={<Trans>Type</Trans>}>
|
||||
{secret.type}
|
||||
</DrawerItem>
|
||||
{!isEmpty(this.data) && (
|
||||
<>
|
||||
<DrawerTitle title={_i18n._(t`Data`)}/>
|
||||
{
|
||||
Object.entries(this.data).map(([name, value]) => {
|
||||
const revealSecret = this.revealSecret[name];
|
||||
let decodedVal = "";
|
||||
try {
|
||||
decodedVal = base64.decode(value);
|
||||
} catch {
|
||||
decodedVal = "";
|
||||
}
|
||||
value = revealSecret ? decodedVal : value;
|
||||
return (
|
||||
<div key={name} className="data">
|
||||
<div className="name">{name}</div>
|
||||
<div className="flex gaps align-center">
|
||||
<Input
|
||||
multiLine
|
||||
theme="round-black"
|
||||
className="box grow"
|
||||
value={value || ""}
|
||||
onChange={value => this.editData(name, value, !revealSecret)}
|
||||
/>
|
||||
{decodedVal && (
|
||||
<Icon
|
||||
material={`visibility${revealSecret ? "" : "_off"}`}
|
||||
tooltip={revealSecret ? <Trans>Hide</Trans> : <Trans>Show</Trans>}
|
||||
onClick={() => this.revealSecret[name] = !revealSecret}
|
||||
/>)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
<Button
|
||||
primary
|
||||
label={_i18n._(t`Save`)} waiting={this.isSaving}
|
||||
className="save-btn"
|
||||
onClick={this.saveSecret}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
apiManager.registerViews(secretsApi, {
|
||||
Details: SecretDetails,
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { RouteProps } from "react-router";
|
||||
import { buildURL } from "../../navigation";
|
||||
|
||||
export const secretsRoute: RouteProps = {
|
||||
path: "/secrets"
|
||||
}
|
||||
|
||||
export interface ISecretsRouteParams {
|
||||
}
|
||||
|
||||
export const secretsURL = buildURL(secretsRoute.path);
|
||||
@@ -0,0 +1,11 @@
|
||||
.Secrets {
|
||||
.TableCell {
|
||||
&.name {
|
||||
flex: 1.5;
|
||||
}
|
||||
|
||||
&.labels {
|
||||
@include table-cell-labels-offsets;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { KubeObjectStore } from "../../kube-object.store";
|
||||
import { autobind } from "../../utils";
|
||||
import { Secret, secretsApi } from "../../api/endpoints";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
|
||||
@autobind()
|
||||
export class SecretsStore extends KubeObjectStore<Secret> {
|
||||
api = secretsApi
|
||||
}
|
||||
|
||||
export const secretsStore = new SecretsStore();
|
||||
apiManager.registerStore(secretsApi, secretsStore);
|
||||
@@ -0,0 +1,86 @@
|
||||
import "./secrets.scss"
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { RouteComponentProps } from "react-router";
|
||||
import { Secret, secretsApi } from "../../api/endpoints";
|
||||
import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu";
|
||||
import { AddSecretDialog } from "./add-secret-dialog";
|
||||
import { ISecretsRouteParams } from "./secrets.route";
|
||||
import { KubeObjectListLayout } from "../kube-object";
|
||||
import { Badge } from "../badge";
|
||||
import { secretsStore } from "./secrets.store";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
|
||||
enum sortBy {
|
||||
name = "name",
|
||||
namespace = "namespace",
|
||||
labels = "labels",
|
||||
keys = "keys",
|
||||
type = "type",
|
||||
age = "age",
|
||||
}
|
||||
|
||||
interface Props extends RouteComponentProps<ISecretsRouteParams> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class Secrets extends React.Component<Props> {
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<KubeObjectListLayout
|
||||
className="Secrets" store={secretsStore}
|
||||
sortingCallbacks={{
|
||||
[sortBy.name]: (item: Secret) => item.getName(),
|
||||
[sortBy.namespace]: (item: Secret) => item.getNs(),
|
||||
[sortBy.labels]: (item: Secret) => item.getLabels(),
|
||||
[sortBy.keys]: (item: Secret) => item.getKeys(),
|
||||
[sortBy.type]: (item: Secret) => item.type,
|
||||
[sortBy.age]: (item: Secret) => item.metadata.creationTimestamp,
|
||||
}}
|
||||
searchFilters={[
|
||||
(item: Secret) => item.getSearchFields(),
|
||||
(item: Secret) => item.getKeys(),
|
||||
]}
|
||||
renderHeaderTitle={<Trans>Secrets</Trans>}
|
||||
renderTableHeader={[
|
||||
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
|
||||
{ title: <Trans>Namespace</Trans>, className: "namespace", sortBy: sortBy.namespace },
|
||||
{ title: <Trans>Labels</Trans>, className: "labels", sortBy: sortBy.labels },
|
||||
{ title: <Trans>Keys</Trans>, className: "keys", sortBy: sortBy.keys },
|
||||
{ title: <Trans>Type</Trans>, className: "type", sortBy: sortBy.type },
|
||||
{ title: <Trans>Age</Trans>, className: "age", sortBy: sortBy.age },
|
||||
]}
|
||||
renderTableContents={(secret: Secret) => [
|
||||
secret.getName(),
|
||||
secret.getNs(),
|
||||
secret.getLabels().map(label => <Badge key={label} label={label}/>),
|
||||
secret.getKeys().join(", "),
|
||||
secret.type,
|
||||
secret.getAge(),
|
||||
]}
|
||||
renderItemMenu={(item: Secret) => {
|
||||
return <SecretMenu object={item}/>
|
||||
}}
|
||||
addRemoveButtons={{
|
||||
onAdd: () => AddSecretDialog.open(),
|
||||
addTooltip: <Trans>Create new Secret</Trans>
|
||||
}}
|
||||
/>
|
||||
<AddSecretDialog/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function SecretMenu(props: KubeObjectMenuProps<Secret>) {
|
||||
return (
|
||||
<KubeObjectMenu {...props}/>
|
||||
)
|
||||
}
|
||||
|
||||
apiManager.registerViews(secretsApi, {
|
||||
Menu: SecretMenu,
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { RouteProps } from "react-router";
|
||||
import { configMapsURL } from "../+config-maps";
|
||||
import { Config } from "./config";
|
||||
import { IURLParams } from "../../navigation";
|
||||
|
||||
export const configRoute: RouteProps = {
|
||||
get path() {
|
||||
return Config.tabRoutes.map(({ path }) => path).flat()
|
||||
}
|
||||
}
|
||||
|
||||
export const configURL = (params?: IURLParams) => configMapsURL(params);
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Redirect, Route, Switch } from "react-router";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { MainLayout, TabRoute } from "../layout/main-layout";
|
||||
import { ConfigMaps, configMapsRoute, configMapsURL } from "../+config-maps";
|
||||
import { Secrets, secretsRoute, secretsURL } from "../+config-secrets";
|
||||
import { namespaceStore } from "../+namespaces/namespace.store";
|
||||
import { resourceQuotaRoute, ResourceQuotas, resourceQuotaURL } from "../+config-resource-quotas";
|
||||
import { configURL } from "./config.route";
|
||||
import { HorizontalPodAutoscalers, hpaRoute, hpaURL } from "../+config-autoscalers";
|
||||
import { buildURL } from "../../navigation";
|
||||
import { isAllowedResource } from "../../api/rbac"
|
||||
|
||||
export const certificatesURL = buildURL("/certificates");
|
||||
export const issuersURL = buildURL("/issuers");
|
||||
export const clusterIssuersURL = buildURL("/clusterissuers");
|
||||
|
||||
@observer
|
||||
export class Config extends React.Component {
|
||||
static get tabRoutes(): TabRoute[] {
|
||||
const query = namespaceStore.getContextParams()
|
||||
const routes: TabRoute[] = []
|
||||
if (isAllowedResource("configmaps")) {
|
||||
routes.push({
|
||||
title: <Trans>ConfigMaps</Trans>,
|
||||
component: ConfigMaps,
|
||||
url: configMapsURL({ query }),
|
||||
path: configMapsRoute.path,
|
||||
})
|
||||
}
|
||||
if (isAllowedResource("secrets")) {
|
||||
routes.push({
|
||||
title: <Trans>Secrets</Trans>,
|
||||
component: Secrets,
|
||||
url: secretsURL({ query }),
|
||||
path: secretsRoute.path,
|
||||
})
|
||||
}
|
||||
if (isAllowedResource("resourcequotas")) {
|
||||
routes.push({
|
||||
title: <Trans>Resource Quotas</Trans>,
|
||||
component: ResourceQuotas,
|
||||
url: resourceQuotaURL({ query }),
|
||||
path: resourceQuotaRoute.path,
|
||||
})
|
||||
}
|
||||
if (isAllowedResource("horizontalpodautoscalers")) {
|
||||
routes.push({
|
||||
title: <Trans>HPA</Trans>,
|
||||
component: HorizontalPodAutoscalers,
|
||||
url: hpaURL({ query }),
|
||||
path: hpaRoute.path,
|
||||
})
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
render() {
|
||||
const tabRoutes = Config.tabRoutes;
|
||||
return (
|
||||
<MainLayout className="Config" tabs={tabRoutes}>
|
||||
<Switch>
|
||||
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
|
||||
<Redirect to={configURL({ query: namespaceStore.getContextParams() })}/>
|
||||
</Switch>
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./config.route"
|
||||
export * from "./config"
|
||||
@@ -0,0 +1,12 @@
|
||||
$cert-status-colors: (
|
||||
ready: $colorOk,
|
||||
);
|
||||
|
||||
@mixin cert-status-bgc {
|
||||
@each $status, $color in $cert-status-colors {
|
||||
&.#{$status} {
|
||||
background: $color;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@import "cert-manager.mixins";
|
||||
|
||||
.CertificateDetails {
|
||||
.Badge {
|
||||
@include cert-status-bgc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import "./certificate-details.scss"
|
||||
|
||||
import React from "react";
|
||||
import moment from "moment"
|
||||
import { observer } from "mobx-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { DrawerItem, DrawerTitle } from "../../drawer";
|
||||
import { Badge } from "../../badge";
|
||||
import { KubeEventDetails } from "../../+events/kube-event-details";
|
||||
import { KubeObjectDetailsProps } from "../../kube-object";
|
||||
import { Certificate, certificatesApi } from "../../../api/endpoints/cert-manager.api";
|
||||
import { cssNames } from "../../../utils";
|
||||
import { apiManager } from "../../../api/api-manager";
|
||||
import { KubeObjectMeta } from "../../kube-object/kube-object-meta";
|
||||
|
||||
interface Props extends KubeObjectDetailsProps<Certificate> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class CertificateDetails extends React.Component<Props> {
|
||||
render() {
|
||||
const { object: cert, className } = this.props;
|
||||
if (!cert) return;
|
||||
const { spec, status } = cert;
|
||||
const { acme, isCA, commonName, secretName, dnsNames, duration, ipAddresses, keyAlgorithm, keySize, organization, renewBefore } = spec;
|
||||
const { lastFailureTime, notAfter } = status;
|
||||
return (
|
||||
<div className={cssNames("CertificateDetails", className)}>
|
||||
<KubeObjectMeta object={cert}/>
|
||||
|
||||
<DrawerItem name={<Trans>Issuer</Trans>}>
|
||||
<Link to={cert.getIssuerDetailsUrl()}>
|
||||
{cert.getIssuerName()}
|
||||
</Link>
|
||||
</DrawerItem>
|
||||
|
||||
<DrawerItem name={<Trans>Secret Name</Trans>}>
|
||||
<Link to={cert.getSecretDetailsUrl()}>
|
||||
{secretName}
|
||||
</Link>
|
||||
</DrawerItem>
|
||||
|
||||
<DrawerItem name="CA">
|
||||
{isCA ? <Trans>Yes</Trans> : <Trans>No</Trans>}
|
||||
</DrawerItem>
|
||||
|
||||
{commonName && (
|
||||
<DrawerItem name={<Trans>Common Name</Trans>}>
|
||||
{commonName}
|
||||
</DrawerItem>
|
||||
)}
|
||||
{dnsNames && (
|
||||
<DrawerItem name={<Trans>DNS names</Trans>} labelsOnly>
|
||||
{dnsNames.map(name => <Badge key={name} label={name}/>)}
|
||||
</DrawerItem>
|
||||
)}
|
||||
{ipAddresses && (
|
||||
<DrawerItem name={<Trans>IP addresses</Trans>}>
|
||||
{ipAddresses.join(", ")}
|
||||
</DrawerItem>
|
||||
)}
|
||||
{organization && (
|
||||
<DrawerItem name={<Trans>Organization</Trans>}>
|
||||
{organization.join(", ")}
|
||||
</DrawerItem>
|
||||
)}
|
||||
{duration && (
|
||||
<DrawerItem name={<Trans>Duration</Trans>}>
|
||||
{duration}
|
||||
</DrawerItem>
|
||||
)}
|
||||
{renewBefore && (
|
||||
<DrawerItem name={<Trans>Renew Before</Trans>}>
|
||||
{renewBefore}
|
||||
</DrawerItem>
|
||||
)}
|
||||
{keySize && (
|
||||
<DrawerItem name={<Trans>Key Size</Trans>}>
|
||||
{keySize}
|
||||
</DrawerItem>
|
||||
)}
|
||||
{keyAlgorithm && (
|
||||
<DrawerItem name={<Trans>Key Algorithm</Trans>}>
|
||||
{keyAlgorithm}
|
||||
</DrawerItem>
|
||||
)}
|
||||
|
||||
<DrawerItem name={<Trans>Not After</Trans>}>
|
||||
{moment(notAfter).format("LLL")}
|
||||
</DrawerItem>
|
||||
|
||||
{lastFailureTime && (
|
||||
<DrawerItem name={<Trans>Last Failure Time</Trans>}>
|
||||
{lastFailureTime}
|
||||
</DrawerItem>
|
||||
)}
|
||||
<DrawerItem name={<Trans>Status</Trans>} labelsOnly>
|
||||
{cert.getConditions().map(({ type, tooltip, isReady }) => {
|
||||
return (
|
||||
<Badge
|
||||
key={type}
|
||||
label={type}
|
||||
tooltip={tooltip}
|
||||
className={cssNames({ [type.toLowerCase()]: isReady })}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</DrawerItem>
|
||||
|
||||
{acme && (
|
||||
<>
|
||||
<DrawerTitle title="ACME"/>
|
||||
{acme.config.map(({ domains, http01, dns01 }, index) => {
|
||||
return (
|
||||
<div key={index} className="acme-config">
|
||||
<DrawerItem name={<Trans>Domains</Trans>} labelsOnly>
|
||||
{domains.map(domain => <Badge key={domain} label={domain}/>)}
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>Http01</Trans>}>
|
||||
{Object.entries(http01).map(([key, val]) => `${key}: ${val}`)[0]}
|
||||
</DrawerItem>
|
||||
{dns01 && (
|
||||
<DrawerItem name={<Trans>DNS Provider</Trans>} labelsOnly>
|
||||
{dns01.provider}
|
||||
</DrawerItem>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
<KubeEventDetails object={cert}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
apiManager.registerViews(certificatesApi, {
|
||||
Details: CertificateDetails
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
@import "cert-manager.mixins";
|
||||
|
||||
.Certificates {
|
||||
.TableCell {
|
||||
&.name {
|
||||
flex: 1.2;
|
||||
}
|
||||
|
||||
&.type {
|
||||
flex: .5;
|
||||
}
|
||||
|
||||
&.status {
|
||||
flex: .5;
|
||||
@include table-cell-labels-offsets;
|
||||
|
||||
.Badge {
|
||||
@include cert-status-bgc;
|
||||
}
|
||||
}
|
||||
|
||||
&.age {
|
||||
flex: .5;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import "./certificates.scss"
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { KubeObjectMenu, KubeObjectMenuProps } from "../../kube-object/kube-object-menu";
|
||||
import { KubeObjectListLayout, KubeObjectListLayoutProps } from "../../kube-object";
|
||||
import { Certificate, certificatesApi } from "../../../api/endpoints/cert-manager.api";
|
||||
import { cssNames, stopPropagation } from "../../../utils";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Badge } from "../../badge";
|
||||
import { apiManager } from "../../../api/api-manager";
|
||||
import { Spinner } from "../../spinner";
|
||||
|
||||
enum sortBy {
|
||||
name = "name",
|
||||
namespace = "namespace",
|
||||
age = "age",
|
||||
commonName = "common-name",
|
||||
secretName = "secret",
|
||||
issuer = "issuer",
|
||||
type = "type",
|
||||
}
|
||||
|
||||
@observer
|
||||
export class Certificates extends React.Component<KubeObjectListLayoutProps> {
|
||||
render() {
|
||||
const { store = apiManager.getStore(certificatesApi), ...layoutProps } = this.props;
|
||||
if (!store) {
|
||||
return <Spinner center/>
|
||||
}
|
||||
return (
|
||||
<KubeObjectListLayout
|
||||
{...layoutProps}
|
||||
store={store}
|
||||
className="Certificates"
|
||||
sortingCallbacks={{
|
||||
[sortBy.name]: (item: Certificate) => item.getName(),
|
||||
[sortBy.namespace]: (item: Certificate) => item.getNs(),
|
||||
[sortBy.secretName]: (item: Certificate) => item.getSecretName(),
|
||||
[sortBy.commonName]: (item: Certificate) => item.getCommonName(),
|
||||
[sortBy.issuer]: (item: Certificate) => item.getIssuerName(),
|
||||
[sortBy.type]: (item: Certificate) => item.getType(),
|
||||
}}
|
||||
searchFilters={[
|
||||
(item: Certificate) => item.getSearchFields(),
|
||||
(item: Certificate) => item.getSecretName(),
|
||||
(item: Certificate) => item.getCommonName(),
|
||||
(item: Certificate) => item.getIssuerName(),
|
||||
(item: Certificate) => item.getType(),
|
||||
]}
|
||||
renderHeaderTitle={<Trans>Certificates</Trans>}
|
||||
renderTableHeader={[
|
||||
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
|
||||
{ title: <Trans>Namespace</Trans>, className: "namespace", sortBy: sortBy.namespace },
|
||||
{ title: <Trans>Common Name</Trans>, className: "common-name", sortBy: sortBy.type },
|
||||
{ title: <Trans>Type</Trans>, className: "type", sortBy: sortBy.type },
|
||||
{ title: <Trans>Issuer</Trans>, className: "issuer", sortBy: sortBy.issuer },
|
||||
{ title: <Trans>Secret</Trans>, className: "secret", sortBy: sortBy.secretName },
|
||||
{ title: <Trans>Age</Trans>, className: "age", sortBy: sortBy.age },
|
||||
{ title: <Trans>Status</Trans>, className: "status" },
|
||||
]}
|
||||
renderTableContents={(cert: Certificate) => {
|
||||
return [
|
||||
cert.getName(),
|
||||
cert.getNs(),
|
||||
cert.getCommonName(),
|
||||
cert.getType(),
|
||||
<Link to={cert.getIssuerDetailsUrl()} onClick={stopPropagation}>
|
||||
{cert.getIssuerName()}
|
||||
</Link>,
|
||||
<Link to={cert.getSecretDetailsUrl()} onClick={stopPropagation}>
|
||||
{cert.getSecretName()}
|
||||
</Link>,
|
||||
cert.getAge(),
|
||||
cert.getConditions().map(({ type, tooltip, isReady }) => {
|
||||
return (
|
||||
<Badge
|
||||
key={type}
|
||||
label={type}
|
||||
tooltip={tooltip}
|
||||
className={cssNames({ [type.toLowerCase()]: isReady })}
|
||||
/>
|
||||
)
|
||||
})
|
||||
]
|
||||
}}
|
||||
renderItemMenu={(item: Certificate) => {
|
||||
return <CertificateMenu object={item}/>
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function CertificateMenu(props: KubeObjectMenuProps<Certificate>) {
|
||||
return (
|
||||
<KubeObjectMenu {...props}/>
|
||||
)
|
||||
}
|
||||
|
||||
apiManager.registerViews(certificatesApi, {
|
||||
List: Certificates,
|
||||
Menu: CertificateMenu
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./certificates"
|
||||
export * from "./certificate-details"
|
||||
export * from "./issuers"
|
||||
export * from "./issuer-details"
|
||||
@@ -0,0 +1,7 @@
|
||||
@import "cert-manager.mixins";
|
||||
|
||||
.IssuerDetails {
|
||||
.Badge {
|
||||
@include cert-status-bgc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import "./issuer-details.scss"
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { Link } from "react-router-dom";
|
||||
import { DrawerItem, DrawerTitle } from "../../drawer";
|
||||
import { Badge } from "../../badge";
|
||||
import { KubeEventDetails } from "../../+events/kube-event-details";
|
||||
import { KubeObjectDetailsProps } from "../../kube-object";
|
||||
import { clusterIssuersApi, Issuer, issuersApi } from "../../../api/endpoints/cert-manager.api";
|
||||
import { autobind, cssNames } from "../../../utils";
|
||||
import { getDetailsUrl } from "../../../navigation";
|
||||
import { secretsApi } from "../../../api/endpoints";
|
||||
import { apiManager } from "../../../api/api-manager";
|
||||
import { KubeObjectMeta } from "../../kube-object/kube-object-meta";
|
||||
|
||||
interface Props extends KubeObjectDetailsProps<Issuer> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class IssuerDetails extends React.Component<Props> {
|
||||
@autobind()
|
||||
renderSecretLink(secretName: string) {
|
||||
const namespace = this.props.object.getNs();
|
||||
if (!namespace) {
|
||||
return secretName;
|
||||
}
|
||||
const secretDetailsUrl = getDetailsUrl(secretsApi.getUrl({
|
||||
namespace: namespace,
|
||||
name: secretName,
|
||||
}));
|
||||
return (
|
||||
<Link to={secretDetailsUrl}>
|
||||
{secretName}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { object: issuer, className } = this.props;
|
||||
if (!issuer) return;
|
||||
const { renderSecretLink } = this;
|
||||
const { spec: { acme, ca, vault, venafi }, status } = issuer;
|
||||
return (
|
||||
<div className={cssNames("IssuerDetails", className)}>
|
||||
<KubeObjectMeta object={issuer}/>
|
||||
|
||||
<DrawerItem name={<Trans>Type</Trans>}>
|
||||
{issuer.getType()}
|
||||
</DrawerItem>
|
||||
|
||||
<DrawerItem name={<Trans>Status</Trans>} labelsOnly>
|
||||
{issuer.getConditions().map(({ type, tooltip, isReady }) => {
|
||||
return (
|
||||
<Badge
|
||||
key={type}
|
||||
label={type}
|
||||
tooltip={tooltip}
|
||||
className={cssNames({ [type.toLowerCase()]: isReady })}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</DrawerItem>
|
||||
|
||||
{acme && (() => {
|
||||
const { email, server, skipTLSVerify, privateKeySecretRef, solvers } = acme;
|
||||
return (
|
||||
<>
|
||||
<DrawerTitle title="ACME"/>
|
||||
<DrawerItem name={<Trans>E-mail</Trans>}>
|
||||
{email}
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>Server</Trans>}>
|
||||
{server}
|
||||
</DrawerItem>
|
||||
{status.acme && (
|
||||
<DrawerItem name={<Trans>Status URI</Trans>}>
|
||||
{status.acme.uri}
|
||||
</DrawerItem>
|
||||
)}
|
||||
<DrawerItem name={<Trans>Private Key Secret</Trans>}>
|
||||
{renderSecretLink(privateKeySecretRef.name)}
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>Skip TLS Verify</Trans>}>
|
||||
{skipTLSVerify ? <Trans>Yes</Trans> : <Trans>No</Trans>}
|
||||
</DrawerItem>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{ca && (() => {
|
||||
const { secretName } = ca;
|
||||
return (
|
||||
<>
|
||||
<DrawerTitle title="CA"/>
|
||||
<DrawerItem name={<Trans>Secret Name</Trans>}>
|
||||
{renderSecretLink(secretName)}
|
||||
</DrawerItem>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{vault && (() => {
|
||||
const { auth, caBundle, path, server } = vault;
|
||||
const { path: authPath, roleId, secretRef } = auth.appRole;
|
||||
return (
|
||||
<>
|
||||
<DrawerTitle title="Vault"/>
|
||||
<DrawerItem name={<Trans>Server</Trans>}>
|
||||
{server}
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>Path</Trans>}>
|
||||
{path}
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>CA Bundle</Trans>} labelsOnly>
|
||||
<Badge label={caBundle}/>
|
||||
</DrawerItem>
|
||||
|
||||
<DrawerTitle title={<Trans>Auth App Role</Trans>}/>
|
||||
<DrawerItem name={<Trans>Path</Trans>}>
|
||||
{authPath}
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>Role ID</Trans>}>
|
||||
{roleId}
|
||||
</DrawerItem>
|
||||
{secretRef && (
|
||||
<DrawerItem name={<Trans>Secret</Trans>}>
|
||||
{renderSecretLink(secretRef.name)}
|
||||
</DrawerItem>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{venafi && (() => {
|
||||
const { zone, cloud, tpp } = venafi;
|
||||
return (
|
||||
<>
|
||||
<DrawerTitle title="CA"/>
|
||||
<DrawerItem name={<Trans>Zone</Trans>}>
|
||||
{zone}
|
||||
</DrawerItem>
|
||||
{cloud && (
|
||||
<DrawerItem name={<Trans>Cloud API Token Secret</Trans>}>
|
||||
{renderSecretLink(cloud.apiTokenSecretRef.name)}
|
||||
</DrawerItem>
|
||||
)}
|
||||
{tpp && (
|
||||
<>
|
||||
<DrawerTitle title="TPP"/>
|
||||
<DrawerItem name={<Trans>URL</Trans>}>
|
||||
{tpp.url}
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>CA Bundle</Trans>} labelsOnly>
|
||||
<Badge label={tpp.caBundle}/>
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>Credentials Ref</Trans>}>
|
||||
{renderSecretLink(tpp.credentialsRef.name)}
|
||||
</DrawerItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
<KubeEventDetails object={issuer}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
apiManager.registerViews([issuersApi, clusterIssuersApi], {
|
||||
Details: IssuerDetails
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
@import "cert-manager.mixins";
|
||||
|
||||
.Issuers {
|
||||
.TableCell {
|
||||
&.name {
|
||||
flex: 1.2;
|
||||
}
|
||||
|
||||
&.labels {
|
||||
flex: 2;
|
||||
@include table-cell-labels-offsets;
|
||||
}
|
||||
|
||||
&.status {
|
||||
flex: .5;
|
||||
@include table-cell-labels-offsets;
|
||||
|
||||
.Badge {
|
||||
@include cert-status-bgc;
|
||||
}
|
||||
}
|
||||
|
||||
&.age {
|
||||
flex: .5;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import "./issuers.scss"
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { KubeObjectMenu, KubeObjectMenuProps } from "../../kube-object/kube-object-menu";
|
||||
import { KubeObjectListLayout, KubeObjectListLayoutProps } from "../../kube-object";
|
||||
import { clusterIssuersApi, Issuer, issuersApi } from "../../../api/endpoints/cert-manager.api";
|
||||
import { cssNames } from "../../../utils";
|
||||
import { Badge } from "../../badge";
|
||||
import { Spinner } from "../../spinner";
|
||||
import { apiManager } from "../../../api/api-manager";
|
||||
|
||||
enum sortBy {
|
||||
name = "name",
|
||||
namespace = "namespace",
|
||||
type = "type",
|
||||
labels = "labels",
|
||||
age = "age",
|
||||
}
|
||||
|
||||
@observer
|
||||
export class ClusterIssuers extends React.Component<KubeObjectListLayoutProps> {
|
||||
render() {
|
||||
const store = apiManager.getStore(clusterIssuersApi);
|
||||
return (
|
||||
<Issuers
|
||||
{...this.props}
|
||||
isClusterScoped={true}
|
||||
store={store}
|
||||
renderHeaderTitle={<Trans>Cluster Issuers</Trans>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@observer
|
||||
export class Issuers extends React.Component<KubeObjectListLayoutProps> {
|
||||
render() {
|
||||
const { store = apiManager.getStore(issuersApi), ...layoutProps } = this.props;
|
||||
if (!store) {
|
||||
return <Spinner center/>
|
||||
}
|
||||
return (
|
||||
<KubeObjectListLayout
|
||||
store={store}
|
||||
renderHeaderTitle={<Trans>Issuers</Trans>}
|
||||
{...layoutProps}
|
||||
className="Issuers"
|
||||
sortingCallbacks={{
|
||||
[sortBy.name]: (item: Issuer) => item.getName(),
|
||||
[sortBy.namespace]: (item: Issuer) => item.getNs(),
|
||||
[sortBy.type]: (item: Issuer) => item.getType(),
|
||||
[sortBy.labels]: (item: Issuer) => item.getLabels(),
|
||||
[sortBy.age]: (item: Issuer) => item.metadata.creationTimestamp,
|
||||
}}
|
||||
searchFilters={[
|
||||
(item: Issuer) => item.getSearchFields(),
|
||||
(item: Issuer) => item.getType(),
|
||||
]}
|
||||
renderTableHeader={[
|
||||
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
|
||||
{ title: <Trans>Namespace</Trans>, className: "namespace", sortBy: sortBy.namespace },
|
||||
{ title: <Trans>Labels</Trans>, className: "labels", sortBy: sortBy.labels },
|
||||
{ title: <Trans>Type</Trans>, className: "type", sortBy: sortBy.type },
|
||||
{ title: <Trans>Age</Trans>, className: "age", sortBy: sortBy.age },
|
||||
{ title: <Trans>Status</Trans>, className: "status" },
|
||||
]}
|
||||
renderTableContents={(issuer: Issuer) => [
|
||||
issuer.getName(),
|
||||
issuer.getNs(),
|
||||
issuer.getLabels().map(label => <Badge key={label} label={label} title={label}/>),
|
||||
issuer.getType(),
|
||||
issuer.getAge(),
|
||||
issuer.getConditions().map(({ type, tooltip, isReady }) => {
|
||||
return (
|
||||
<Badge
|
||||
key={type}
|
||||
label={type}
|
||||
tooltip={tooltip}
|
||||
className={cssNames({ [type.toLowerCase()]: isReady })}
|
||||
/>
|
||||
)
|
||||
})
|
||||
]}
|
||||
renderItemMenu={(item: Issuer) => {
|
||||
return <IssuerMenu object={item}/>
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function IssuerMenu(props: KubeObjectMenuProps<Issuer>) {
|
||||
return (
|
||||
<KubeObjectMenu {...props}/>
|
||||
)
|
||||
}
|
||||
|
||||
apiManager.registerViews([issuersApi, clusterIssuersApi], {
|
||||
List: Issuers,
|
||||
Menu: IssuerMenu,
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
@import "crd.mixins";
|
||||
|
||||
.CRDDetails {
|
||||
.conditions {
|
||||
.Badge {
|
||||
@include crd-condition-bgc;
|
||||
}
|
||||
}
|
||||
|
||||
.Table {
|
||||
margin-left: -$margin * 2;
|
||||
margin-right: -$margin * 2;
|
||||
|
||||
.TableRow {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&.printer-columns {
|
||||
.type {
|
||||
flex: 0.5;
|
||||
}
|
||||
|
||||
.description {
|
||||
flex: 1.5;
|
||||
|
||||
.Badge {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.json-path {
|
||||
flex: 3;
|
||||
|
||||
.Badge {
|
||||
white-space: pre-line;
|
||||
text-overflow: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.validation {
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import "./crd-details.scss";
|
||||
|
||||
import React from "react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { Link } from "react-router-dom";
|
||||
import { observer } from "mobx-react";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
import { crdApi, CustomResourceDefinition } from "../../api/endpoints/crd.api";
|
||||
import { cssNames } from "../../utils";
|
||||
import { AceEditor } from "../ace-editor";
|
||||
import { Badge } from "../badge";
|
||||
import { DrawerItem, DrawerTitle } from "../drawer";
|
||||
import { KubeObjectDetailsProps } from "../kube-object";
|
||||
import { Table, TableCell, TableHead, TableRow } from "../table";
|
||||
import { Input } from "../input";
|
||||
import { KubeObjectMeta } from "../kube-object/kube-object-meta";
|
||||
|
||||
interface Props extends KubeObjectDetailsProps<CustomResourceDefinition> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class CRDDetails extends React.Component<Props> {
|
||||
render() {
|
||||
const { object: crd } = this.props;
|
||||
if (!crd) return null;
|
||||
const { plural, singular, kind, listKind } = crd.getNames();
|
||||
const printerColumns = crd.getPrinterColumns();
|
||||
const validation = crd.getValidation();
|
||||
return (
|
||||
<div className="CRDDetails">
|
||||
<KubeObjectMeta object={crd}/>
|
||||
|
||||
<DrawerItem name={<Trans>Group</Trans>}>
|
||||
{crd.getGroup()}
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>Version</Trans>}>
|
||||
{crd.getVersion()}
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>Stored versions</Trans>}>
|
||||
{crd.getStoredVersions()}
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>Scope</Trans>}>
|
||||
{crd.getScope()}
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>Resource</Trans>}>
|
||||
<Link to={crd.getResourceUrl()}>
|
||||
{crd.getResourceTitle()}
|
||||
</Link>
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>Conversion</Trans>} className="flex gaps align-flex-start">
|
||||
<Input
|
||||
multiLine
|
||||
theme="round-black"
|
||||
className="box grow"
|
||||
value={crd.getConversion()}
|
||||
readOnly
|
||||
/>
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>Conditions</Trans>} className="conditions" labelsOnly>
|
||||
{
|
||||
crd.getConditions().map(condition => {
|
||||
const { type, message, lastTransitionTime, status } = condition
|
||||
return (
|
||||
<Badge
|
||||
key={type}
|
||||
label={type}
|
||||
className={cssNames({ disabled: status === "False" }, type)}
|
||||
tooltip={(
|
||||
<>
|
||||
<p>{message}</p>
|
||||
<p><Trans>Last transition time: {lastTransitionTime}</Trans></p>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</DrawerItem>
|
||||
<DrawerTitle title={<Trans>Names</Trans>}/>
|
||||
<Table selectable className="names box grow">
|
||||
<TableHead>
|
||||
<TableCell><Trans>plural</Trans></TableCell>
|
||||
<TableCell><Trans>singular</Trans></TableCell>
|
||||
<TableCell><Trans>kind</Trans></TableCell>
|
||||
<TableCell><Trans>listKind</Trans></TableCell>
|
||||
</TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{plural}</TableCell>
|
||||
<TableCell>{singular}</TableCell>
|
||||
<TableCell>{kind}</TableCell>
|
||||
<TableCell>{listKind}</TableCell>
|
||||
</TableRow>
|
||||
</Table>
|
||||
{printerColumns.length > 0 &&
|
||||
<>
|
||||
<DrawerTitle title={<Trans>Additional Printer Columns</Trans>}/>
|
||||
<Table selectable className="printer-columns box grow">
|
||||
<TableHead>
|
||||
<TableCell className="name"><Trans>Name</Trans></TableCell>
|
||||
<TableCell className="type"><Trans>Type</Trans></TableCell>
|
||||
<TableCell className="json-path"><Trans>JSON Path</Trans></TableCell>
|
||||
</TableHead>
|
||||
{
|
||||
printerColumns.map((column, index) => {
|
||||
const { name, type, JSONPath } = column;
|
||||
return (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="name">{name}</TableCell>
|
||||
<TableCell className="type">{type}</TableCell>
|
||||
<TableCell className="json-path">
|
||||
<Badge label={JSONPath}/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
}
|
||||
</Table>
|
||||
</>
|
||||
}
|
||||
{validation &&
|
||||
<>
|
||||
<DrawerTitle title={<Trans>Validation</Trans>}/>
|
||||
<AceEditor
|
||||
mode="json"
|
||||
className="validation"
|
||||
value={validation}
|
||||
readOnly
|
||||
/>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
apiManager.registerViews(crdApi, {
|
||||
Details: CRDDetails
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
@import "crd.mixins";
|
||||
|
||||
.CrdList {
|
||||
.TableCell {
|
||||
&.kind {
|
||||
flex: 1.5;
|
||||
}
|
||||
|
||||
&.group {
|
||||
flex: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.group-select {
|
||||
.Select {
|
||||
&__placeholder {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import "./crd-list.scss"
|
||||
|
||||
import React from "react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { computed } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { stopPropagation } from "../../utils";
|
||||
import { KubeObjectListLayout } from "../kube-object";
|
||||
import { crdStore } from "./crd.store";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
import { crdApi, CustomResourceDefinition } from "../../api/endpoints/crd.api";
|
||||
import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu";
|
||||
import { Select, SelectOption } from "../select";
|
||||
import { navigation, setQueryParams } from "../../navigation";
|
||||
import { Icon } from "../icon";
|
||||
|
||||
enum sortBy {
|
||||
kind = "kind",
|
||||
group = "group",
|
||||
version = "version",
|
||||
scope = "scope",
|
||||
age = "age",
|
||||
}
|
||||
|
||||
@observer
|
||||
export class CrdList extends React.Component {
|
||||
@computed get groups() {
|
||||
return navigation.searchParams.getAsArray("groups")
|
||||
}
|
||||
|
||||
onGroupChange(group: string) {
|
||||
const groups = [...this.groups];
|
||||
const index = groups.findIndex(item => item == group);
|
||||
if (index !== -1) groups.splice(index, 1);
|
||||
else groups.push(group);
|
||||
setQueryParams({ groups })
|
||||
}
|
||||
|
||||
render() {
|
||||
const selectedGroups = this.groups;
|
||||
const sortingCallbacks = {
|
||||
[sortBy.kind]: (crd: CustomResourceDefinition) => crd.getResourceKind(),
|
||||
[sortBy.group]: (crd: CustomResourceDefinition) => crd.getGroup(),
|
||||
[sortBy.version]: (crd: CustomResourceDefinition) => crd.getVersion(),
|
||||
[sortBy.scope]: (crd: CustomResourceDefinition) => crd.getScope(),
|
||||
};
|
||||
return (
|
||||
<KubeObjectListLayout
|
||||
className="CrdList"
|
||||
isClusterScoped={true}
|
||||
store={crdStore}
|
||||
sortingCallbacks={sortingCallbacks}
|
||||
searchFilters={Object.values(sortingCallbacks)}
|
||||
filterItems={[
|
||||
(items: CustomResourceDefinition[]) => {
|
||||
return selectedGroups.length ? items.filter(item => selectedGroups.includes(item.getGroup())) : items
|
||||
}
|
||||
]}
|
||||
renderHeaderTitle={<Trans>Custom Resources</Trans>}
|
||||
customizeHeader={() => {
|
||||
let placeholder = <Trans>All groups</Trans>;
|
||||
if (selectedGroups.length == 1) placeholder = <><Trans>Group</Trans>: {selectedGroups[0]}</>
|
||||
if (selectedGroups.length >= 2) placeholder = <><Trans>Groups</Trans>: {selectedGroups.join(", ")}</>
|
||||
return {
|
||||
// fixme: move to global filters
|
||||
filters: (
|
||||
<Select
|
||||
className="group-select"
|
||||
placeholder={placeholder}
|
||||
options={Object.keys(crdStore.groups)}
|
||||
onChange={({ value: group }: SelectOption) => this.onGroupChange(group)}
|
||||
controlShouldRenderValue={false}
|
||||
formatOptionLabel={({ value: group }: SelectOption) => {
|
||||
const isSelected = selectedGroups.includes(group);
|
||||
return (
|
||||
<div className="flex gaps align-center">
|
||||
<Icon small material="folder"/>
|
||||
<span>{group}</span>
|
||||
{isSelected && <Icon small material="check" className="box right"/>}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}}
|
||||
renderTableHeader={[
|
||||
{ title: <Trans>Resource</Trans>, className: "kind", sortBy: sortBy.kind },
|
||||
{ title: <Trans>Group</Trans>, className: "group", sortBy: sortBy.group },
|
||||
{ title: <Trans>Version</Trans>, className: "version", sortBy: sortBy.group },
|
||||
{ title: <Trans>Scope</Trans>, className: "scope", sortBy: sortBy.scope },
|
||||
{ title: <Trans>Age</Trans>, className: "age", sortBy: sortBy.age },
|
||||
]}
|
||||
renderTableContents={(crd: CustomResourceDefinition) => {
|
||||
return [
|
||||
<Link to={crd.getResourceUrl()} onClick={stopPropagation}>
|
||||
{crd.getResourceTitle()}
|
||||
</Link>,
|
||||
crd.getGroup(),
|
||||
crd.getVersion(),
|
||||
crd.getScope(),
|
||||
crd.getAge(),
|
||||
]
|
||||
}}
|
||||
renderItemMenu={(item: CustomResourceDefinition) => {
|
||||
return <CRDMenu object={item}/>
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function CRDMenu(props: KubeObjectMenuProps<CustomResourceDefinition>) {
|
||||
return (
|
||||
<KubeObjectMenu {...props}/>
|
||||
)
|
||||
}
|
||||
|
||||
apiManager.registerViews(crdApi, {
|
||||
Menu: CRDMenu,
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
.CrdResourceDetails {
|
||||
.status {
|
||||
.value {
|
||||
.Badge {
|
||||
&.ready {
|
||||
color: white;
|
||||
background-color: $colorOk;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import "./crd-resource-details.scss";
|
||||
|
||||
import React from "react";
|
||||
import jsonPath from "jsonpath";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { observer } from "mobx-react";
|
||||
import { computed } from "mobx";
|
||||
import { cssNames } from "../../utils";
|
||||
import { Badge } from "../badge";
|
||||
import { DrawerItem } from "../drawer";
|
||||
import { KubeObjectDetailsProps } from "../kube-object";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
import { crdStore } from "./crd.store";
|
||||
import { KubeObject } from "../../api/kube-object";
|
||||
import { KubeObjectMeta } from "../kube-object/kube-object-meta";
|
||||
|
||||
// fixme: provide type-safe check for status
|
||||
interface Props extends KubeObjectDetailsProps<KubeObject & { status: any }> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class CrdResourceDetails extends React.Component<Props> {
|
||||
@computed get crd() {
|
||||
return crdStore.getByObject(this.props.object);
|
||||
}
|
||||
|
||||
@computed get CustomDetailsViews() {
|
||||
return apiManager.getViews(this.props.object.selfLink).Details;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { object } = this.props;
|
||||
const { crd, CustomDetailsViews } = this;
|
||||
if (!object || !crd) return null;
|
||||
const className = cssNames("CrdResourceDetails", crd.getResourceKind());
|
||||
const extraColumns = crd.getPrinterColumns();
|
||||
const showStatus = !extraColumns.find(column => column.name == "Status") && object.status?.conditions;
|
||||
if (CustomDetailsViews) {
|
||||
return <CustomDetailsViews className={className} object={object}/>
|
||||
}
|
||||
return (
|
||||
<div className={className}>
|
||||
<KubeObjectMeta object={object}/>
|
||||
{extraColumns.map(column => {
|
||||
const { name } = column;
|
||||
return (
|
||||
<DrawerItem key={name} name={name}>
|
||||
{jsonPath.query(object, column.JSONPath.slice(1))}
|
||||
</DrawerItem>
|
||||
)
|
||||
})}
|
||||
{showStatus && (
|
||||
<DrawerItem name={<Trans>Status</Trans>} className="status" labelsOnly>
|
||||
{object.status.conditions.map((condition: { type: string; message: string; status: string }) => {
|
||||
const { type, message, status } = condition;
|
||||
return (
|
||||
<Badge
|
||||
key={type} label={type}
|
||||
className={cssNames({ disabled: status === "False" }, type.toLowerCase())}
|
||||
tooltip={message}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DrawerItem>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeApi } from "../../api/kube-api";
|
||||
import { KubeObjectStore } from "../../kube-object.store";
|
||||
import { KubeObject } from "../../api/kube-object";
|
||||
|
||||
@autobind()
|
||||
export class CRDResourceStore<T extends KubeObject = any> extends KubeObjectStore<T> {
|
||||
api: KubeApi;
|
||||
|
||||
constructor(api: KubeApi<T>) {
|
||||
super();
|
||||
this.api = api;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.CrdResources {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import "./crd-resources.scss";
|
||||
|
||||
import React from "react";
|
||||
import jsonPath from "jsonpath";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { RouteComponentProps } from "react-router";
|
||||
import { KubeObjectListLayout } from "../kube-object";
|
||||
import { KubeObject } from "../../api/kube-object";
|
||||
import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu";
|
||||
import { ICRDRouteParams } from "./crd.route";
|
||||
import { autorun, computed } from "mobx";
|
||||
import { crdStore } from "./crd.store";
|
||||
import { SortingCallback } from "../table";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
|
||||
interface Props extends RouteComponentProps<ICRDRouteParams> {
|
||||
}
|
||||
|
||||
enum sortBy {
|
||||
name = "name",
|
||||
namespace = "namespace",
|
||||
age = "age",
|
||||
}
|
||||
|
||||
@observer
|
||||
export class CrdResources extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
disposeOnUnmount(this, [
|
||||
autorun(() => {
|
||||
const { store } = this;
|
||||
if (store && !store.isLoading && !store.isLoaded) {
|
||||
store.loadAll();
|
||||
}
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
@computed get crd() {
|
||||
const { group, name } = this.props.match.params;
|
||||
return crdStore.getByGroup(group, name);
|
||||
}
|
||||
|
||||
@computed get store() {
|
||||
if (!this.crd) return null
|
||||
return apiManager.getStore(this.crd.getResourceApiBase());
|
||||
}
|
||||
|
||||
render() {
|
||||
const { crd, store } = this;
|
||||
if (!crd) return null;
|
||||
const isNamespaced = crd.isNamespaced();
|
||||
const extraColumns = crd.getPrinterColumns(false); // Cols with priority bigger than 0 are shown in details
|
||||
const sortingCallbacks: { [sortBy: string]: SortingCallback } = {
|
||||
[sortBy.name]: (item: KubeObject) => item.getName(),
|
||||
[sortBy.namespace]: (item: KubeObject) => item.getNs(),
|
||||
[sortBy.age]: (item: KubeObject) => item.metadata.creationTimestamp,
|
||||
}
|
||||
extraColumns.forEach(column => {
|
||||
sortingCallbacks[column.name] = (item: KubeObject) => jsonPath.query(item, column.JSONPath.slice(1))
|
||||
})
|
||||
// todo: merge extra columns and other params to predefined view
|
||||
const { List } = apiManager.getViews(crd.getResourceApiBase());
|
||||
const ListView = List || KubeObjectListLayout;
|
||||
return (
|
||||
<ListView
|
||||
className="CrdResources"
|
||||
isClusterScoped={!isNamespaced}
|
||||
store={store}
|
||||
sortingCallbacks={sortingCallbacks}
|
||||
searchFilters={[
|
||||
(item: KubeObject) => item.getSearchFields(),
|
||||
]}
|
||||
renderHeaderTitle={crd.getResourceTitle()}
|
||||
renderTableHeader={[
|
||||
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
|
||||
isNamespaced && { title: <Trans>Namespace</Trans>, className: "namespace", sortBy: sortBy.namespace },
|
||||
...extraColumns.map(column => {
|
||||
const { name } = column;
|
||||
return {
|
||||
title: name,
|
||||
className: name.toLowerCase(),
|
||||
sortBy: name
|
||||
}
|
||||
}),
|
||||
{ title: <Trans>Age</Trans>, className: "age", sortBy: sortBy.age },
|
||||
]}
|
||||
renderTableContents={(crdInstance: KubeObject) => [
|
||||
crdInstance.getName(),
|
||||
isNamespaced && crdInstance.getNs(),
|
||||
...extraColumns.map(column =>
|
||||
jsonPath.query(crdInstance, column.JSONPath.slice(1))
|
||||
),
|
||||
crdInstance.getAge(),
|
||||
]}
|
||||
renderItemMenu={(item: KubeObject) => {
|
||||
return <CrdResourceMenu object={item}/>
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function CrdResourceMenu(props: KubeObjectMenuProps<KubeObject>) {
|
||||
const { Menu } = apiManager.getViews(props.object.selfLink);
|
||||
if (Menu) {
|
||||
return <Menu {...props}/>
|
||||
}
|
||||
return (
|
||||
<KubeObjectMenu {...props}/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// CRD conditions from here https://github.com/kubernetes/apiextensions-apiserver/blob/master/pkg/apis/apiextensions/types.go
|
||||
$crd-condition-colors: (
|
||||
Established: $colorSuccess,
|
||||
NamesAccepted: $colorOk,
|
||||
NonStructuralSchema: $colorError,
|
||||
Terminating: $colorTerminated,
|
||||
KubernetesAPIApprovalPolicyConformant: $colorWarning
|
||||
);
|
||||
|
||||
@mixin crd-condition-bgc {
|
||||
@each $status, $color in $crd-condition-colors {
|
||||
&.#{$status} {
|
||||
background: $color;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { RouteProps } from "react-router";
|
||||
import { buildURL } from "../../navigation";
|
||||
|
||||
export const crdRoute: RouteProps = {
|
||||
path: "/crd"
|
||||
}
|
||||
|
||||
export const crdDefinitionsRoute: RouteProps = {
|
||||
path: crdRoute.path + "/definitions"
|
||||
}
|
||||
|
||||
export const crdResourcesRoute: RouteProps = {
|
||||
path: crdRoute.path + "/:group/:name"
|
||||
}
|
||||
|
||||
export interface ICRDListQuery {
|
||||
groups?: string;
|
||||
}
|
||||
|
||||
export interface ICRDRouteParams {
|
||||
group: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const crdURL = buildURL<{}, ICRDListQuery>(crdDefinitionsRoute.path);
|
||||
export const crdResourcesURL = buildURL<ICRDRouteParams>(crdResourcesRoute.path);
|
||||
@@ -0,0 +1,74 @@
|
||||
import { computed, reaction } from "mobx";
|
||||
import { KubeObjectStore } from "../../kube-object.store";
|
||||
import { autobind } from "../../utils";
|
||||
import { crdApi, CustomResourceDefinition } from "../../api/endpoints/crd.api";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
import { KubeApi } from "../../api/kube-api";
|
||||
import { CRDResourceStore } from "./crd-resource.store";
|
||||
import { KubeObject } from "../../api/kube-object";
|
||||
|
||||
@autobind()
|
||||
export class CRDStore extends KubeObjectStore<CustomResourceDefinition> {
|
||||
api = crdApi
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// auto-init stores for crd-s
|
||||
reaction(() => this.items.toJS(), items => {
|
||||
items.forEach(this.initStore);
|
||||
})
|
||||
}
|
||||
|
||||
protected sortItems(items: CustomResourceDefinition[]) {
|
||||
return super.sortItems(items, [
|
||||
crd => crd.getGroup(),
|
||||
crd => crd.getName(),
|
||||
])
|
||||
}
|
||||
|
||||
protected initStore(crd: CustomResourceDefinition) {
|
||||
const apiBase = crd.getResourceApiBase();
|
||||
let api = apiManager.getApi(apiBase);
|
||||
if (!api) {
|
||||
api = new KubeApi({
|
||||
apiBase: apiBase,
|
||||
kind: crd.getResourceKind(),
|
||||
isNamespaced: crd.isNamespaced(),
|
||||
});
|
||||
}
|
||||
let store = apiManager.getStore(api);
|
||||
if (!store) {
|
||||
store = new CRDResourceStore(api);
|
||||
apiManager.registerStore(api, store);
|
||||
}
|
||||
}
|
||||
|
||||
@computed get groups() {
|
||||
const groups: Record<string, CustomResourceDefinition[]> = {};
|
||||
return this.items.reduce((groups, crd) => {
|
||||
const group = crd.getGroup();
|
||||
if (!groups[group]) groups[group] = [];
|
||||
groups[group].push(crd);
|
||||
return groups;
|
||||
}, groups)
|
||||
}
|
||||
|
||||
getByGroup(group: string, pluralName: string) {
|
||||
const crdInGroup = this.groups[group];
|
||||
if (!crdInGroup) return null;
|
||||
return crdInGroup.find(crd => crd.getPluralName() === pluralName);
|
||||
}
|
||||
|
||||
getByObject(obj: KubeObject) {
|
||||
if (!obj) return null
|
||||
const { kind, apiVersion } = obj;
|
||||
return this.items.find(crd => {
|
||||
return kind === crd.getResourceKind() && apiVersion === `${crd.getGroup()}/${crd.getVersion()}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const crdStore = new CRDStore();
|
||||
|
||||
apiManager.registerStore(crdApi, crdStore);
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Redirect, Route, Switch } from "react-router";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { MainLayout, TabRoute } from "../layout/main-layout";
|
||||
import { crdResourcesRoute, crdRoute, crdURL, crdDefinitionsRoute } from "./crd.route";
|
||||
import { CrdList } from "./crd-list";
|
||||
import { CrdResources } from "./crd-resources";
|
||||
|
||||
// todo: next steps - customization via plugins
|
||||
// todo: list views (rows content), full details view and if possible chart/prometheus hooks
|
||||
|
||||
@observer
|
||||
export class CustomResources extends React.Component {
|
||||
static get tabRoutes(): TabRoute[] {
|
||||
return [
|
||||
{
|
||||
title: <Trans>Definitions</Trans>,
|
||||
component: CustomResources,
|
||||
url: crdURL(),
|
||||
path: crdRoute.path,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<MainLayout>
|
||||
<Switch>
|
||||
<Route component={CrdList} {...crdDefinitionsRoute} exact/>
|
||||
<Route component={CrdResources} {...crdResourcesRoute}/>
|
||||
<Redirect to={crdURL()}/>
|
||||
</Switch>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./crd.route";
|
||||
export * from "./crd-list";
|
||||
export * from "./crd-details";
|
||||
export * from "./crd-resources";
|
||||
export * from "./crd-resource-details";
|
||||
|
||||
// customized crd-s
|
||||
export * from "./certmanager.k8s.io"
|
||||
@@ -0,0 +1,7 @@
|
||||
.EventDetails {
|
||||
.type {
|
||||
.warning {
|
||||
color: $colorError;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import "./event-details.scss";
|
||||
|
||||
import React from "react";
|
||||
import kebabCase from "lodash/kebabCase";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { DrawerItem, DrawerTitle } from "../drawer";
|
||||
import { Link } from "react-router-dom";
|
||||
import { observer } from "mobx-react";
|
||||
import { KubeObjectDetailsProps } from "../kube-object";
|
||||
import { eventApi, KubeEvent } from "../../api/endpoints/events.api";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
import { KubeObjectMeta } from "../kube-object/kube-object-meta";
|
||||
import { getDetailsUrl } from "../../navigation";
|
||||
import { Table, TableCell, TableHead, TableRow } from "../table";
|
||||
import { lookupApiLink } from "../../api/kube-api";
|
||||
|
||||
interface Props extends KubeObjectDetailsProps<KubeEvent> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class EventDetails extends React.Component<Props> {
|
||||
render() {
|
||||
const { object: event } = this.props;
|
||||
if (!event) return;
|
||||
const { message, reason, count, type, involvedObject } = event;
|
||||
const { kind, name, namespace, fieldPath } = involvedObject;
|
||||
return (
|
||||
<div className="EventDetails">
|
||||
<KubeObjectMeta object={event}/>
|
||||
|
||||
<DrawerItem name={<Trans>Message</Trans>}>
|
||||
{message}
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>Reason</Trans>}>
|
||||
{reason}
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>Source</Trans>}>
|
||||
{event.getSource()}
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>First seen</Trans>}>
|
||||
{event.getFirstSeenTime()} <Trans>ago</Trans> {event.firstTimestamp}
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>Last seen</Trans>}>
|
||||
{event.getLastSeenTime()} <Trans>ago</Trans> {event.lastTimestamp}
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>Count</Trans>}>
|
||||
{count}
|
||||
</DrawerItem>
|
||||
<DrawerItem name={<Trans>Type</Trans>} className="type">
|
||||
<span className={kebabCase(type)}>{type}</span>
|
||||
</DrawerItem>
|
||||
|
||||
<DrawerTitle title={<Trans>Involved object</Trans>}/>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableCell><Trans>Name</Trans></TableCell>
|
||||
<TableCell><Trans>Namespace</Trans></TableCell>
|
||||
<TableCell><Trans>Kind</Trans></TableCell>
|
||||
<TableCell><Trans>Field Path</Trans></TableCell>
|
||||
</TableHead>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Link to={getDetailsUrl(lookupApiLink(involvedObject, event))}>
|
||||
{name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{namespace}</TableCell>
|
||||
<TableCell>{kind}</TableCell>
|
||||
<TableCell>{fieldPath}</TableCell>
|
||||
</TableRow>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
apiManager.registerViews(eventApi, {
|
||||
Details: EventDetails,
|
||||
});
|
||||
Loaded 100 of 786 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user