Lens restructure (#540)

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

No files matched your search

@@ -0,0 +1,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"